From e4b82dd6854a27fd52da9505bfb683268dd9e852 Mon Sep 17 00:00:00 2001 From: Jerrit Fritzsche Date: Mon, 27 Jul 2026 18:55:32 +0200 Subject: [PATCH] Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aether-medical: Stationen/Belegung, Intensivstation, Blutbank, Fuhrpark, Nachrichten/Aushänge, Berichte und Textbausteine (server + NUI) - aether-admin: vollständiges Server-Admin-Panel als echtes FiveM-Resource, an aether-core gekoppelt (Rechte über aether_users.admin_group), mit WebRTC-Live-Kameras (Bildschirmfreigabe) und separatem Media-Server (SFU) - Prototypen admin/ vervollständigt - SQL-Schema aether_admin.sql - Docker-Compose mit MariaDB und idempotenter Auto-Migration (schema_migrations) Co-Authored-By: Claude Opus 4.8 --- .env.example | 21 + .gitignore | 9 + README.md | 682 +++++ admin/index.html | 977 ++++++ casino/index.html | 366 +++ clothing/index.html | 227 ++ docker-compose.yml | 68 + docker/README.md | 48 + docker/migrate.sh | 88 + factions/index.html | 381 +++ fuel/index.html | 424 +++ immobilien/index.html | 296 ++ minigames/index.html | 404 +++ radial/index.html | 294 ++ resources/[aether]/aether-admin/README.md | 87 + .../[aether]/aether-admin/client/kamera.lua | 50 + .../[aether]/aether-admin/client/main.lua | 282 ++ .../[aether]/aether-admin/fxmanifest.lua | 62 + .../[aether]/aether-admin/html/css/app.css | 99 + .../[aether]/aether-admin/html/index.html | 28 + .../[aether]/aether-admin/html/js/app.js | 202 ++ .../[aether]/aether-admin/html/js/sections.js | 488 +++ .../[aether]/aether-admin/html/js/webrtc.js | 247 ++ .../aether-admin/media-server/README.md | 79 + .../aether-admin/media-server/package.json | 18 + .../aether-admin/media-server/server.js | 198 ++ .../[aether]/aether-admin/server/auth.lua | 95 + resources/[aether]/aether-admin/server/db.lua | 150 + .../[aether]/aether-admin/server/kamera.lua | 218 ++ .../[aether]/aether-admin/server/kern.lua | 89 + .../[aether]/aether-admin/server/log.lua | 69 + .../[aether]/aether-admin/server/main.lua | 80 + .../aether-admin/server/module/broadcast.lua | 32 + .../aether-admin/server/module/dashboard.lua | 59 + .../aether-admin/server/module/fahrzeuge.lua | 85 + .../aether-admin/server/module/fraktionen.lua | 88 + .../aether-admin/server/module/immobilien.lua | 51 + .../aether-admin/server/module/logs.lua | 17 + .../aether-admin/server/module/moderation.lua | 59 + .../aether-admin/server/module/reports.lua | 114 + .../aether-admin/server/module/ressourcen.lua | 68 + .../aether-admin/server/module/settings.lua | 48 + .../aether-admin/server/module/spieler.lua | 360 +++ .../aether-admin/server/module/staat.lua | 77 + .../aether-admin/server/module/team.lua | 77 + .../aether-admin/server/module/welt.lua | 87 + .../aether-admin/server/module/weltkarte.lua | 31 + .../aether-admin/server/module/wirtschaft.lua | 65 + .../[aether]/aether-admin/shared/config.lua | 193 ++ .../[aether]/aether-bank/client/main.lua | 180 ++ resources/[aether]/aether-bank/fxmanifest.lua | 34 + .../[aether]/aether-bank/html/index.html | 938 ++++++ .../[aether]/aether-bank/server/main.lua | 295 ++ .../aether-character/client/aussehen.lua | 132 + .../[aether]/aether-character/client/main.lua | 157 + .../aether-character/client/vorschau.lua | 189 ++ .../[aether]/aether-character/fxmanifest.lua | 39 + .../[aether]/aether-character/html/index.html | 519 ++++ .../[aether]/aether-character/server/main.lua | 221 ++ .../[aether]/aether-core/client/main.lua | 165 + resources/[aether]/aether-core/client/nui.lua | 76 + resources/[aether]/aether-core/fxmanifest.lua | 32 + .../[aether]/aether-core/server/database.lua | 131 + .../[aether]/aether-core/server/main.lua | 305 ++ .../[aether]/aether-core/server/player.lua | 204 ++ .../[aether]/aether-core/shared/config.lua | 78 + .../[aether]/aether-core/shared/utils.lua | 97 + .../[aether]/aether-death/client/main.lua | 187 ++ .../[aether]/aether-death/fxmanifest.lua | 34 + .../[aether]/aether-death/html/index.html | 405 +++ .../[aether]/aether-death/server/main.lua | 249 ++ .../[aether]/aether-death/shared/config.lua | 39 + .../[aether]/aether-documents/client/main.lua | 129 + .../[aether]/aether-documents/fxmanifest.lua | 36 + .../[aether]/aether-documents/html/index.html | 709 +++++ .../[aether]/aether-documents/server/main.lua | 386 +++ .../aether-documents/shared/config.lua | 49 + .../[aether]/aether-garage/client/main.lua | 330 ++ .../[aether]/aether-garage/fxmanifest.lua | 34 + .../[aether]/aether-garage/html/index.html | 330 ++ .../[aether]/aether-garage/server/main.lua | 511 ++++ .../[aether]/aether-garage/shared/garagen.lua | 93 + .../[aether]/aether-hud/client/fahrzeug.lua | 77 + resources/[aether]/aether-hud/client/main.lua | 195 ++ resources/[aether]/aether-hud/fxmanifest.lua | 30 + resources/[aether]/aether-hud/html/index.html | 484 +++ .../aether-inventory/client/drops.lua | 164 + .../[aether]/aether-inventory/client/main.lua | 151 + .../[aether]/aether-inventory/fxmanifest.lua | 38 + .../[aether]/aether-inventory/html/index.html | 772 +++++ .../aether-inventory/server/inventar.lua | 369 +++ .../[aether]/aether-inventory/server/main.lua | 403 +++ .../aether-inventory/shared/items.lua | 264 ++ .../[aether]/aether-jobs/client/main.lua | 344 +++ resources/[aether]/aether-jobs/fxmanifest.lua | 34 + .../[aether]/aether-jobs/html/index.html | 340 +++ .../[aether]/aether-jobs/server/main.lua | 344 +++ .../[aether]/aether-jobs/shared/jobs.lua | 271 ++ .../[aether]/aether-loading/fxmanifest.lua | 19 + .../[aether]/aether-loading/html/index.html | 380 +++ .../[aether]/aether-medical/client/main.lua | 261 ++ .../[aether]/aether-medical/fxmanifest.lua | 76 + .../[aether]/aether-medical/html/css/app.css | 1156 +++++++ .../[aether]/aether-medical/html/index.html | 145 + .../[aether]/aether-medical/html/js/app.js | 628 ++++ .../aether-medical/html/js/bausteine.js | 156 + .../html/js/module/behandlung.js | 730 +++++ .../aether-medical/html/js/module/bericht.js | 848 ++++++ .../aether-medical/html/js/module/blutbank.js | 755 +++++ .../html/js/module/dashboard.js | 259 ++ .../aether-medical/html/js/module/einsatz.js | 641 ++++ .../aether-medical/html/js/module/fahrzeug.js | 621 ++++ .../aether-medical/html/js/module/intensiv.js | 747 +++++ .../html/js/module/kommunikation.js | 741 +++++ .../aether-medical/html/js/module/labor.js | 603 ++++ .../aether-medical/html/js/module/mdt.js | 766 +++++ .../html/js/module/medikament.js | 679 +++++ .../html/js/module/operation.js | 735 +++++ .../html/js/module/patienten.js | 667 +++++ .../aether-medical/html/js/module/station.js | 849 ++++++ .../aether-medical/html/js/module/vorlage.js | 272 ++ .../[aether]/aether-medical/server/dienst.lua | 296 ++ .../aether-medical/server/hinweise.lua | 484 +++ .../[aether]/aether-medical/server/kern.lua | 241 ++ .../[aether]/aether-medical/server/main.lua | 241 ++ .../server/module/behandlung.lua | 673 +++++ .../aether-medical/server/module/bericht.lua | 782 +++++ .../aether-medical/server/module/blutbank.lua | 690 +++++ .../server/module/dashboard.lua | 226 ++ .../aether-medical/server/module/einsatz.lua | 633 ++++ .../aether-medical/server/module/fahrzeug.lua | 651 ++++ .../aether-medical/server/module/intensiv.lua | 694 +++++ .../server/module/kommunikation.lua | 760 +++++ .../aether-medical/server/module/labor.lua | 540 ++++ .../aether-medical/server/module/mdt.lua | 735 +++++ .../server/module/medikament.lua | 571 ++++ .../server/module/operation.lua | 807 +++++ .../server/module/patienten.lua | 806 +++++ .../aether-medical/server/module/station.lua | 822 +++++ .../aether-medical/server/module/vorlage.lua | 366 +++ .../aether-medical/server/protokoll.lua | 66 + .../[aether]/aether-medical/server/rechte.lua | 61 + .../[aether]/aether-medical/shared/config.lua | 271 ++ .../[aether]/aether-medical/shared/rollen.lua | 234 ++ .../[aether]/aether-phone/client/main.lua | 185 ++ .../[aether]/aether-phone/fxmanifest.lua | 35 + .../[aether]/aether-phone/html/index.html | 2654 +++++++++++++++++ .../[aether]/aether-phone/server/main.lua | 554 ++++ .../[aether]/aether-phone/shared/config.lua | 33 + .../[aether]/aether-police/client/main.lua | 376 +++ .../[aether]/aether-police/fxmanifest.lua | 73 + .../[aether]/aether-police/html/css/app.css | 1079 +++++++ .../[aether]/aether-police/html/index.html | 128 + .../[aether]/aether-police/html/js/app.js | 536 ++++ .../aether-police/html/js/module/admin.js | 495 +++ .../aether-police/html/js/module/anzeige.js | 553 ++++ .../aether-police/html/js/module/asservat.js | 394 +++ .../aether-police/html/js/module/bericht.js | 602 ++++ .../aether-police/html/js/module/beweis.js | 368 +++ .../aether-police/html/js/module/dashboard.js | 246 ++ .../aether-police/html/js/module/einsatz.js | 483 +++ .../aether-police/html/js/module/fahndung.js | 458 +++ .../aether-police/html/js/module/fahrzeug.js | 451 +++ .../aether-police/html/js/module/gewahrsam.js | 432 +++ .../html/js/module/kommunikation.js | 568 ++++ .../aether-police/html/js/module/mdt.js | 892 ++++++ .../aether-police/html/js/module/personal.js | 633 ++++ .../aether-police/html/js/module/statistik.js | 373 +++ .../[aether]/aether-police/server/dienst.lua | 243 ++ .../aether-police/server/hinweise.lua | 219 ++ .../[aether]/aether-police/server/kern.lua | 227 ++ .../[aether]/aether-police/server/main.lua | 195 ++ .../aether-police/server/module/admin.lua | 357 +++ .../aether-police/server/module/anzeige.lua | 443 +++ .../aether-police/server/module/asservat.lua | 320 ++ .../aether-police/server/module/bericht.lua | 582 ++++ .../aether-police/server/module/beweis.lua | 305 ++ .../aether-police/server/module/dashboard.lua | 170 ++ .../aether-police/server/module/einsatz.lua | 495 +++ .../aether-police/server/module/fahndung.lua | 399 +++ .../aether-police/server/module/fahrzeug.lua | 338 +++ .../aether-police/server/module/gewahrsam.lua | 383 +++ .../server/module/kommunikation.lua | 573 ++++ .../aether-police/server/module/mdt.lua | 635 ++++ .../aether-police/server/module/personal.lua | 564 ++++ .../aether-police/server/module/statistik.lua | 436 +++ .../aether-police/server/protokoll.lua | 66 + .../[aether]/aether-police/server/rechte.lua | 61 + .../[aether]/aether-police/shared/config.lua | 127 + .../[aether]/aether-police/shared/rollen.lua | 208 ++ .../[aether]/aether-shop/client/main.lua | 149 + resources/[aether]/aether-shop/fxmanifest.lua | 34 + .../[aether]/aether-shop/html/index.html | 540 ++++ .../[aether]/aether-shop/server/main.lua | 262 ++ .../[aether]/aether-shop/shared/shops.lua | 140 + server.cfg | 102 + sql/aether.sql | 281 ++ sql/aether_admin.sql | 204 ++ sql/aether_medical.sql | 607 ++++ sql/aether_police.sql | 453 +++ sql/updates/001_fahrzeug_position.sql | 17 + untitled.md | 0 202 files changed, 67050 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 admin/index.html create mode 100644 casino/index.html create mode 100644 clothing/index.html create mode 100644 docker-compose.yml create mode 100644 docker/README.md create mode 100644 docker/migrate.sh create mode 100644 factions/index.html create mode 100644 fuel/index.html create mode 100644 immobilien/index.html create mode 100644 minigames/index.html create mode 100644 radial/index.html create mode 100644 resources/[aether]/aether-admin/README.md create mode 100644 resources/[aether]/aether-admin/client/kamera.lua create mode 100644 resources/[aether]/aether-admin/client/main.lua create mode 100644 resources/[aether]/aether-admin/fxmanifest.lua create mode 100644 resources/[aether]/aether-admin/html/css/app.css create mode 100644 resources/[aether]/aether-admin/html/index.html create mode 100644 resources/[aether]/aether-admin/html/js/app.js create mode 100644 resources/[aether]/aether-admin/html/js/sections.js create mode 100644 resources/[aether]/aether-admin/html/js/webrtc.js create mode 100644 resources/[aether]/aether-admin/media-server/README.md create mode 100644 resources/[aether]/aether-admin/media-server/package.json create mode 100644 resources/[aether]/aether-admin/media-server/server.js create mode 100644 resources/[aether]/aether-admin/server/auth.lua create mode 100644 resources/[aether]/aether-admin/server/db.lua create mode 100644 resources/[aether]/aether-admin/server/kamera.lua create mode 100644 resources/[aether]/aether-admin/server/kern.lua create mode 100644 resources/[aether]/aether-admin/server/log.lua create mode 100644 resources/[aether]/aether-admin/server/main.lua create mode 100644 resources/[aether]/aether-admin/server/module/broadcast.lua create mode 100644 resources/[aether]/aether-admin/server/module/dashboard.lua create mode 100644 resources/[aether]/aether-admin/server/module/fahrzeuge.lua create mode 100644 resources/[aether]/aether-admin/server/module/fraktionen.lua create mode 100644 resources/[aether]/aether-admin/server/module/immobilien.lua create mode 100644 resources/[aether]/aether-admin/server/module/logs.lua create mode 100644 resources/[aether]/aether-admin/server/module/moderation.lua create mode 100644 resources/[aether]/aether-admin/server/module/reports.lua create mode 100644 resources/[aether]/aether-admin/server/module/ressourcen.lua create mode 100644 resources/[aether]/aether-admin/server/module/settings.lua create mode 100644 resources/[aether]/aether-admin/server/module/spieler.lua create mode 100644 resources/[aether]/aether-admin/server/module/staat.lua create mode 100644 resources/[aether]/aether-admin/server/module/team.lua create mode 100644 resources/[aether]/aether-admin/server/module/welt.lua create mode 100644 resources/[aether]/aether-admin/server/module/weltkarte.lua create mode 100644 resources/[aether]/aether-admin/server/module/wirtschaft.lua create mode 100644 resources/[aether]/aether-admin/shared/config.lua create mode 100644 resources/[aether]/aether-bank/client/main.lua create mode 100644 resources/[aether]/aether-bank/fxmanifest.lua create mode 100644 resources/[aether]/aether-bank/html/index.html create mode 100644 resources/[aether]/aether-bank/server/main.lua create mode 100644 resources/[aether]/aether-character/client/aussehen.lua create mode 100644 resources/[aether]/aether-character/client/main.lua create mode 100644 resources/[aether]/aether-character/client/vorschau.lua create mode 100644 resources/[aether]/aether-character/fxmanifest.lua create mode 100644 resources/[aether]/aether-character/html/index.html create mode 100644 resources/[aether]/aether-character/server/main.lua create mode 100644 resources/[aether]/aether-core/client/main.lua create mode 100644 resources/[aether]/aether-core/client/nui.lua create mode 100644 resources/[aether]/aether-core/fxmanifest.lua create mode 100644 resources/[aether]/aether-core/server/database.lua create mode 100644 resources/[aether]/aether-core/server/main.lua create mode 100644 resources/[aether]/aether-core/server/player.lua create mode 100644 resources/[aether]/aether-core/shared/config.lua create mode 100644 resources/[aether]/aether-core/shared/utils.lua create mode 100644 resources/[aether]/aether-death/client/main.lua create mode 100644 resources/[aether]/aether-death/fxmanifest.lua create mode 100644 resources/[aether]/aether-death/html/index.html create mode 100644 resources/[aether]/aether-death/server/main.lua create mode 100644 resources/[aether]/aether-death/shared/config.lua create mode 100644 resources/[aether]/aether-documents/client/main.lua create mode 100644 resources/[aether]/aether-documents/fxmanifest.lua create mode 100644 resources/[aether]/aether-documents/html/index.html create mode 100644 resources/[aether]/aether-documents/server/main.lua create mode 100644 resources/[aether]/aether-documents/shared/config.lua create mode 100644 resources/[aether]/aether-garage/client/main.lua create mode 100644 resources/[aether]/aether-garage/fxmanifest.lua create mode 100644 resources/[aether]/aether-garage/html/index.html create mode 100644 resources/[aether]/aether-garage/server/main.lua create mode 100644 resources/[aether]/aether-garage/shared/garagen.lua create mode 100644 resources/[aether]/aether-hud/client/fahrzeug.lua create mode 100644 resources/[aether]/aether-hud/client/main.lua create mode 100644 resources/[aether]/aether-hud/fxmanifest.lua create mode 100644 resources/[aether]/aether-hud/html/index.html create mode 100644 resources/[aether]/aether-inventory/client/drops.lua create mode 100644 resources/[aether]/aether-inventory/client/main.lua create mode 100644 resources/[aether]/aether-inventory/fxmanifest.lua create mode 100644 resources/[aether]/aether-inventory/html/index.html create mode 100644 resources/[aether]/aether-inventory/server/inventar.lua create mode 100644 resources/[aether]/aether-inventory/server/main.lua create mode 100644 resources/[aether]/aether-inventory/shared/items.lua create mode 100644 resources/[aether]/aether-jobs/client/main.lua create mode 100644 resources/[aether]/aether-jobs/fxmanifest.lua create mode 100644 resources/[aether]/aether-jobs/html/index.html create mode 100644 resources/[aether]/aether-jobs/server/main.lua create mode 100644 resources/[aether]/aether-jobs/shared/jobs.lua create mode 100644 resources/[aether]/aether-loading/fxmanifest.lua create mode 100644 resources/[aether]/aether-loading/html/index.html create mode 100644 resources/[aether]/aether-medical/client/main.lua create mode 100644 resources/[aether]/aether-medical/fxmanifest.lua create mode 100644 resources/[aether]/aether-medical/html/css/app.css create mode 100644 resources/[aether]/aether-medical/html/index.html create mode 100644 resources/[aether]/aether-medical/html/js/app.js create mode 100644 resources/[aether]/aether-medical/html/js/bausteine.js create mode 100644 resources/[aether]/aether-medical/html/js/module/behandlung.js create mode 100644 resources/[aether]/aether-medical/html/js/module/bericht.js create mode 100644 resources/[aether]/aether-medical/html/js/module/blutbank.js create mode 100644 resources/[aether]/aether-medical/html/js/module/dashboard.js create mode 100644 resources/[aether]/aether-medical/html/js/module/einsatz.js create mode 100644 resources/[aether]/aether-medical/html/js/module/fahrzeug.js create mode 100644 resources/[aether]/aether-medical/html/js/module/intensiv.js create mode 100644 resources/[aether]/aether-medical/html/js/module/kommunikation.js create mode 100644 resources/[aether]/aether-medical/html/js/module/labor.js create mode 100644 resources/[aether]/aether-medical/html/js/module/mdt.js create mode 100644 resources/[aether]/aether-medical/html/js/module/medikament.js create mode 100644 resources/[aether]/aether-medical/html/js/module/operation.js create mode 100644 resources/[aether]/aether-medical/html/js/module/patienten.js create mode 100644 resources/[aether]/aether-medical/html/js/module/station.js create mode 100644 resources/[aether]/aether-medical/html/js/module/vorlage.js create mode 100644 resources/[aether]/aether-medical/server/dienst.lua create mode 100644 resources/[aether]/aether-medical/server/hinweise.lua create mode 100644 resources/[aether]/aether-medical/server/kern.lua create mode 100644 resources/[aether]/aether-medical/server/main.lua create mode 100644 resources/[aether]/aether-medical/server/module/behandlung.lua create mode 100644 resources/[aether]/aether-medical/server/module/bericht.lua create mode 100644 resources/[aether]/aether-medical/server/module/blutbank.lua create mode 100644 resources/[aether]/aether-medical/server/module/dashboard.lua create mode 100644 resources/[aether]/aether-medical/server/module/einsatz.lua create mode 100644 resources/[aether]/aether-medical/server/module/fahrzeug.lua create mode 100644 resources/[aether]/aether-medical/server/module/intensiv.lua create mode 100644 resources/[aether]/aether-medical/server/module/kommunikation.lua create mode 100644 resources/[aether]/aether-medical/server/module/labor.lua create mode 100644 resources/[aether]/aether-medical/server/module/mdt.lua create mode 100644 resources/[aether]/aether-medical/server/module/medikament.lua create mode 100644 resources/[aether]/aether-medical/server/module/operation.lua create mode 100644 resources/[aether]/aether-medical/server/module/patienten.lua create mode 100644 resources/[aether]/aether-medical/server/module/station.lua create mode 100644 resources/[aether]/aether-medical/server/module/vorlage.lua create mode 100644 resources/[aether]/aether-medical/server/protokoll.lua create mode 100644 resources/[aether]/aether-medical/server/rechte.lua create mode 100644 resources/[aether]/aether-medical/shared/config.lua create mode 100644 resources/[aether]/aether-medical/shared/rollen.lua create mode 100644 resources/[aether]/aether-phone/client/main.lua create mode 100644 resources/[aether]/aether-phone/fxmanifest.lua create mode 100644 resources/[aether]/aether-phone/html/index.html create mode 100644 resources/[aether]/aether-phone/server/main.lua create mode 100644 resources/[aether]/aether-phone/shared/config.lua create mode 100644 resources/[aether]/aether-police/client/main.lua create mode 100644 resources/[aether]/aether-police/fxmanifest.lua create mode 100644 resources/[aether]/aether-police/html/css/app.css create mode 100644 resources/[aether]/aether-police/html/index.html create mode 100644 resources/[aether]/aether-police/html/js/app.js create mode 100644 resources/[aether]/aether-police/html/js/module/admin.js create mode 100644 resources/[aether]/aether-police/html/js/module/anzeige.js create mode 100644 resources/[aether]/aether-police/html/js/module/asservat.js create mode 100644 resources/[aether]/aether-police/html/js/module/bericht.js create mode 100644 resources/[aether]/aether-police/html/js/module/beweis.js create mode 100644 resources/[aether]/aether-police/html/js/module/dashboard.js create mode 100644 resources/[aether]/aether-police/html/js/module/einsatz.js create mode 100644 resources/[aether]/aether-police/html/js/module/fahndung.js create mode 100644 resources/[aether]/aether-police/html/js/module/fahrzeug.js create mode 100644 resources/[aether]/aether-police/html/js/module/gewahrsam.js create mode 100644 resources/[aether]/aether-police/html/js/module/kommunikation.js create mode 100644 resources/[aether]/aether-police/html/js/module/mdt.js create mode 100644 resources/[aether]/aether-police/html/js/module/personal.js create mode 100644 resources/[aether]/aether-police/html/js/module/statistik.js create mode 100644 resources/[aether]/aether-police/server/dienst.lua create mode 100644 resources/[aether]/aether-police/server/hinweise.lua create mode 100644 resources/[aether]/aether-police/server/kern.lua create mode 100644 resources/[aether]/aether-police/server/main.lua create mode 100644 resources/[aether]/aether-police/server/module/admin.lua create mode 100644 resources/[aether]/aether-police/server/module/anzeige.lua create mode 100644 resources/[aether]/aether-police/server/module/asservat.lua create mode 100644 resources/[aether]/aether-police/server/module/bericht.lua create mode 100644 resources/[aether]/aether-police/server/module/beweis.lua create mode 100644 resources/[aether]/aether-police/server/module/dashboard.lua create mode 100644 resources/[aether]/aether-police/server/module/einsatz.lua create mode 100644 resources/[aether]/aether-police/server/module/fahndung.lua create mode 100644 resources/[aether]/aether-police/server/module/fahrzeug.lua create mode 100644 resources/[aether]/aether-police/server/module/gewahrsam.lua create mode 100644 resources/[aether]/aether-police/server/module/kommunikation.lua create mode 100644 resources/[aether]/aether-police/server/module/mdt.lua create mode 100644 resources/[aether]/aether-police/server/module/personal.lua create mode 100644 resources/[aether]/aether-police/server/module/statistik.lua create mode 100644 resources/[aether]/aether-police/server/protokoll.lua create mode 100644 resources/[aether]/aether-police/server/rechte.lua create mode 100644 resources/[aether]/aether-police/shared/config.lua create mode 100644 resources/[aether]/aether-police/shared/rollen.lua create mode 100644 resources/[aether]/aether-shop/client/main.lua create mode 100644 resources/[aether]/aether-shop/fxmanifest.lua create mode 100644 resources/[aether]/aether-shop/html/index.html create mode 100644 resources/[aether]/aether-shop/server/main.lua create mode 100644 resources/[aether]/aether-shop/shared/shops.lua create mode 100644 server.cfg create mode 100644 sql/aether.sql create mode 100644 sql/aether_admin.sql create mode 100644 sql/aether_medical.sql create mode 100644 sql/aether_police.sql create mode 100644 sql/updates/001_fahrzeug_position.sql create mode 100644 untitled.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..800376a --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# ===================================================================== +# Aether — Datenbank-Zugangsdaten für docker-compose +# Kopieren nach `.env` und anpassen. Die .env NICHT ins Git committen. +# ===================================================================== + +# Root-Passwort (nur der Migrator nutzt es) +DB_ROOT_PASSWORD=aether_root + +# Datenbank und Anwendungsbenutzer (für oxmysql) +DB_NAME=aether +DB_USER=aether +DB_PASSWORD=aether + +# Host-Port, unter dem die DB erreichbar ist (für den FiveM-Server) +DB_PORT=3306 + +# Port der optionalen Adminer-Oberfläche +ADMINER_PORT=8080 + +# -> Passende Zeile in server.cfg (Benutzer/Passwort/Port wie oben): +# set mysql_connection_string "mysql://aether:aether@127.0.0.1:3306/aether?charset=utf8mb4" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80dbd53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Lokale Zugangsdaten (aus .env.example erzeugt) — niemals committen +.env + +# Node-Abhängigkeiten des Media-Servers +resources/[aether]/aether-admin/media-server/node_modules/ + +# Übliches +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..3da0360 --- /dev/null +++ b/README.md @@ -0,0 +1,682 @@ +# Aether Framework + +Deutschsprachiges FiveM GTA-RP-Framework (in Entwicklung). + +Das Projekt besteht aus zwei Teilen: +1. **Lauffähiger FiveM-Server** (`server.cfg`, `resources/[aether]/`) — im Aufbau +2. **UI-Prototypen** (die restlichen Ordner) — reine HTML-Entwürfe, die modulweise + in echte Resources überführt werden + +--- + +## Server einrichten + +**Voraussetzungen:** FiveM-Server-Artifacts, MySQL/MariaDB, ein Lizenzschlüssel von +[keymaster.fivem.net](https://keymaster.fivem.net). + +1. **Datenbank anlegen** — Schema einspielen: + ```bash + mysql -u root -p < sql/aether.sql + ``` +2. **oxmysql herunterladen** (nicht in diesem Repo enthalten) von + [github.com/overextended/oxmysql](https://github.com/overextended/oxmysql/releases) + und nach `resources/[standalone]/oxmysql/` entpacken. +3. **`server.cfg` anpassen** — Lizenzschlüssel und `mysql_connection_string` eintragen. +4. **Server starten:** + ```bash + ./run.sh +exec server.cfg # Linux + FXServer.exe +exec server.cfg # Windows + ``` + +### Aufbau + +| Pfad | Inhalt | +|---|---| +| `server.cfg` | Serverkonfiguration, Startreihenfolge der Ressourcen | +| `sql/aether.sql` | Datenbankschema (Benutzer, Charaktere, Inventar, Fahrzeuge, Transaktionen) | +| `resources/[aether]/aether-core/` | **Fundament**: Spielerverwaltung, Charaktere, Geld, Zustand, NUI-Helfer | +| `resources/[aether]/aether-loading/` | Ladebildschirm (migriert) | + +### aether-core + +Das Fundament, von dem alle Module abhängen. Es übernimmt: +- **Verbindungsaufbau** mit Bannprüfung und Benutzeranlage (erkannt an der Rockstar-Lizenz) +- **Charakterverwaltung** (Multicharacter vorbereitet; legt vorerst automatisch einen + Startcharakter an, bis `aether-character` migriert ist) +- **Spawn** an der zuletzt gespeicherten Position, Ped-Modell nach Geschlecht +- **Geld** (Bargeld / Konto / Schwarzgeld) inkl. Deckungsprüfung und Transaktionsprotokoll +- **Zustand** (Leben, Hunger, Durst, Ausdauer) mit langsamem Verbrauch und Schaden bei Leere +- **Automatisches Speichern** alle 5 Minuten sowie bei Disconnect und Ressourcen-Stopp +- **NUI-Helfer**, damit Module Oberflächen einheitlich öffnen/schließen + +**Schnittstelle für andere Module (Exports):** +```lua +-- Serverseite +local spieler = exports['aether-core']:HoleSpieler(source) +exports['aether-core']:FuegeGeldHinzu(source, 'bargeld', 500, 'Lohn') +exports['aether-core']:EntferneGeld(source, 'konto', 250, 'Einkauf') + +-- Clientseite +local daten = exports['aether-core']:HoleSpielerDaten() +exports['aether-core']:OeffneNui('aether-phone', { aktion = 'oeffnen' }) +exports['aether-core']:SchliesseNui() +``` + +Konfiguration liegt zentral in `aether-core/shared/config.lua` (Spawnpunkt, Startgeld, +Verbrauchsraten, Speicherintervall, Rechtegruppen). + +--- + +## Systeme + +### 🖥️ Ladebildschirm (`resources/[aether]/aether-loading/`) ✅ migriert +Bereits als FiveM-Ressource eingebunden (`loadscreen`). Bleibt sichtbar, bis der Core den +Charakter gespawnt hat (`loadscreen_manual_shutdown`). +Zum Ansehen im Browser: `resources/[aether]/aether-loading/html/index.html`. + +**Enthaltene Elemente:** +- **Animierter Hintergrund**: Aurora-Verlauf + aufsteigende Partikel (Canvas) +- **Aether-Branding** mit Titel und Server-Statistiken (Spieler online/max, Voice, Discord) +- **Fortschrittsbalken** mit Prozentanzeige und wechselndem Status („Lade Ressource …"), + am Ende **„Bereit"-Zustand** +- **Info-Karussell**: News, Serverregeln und Spieltipps im automatischen Wechsel (klickbare Punkte) +- **Musik-Player**: sanftes Ambient-Pad per **WebAudio** erzeugt (kein externes Audio), + Play/Pause, nächster Titel, Lautstärke + +> Der Fortschritt ist hier simuliert — in FiveM von echten Lade-Events getrieben. Server-Daten +> (`server`), Karussell-Inhalte (`karussell`) und Playlist (`playlist`) sind vom UI getrennt. + +### ❤️ HUD (`resources/[aether]/aether-hud/`) ✅ migriert +Läuft als dauerhaftes Overlay im Spiel. Die Werte kommen aus dem Core und vom Fahrzeug — +es wird nur gesendet, wenn sich tatsächlich etwas geändert hat. + +**Datenquellen:** +- **Geld** aus `aether:client:geldGeaendert` (Schwarzgeld erscheint nur, wenn vorhanden) +- **Hunger/Durst** aus `aether:client:zustandGeaendert` (Core-Verbrauchsschleife) +- **Leben** aus `GetEntityHealth` (GTA zählt ab 100 → wird in 0–100 % umgerechnet) +- **Ausdauer** aus dem Sprint-Vorrat des Spielers +- **Voice** aus dem eingebauten Mumble (`NetworkIsPlayerTalking`, Reichweite → Flüstern/Normal/Rufen) +- **Fahrzeug** (`client/fahrzeug.lua`): Tempo, Tank, Motor sowie ein **Sicherheitsgurt** als + Skriptmechanik — Taste `B`, verhindert bei angelegtem Gurt das Herausschleudern + +**Export für andere Module:** +```lua +exports['aether-hud']:SetzeSichtbar(false) -- HUD ausblenden, z. B. bei offenen Menüs +``` + +> **Browser-Fallback:** Ohne FiveM blendet die Seite Kulisse und **Demo-Panel** wieder ein und +> lässt die Verbrauchssimulation laufen — weiterhin eigenständig ansehbar unter +> `resources/[aether]/aether-hud/html/index.html`. + +**Anzeigen:** +- **Statusringe**: Leben, Hunger, Durst, Ausdauer (SVG-Ringe mit Zahl) +- **Voice-Anzeige** mit Reichweite (Flüstern/Normal/Rufen) und Sprech-Indikator +- **Geld**: Bargeld, Konto und optional **Schwarzgeld** (blinkt bei Änderung) +- **Fahrzeug-HUD**: Tacho (270°-Bogen), Gang, Tankanzeige, Warnleuchten für Gurt & Motor + +**Simulation (Prototyp):** +- Hunger/Durst sinken langsam; leerer Magen/Durst zieht am Leben; Tank sinkt bei Fahrt +- Schaden geht auf das Leben; Aktionen wie Essen/Trinken/Heilen +- Fahrzeug-Modus mit Tempo-/Tank-Slider, Gurt- und Motor-Umschaltung + +> Der komplette Zustand liegt getrennt im `hud`-Objekt (Status, Geld, Fahrzeugwerte). In FiveM +> per NUI vom Client gesetzt; das Demo-Panel entfällt dort. Geld-/Voice-Konzepte passen zu +> `bank/` und dem Voice-System. + +### 💀 Tod / Respawn (`resources/[aether]/aether-death/`) ✅ migriert +Löst automatisch aus, sobald die Lebensenergie fällt. Der Spieler geht zu Boden statt zu +sterben — GTAs eigener Respawn wird unterbunden. + +**Server führt den Countdown:** Verblutungszeit (5:00) und Respawn-Sperre (45 s) laufen +serverseitig. Der Client zeigt sie nur an und kann sie nicht beschleunigen. + +**Ablauf:** +- **Rettungsdienst rufen** → löst `aether:death:notruf` aus; ein Rettungsdienst-Modul kann + daraus einen Einsatz erzeugen. Die Position wird aktuell gehalten (man kann kriechen) +- **Um Hilfe rufen** → hörbarer Schmerzlaut für Umstehende +- **Aufgeben** erst nach Ablauf der Sperre; danach Aufwachen im **nächstgelegenen Krankenhaus** + (drei Standorte) gegen **Behandlungspauschale** (erst Bargeld, dann Konto) +- **Verbluten** führt zum selben Ergebnis +- **Wiederbelebung** durch den Rettungsdienst über einen Export — mit Entfernungsprüfung + +**Exports für den Rettungsdienst:** +```lua +exports['aether-death']:Wiederbeleben(quelle, helferQuelle) -- prüft die Entfernung +exports['aether-death']:IstVerletzt(source) +exports['aether-death']:HoleVerletzte() -- für eine Einsatzkarte +``` + +**Verwundet-Zustand:** +- **Verblutungs-Countdown** (5:00) mit animierter **EKG-Linie**, deren Herzschlag mit der + Restzeit langsamer wird; pulsierende blutrote Vignette (bei < 60 s „kritisch") +- **Rettungsdienst rufen** → Notruf mit zufälliger **Eintreff-ETA**, danach Behandlung → wiederbelebt +- **Um Hilfe rufen** (Ruf an Umstehende), **Aufgeben** erst nach **Respawn-Sperre** (45 s) + +**Folgezustände:** +- **Behandelt** — rechtzeitig vom Sanitäter wiederbelebt (Leben 100 %) +- **Krankenhaus** — nach Aufgeben oder Verbluten; **Behandlungspauschale** als EMS-Rechnung + +> Zustand liegt getrennt im `tod`-Objekt (Phase, Countdowns, EMS-Status). Der Notruf koppelt +> konzeptionell an den **Dispatch/EMS-Teil** von `faction/`, die Behandlungskosten an dessen +> **EMS-Finanzen**. In FiveM per NUI von echten Lebens-Events getrieben. + +### 🎯 Radial-Menü (`radial/`) +Web-Prototyp des **kreisförmigen Interaktionsmenüs** — reines HTML/CSS/JS. +Zum Ansehen `radial/index.html` im Browser öffnen. **Taste `G`** öffnet/schließt das Rad, `Esc` schließt. + +**Funktionen:** +- **SVG-Kuchenrad** mit farbigen Kategorie-Segmenten, Hover-Highlight und zentraler **Nabe**, + die das überfahrene Segment anzeigt +- **Untermenüs** (▸): Interaktion (Hände hoch, Ausweis zeigen …), Fahrzeug (Ab-/Aufschließen, + Motor, Kofferraum …), Emotes, Job, Notdienst — **Nabe = Zurück** +- **Direktaktionen**: Inventar, Handy (öffnen) +- Auswahl bestätigt per **Toast** und schließt das Rad; abdunkelnder Hintergrund bei geöffnetem Menü + +> Menüstruktur liegt getrennt in `menue` (Kategorien mit `kinder`/`aktion`), Rad-Geometrie +> parametrisch (Segmentzahl beliebig). Aktionen sind kosmetisch — in FiveM NUI-Callbacks, die +> auf `inventory/`, `phone/`, `documents/`, `jobs/` und den Dispatch von `faction/` verweisen. + +### ⛽ Aether Fuel — Tankstelle (`fuel/`) +Web-Prototyp der **Zapfsäulen-Oberfläche** — reines HTML/CSS/JS. +Zum Ansehen `fuel/index.html` im Browser öffnen. Ein **Demo-Panel** leert den Tank / füllt Geld auf. + +**Funktionen:** +- **Digitale Zähler** (Liter + Preis) und **Tankanzeige** (E → F) mit Live-Zapf-Animation +- **Fahrzeugkarte** (Modell, Kennzeichen, Tankgröße) und **Spritsorten** mit Literpreis + (Benzin E10, Super Plus, Diesel) +- **Drei Tankmodi**: Volltanken, nach **Litern**, nach **Betrag (€)** — Zielmenge automatisch durch + Tankgröße **und** Guthaben begrenzt +- **Zahlung** per Bargeld oder Karte (Deckungsprüfung), **Stopp** mitten im Vorgang möglich +- **Kassenbeleg** nach dem Tanken; Geld und Füllstand werden aktualisiert + +> Zustand getrennt: `spieler` (Geld), `fahrzeug` (Tank in Litern), `spritSorten` (Preise). Der +> Tank-Füllstand passt konzeptionell zum Tank-Wert im **Fahrzeug-HUD** (`hud/`); Geld später an +> `bank/` koppeln. + +### 🕹️ Minispiele (`minigames/`) +Web-Prototyp einer **Skill-Check-Sammlung** — reines HTML/CSS/JS. +Zum Ansehen `minigames/index.html` im Browser öffnen. Übersicht + Nav wie beim Casino. + +**Enthaltene Minispiele (je mit Erfolg/Fehlschlag-Ausgang):** +- **🎯 Skill-Check** — rotierenden Zeiger in der grünen Zone stoppen; mehrere Runden, wird + schneller und enger (Leertaste/Klick) +- **🔓 Lockpick** — Marker je Pin in der Zone treffen; 4 Pins, 3 Versuche, Zone schrumpft +- **🧠 Memory** — wachsende Feld-Sequenz merken und fehlerfrei wiederholen (Simon-artig) +- **🔢 Code knacken** — 4-stelligen Code per Deduktion (🟩 richtige Stelle / 🟨 richtige Ziffer) + gegen Zeit und begrenzte Versuche erraten + +> Gemeinsame Shell mit Timer-Verwaltung (`stopAlle`) und Ergebnis-Banner (`spielEnde`). In FiveM +> per NUI ausgelöst und **`success`/`fail` zurückgebend** — nutzbar für Fahrzeug-Lockpick, +> Türen/ATM-Hacks, Reparaturen u. a. quer durch die anderen Systeme. + +### 📱 Handy (`resources/[aether]/aether-phone/`) ✅ teilweise migriert +Öffnen mit **`F1`** — setzt das **Handy-Item** voraus. Ohne SIM-Karte gibt es kein Netz +(keine Anrufe, keine Nachrichten), bei leerem Akku geht es gar nicht erst an. + +**Server-gestützt (echt):** +- **Telefonnummer** je Charakter (`555-XXXX`, automatisch vergeben und eindeutig) +- **Kontakte** (anlegen, bearbeiten, löschen) +- **Nachrichten** zwischen Spielern — werden zugestellt, gespeichert und auch bei + geschlossenem Handy als Hinweis gemeldet +- **Anrufe** zwischen Spielern mit vollem Ablauf: klingeln, annehmen, auflegen, besetzt, + nicht erreichbar; verpasste Anrufe landen im Verlauf. Notrufe (110/112) lösen ein Event + für die spätere Leitstelle aus +- **Bank**-Kachel zeigt echten Kontostand und IBAN +- **Sicherheit:** PIN und Face-ID liegen in der Datenbank, die **PIN verlässt den Server nie** +- **Akku** läuft langsam leer (1 % alle 3 Minuten) und lässt sich mit einem **Akku-Item** tauschen +- Einstellungen (Theme, Wallpaper, Dock) werden gespeichert + +> **Noch lokal (bewusst):** Wetter, Rechner, Uhr/Wecker, Notizen, Musik, Kamera/Galerie, Birdy, +> InstaPic, Staat, Dispatch, Gelbe Seiten und Darkchat laufen weiterhin ohne Server. Jede +> dieser Apps wäre ein eigenes Feature — sie funktionieren, halten ihre Daten aber nur +> innerhalb einer Sitzung. + +**Export für andere Module:** +```lua +exports['aether-phone']:HoleNummer(source) +exports['aether-phone']:SendeNachricht('555-0182', 'EMS', 'Deine Rechnung: 1.500 €') +``` + +**Enthaltene Apps & Funktionen:** +- Telefon (Wähler, Verlauf, Kontakte, animierter Anruf) +- Nachrichten (Chats, Senden) +- Bank (Kontostand, Überweisen, Transaktionen) +- Staat (Staatskasse mit Graph, Einnahmen/Ausgaben, Projekt-Abstimmungen) +- Dispatch (Notfall an die Leitstelle melden, vordefinierte Gründe) +- Birdy (Social Media), InstaPic/Galerie, Kamera +- Uhr (Live, Wecker stellen/löschen), Notizen (bearbeiten/löschen) +- Wetter, Rechner, Garage, Gelbe Seiten, Musik, Darkchat (freischaltbar) +- Einstellungen (Theme, Wallpaper, anpassbares Dock, Sicherheit) + +**RP-Mechaniken (als Prototyp angelegt):** +- **Ersteinrichtung** beim Erststart: Code festlegen + Face-ID einrichten +- **Entsperren** per Face-ID (Apple-artige Scan-Animation) oder Code +- **Hardware als Items**: Handy, SIM-Karte und Akku (leer werdend) — später ingame kombinierbar +- **Push-Benachrichtigungen** (Banner + Sperrbildschirm, wegwischbar) +- **Gesperrte Apps** per Gamemechanik freischaltbar (z. B. Darkchat) + +> Alle "Server-Daten" (Kontakte, Konten, Chats, Hardware-Zustand …) sind bewusst +> vom UI getrennt im `spielerZustand`/Datenlisten gehalten, um später an +> NUI-Callbacks und die Datenbank angebunden zu werden. + +### 🏧 Bankautomat (`resources/[aether]/aether-bank/`) ✅ migriert +Öffnet sich an **echten Geldautomaten** in der Welt (`prop_atm_01/02/03`, `prop_fleeca_atm`) — +in die Nähe gehen und **`E`** drücken. Schließen mit `Esc`. + +**Sicherheit:** +- Die **PIN verlässt den Server nie.** Sie wird ausschließlich serverseitig gegen + `aether_characters.bank_pin` geprüft; die Oberfläche kennt sie nicht. +- Jede Sitzung beginnt mit der PIN-Eingabe; Buchungen sind erst nach erfolgreicher + Anmeldung möglich (der Server merkt sich das je Spieler). +- Beträge werden auf positive Ganzzahlen und ein Maximum geprüft, Deckung prüft der Core. + +**Funktionen:** Auszahlen (Konto → Bargeld), Einzahlen (Bargeld → Konto), Überweisen per **IBAN** +(funktioniert auch an **Offline-Spieler** — dann direkt in der Datenbank), PIN ändern, Kontoauszug +und Statistik. + +> **Echte Daten statt erfundener Kurve:** Der Kontostand-Verlauf wird aus den tatsächlichen +> Buchungen abgeleitet (jede Buchung speichert den Stand danach). Bei zu wenig Historie bleibt +> die Linie flach — es wird nichts dazuerfunden. + +> **Browser-Fallback:** Ohne FiveM zeigt die Seite Beispieldaten mit Test-PIN `1234` — +> `resources/[aether]/aether-bank/html/index.html` bleibt eigenständig ansehbar. + +**Funktionen:** +- **PIN-Eingabe** (Login) und **PIN-Änderung** (aktuelle → neue → bestätigen) +- **Auszahlen** (Schnellbeträge + eigener Betrag, „Geld entnehmen"-Animation) +- **Einzahlen** und **Überweisen** (mit Bestätigung) +- **Kontoauszug** (laufender Saldo, Filter Eingang/Ausgang) +- **Statistiken**: Aktien-artiges Verlaufsdiagramm (glatte Linie + Flächenverlauf, + Hover-Fadenkreuz, Zeitraum 7T/30T/12M), Einnahmen/Ausgaben, Sparquote + +Besonderheit: Das Display ist standardmäßig durchscheinend und wird erst bei +Maus-Hover voll sichtbar (verlässt die Maus das Display, sinkt die Opazität). + +> Kontodaten (Saldo, IBAN, PIN, Transaktionen) liegen getrennt im `konto`-Objekt, +> bereit für die Anbindung an DB/Server. + +### 🎒 Inventar (`resources/[aether]/aether-inventory/`) ✅ migriert +**Vollständig server-geprüft.** Die Oberfläche entscheidet nichts mehr selbst — sie schickt nur +Wünsche („verschiebe Slot 3 auf Slot 7"), der Server prüft und antwortet mit dem neuen Zustand. +Öffnen im Spiel mit **Taste `I`**, schließen mit `Esc`. + +**Slot-Aufteilung** (alles in einer Tabelle, die Nummer bestimmt den Bereich): +Tasche `0–24` · Hotbar `100–104` · Ausrüstung `200–204` + +**Serverseitige Prüfungen:** +- Gültigkeit jeder Slotnummer, Passform der Ausrüstungsplätze, Tauschbarkeit +- **Traglast** (30 kg) bei Aufheben, Geben und Hinzufügen +- **Entfernung** beim Aufheben (3 m) und Geben (3 m) — kein Zugriff quer über die Karte +- Beim Geben wird **zuerst** geprüft, ob der Empfänger tragen kann; schlägt die Übergabe fehl, + geht das Item an den Absender zurück (nichts verschwindet) + +**Item-Registry** (`shared/items.lua`) wird von Client *und* Server geladen, damit beide +garantiert dieselben Werte verwenden. Benutzen wertet der Server aus (Essen/Trinken über +`AendereZustand` im Core, Heilung über ein Client-Event). + +**Exports für andere Module** (Shop, Jobs, Dokumente …): +```lua +exports['aether-inventory']:GebeItem(source, 'wasser', 2) +exports['aether-inventory']:ZaehleItem(source, 'dietrich') +exports['aether-inventory']:EntferneItem(source, 'bargeld', 500) +``` + +**Fallengelassene Items** liegen als sichtbare **Kartons** in der Welt und sind **dauerhaft** +gespeichert (Tabelle `aether_drops`) — sie überleben Serverneustarts: +- Beim Fallenlassen wird zufällig einer von drei Kartons gewählt + (`prop_cardbordbox_03a/04a/05a`), damit alle Drops einheitlich aussehen +- Props werden **nur lokal** je Client erzeugt und ab 30 m Entfernung wieder entfernt +- Aufheben per **`E`** am Karton (mit Hinweis über dem Objekt) oder per Drag & Drop im Inventar +- Drops älter als 48 h werden beim Serverstart aufgeräumt (`DROP_MAX_STUNDEN`) + +> **Browser-Fallback:** Ohne FiveM zeigt die Seite Beispieldaten und rechnet das Gewicht selbst — +> `resources/[aether]/aether-inventory/html/index.html` bleibt eigenständig ansehbar. + +**Funktionen:** +- **Drag & Drop** zwischen Tasche, Ausrüstung, Hotbar, Boden und Spielern +- **Gewichtssystem** (getragene kg / Limit), Einzelgewicht je Item +- **Hover-Details** (Name nach Seltenheit gefärbt, Kategorie, Beschreibung, Gewicht) +- **3D-artiger Avatar** mit Ausrüstungs-Slots (Helm, Brust, Hose, Schuhe, Schmuck) + Hotbar 1–5 +- **Fallenlassen** (auf Boden ziehen), **Geben** (auf Spieler ziehen) +- **Splitten** per Rechtsklick → Slider; Rechtsklick-Menü (Benutzen, Ausrüsten, Geben, Fallenlassen) +- **Doppelklick** = Benutzen / Ausrüsten +- **Assets & Waffen**: Handy zeigt verbaute Komponenten (SIM, Akku), Waffen zeigen + Seriennummer + Aufsätze (Basis für späteren Waffenschein) +- **Anonyme Spieler**: Namen erscheinen erst, wenn die Person bekannt ist + (im Spiel via Ausweis/Visitenkarte) — sonst „Unbekannt · ID #xxxx" + +> Item-Registry (`itemDefs`) und Orte (Tasche, Ausrüstung, Hotbar, Boden) sind +> getrennt gehalten, bereit für die Anbindung an das Server-Inventar. + +### 👛 Börse & Dokumente (`resources/[aether]/aether-documents/`) ✅ migriert +Öffnen mit **`F2`** — setzt voraus, dass du mindestens eine **Börse** (Item aus dem 24/7) +dabei hast. Die Kartendesigns des Prototyps sind unverändert übernommen. + +**Datenmodell:** Tabelle `aether_documents`. Name und Geburtsdatum kommen **live aus dem +Charakter**, nur die dokumentspezifischen Felder (Nummern, Fahrklassen, registrierte Waffen) +liegen im Dokument — so muss nichts doppelt gepflegt werden. + +**Automatisch** beim ersten Laden: Personalausweis, Gesundheitskarte, Visitenkarte. +**Behördlich** (nur über Exports ausstellbar): Führerschein, Waffenschein. + +**Anzahl Börsen = Anzahl `boerse`-Items im Inventar** — kaufst du eine zweite Börse im 24/7, +erscheint sie als weiterer Reiter (je max. 10 Dokumente). + +**Vorzeigen** an Personen in 4 m Reichweite; der Empfänger sieht die Karte als Vollbild. +Dabei werden die Daten des *Zeigenden* gerendert, nicht die eigenen. + +**Exports:** +```lua +-- Wird vom Shop (Ammu-Nation) abgefragt: +exports['aether-documents']:HatWaffenschein(source) +exports['aether-documents']:HatDokument(source, 'fs') +-- Für ein Polizei-Modul (stellt aus / entzieht): +exports['aether-documents']:StelleAus(charakterId, 'ws', { waffen = {...} }, 'LSPD') +exports['aether-documents']:Entziehe(charakterId, 'ws') +``` + +> **Damit greift die Waffenschein-Prüfung im Shop.** Ammu-Nation gibt lizenzpflichtige Waren +> jetzt nur noch an Spieler mit gültigem, nicht entzogenem Waffenschein heraus. + +**Dokumente (jeweils eigenes Design):** +- **Personalausweis** (Guilloché, Wappen, MRZ-Zeile) +- **Führerschein** (EU-Stil, Sternenkreis, Fahrklassen) +- **Waffenschein** (Behörden-Look, Stempel, Tabelle mehrerer registrierter Waffen mit Seriennummer) +- **Gesundheitskarte** (eGK mit Chip) +- **Visitenkarte** (nur Name + Telefonnummer, für Handykontakt) + +**Funktionen:** +- **Börse = ein Item**, das alle Karten enthält +- **Multi-Börse**: mehrere Börsen (im Shop kaufbar), je **max. 10 Slots**, Wechsel per Tabs +- **Verschieben** von Dokumenten zwischen Börsen +- **Vorzeigen** (präsentieren, optional gezielt an eine — anonymisierte — Person) +- **Verlieren** (Dokument aus der Börse entfernen) + +> Stammdaten liegen zentral im `person`-Objekt, Karten-Designs pro Typ in +> `kartenRenderer`, Börsen in `boersen[]` (mit `MAX_SLOTS`). Die Waffen-Seriennummern +> passen zu den Items im Inventar — Basis für spätere Waffenschein-Prüfungen. + +### 🛒 Läden (`resources/[aether]/aether-shop/`) ✅ migriert +Öffnet sich an echten Filialen in der Welt — **10 24/7-Läden** und **9 Ammu-Nations** mit +Karten-Markierungen. In die Nähe gehen und **`E`** drücken, schließen mit `Esc`. + +**Der Server rechnet den Warenkorb komplett neu durch:** +- **Preise kommen immer vom Server** — was der Client schickt, sind nur Produkt-IDs und Mengen +- Führt der Laden das Produkt überhaupt? Ist der **Bestand** da? Ist die **Menge** plausibel? +- **Waffenschein** bei lizenzpflichtigen Waren, **Deckung** über den Core, **Traglast** über das Inventar +- Steht die Entfernung zur Filiale? (kein Einkaufen aus der Ferne) +- Passt etwas nicht ins Inventar, wird der Anteil **automatisch erstattet** + +**Bestand & Nachschub:** Jede Filiale hat eigene Bestände im Speicher, die alle 15 Minuten um +10 % aufgefüllt werden. + +**Gekaufte Waren landen im Inventar** — dafür wurde die Item-Registry um alle fehlenden Waren +erweitert (Getränke, Snacks, Elektronik, Zubehör, Waffen, Munition, Aufsätze). Munition wird +paketweise verkauft (z. B. „9mm ×30" liefert 30 Stück). + +> **Browser-Fallback:** Ohne FiveM rechnet die Seite lokal mit Beispieldaten und zeigt beide +> Läden zum Umschalten — `resources/[aether]/aether-shop/html/index.html`. + +**Enthaltene Läden (oben umschaltbar, Akzentfarbe passt sich an):** +- **LS 24/7** — Getränke, Snacks, Gesundheit, Werkzeug, Elektronik (Handy, SIM, Akku), Zubehör (Börse, Rucksack) +- **Ammu-Nation** — Waffen, Munition, Aufsätze, Schutz (mit „Waffenschein nötig"-Badge) + +**Funktionen:** +- Kategorien-Sidebar (mit Anzahl) + Live-Suche +- Produktkarten mit Bestand, Angeboten (Rabatt), Menge-im-Korb +- Warenkorb mit Mengen-Steppern, Zwischensummen, Gesamtpreis +- Zahlung per Bargeld oder Karte (Guthaben-Anzeige + Deckungsprüfung) +- Kassenbeleg nach dem Kauf; Bestand/Geld werden aktualisiert + +> Zentrale Produkt-Registry (`produkte`) + `shops[]`, die nur Produkt-IDs referenzieren — +> beliebige weitere Läden mit wenigen Zeilen anlegbar. Geld liegt in `spieler` +> (später an Bank/Item-System koppeln). + +### 🚓 Polizei & Rettungsdienst — *neu zu bauen* +Das gemeinsame Fraktions-CAD wurde entfernt. Polizei und Rettungsdienst sollen als +**zwei getrennte Module** entstehen (`aether-police`, `aether-ems`). + +Die Datenbanktabellen aus dem CAD bleiben bestehen und sind weiterverwendbar: +`aether_records` (Personenakten), `aether_wanted` (Fahndungen), `aether_jail` (Haft), +`aether_treasury` (Staatskasse). + +Offene Anschlusspunkte, die auf ein solches Modul warten: +- **Notruf-Events** `aether:phone:notruf` (110/112 aus dem Handy) und `aether:death:notruf` + (Verletzt-Screen) werden ausgelöst, aber derzeit von niemandem verarbeitet +- **Waffenschein**: `exports['aether-documents']:StelleAus(charakterId, 'ws', …)` — solange + niemand ausstellt, bleiben Waffen bei Ammu-Nation gesperrt +- **Kennzeichenabfrage**: `exports['aether-garage']:HoleHalter(kennzeichen)` +- **Wiederbelebung**: `exports['aether-death']:Wiederbeleben(quelle, helferQuelle)` + +### 🧑 Charakterauswahl (`resources/[aether]/aether-character/`) ✅ migriert +Vollständig an den Server angebunden: Die Liste kommt aus der Datenbank, Erstellen und Löschen +laufen über geprüfte NUI-Callbacks, und die Auswahl lässt den Core den Charakter spawnen. +Im Browser weiterhin ansehbar (zeigt dann Beispieldaten): +`resources/[aether]/aether-character/html/index.html`. + +**Serverseitige Prüfungen:** Namen (nur Buchstaben, 2–32 Zeichen), Geburtsdatum (TT.MM.JJJJ, +Jahr 1900–2010), Slotgrenze aus `Config.MaxCharaktere` und Eigentumsprüfung bei Spielen/Löschen. + +**3D-Vorschau:** `client/vorschau.lua` spawnt einen **lokalen Ped** als lebende Vorschau und +richtet eine Kamera darauf; die NUI liegt mit einem **transparenten Bereich** darüber. Jede +Änderung an Slidern oder Paletten wird sofort auf den Ped übertragen (gebündelt, damit Slider +die Verbindung nicht fluten). Enthalten: +- Ansichten **Gesicht / Körper** (Kamerafahrt statt SVG-Wechsel) +- **Drehen** des Peds per ◀ ▶ unter der Vorschau +- **Geschlechtswechsel** erzeugt den Ped mit dem passenden Modell neu (Drehung/Ansicht bleiben) +- Auf der Auswahlseite zeigt der Ped den gerade **markierten Charakter** + +**Aussehen:** `client/aussehen.lua` überträgt die Werte des Erstellers auf den GTA-Ped +(Kopfform, Hautton, Frisur, Haar-/Augenfarbe, Bart, Augenbrauen, Gesichts-Slider, +Kleidungsfarben). Die Zuordnungstabellen sind bewusst als **Annäherung** angelegt und leicht +nachjustierbar. + +> **Browser-Fallback:** Ohne FiveM (Datei direkt öffnen) fehlt der 3D-Ped. Die Seite erkennt das +> und schaltet auf das frühere Kasten-Layout mit der stilisierten **SVG-Vorschau** zurück — das +> Modul bleibt also weiter eigenständig ansehbar. + +**Auswahl:** +- Bis zu 5 Charakter-Slots mit Portrait-Vorschau, Name, Job, Spielzeit +- Aktionen: Spielen, Löschen (kein Bearbeiten — Änderungen am Charakter erfolgen ingame) +- „+ Neuen Charakter erstellen" + +**Ersteller (live aktualisierte Vektor-Vorschau, Gesicht-/Körper-Ansicht):** +- Identität (Vor-/Nachname, Geschlecht, Geburtsdatum, Herkunft) +- Gesicht (Hautton, Augenfarbe) +- Merkmale (Slider: Gesichtsbreite, Kiefer, Nase, Lippen, Körperbau) +- Haare & Bart (Frisuren, Haarfarben, Augenbrauen, Bartstile) +- Kleidung (Oberteil/Hose/Schuhe-Farben) + +> Das Aussehen liegt als kompaktes `aussehen`-Objekt je Charakter (Slider 0–100), +> später auf FiveM-Ped-Komponenten (Heritage/Features/Components) mappbar. Die Vorschau +> ist eine stilisierte SVG-Darstellung, kein 3D-Ped. + +### 🚗 Garage (`resources/[aether]/aether-garage/`) ✅ migriert +Vier öffentliche Garagen (Legion Square, Sandy Shores, Del Perro, Paleto) mit +Karten-Markierungen — hingehen und **`E`** drücken. Nutzt die Tabelle `aether_vehicles`. + +**Regeln, die der Server durchsetzt:** +- **Standort-Regel:** Ausparken nur an der Garage, wo das Fahrzeug wirklich steht — sonst + nennt die Meldung den tatsächlichen Standort +- **Zugangs-Regel:** Es werden nur Garagen angezeigt, zu denen Zugang besteht. Haus-Garagen + fragen bei `aether-immobilien` an (noch nicht migriert → bleiben verschlossen) +- **Besitz** bei jeder Aktion, **Entfernung** beim Öffnen und Einparken +- Beschlagnahmte Fahrzeuge lassen sich nur gegen **Gebühr** auslösen + +**Fahrzeuge werden serverseitig erzeugt** und überleben dadurch Serverneustarts: +- Die Position ausgeparkter Fahrzeuge wird **alle 30 Sekunden** sowie beim Stoppen der + Ressource gesichert (Spalten `pos_x/pos_y/pos_z/heading`) +- Beim Serverstart stehen sie wieder **exakt dort, wo sie zuletzt waren** +- Lässt sich ein Fahrzeug nicht setzen (z. B. Modell entfernt), wandert es zurück in seine + Garage statt verloren zu gehen +- Eigenschaften, die nur clientseitig gesetzt werden können (Kennzeichen, Farbe, Zustand), + reicht der Server über einen **State Bag** weiter; der Client wendet sie an, sobald das + Fahrzeug bei ihm auftaucht + +**Zustand wird echt gespeichert:** Beim Einparken liest der Client Tank, Motor- und +Karosserieschaden vom Fahrzeug; der Server begrenzt die Werte und schreibt sie zurück. +Während der Fahrt wird der Zustand zusätzlich alle 30 Sekunden gemeldet, damit er auch bei +einem unerwarteten Serverstopp nicht verloren geht. + +**Kilometerstand:** GTA führt keinen Tacho — der Client zählt die gefahrene Strecke selbst +mit (Sprünge durch Teleport/Nachladen werden ignoriert), der Server addiert sie beim +Einparken auf und lässt den Stand nie sinken. + +**Neue Charaktere** bekommen ein Startfahrzeug (Blista in Legion Square, abschaltbar). + +**Exports:** +```lua +exports['aether-garage']:GebeFahrzeug(charakterId, 'sultan', 'legion', { name = 'Sultan' }) +exports['aether-garage']:Beschlagnahmen(kennzeichen) -- fürs Polizei-CAD +exports['aether-garage']:HoleHalter(kennzeichen) -- Kennzeichenabfrage +``` + +**Funktionen:** +- **Auto-Vorschau** je Fahrzeugtyp (Sport/Limousine/SUV/Pickup/Kleinwagen/Muscle/Motorrad), + parametrisches SVG in Fahrzeugfarbe +- **San-Andreas-Kennzeichen** (Stile SA/Gelb/Schwarz) — Farbe **fix**, wird nur beim Kauf festgelegt +- **Zustand** (Tank/Motor/Karosserie), Kilometerstand, Klasse +- **Standort-Regel**: Ausparken nur an der Garage, wo das Fahrzeug steht; Ansehen aller + zugänglichen Garagen erlaubt („Alle"-Übersicht), Einparken lagert ins aktuelle Standort-Garage +- **Zugangs-Regel**: sichtbar sind nur **öffentliche** Garagen und **Haus-Garagen mit Zugang** + (Besitz/berechtigt); Garagen ohne Zugang erscheinen nicht + +> `garagen` (mit `typ`/`zugang`), `standort` (physischer Standort), `zugaenglich()`. +> Später `standort` an die Spielerposition und `zugang` an Haus-/Schlüsselbesitz koppeln. +> Kennzeichen passen konzeptionell zur Kennzeichenabfrage im CAD. + +### 🏡 AetherImmos — Immobilien (`immobilien/`) +Web-Prototyp des **Immobilien-/Wohnungs-Systems** — reines HTML/CSS/JS. +Zum Ansehen `immobilien/index.html` im Browser öffnen. + +**Bereiche:** +- **Markt (AetherImmos)** — Angebote mit stilisiertem Objekt-„Foto" je Typ (Wohnung/Haus/Villa/ + Gewerbe), Filter + Suche, Detailansicht mit Ausstattung +- **Kaufmenü** (separat) — Kaufpreis + Notar-/Bearbeitungsgebühr, Zahlung per Konto/Bargeld; + beim Kauf gibt es **5 Hausschlüssel (Items)** +- **Meine Immobilien** — Betreten, **Schlüssel-Verwaltung** (Anzahl gehaltener Schlüssel, + **Nachmachen** beim Schlüsseldienst; Weitergabe erfolgt übers Inventar, da Schlüssel = Item), + **Verkauf mit anteiliger Rückzahlung** (60 % des Kaufpreises) + +> `marktplatz` (Angebote), `meine` (Besitz mit `schluessel`-Anzahl + `kaufpreis`), +> `RUECKKAUF=0.6`, `START_SCHLUESSEL=5`. Schlüssel koppeln ans Inventar-System; die Zugangslogik +> passt zur Haus-Garagen-Zugangsregel in `garage/`. + +### 👕 Aether Threads — Kleidungsladen (`clothing/`) +Web-Prototyp des **Bekleidungsladens** mit Live-Vorschau — reines HTML/CSS/JS. +Zum Ansehen `clothing/index.html` im Browser öffnen. + +**Funktionen:** +- **Live-Charaktervorschau** (SVG-Figur), Zoom **Ganzkörper / Kopf** +- Kategorien: Kopfbedeckung (Cap/Beanie/Hut/Helm), Brille, Maske (Halbmaske/Sturmhaube), + Oberteil (T-Shirt/Hemd/Hoodie/Jacke/Anzug), Hose (Jeans/Cargo/Shorts/Anzughose), + Schuhe (Sneaker/Stiefel/Sandalen), Kette (Silber/Gold) +- **Farbwahl** je Teil; bereits getragene Teile als „im Besitz" markiert (kostenlos) +- **Warenkorb** der geänderten Teile, **Zahlung** Bargeld/Karte (Deckungsprüfung), + „Kaufen & anziehen" / „Zurücksetzen" + +> `kategorien` (Stile + Preise), `farben`, `getragen` (Besitz) vs. `outfit` (Anprobe). +> Der Avatar koppelt konzeptionell an dieselben Komponenten wie der Charakter-Ersteller (`character/`). + +### 🛡️ Admin-Dashboard (`admin/`) +Web-Prototyp des **zentralen Server-Admin-Dashboards** — reines HTML/CSS/JS. +Zum Ansehen `admin/index.html` im Browser öffnen. + +**Bereiche:** +- **Dashboard** (Live-KPIs, 24h-Chart, Schnellaktionen, Ressourcen-Status, Admin-Log) +- **Weltkarte** mit Live-Standorten + **Quick-Menü** je Spieler (Verwalten/Kamera/TP/Freeze/Kick) +- **Spieler**: Tabelle + gruppiertes Aktions-Panel (Teleport, Zustand, Geben, Moderation: + Heal/Revive/Freeze/God/Mute, Geld, Item, Fahrzeug, Job, Warn/Kick/Ban) +- **Fahrzeuge**: Bild-Spawner (Autos/Bikes/Helis/Boote/Flugzeuge) + **Tuning** (Motor/Getriebe/ + Bremsen/Panzerung/Turbo/Lack), Reparieren/Herholen/Löschen +- **Live-Kameras** (WebRTC-Spectate, Einzel- & Multi-Cam-Views), **Kill-/Deathcams** + (manuell speicherbar, sonst Auto-Löschung nach 24 h) +- **Staatsprojekte** (aktivierbar nach positiver Abstimmung aus der Handy-Staat-App) +- **Immobilien**, **Fraktionen** (Gehalt/h, erstellen/löschen), **Wirtschaft** +- **Weltsteuerung** (Zeit/Wetter/Blackout/Cleanup), **Ankündigungen** +- **Bans & Warns**, **Reports/Support**, **Ressourcen**, **Audit-Log**, **Admin-Team**, **Einstellungen** + +> Die „Kamera-Feeds" sind stilisierte Simulationen (in FiveM WebRTC-Stream). Datenmodelle +> (`players`, `weltFahrzeuge`, `staatsprojekte`, `killcams`, `fraktionen` …) sind vom UI getrennt. + +### 🎖️ Fraktions-/Gang-Verwaltung (`factions/`) +Web-Prototyp der **Fraktions-/Gang-Verwaltung** aus Führungssicht — reines HTML/CSS/JS. +Zum Ansehen `factions/index.html` im Browser öffnen. Oben Fraktion + „Als Rang ansehen" wählen. + +**Funktionen:** +- **Mehrere Fraktionen** (Gangs & legaler Betrieb), Akzentfarbe passt sich an +- **Mitglieder**: Roster, Befördern/Degradieren/Kicken (rang-gated), Einladen +- **Ränge & Rechte**: Gehalt je Rang + Rechte-Matrix (Einladen/Kicken/Befördern/Kasse/Ränge/ + Territorien/Fahrzeuge/Einstellungen), Ränge anlegen/löschen; Leitung hat alle Rechte +- **Kasse**: Einzahlen/Auszahlen, **Payday** (zieht alle Gehälter), Transaktionsliste +- **Territorien** (nur Gangs): Karte mit Kontroll-% (kontrolliert/umkämpft/feindlich), + Angreifen/Verstärken/Ausbauen (Ausbau kostet Kasse, erhöht Einnahmen/h) +- **Fraktionsfahrzeuge** (gemeinsame Garage, Freigabe ab Rang), **Logbuch**, **Einstellungen** + +> Rechtesystem greift überall über `darf(recht)` (abhängig vom gewählten Rang). Datenmodell +> `fraktionen[]` (Ränge/Mitglieder/Kasse/Territorien/Fahrzeuge/Logs). Deckt die im Admin-Panel +> referenzierten Fraktionen ab. + +### 💼 Aether Jobs (`resources/[aether]/aether-jobs/`) ✅ migriert +Am **Jobcenter** (Karten-Markierung) mit **`E`** öffnen; wer im Dienst ist, erreicht die +Arbeitsansicht jederzeit mit **`J`**. Acht Jobs mit Level und XP, gespeichert in `aether_jobs`. + +**Gearbeitet wird in der Welt, nicht im Menü.** Jeder Arbeitsschritt hat einen echten Ort: +- Nach dem Antreten schließt sich das Menü und eine **Route auf der Karte** führt zum Arbeitsort +- Dort steht ein **Bodenmarker**; in Reichweite erscheint `[E] Paket laden` +- Die Handlung läuft mit **Animation und Fortschrittsbalken** ab (Angeln 12 s, Baum fällen 10 s, + Erz abbauen 12 s …), erst danach meldet der Client sie +- Eine **Aufgabenanzeige** am Bildschirmrand zeigt durchgehend Aufgabe, Ziel und Entfernung +- Jobs mit Fahrzeug (Lieferdienst, Müllabfuhr, Taxi, Trucker) bekommen beim Antreten ein + **Dienstfahrzeug** gestellt, das bei Feierabend wieder verschwindet + +**Der Server prüft alles Wesentliche:** +- **Ortsprüfung:** Der Spieler muss tatsächlich am Arbeitsort stehen, sonst wird abgelehnt +- **Zeitprüfung:** Die Handlung muss ihre vorgesehene Dauer gebraucht haben +- **Aufträge, Orte und Löhne entstehen ausschließlich serverseitig** — der Client meldet nur + „Schritt erledigt" und kann weder Ziel noch Bezahlung beeinflussen +- Nur **ein Job gleichzeitig**; Level/XP und Verdienst werden pro Job dauerhaft gespeichert +- **Levelbonus** von 5 % je Level fließt in den Lohn ein, Höchstlevel 20 + +**Exports:** +```lua +exports['aether-jobs']:HoleAktivenJob(source) -- z. B. für Job-Fahrzeuge +exports['aether-jobs']:HoleLevel(source, 'trucker') +``` + +**Funktionen:** +- **Jobcenter** mit 8 Jobs (Lieferdienst, Müllabfuhr, Taxi, Trucker, Tankwart, Angeln, + Holzfäller, Bergbau) inkl. Level/XP je Job +- **Dienst an/aus** (nur ein Job gleichzeitig), Akzentfarbe passt sich dem Job an +- **Arbeits-Loop**: mehrschrittige Aufträge mit Fortschrittsanzeige, Lohn-Vorschau, + Fang/Ausbeute-Flavor; bei Abschluss Geld + XP, ggf. Level-Up (erhöht den Lohn) +- **Schicht-Statistik** (Verdient, Aufträge, Dienstzeit-Timer), **Feierabend** +- **Statistik** über alle Jobs (Level/XP) + +> `jobs[]` (mit `schritte` + `auftrag(level)`), `fortschritt` (Level/XP je Job), `spieler` +> (Bargeld/Konto). Aktionen später an echte Ingame-Zonen/Fahrten koppelbar. + +### 🎰 Aether Casino (`casino/`) +Web-Prototyp des **Casino-UIs** mit fünf spielbaren Glücksspielen — reines HTML/CSS/JS. +Zum Ansehen `casino/index.html` im Browser öffnen. + +**Guthaben & Session:** +- **Guthaben** (Casino-Chips) getrennt vom **Bargeld**; **Aufladen/Auszahlen** zwischen beiden +- **Session-Bilanz** (Netto-Gewinn/-Verlust der aktuellen Sitzung, grün/rot) +- Wiederverwendbare **Einsatz-Steuerung** (Schnell-Chips 50/100/500/1000 + „Max") + +**Spiele (mit bewusst kalkuliertem Hausvorteil):** +- **Spielautomat** — 3 gewichtete Walzen, Auszahlungstabelle, 7️⃣7️⃣7️⃣-Jackpot ×180 (RTP ~90 %) +- **Blackjack** — gegen den Dealer (Ziehen/Stehen/Verdoppeln), echter Blackjack ×2, Dealer zieht bis 17 +- **Roulette** — einfache Chancen (×2), Dutzende (×3), Einzelzahl (×36), mehrere Einsätze gleichzeitig +- **Glücksrad** — 12 Felder, Multiplikator ×0 bis ×5 (Hausvorteil ~21 %) +- **Münzwurf** — Kopf oder Zahl, ×2 bei richtiger Wahl + +> Zustand liegt getrennt: `spieler` (Bargeld), `guthaben` (Chips), `session` (Ein/Aus). Jedes Spiel +> hält sein eigenes Datenmodell (`slotReel`/`slotPay`, `bj`, `rlt.bets`, `wSegs`) — Gewichtungen/ +> Multiplikatoren zentral definiert und leicht anpassbar. Der Spielwechsel erstattet offene Einsätze. + +## Konventionen +- Jedes System ist eine eigenständige `index.html` (self-contained, ohne externe Abhängigkeiten). +- Daten/Zustand sind bewusst vom UI getrennt gehalten — vorbereitet für die spätere + Anbindung an FiveM (NUI-Callbacks) und Datenbank. diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..74170f5 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,977 @@ + + + + + + + + +Aether — Admin Dashboard + + +
+
🛡️
Aether AdminServer-Verwaltung
+
+ +
+
+
+
+
+ + + + + diff --git a/casino/index.html b/casino/index.html new file mode 100644 index 0000000..1c25fa4 --- /dev/null +++ b/casino/index.html @@ -0,0 +1,366 @@ + + + + + + + + +Aether — Casino + + +
+
🎰
Aether CasinoLos Santos
+
+ +
+
+
+ + + + + diff --git a/clothing/index.html b/clothing/index.html new file mode 100644 index 0000000..0beb3c0 --- /dev/null +++ b/clothing/index.html @@ -0,0 +1,227 @@ + + + + + + + + +Aether Threads — Bekleidung + + +
+
+

AetherThreads

Bekleidung · Los Santos
+
+
+
+
+
+
Umkleide
Anprobieren & kaufen
+
Bargeld
+
Konto
+
+
+
+
+
+
+ + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..afaf3e0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,68 @@ +# ===================================================================== +# Aether Framework — Datenbank + Auto-Migration +# +# Startet eine MariaDB und spielt beim Hochfahren alle Schemas ein. +# Bereits angewendete Migrationen werden über `schema_migrations` +# erkannt und übersprungen — mehrfaches `up` ist gefahrlos. +# +# cp .env.example .env # Zugangsdaten anpassen +# docker compose up -d # DB starten + migrieren +# docker compose logs -f migrate # Migration mitlesen +# +# Adminer (Web-Oberfläche für die DB) optional dazu: +# docker compose --profile tools up -d +# ===================================================================== + +services: + db: + image: mariadb:11 + container_name: aether-db + restart: unless-stopped + environment: + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-aether_root} + MARIADB_DATABASE: ${DB_NAME:-aether} + MARIADB_USER: ${DB_USER:-aether} + MARIADB_PASSWORD: ${DB_PASSWORD:-aether} + ports: + - "${DB_PORT:-3306}:3306" + volumes: + - db_data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 5s + timeout: 5s + retries: 30 + + # Läuft einmal pro `up`, wartet auf die DB und spielt die Migrationen ein. + migrate: + image: mariadb:11 + container_name: aether-migrate + depends_on: + db: + condition: service_healthy + environment: + DB_HOST: db + DB_NAME: ${DB_NAME:-aether} + DB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-aether_root} + volumes: + - ./sql:/sql:ro + - ./docker/migrate.sh:/migrate.sh:ro + entrypoint: ["sh", "/migrate.sh"] + restart: "no" + + # Optional: schlanke Web-Oberfläche auf http://localhost:8080 + adminer: + image: adminer:4 + container_name: aether-adminer + restart: unless-stopped + depends_on: + db: + condition: service_healthy + ports: + - "${ADMINER_PORT:-8080}:8080" + environment: + ADMINER_DEFAULT_SERVER: db + profiles: ["tools"] + +volumes: + db_data: diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000..2038011 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,48 @@ +# Datenbank per Docker + +Startet eine MariaDB und spielt alle Schemas automatisch ein. Der FiveM- +Server läuft dabei **wie gewohnt auf dem Host** und verbindet sich über +`127.0.0.1:3306`. + +## Start + +```bash +cp .env.example .env # Zugangsdaten anpassen (mind. Passwörter ändern) +docker compose up -d # DB starten + migrieren +docker compose logs -f migrate +``` + +Beim ersten `up` wird die Datenbank angelegt und alle Schemas eingespielt. +Bei jedem weiteren `up` prüft der Migrator die Tabelle `schema_migrations` +und überspringt, was schon drin ist — mehrfaches Starten ist gefahrlos. + +## Verbindung im FiveM-Server + +In `server.cfg` (Benutzer/Passwort/Port wie in der `.env`): + +```cfg +set mysql_connection_string "mysql://aether:aether@127.0.0.1:3306/aether?charset=utf8mb4" +``` + +## Neue Migrationen hinzufügen + +- **Neues Modul-Schema:** einfach eine `.sql` nach `sql/` legen — der + Migrator nimmt sie beim nächsten `up` automatisch mit (idempotent, also + am besten mit `CREATE TABLE IF NOT EXISTS`). +- **Nachtrag/Änderung an bestehenden Tabellen:** als `sql/updates/00X_….sql` + ablegen. Diese laufen fehlertolerant (ein „Duplicate column" auf einer + frischen DB wird nur vermerkt, nicht als Fehler gewertet). + +Reihenfolge: `aether.sql` zuerst, dann die übrigen Basis-Schemas, dann +`updates/*.sql`. + +## Nützliche Befehle + +```bash +docker compose --profile tools up -d # zusätzlich Adminer auf :8080 +docker compose exec db mariadb -uroot -p aether # DB-Shell +docker compose down # stoppen (Daten bleiben) +docker compose down -v # stoppen + Daten löschen (frisch) +``` + +Nach `down -v` ist die DB leer — das nächste `up` migriert komplett neu. diff --git a/docker/migrate.sh b/docker/migrate.sh new file mode 100644 index 0000000..0be38e3 --- /dev/null +++ b/docker/migrate.sh @@ -0,0 +1,88 @@ +#!/bin/sh +# ===================================================================== +# Aether — Datenbank-Migrator +# +# Spielt die Schemas aus /sql in fester Reihenfolge ein und merkt sich +# jede angewendete Datei in der Tabelle `schema_migrations`. Ein +# erneuter Lauf überspringt, was schon drin ist. +# +# Basis-Schemas : idempotent (CREATE TABLE IF NOT EXISTS) — Abbruch bei Fehler +# updates/*.sql : Nachträge — fehlertolerant (z. B. "Duplicate column" +# auf frischer DB ist unkritisch und wird nur vermerkt) +# ===================================================================== +set -eu + +HOST="${DB_HOST:-db}" +DB="${DB_NAME:-aether}" +SQLDIR="${SQL_DIR:-/sql}" +export MYSQL_PWD="${DB_ROOT_PASSWORD:-}" + +# Alle Client-Aufrufe laufen als root über diesen Wrapper +run() { mariadb -h "$HOST" -u root "$@"; } + +# --- Auf die Datenbank warten (depends_on sollte reichen, sicher ist sicher) --- +echo "[migrate] warte auf ${HOST} ..." +i=0 +until run -e "SELECT 1" >/dev/null 2>&1; do + i=$((i + 1)) + if [ "$i" -gt 60 ]; then echo "[migrate] DB nicht erreichbar — Abbruch."; exit 1; fi + sleep 2 +done + +# --- Datenbank + Migrationstabelle sicherstellen --- +run -e "CREATE DATABASE IF NOT EXISTS \`${DB}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +run "$DB" -e "CREATE TABLE IF NOT EXISTS schema_migrations ( + name VARCHAR(255) PRIMARY KEY, + applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +);" + +schon_angewendet() { + [ "$(run -N -s "$DB" -e "SELECT COUNT(*) FROM schema_migrations WHERE name='$1'")" != "0" ] +} +merken() { + run "$DB" -e "INSERT IGNORE INTO schema_migrations (name) VALUES ('$1')" +} + +# anwenden +anwenden() { + datei="$1"; schluessel="$2"; tolerant="${3:-0}" + if schon_angewendet "$schluessel"; then + echo "[migrate] ✓ ${schluessel} (bereits angewendet)" + return 0 + fi + echo "[migrate] → ${schluessel}" + if run "$DB" < "$datei"; then + merken "$schluessel" + echo "[migrate] ok" + elif [ "$tolerant" = "1" ]; then + echo "[migrate] ! Fehler ignoriert (Nachtrag evtl. schon enthalten) — als angewendet vermerkt" + merken "$schluessel" + else + echo "[migrate] ✗ FEHLER beim Einspielen von ${schluessel} — Abbruch." + exit 1 + fi +} + +# --- Basis-Schemas in fester Reihenfolge (aether zuerst) --- +for f in aether.sql aether_medical.sql aether_police.sql aether_admin.sql; do + [ -f "${SQLDIR}/${f}" ] && anwenden "${SQLDIR}/${f}" "$f" 0 +done + +# --- Weitere Schemas, die evtl. später hinzukommen (alphabetisch) --- +for f in "${SQLDIR}"/*.sql; do + [ -f "$f" ] || continue + base="$(basename "$f")" + case "$base" in + aether.sql|aether_medical.sql|aether_police.sql|aether_admin.sql) ;; # schon oben + *) anwenden "$f" "$base" 0 ;; + esac +done + +# --- Nachträge (fehlertolerant) --- +if [ -d "${SQLDIR}/updates" ]; then + for f in "${SQLDIR}"/updates/*.sql; do + [ -f "$f" ] && anwenden "$f" "updates/$(basename "$f")" 1 + done +fi + +echo "[migrate] fertig." diff --git a/factions/index.html b/factions/index.html new file mode 100644 index 0000000..c3fec4a --- /dev/null +++ b/factions/index.html @@ -0,0 +1,381 @@ + + + + + + + + +Aether — Fraktionsverwaltung + + +
+
FraktionVerwaltung
+
+ +
+
+
+
+ + + + + diff --git a/fuel/index.html b/fuel/index.html new file mode 100644 index 0000000..8af829d --- /dev/null +++ b/fuel/index.html @@ -0,0 +1,424 @@ + + + + + + +Aether — Tankstelle + + + + +
+ +
+ ⛽ +
Aether FuelTankstelle · Los Santos
+
+
Bargeld
–
+
Konto
–
+
+
+ +
+ +
+
+
Menge
0,00
Liter
+
Zu zahlen
0,00
Euro
+
+
Grundpreis –
+
+
⛽
+
+
E — leervoll — F
+
0 %
+
+
+
+ + +
+
+
Fahrzeug
+
+ 🚗 +
–
–
+ – +
+
+ +
+
Spritsorte
+
+
+ +
+
Menge wählen
+
+ + + +
+ +
+ +
+
Zahlung
+
+ + +
+
+ + +
Wähle Sorte und Menge, dann tanken.
+
+
+ + +
+ Demo + + +
+ + +
+
+ + + + + diff --git a/immobilien/index.html b/immobilien/index.html new file mode 100644 index 0000000..89e690d --- /dev/null +++ b/immobilien/index.html @@ -0,0 +1,296 @@ + + + + + + + + +AetherImmos — Immobilien + + +
+

AetherImmosImmobilien in Los Santos

+
+
Bargeld
Konto
+
+
+
+
+ + + + + diff --git a/minigames/index.html b/minigames/index.html new file mode 100644 index 0000000..8824474 --- /dev/null +++ b/minigames/index.html @@ -0,0 +1,404 @@ + + + + + + +Aether — Minispiele + + + + +
+
🕹️
Aether MinigamesSkill-Checks
+
Sammlung von Minispielen · in FiveM per NUI ausgelöst, geben Erfolg / Fehlschlag zurück.
+ +
+
+ + + + + diff --git a/radial/index.html b/radial/index.html new file mode 100644 index 0000000..e5486a8 --- /dev/null +++ b/radial/index.html @@ -0,0 +1,294 @@ + + + + + + +Aether — Radial-Menü + + + + + +
+
+ + +
+
+ +
+
+
+
+
🎯
+

Interaktionsmenü

+

Halte G gedrückt, um das Rad zu öffnen.

+ +
+
+
+ +
+
G Menü öffnen/schließen · Klick auf ein Segment wählt · Mitte = zurück
+ + + + + diff --git a/resources/[aether]/aether-admin/README.md b/resources/[aether]/aether-admin/README.md new file mode 100644 index 0000000..b07aee0 --- /dev/null +++ b/resources/[aether]/aether-admin/README.md @@ -0,0 +1,87 @@ +# aether-admin + +Zentrales Server-Admin-Panel für das Aether-Framework — als echtes +FiveM-Resource, mit Anbindung an `aether-core` und **echten WebRTC-Live- +Kameras** über einen separaten Media-Server. + +Im Spiel mit **F9** öffnen (Command `/adminpanel`). Zugriff hat nur, wessen +`aether_users.admin_group` mindestens `support` ist. + +## Rechte (Kopplung an aether-core) + +Die Gruppe eines Spielers steht in **`aether_users.admin_group`** — genau +dort, wo der Core sie ohnehin führt (`user` < `support` < `admin` < `owner`). +Dieses Modul liest sie und prüft **jede** Aktion serverseitig gegen die +Rechte-Matrix in `shared/config.lua`. Die Oberfläche blendet nur aus, was +ohnehin abgelehnt würde — sie ist nie der Torwächter. + +Eigene Gruppe setzen: + +```sql +UPDATE aether_users SET admin_group='owner' WHERE license='DEINE_LIZENZ'; +``` + +Oder direkt im Panel unter **Admin-Team** (ab `owner`). + +## Datenbank + +``` +mysql < sql/aether_admin.sql +``` + +Setzt auf `aether.sql` auf (aus aether-core). **Bans nutzen `aether_users` +mit** (`is_banned`/`ban_reason`/`ban_until`) — der Core sperrt darüber schon +beim Connect. Eigene Tabellen: Warns, Bann-Verlauf, Reports, Notizen, +Prüfprotokoll, Ankündigungen, Staatsprojekte, Immobilien, Fraktions-Meta, +Einstellungen. + +## Bereiche + +Dashboard · Weltkarte · Spieler (alle Eingriffe) · Fahrzeuge · Immobilien · +Fraktionen (inkl. Payday) · Wirtschaft · Staatsprojekte · **Live-Kameras +(WebRTC)** · Weltsteuerung · Ankündigungen · Bans & Warns · Reports · +Ressourcen · Audit-Log · Admin-Team · Einstellungen. + +Alles läuft über ein einziges Netz-Event (`aether-admin:aufruf`), das im +Kern rechtegeprüft an die Fachmodule verteilt. Jede eingreifende Aktion +landet im Prüfprotokoll (`aether_admin_log`). + +Spieler melden aus dem Spiel mit **`/report `** — Tickets erscheinen +im Bereich *Reports*. + +## Live-Kameras (WebRTC) + +Echtes WebRTC über einen **eigenen Media-Server (SFU)**. Der FiveM-Server +routet kein Video: er prüft Rechte, lässt sich vom Media-Server kurzlebige +Token ausstellen und weist die Ziel-Clients an, in einen Raum zu senden. +Viewer (das Panel) und Publisher (der Ziel-Client, im Hintergrund) sprechen +per WebSocket direkt mit dem Media-Server. + +Einrichtung und die **einzige ehrliche Grenze** — die NUI kann das Spielbild +nicht selbst abgreifen, die echte Videoquelle wird extern angeklemmt +(`window.__aetherCapture` / OBS-Virtual-Cam) — stehen in +[`media-server/README.md`](media-server/README.md). + +## Aufbau + +``` +shared/config.lua Rechtegruppen, Rechte-Matrix, Sektionen, Media-Config +server/ + db.lua auth.lua log.lua kern.lua Basis: DB, Rechte, Protokoll, Dispatcher + kamera.lua WebRTC-Orchestrierung (Token, Publisher-Aufträge) + module/*.lua je Sektion ein Fachmodul + main.lua Netz-Schnittstelle +client/ + main.lua Öffnen, Spielaktionen (Teleport/Heal/Spectate/…) + kamera.lua Brücke Server → NUI (Publisher steuern) +html/ Oberfläche + echtes WebRTC (js/webrtc.js) +media-server/ eigenständiger Node-SFU (nicht Teil des Resources) +``` + +## Status / Tests + +Server- und Client-Lua sowie die NUI-Skripte sind syntaktisch geprüft. Der +Media-Server (`media-server/server.js`) ist syntaktisch geprüft, aber ein +WebRTC-SFU lässt sich nur **im laufenden Betrieb** wirklich verifizieren — +siehe die Hinweise in `media-server/README.md`. Ein erster Lauf gegen einen +echten FiveM-Server mit eingespieltem Schema steht noch aus. diff --git a/resources/[aether]/aether-admin/client/kamera.lua b/resources/[aether]/aether-admin/client/kamera.lua new file mode 100644 index 0000000..74eded9 --- /dev/null +++ b/resources/[aether]/aether-admin/client/kamera.lua @@ -0,0 +1,50 @@ +-- ===================================================================== +-- aether-admin — Kamera-Bridge (Client) +-- +-- Die NUI (webrtc.js) macht das eigentliche WebRTC. Dieser Client-Code +-- ist nur die Brücke: Wenn der Server anordnet „publiziere in Raum X", +-- reicht er das an die immer geladene NUI weiter. Signaling und Medien +-- laufen direkt zwischen NUI und Media-Server — nicht über FiveM. +-- +-- Wichtig: Das Publizieren läuft im Hintergrund, auch ohne geöffnetes +-- Panel. Die NUI-Seite ist die ehrliche Stelle, an der die echte +-- Videoquelle angeklemmt wird (siehe html/js/webrtc.js). +-- ===================================================================== + +local function SendeAnNui(nachricht) + SendNuiMessage(json.encode(nachricht)) +end + +--- Der Server fordert diesen Client auf, als Publisher zu senden +RegisterNetEvent('aether-admin:kamera:publiziere', function(daten) + if type(daten) ~= 'table' or not daten.token then return end + SendeAnNui({ + aktion = 'webrtcPublish', + raum = daten.raum, + url = daten.url, + token = daten.token, + }) +end) + +--- Der Server beendet die Übertragung dieses Raums +RegisterNetEvent('aether-admin:kamera:stopp', function(raum) + SendeAnNui({ aktion = 'webrtcStop', raum = raum }) +end) + +--- Statusmeldung der NUI (Bildschirmfreigabe geklappt oder blockiert) +RegisterNUICallback('kameraStatus', function(daten, cb) + TriggerServerEvent('aether-admin:kamera:status', + tostring(daten.raum or ''), tostring(daten.typ or ''), tostring(daten.fehler or '')) + cb({ ok = true }) +end) + +--- Der Admin bekommt die Statusmeldung eines Ziel-Clients +RegisterNetEvent('aether-admin:kamera:status', function(daten) + SendeAnNui({ aktion = 'kameraStatus', quelle = daten.quelle, typ = daten.typ, fehler = daten.fehler }) +end) + +-- Beim Stoppen der Ressource alle Übertragungen kappen +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + SendeAnNui({ aktion = 'webrtcStopAll' }) +end) diff --git a/resources/[aether]/aether-admin/client/main.lua b/resources/[aether]/aether-admin/client/main.lua new file mode 100644 index 0000000..3499abc --- /dev/null +++ b/resources/[aether]/aether-admin/client/main.lua @@ -0,0 +1,282 @@ +-- ===================================================================== +-- aether-admin — Clientlogik +-- +-- Öffnet das Panel und führt die Eingriffe aus, die nur am Client +-- möglich sind (Teleport, Heilen, Spectate, Fahrzeug spawnen …). Der +-- Client entscheidet nichts selbst — er tut, was der (rechteprüfende) +-- Server ihm aufträgt. +-- ===================================================================== + +local istOffen = false +local darfPanel = false -- vom Server bestätigt + +-- --------------------------------------------------------------------- +-- Hilfen +-- --------------------------------------------------------------------- +local function SendeAnNui(nachricht) + SendNuiMessage(json.encode(nachricht)) +end + +local function Meldung(text) + if not text or text == '' then return end + BeginTextCommandThefeedPost('STRING') + AddTextComponentSubstringPlayerName(text) + EndTextCommandThefeedPostTicker(false, true) +end + +local function Schliessen() + if not istOffen then return end + istOffen = false + SetNuiFocus(false, false) + SendeAnNui({ aktion = 'schliessen' }) +end + +-- --------------------------------------------------------------------- +-- Panel öffnen / schließen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether-admin:zeige', function(daten) + darfPanel = true + istOffen = true + SetNuiFocus(true, true) + daten.aktion = 'oeffnen' + SendeAnNui(daten) +end) + +RegisterNetEvent('aether-admin:verweigert', function(text) + Meldung('~r~' .. (text or 'Kein Zugriff.')) +end) + +RegisterNetEvent('aether-admin:antwort', function(ruf, ergebnis, fehler) + SendeAnNui({ aktion = 'antwort', ruf = ruf, ergebnis = ergebnis, fehler = fehler }) +end) + +RegisterCommand('adminpanel', function() + TriggerServerEvent('aether-admin:oeffnen') +end, false) +RegisterKeyMapping('adminpanel', 'Admin-Panel öffnen', 'keyboard', 'F9') + +-- Melder aus dem Spiel: /report +RegisterCommand('report', function(source, args) + local text = table.concat(args, ' ') + if #text < 2 then + Meldung('~y~Nutzung: /report ') + return + end + TriggerServerEvent('aether-admin:report', text:sub(1, 120), text) +end, false) + +-- --------------------------------------------------------------------- +-- NUI-Callbacks +-- --------------------------------------------------------------------- +RegisterNUICallback('schliessen', function(_, cb) + Schliessen() + cb({ ok = true }) +end) + +--- Zentraler Aufruf: Oberfläche → Server → Antwort +RegisterNUICallback('aufruf', function(daten, cb) + TriggerServerEvent('aether-admin:aufruf', + tostring(daten.modul or ''), tostring(daten.aktion or ''), + daten.daten or {}, daten.ruf) + cb({ ok = true }) +end) + +--- Wegpunkt setzen (aus Weltkarte / Spieleraktionen) +RegisterNUICallback('wegpunkt', function(daten, cb) + if daten.x and daten.y then SetNewWaypoint(daten.x + 0.0, daten.y + 0.0) end + cb({ ok = true }) +end) + +-- --------------------------------------------------------------------- +-- Zustands-Eingriffe am eigenen Ped (vom Server ausgelöst) +-- --------------------------------------------------------------------- +RegisterNetEvent('aether-admin:client:heal', function() + local ped = PlayerPedId() + SetEntityHealth(ped, GetEntityMaxHealth(ped)) + ClearPedBloodDamage(ped) + ClearPedWetness(ped) + Meldung('~g~Du wurdest geheilt.') +end) + +RegisterNetEvent('aether-admin:client:revive', function() + local ped = PlayerPedId() + local erledigt = pcall(function() exports['aether-death']:Wiederbeleben() end) + if not erledigt then + local pos = GetEntityCoords(ped) + NetworkResurrectLocalPlayer(pos.x, pos.y, pos.z, GetEntityHeading(ped), true, false) + SetEntityHealth(ped, GetEntityMaxHealth(ped)) + end + Meldung('~g~Du wurdest wiederbelebt.') +end) + +RegisterNetEvent('aether-admin:client:armor', function() + SetPedArmour(PlayerPedId(), 100) + Meldung('~g~Rüstung aufgefüllt.') +end) + +RegisterNetEvent('aether-admin:client:freeze', function(an) + local ped = PlayerPedId() + FreezeEntityPosition(ped, an and true or false) + Meldung(an and '~b~Du wurdest eingefroren.' or '~g~Du wurdest aufgetaut.') +end) + +RegisterNetEvent('aether-admin:client:god', function(an) + local ped = PlayerPedId() + SetEntityInvincible(ped, an and true or false) + SetPlayerInvincible(PlayerId(), an and true or false) +end) + +RegisterNetEvent('aether-admin:client:teleport', function(pos) + if not pos or not pos.x then return end + local ped = PlayerPedId() + SetEntityCoords(ped, pos.x + 0.0, pos.y + 0.0, pos.z + 0.0, false, false, false, false) +end) + +RegisterNetEvent('aether-admin:client:hinweis', function(text) + Meldung('~y~' .. tostring(text)) +end) + +RegisterNetEvent('aether-admin:client:broadcast', function(daten) + local farbe = daten.typ == 'Warnung' and '~r~' or daten.typ == 'Neustart' and '~o~' or '~b~' + Meldung(('%s[%s] ~s~%s'):format(farbe, daten.typ or 'Info', daten.text or '')) + PlaySoundFrontend(-1, 'Menu_Accept', 'Phone_SoundSet_Default', true) +end) + +-- --------------------------------------------------------------------- +-- Fahrzeug spawnen (am eigenen Ped) +-- --------------------------------------------------------------------- +RegisterNetEvent('aether-admin:client:fahrzeug', function(modell) + local hash = GetHashKey(modell) + if not IsModelInCdimage(hash) or not IsModelAVehicle(hash) then + Meldung('~r~Unbekanntes Fahrzeugmodell: ' .. tostring(modell)) + return + end + RequestModel(hash) + local zeit = GetGameTimer() + while not HasModelLoaded(hash) and GetGameTimer() - zeit < 5000 do Wait(10) end + if not HasModelLoaded(hash) then return end + + local ped = PlayerPedId() + local pos = GetEntityCoords(ped) + local heading = GetEntityHeading(ped) + local veh = CreateVehicle(hash, pos.x + 2.5, pos.y, pos.z, heading, true, false) + SetVehicleNumberPlateText(veh, 'ADMIN') + SetPedIntoVehicle(ped, veh, -1) + SetModelAsNoLongerNeeded(hash) + Meldung('~g~Fahrzeug gespawnt: ' .. modell) +end) + +RegisterNetEvent('aether-admin:client:fahrzeugFix', function(netId) + local veh = NetworkGetEntityFromNetworkId(netId) + if veh and veh ~= 0 and DoesEntityExist(veh) then + SetVehicleFixed(veh); SetVehicleDeformationFixed(veh) + SetVehicleEngineHealth(veh, 1000.0); SetVehicleBodyHealth(veh, 1000.0) + SetVehicleDirtLevel(veh, 0.0) + Meldung('~g~Fahrzeug repariert.') + else + Meldung('~y~Fahrzeug nicht in Reichweite — bitte näher heranfahren.') + end +end) + +RegisterNetEvent('aether-admin:client:fahrzeugHer', function(netId) + local veh = NetworkGetEntityFromNetworkId(netId) + if veh and veh ~= 0 and DoesEntityExist(veh) then + local pos = GetEntityCoords(PlayerPedId()) + local heading = GetEntityHeading(PlayerPedId()) + SetEntityCoords(veh, pos.x + 3.0, pos.y, pos.z, false, false, false, false) + SetEntityHeading(veh, heading) + Meldung('~g~Fahrzeug hergeholt.') + else + Meldung('~y~Fahrzeug nicht in Reichweite.') + end +end) + +-- --------------------------------------------------------------------- +-- Beobachten (native Spectate) — zusätzlich zur WebRTC-Kamera +-- --------------------------------------------------------------------- +local beobachtet = false + +RegisterNetEvent('aether-admin:client:spectate', function(daten) + if beobachtet then StoppeSpectate(); return end + local zielSpieler = GetPlayerFromServerId(daten.ziel) + + -- Ohne Ziel in Reichweite zuerst zur letzten bekannten Position + if daten.pos then + SetEntityCoords(PlayerPedId(), daten.pos.x + 0.0, daten.pos.y + 0.0, + daten.pos.z + 30.0, false, false, false, false) + Wait(600) + end + + local zielPed = GetPlayerFromServerId(daten.ziel) ~= -1 + and GetPlayerPed(GetPlayerFromServerId(daten.ziel)) or 0 + if zielPed == 0 or not DoesEntityExist(zielPed) then + Meldung('~y~Ziel nicht erreichbar (zu weit weg?).') + return + end + + NetworkSetInSpectatorMode(true, zielPed) + beobachtet = true + Meldung('~b~Beobachtungsmodus — [Rücktaste] beendet ihn.') + + CreateThread(function() + while beobachtet do + Wait(0) + if IsControlJustReleased(0, 194) then StoppeSpectate() end -- Backspace + end + end) +end) + +function StoppeSpectate() + if not beobachtet then return end + NetworkSetInSpectatorMode(false, PlayerPedId()) + beobachtet = false + Meldung('~g~Beobachtung beendet.') +end + +-- --------------------------------------------------------------------- +-- Weltzustand anwenden (Zeit / Wetter / Blackout) +-- --------------------------------------------------------------------- +local welt = { stunde = 12, minute = 0, wetter = 'CLEAR', + zeitFix = false, wetterFix = false, blackout = false } + +RegisterNetEvent('aether-admin:client:welt', function(neu) + if type(neu) ~= 'table' then return end + welt = neu + SetWeatherTypeOverTime(welt.wetter, 15.0) + SetArtificialLightsState(welt.blackout and true or false) +end) + +CreateThread(function() + while true do + Wait(welt.zeitFix and 0 or 2000) + if welt.zeitFix then + NetworkOverrideClockTime(welt.stunde, welt.minute, 0) + end + if welt.wetterFix then + SetWeatherTypeNow(welt.wetter) + end + if welt.blackout then + SetArtificialLightsState(true) + end + end +end) + +-- --------------------------------------------------------------------- +-- Positions-Cache füttern (Weltkarte, Teleport, Spectate) +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(3000) + local pos = GetEntityCoords(PlayerPedId()) + TriggerServerEvent('aether-admin:pos', { x = pos.x, y = pos.y, z = pos.z }) + end +end) + +-- --------------------------------------------------------------------- +-- Aufräumen +-- --------------------------------------------------------------------- +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + SetNuiFocus(false, false) + if beobachtet then NetworkSetInSpectatorMode(false, PlayerPedId()) end +end) diff --git a/resources/[aether]/aether-admin/fxmanifest.lua b/resources/[aether]/aether-admin/fxmanifest.lua new file mode 100644 index 0000000..92b9359 --- /dev/null +++ b/resources/[aether]/aether-admin/fxmanifest.lua @@ -0,0 +1,62 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +name 'aether-admin' +author 'Jerrit Fritzsche' +description 'Zentrales Server-Admin-Panel mit WebRTC-Live-Kameras' +version '0.1.0' + +ui_page 'html/index.html' + +files { + 'html/index.html', + 'html/css/app.css', + 'html/js/app.js', + 'html/js/webrtc.js', + 'html/js/sections.js', +} + +shared_scripts { + '@aether-core/shared/utils.lua', + 'shared/config.lua', +} + +client_scripts { + 'client/main.lua', + 'client/kamera.lua', +} + +-- Reihenfolge: Kern und Basisdienste vor den Fachmodulen. +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/db.lua', + 'server/auth.lua', + 'server/log.lua', + 'server/kern.lua', + 'server/kamera.lua', + -- --- Fachmodule (hier neue Sektionen ergänzen) --- + 'server/module/dashboard.lua', + 'server/module/spieler.lua', + 'server/module/weltkarte.lua', + 'server/module/fahrzeuge.lua', + 'server/module/immobilien.lua', + 'server/module/fraktionen.lua', + 'server/module/wirtschaft.lua', + 'server/module/staat.lua', + 'server/module/welt.lua', + 'server/module/broadcast.lua', + 'server/module/moderation.lua', + 'server/module/reports.lua', + 'server/module/logs.lua', + 'server/module/ressourcen.lua', + 'server/module/team.lua', + 'server/module/settings.lua', + -- --- Schnittstelle zuletzt --- + 'server/main.lua', +} + +dependencies { + 'aether-core', + 'oxmysql', +} diff --git a/resources/[aether]/aether-admin/html/css/app.css b/resources/[aether]/aether-admin/html/css/app.css new file mode 100644 index 0000000..2c1aca5 --- /dev/null +++ b/resources/[aether]/aether-admin/html/css/app.css @@ -0,0 +1,99 @@ +:root{ + --bg:#070a10;--fg:#e8ecf4;--muted:#7e879b;--surface:rgba(22,26,36,0.72);--surface2:rgba(34,39,52,0.85); + --slot:#111520;--border:rgba(255,255,255,0.08);--accent:#5b8cff;--ink:#04102b;--accent-soft:rgba(91,140,255,0.16); + --green:#34d39a;--amber:#f5b23d;--red:#f0506e;--violet:#a56bff;--cyan:#22c1d4; + } + *{box-sizing:border-box;} html,body{margin:0;height:100%;} + body{min-height:100%;background:radial-gradient(1200px 700px at 15% 0%,rgba(91,140,255,0.08),transparent 55%),radial-gradient(1000px 800px at 90% 100%,rgba(52,211,153,0.05),transparent 55%),var(--bg); + font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:var(--fg); + display:flex;align-items:center;justify-content:center;padding:20px 14px;overflow-x:hidden;} + .admin{position:relative;width:min(1360px,98vw);aspect-ratio:16/10;border-radius:20px;overflow:hidden; + background:linear-gradient(160deg,rgba(18,22,30,0.92),rgba(9,11,16,0.96));backdrop-filter:blur(20px);border:1px solid var(--border); + box-shadow:0 50px 100px -30px rgba(0,0,0,0.85);display:grid;grid-template-columns:224px 1fr;grid-template-rows:auto 1fr;grid-template-areas:"brand top" "nav main";} + .brand{grid-area:brand;padding:14px 18px;border-right:1px solid var(--border);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;} + .brand .bl{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;font-size:18px;background:linear-gradient(150deg,var(--accent),#2a52c9);} + .brand .bn{font-size:15px;font-weight:800;letter-spacing:-0.02em;line-height:1.1;} .brand .bn small{display:block;font-size:10px;color:var(--muted);font-weight:500;} + .top{grid-area:top;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;padding:0 18px;} + .srv-chip{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:700;} .srv-dot{width:9px;height:9px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green);} + .top .live{display:flex;gap:16px;margin-left:8px;font-size:12px;color:var(--muted);} .top .live b{color:var(--fg);font-variant-numeric:tabular-nums;} + .top .restart{margin-left:auto;font-size:12px;color:var(--amber);font-weight:700;} + .top .me{display:flex;align-items:center;gap:9px;padding-left:14px;border-left:1px solid var(--border);} .top .me .av{width:32px;height:32px;border-radius:50%;background:linear-gradient(150deg,var(--violet),#6b3fb0);display:grid;place-items:center;font-weight:800;font-size:12px;} .top .me .mn{font-size:13px;font-weight:700;} .top .me .mr{font-size:10.5px;color:var(--amber);font-weight:700;} + .nav{grid-area:nav;border-right:1px solid var(--border);padding:10px 9px;display:flex;flex-direction:column;gap:2px;overflow-y:auto;} + .nav .grp{font-size:9px;text-transform:uppercase;letter-spacing:0.09em;color:#535c70;font-weight:800;margin:10px 12px 4px;} + .nav button{display:flex;align-items:center;gap:10px;padding:8px 11px;border-radius:9px;border:none;background:none;color:var(--muted);font-family:inherit;font-size:13px;font-weight:700;cursor:pointer;text-align:left;transition:.12s;} + .nav button .ni{font-size:15px;width:18px;text-align:center;} .nav button:hover{background:var(--surface);color:var(--fg);} .nav button.active{background:var(--accent-soft);color:var(--accent);} + .nav button .badge{margin-left:auto;background:var(--red);color:#fff;font-size:9.5px;font-weight:800;border-radius:999px;padding:1px 6px;} + .main{grid-area:main;overflow-y:auto;padding:18px 22px;} .main::-webkit-scrollbar,.nav::-webkit-scrollbar,.tblwrap::-webkit-scrollbar{width:7px;} .main::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.09);border-radius:4px;} + .phead{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;margin-bottom:14px;} .phead h2{font-size:20px;margin:0;font-weight:800;letter-spacing:-0.02em;} .phead p{margin:2px 0 0;color:var(--muted);font-size:12.5px;} + + .grid{display:grid;gap:12px;} .card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:15px;} + .kpi .l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:0.05em;font-weight:800;display:flex;align-items:center;gap:6px;} .kpi .v{font-size:26px;font-weight:800;font-variant-numeric:tabular-nums;margin-top:3px;} .kpi .d{font-size:11.5px;font-weight:600;margin-top:1px;} + .list-h{font-size:10.5px;text-transform:uppercase;letter-spacing:0.06em;color:var(--muted);font-weight:800;margin:0 0 10px;display:flex;align-items:center;gap:8px;} .list-h .r{margin-left:auto;font-weight:600;text-transform:none;letter-spacing:0;} + .btn{border:none;border-radius:10px;padding:9px 14px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit;transition:.12s;} .btn.primary{background:var(--accent);color:#fff;} .btn.primary:hover{filter:brightness(1.08);} .btn.ghost{background:var(--surface2);color:var(--fg);border:1px solid var(--border);} .btn.red{background:var(--red);color:#2a0910;} .btn.green{background:var(--green);color:#06231a;} .btn.amber{background:var(--amber);color:#3a2600;} .btn.sm{padding:6px 10px;font-size:12px;} .btn:disabled{opacity:.4;cursor:not-allowed;} .btn:active{transform:scale(0.98);} + .input,select.input,textarea.input{width:100%;background:var(--slot);border:1px solid var(--border);border-radius:9px;padding:10px 11px;color:var(--fg);font-size:13.5px;font-family:inherit;outline:none;} .input:focus{border-color:var(--accent);} textarea.input{resize:vertical;min-height:64px;} + .flabel{font-size:10.5px;color:var(--muted);font-weight:800;text-transform:uppercase;letter-spacing:0.04em;margin:11px 2px 5px;display:block;} + .searchbar{display:flex;align-items:center;gap:9px;background:var(--slot);border:1px solid var(--border);border-radius:10px;padding:9px 12px;} .searchbar input{flex:1;background:none;border:none;color:var(--fg);font-family:inherit;font-size:13.5px;outline:none;} + .seg{display:inline-flex;background:var(--slot);border-radius:9px;padding:3px;} .seg button{border:none;background:none;color:var(--muted);font-weight:700;font-size:12px;padding:6px 12px;border-radius:7px;cursor:pointer;font-family:inherit;} .seg button.active{background:var(--accent);color:#fff;} + + .pill{font-size:10px;font-weight:800;padding:3px 8px;border-radius:999px;display:inline-block;} .pill.g{background:rgba(52,211,153,0.16);color:var(--green);} .pill.a{background:rgba(245,178,61,0.16);color:var(--amber);} .pill.r{background:rgba(240,80,110,0.16);color:var(--red);} .pill.b{background:rgba(91,140,255,0.16);color:var(--accent);} .pill.v{background:rgba(165,107,255,0.16);color:var(--violet);} .pill.m{background:rgba(255,255,255,0.08);color:var(--muted);} + .tblwrap{overflow-x:auto;} table.tbl{width:100%;border-collapse:collapse;font-size:12.5px;} .tbl th{text-align:left;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:0.05em;padding:8px 9px;border-bottom:1px solid var(--border);white-space:nowrap;} .tbl td{padding:9px 9px;border-bottom:1px solid var(--border);white-space:nowrap;} .tbl tr:last-child td{border:none;} .tbl tr:hover td{background:rgba(255,255,255,0.02);} .tbl .num{text-align:right;font-variant-numeric:tabular-nums;} .tbl .av{width:26px;height:26px;border-radius:50%;display:inline-grid;place-items:center;font-weight:800;color:#fff;font-size:10px;vertical-align:middle;margin-right:7px;} + .barmini{height:7px;border-radius:4px;background:var(--slot);overflow:hidden;width:90px;display:inline-block;vertical-align:middle;} .barmini>i{display:block;height:100%;border-radius:4px;} + .flags{display:inline-flex;gap:4px;} + + .quick{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;} .quick button{justify-content:flex-start;text-align:left;} + .actiongrid .ag-h{grid-column:1/-1;font-size:9.5px;text-transform:uppercase;letter-spacing:0.07em;color:var(--muted);font-weight:800;margin:10px 2px 0;} .actiongrid .ag-h:first-child{margin-top:0;} + .vehgrid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;max-height:320px;overflow-y:auto;padding-right:4px;} + .vehcard{background:var(--slot);border:1px solid var(--border);border-radius:11px;padding:8px;cursor:pointer;font-family:inherit;color:var(--fg);transition:.12s;text-align:center;} .vehcard:hover{border-color:var(--accent);transform:translateY(-2px);} + .vehimg{aspect-ratio:16/10;border-radius:8px;overflow:hidden;background:linear-gradient(180deg,#1b2432,#0d1119);display:grid;place-items:center;} .vehimg svg{width:92%;} + .vehname{font-size:11.5px;font-weight:700;margin-top:6px;} + .veh-thumb{display:inline-block;width:46px;height:26px;vertical-align:middle;margin-right:8px;} .veh-thumb svg{width:100%;height:100%;} + .sw2{width:30px;height:30px;border-radius:8px;cursor:pointer;} + .qmenu{position:fixed;z-index:200;min-width:180px;background:rgba(16,20,28,0.98);border:1px solid var(--border);border-radius:12px;padding:5px;display:none;box-shadow:0 20px 50px -12px rgba(0,0,0,0.8);} .qmenu.show{display:block;animation:qm .12s ease;} @keyframes qm{from{opacity:0;transform:scale(.96);}to{opacity:1;}} + .qmenu .qm-h{padding:7px 10px 6px;font-size:12px;font-weight:800;border-bottom:1px solid var(--border);margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} + .qmenu button{display:flex;align-items:center;gap:9px;width:100%;text-align:left;border:none;background:none;color:var(--fg);font-family:inherit;font-size:13px;font-weight:600;padding:8px 10px;border-radius:8px;cursor:pointer;} .qmenu button:hover{background:var(--surface2);} .qmenu button.danger{color:var(--red);} + .actiongrid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:6px;} .actiongrid button{display:flex;flex-direction:column;align-items:flex-start;gap:2px;padding:10px;border-radius:10px;background:var(--slot);border:1px solid var(--border);color:var(--fg);font-family:inherit;cursor:pointer;font-weight:700;font-size:12px;transition:.12s;} .actiongrid button:hover{border-color:var(--accent);} .actiongrid button small{color:var(--muted);font-weight:500;font-size:10px;} .actiongrid button.on{border-color:var(--green);background:rgba(52,211,153,0.1);} .actiongrid button.danger:hover{border-color:var(--red);} + + .chk{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:9px;background:var(--slot);border:1px solid var(--border);margin-bottom:5px;cursor:pointer;} .chk.sel{border-color:var(--accent);background:var(--accent-soft);} .chk .av{width:26px;height:26px;border-radius:50%;display:grid;place-items:center;font-weight:800;color:#fff;font-size:10px;flex-shrink:0;} + /* Weltkarte */ + .map{position:relative;border-radius:14px;overflow:hidden;border:1px solid var(--border);height:100%; + background:linear-gradient(90deg,transparent 49.6%,rgba(255,255,255,0.04) 49.6% 50.4%,transparent 50.4%),linear-gradient(0deg,transparent 49.6%,rgba(255,255,255,0.04) 49.6% 50.4%,transparent 50.4%),repeating-linear-gradient(90deg,rgba(255,255,255,0.02) 0 1px,transparent 1px 60px),repeating-linear-gradient(0deg,rgba(255,255,255,0.02) 0 1px,transparent 1px 60px),linear-gradient(135deg,#101823,#0a0f16);} + .map .water{position:absolute;right:-6%;bottom:-8%;width:44%;height:52%;background:radial-gradient(circle at 70% 70%,rgba(40,90,140,0.5),transparent 70%);border-radius:50% 0 0 0;} + .map .distr{position:absolute;font-size:10px;font-weight:800;letter-spacing:0.1em;color:rgba(255,255,255,0.14);text-transform:uppercase;} + .mk{position:absolute;transform:translate(-50%,-50%);z-index:2;cursor:pointer;transition:left 2.8s linear,top 2.8s linear;} + .mk .dot{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;font-size:9px;font-weight:800;color:#fff;border:2px solid rgba(255,255,255,0.85);box-shadow:0 2px 6px rgba(0,0,0,0.6);transition:transform .2s;} + .mk .lbl{position:absolute;top:24px;left:50%;transform:translateX(-50%);white-space:nowrap;font-size:9px;font-weight:700;background:rgba(0,0,0,0.6);padding:1px 5px;border-radius:5px;} + .mk.dead .dot{background:var(--red)!important;animation:mkp 1.2s infinite;} @keyframes mkp{0%,100%{box-shadow:0 0 0 0 rgba(240,80,110,0.5);}50%{box-shadow:0 0 0 9px rgba(240,80,110,0);}} + /* Live-Kameras */ + .camgrid{display:grid;gap:10px;} .camtile{position:relative;aspect-ratio:16/9;border-radius:12px;overflow:hidden;border:1px solid var(--border);background:#05070b;} + .camfeed{position:absolute;inset:0;} .camfeed .scene{position:absolute;inset:0;} .camfeed .scan{position:absolute;inset:0;background:repeating-linear-gradient(0deg,rgba(0,0,0,0.14) 0 2px,transparent 2px 4px);pointer-events:none;} + .camfeed .noise{position:absolute;inset:0;opacity:0.05;background-image:radial-gradient(rgba(255,255,255,0.8) 1px,transparent 1px);background-size:3px 3px;animation:noise .3s steps(2) infinite;} @keyframes noise{to{transform:translate(1px,1px);}} + .camfeed .sweep{position:absolute;left:0;right:0;height:40%;background:linear-gradient(180deg,rgba(255,255,255,0.05),transparent);animation:sweep 4s linear infinite;} @keyframes sweep{0%{top:-40%;}100%{top:100%;}} + .cam-hud{position:absolute;inset:0;z-index:2;font-family:ui-monospace,monospace;pointer-events:none;} + .cam-hud .rec{position:absolute;top:8px;right:10px;display:flex;align-items:center;gap:6px;font-size:10px;font-weight:800;color:#ff5a6e;} .cam-hud .rec i{width:8px;height:8px;border-radius:50%;background:#ff5a6e;animation:blink 1s infinite;} @keyframes blink{50%{opacity:0.2;}} + .cam-hud .who{position:absolute;top:8px;left:10px;font-size:11px;font-weight:800;color:#fff;text-shadow:0 1px 2px #000;} + .cam-hud .foot{position:absolute;bottom:8px;left:10px;right:10px;display:flex;justify-content:space-between;gap:8px;font-size:9.5px;color:rgba(255,255,255,0.85);text-shadow:0 1px 2px #000;} + .cam-hud .cross{position:absolute;top:50%;left:50%;width:26px;height:26px;transform:translate(-50%,-50%);opacity:0.55;} .cam-hud .cross::before,.cam-hud .cross::after{content:"";position:absolute;background:rgba(255,255,255,0.7);} .cam-hud .cross::before{left:50%;top:0;bottom:0;width:1px;transform:translateX(-50%);} .cam-hud .cross::after{top:50%;left:0;right:0;height:1px;transform:translateY(-50%);} + .camtile .ctrls{position:absolute;bottom:8px;right:8px;z-index:3;display:flex;gap:5px;} .camtile .ctrls button{border:none;background:rgba(0,0,0,0.55);color:#fff;border-radius:7px;padding:4px 8px;font-family:inherit;font-size:11px;font-weight:700;cursor:pointer;} .camtile .ctrls button:hover{background:rgba(0,0,0,0.85);} + .viewlist>button{width:100%;text-align:left;margin-bottom:6px;} + /* Killcams */ + .kc{display:flex;gap:12px;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:10px;margin-bottom:8px;} + .kc .thumb{width:120px;aspect-ratio:16/9;border-radius:8px;overflow:hidden;flex-shrink:0;position:relative;border:1px solid var(--border);} .kc .thumb .scan{position:absolute;inset:0;background:repeating-linear-gradient(0deg,rgba(0,0,0,0.18) 0 2px,transparent 2px 4px);} .kc .thumb .pl{position:absolute;inset:0;display:grid;place-items:center;color:rgba(255,255,255,0.85);font-size:20px;} .kc .grow{flex:1;min-width:0;} + + .overlay{position:fixed;inset:0;z-index:40;background:rgba(4,6,10,0.72);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;padding:22px;} .overlay.show{display:flex;} + .modal{width:560px;max-width:97%;max-height:90%;overflow-y:auto;background:rgba(18,22,30,0.98);border:1px solid var(--border);border-radius:16px;padding:20px;box-shadow:0 30px 70px -18px rgba(0,0,0,0.8);} .modal.sm{width:400px;} + .modal h3{margin:0 0 3px;font-size:18px;} .modal .msub{color:var(--muted);font-size:12.5px;margin-bottom:12px;} .modal-actions{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:14px;} + .kv{display:flex;justify-content:space-between;font-size:12.5px;padding:5px 0;border-bottom:1px solid var(--border);} .kv:last-child{border:none;} .kv span:first-child{color:var(--muted);} + .toast{position:fixed;bottom:16px;left:50%;transform:translateX(-50%) translateY(16px);background:rgba(12,15,22,0.97);border:1px solid var(--border);color:var(--fg);padding:10px 18px;border-radius:11px;font-size:13px;font-weight:600;z-index:50;opacity:0;pointer-events:none;transition:.3s;} .toast.show{opacity:1;transform:translateX(-50%) translateY(0);} .toast.good{border-color:var(--green);} .toast.warn{border-color:var(--red);} + .empty{text-align:center;color:var(--muted);padding:36px 10px;} .empty .big{font-size:36px;margin-bottom:8px;} + .chart svg{width:100%;display:block;} + /* Zeilen-Layout (Moderation, Weltkarte, Einstellungen) */ + .row{display:flex;align-items:center;gap:10px;padding:9px 11px;border-radius:9px;background:var(--slot);border:1px solid var(--border);} + .row .grow,.grow{flex:1;min-width:0;} + .row .t{font-weight:700;font-size:13px;} .row .m{font-size:11.5px;color:var(--muted);margin-top:1px;} + .mono{font-family:ui-monospace,"SF Mono",Menlo,monospace;font-variant-numeric:tabular-nums;} + .swatches{display:flex;flex-wrap:wrap;gap:8px;} + + /* WebRTC-Video-Kacheln */ + .camtile video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;background:#05070b;} + .camtile .cam-hud .who{position:absolute;bottom:8px;left:10px;top:auto;} + .camgrid .empty{grid-column:1/-1;} diff --git a/resources/[aether]/aether-admin/html/index.html b/resources/[aether]/aether-admin/html/index.html new file mode 100644 index 0000000..0cc169b --- /dev/null +++ b/resources/[aether]/aether-admin/html/index.html @@ -0,0 +1,28 @@ + + + + + + +Aether Admin + + + + + + + + +
+
+ + + + + + diff --git a/resources/[aether]/aether-admin/html/js/app.js b/resources/[aether]/aether-admin/html/js/app.js new file mode 100644 index 0000000..1107012 --- /dev/null +++ b/resources/[aether]/aether-admin/html/js/app.js @@ -0,0 +1,202 @@ +/* ===================================================================== + aether-admin — Oberflächenkern + + Die Shell (Seitenleiste, Kopfleiste, Dialoge, Meldungen) und die + einzige Brücke zum Server: `App.aufruf(modul, aktion, daten)`. Jede + Sektion rendert aus echten Serverdaten — die Oberfläche hält keinen + eigenen Spielzustand und entscheidet nichts. Rechte werden serverseitig + geprüft; die UI blendet nur aus, was ohnehin abgelehnt würde. +===================================================================== */ +"use strict"; + +const IST_FIVEM = (typeof GetParentResourceName === "function"); +const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-admin"; + +const App = (() => { + const zustand = { + admin: { name: "—", gruppe: "user" }, + sektionen: [], + rechte: {}, + media: { aktiv: false }, + serverName: "Aether RP", + sektion: "dashboard", + }; + + const sektionen = {}; // [id] = renderfunktion + const offeneRufe = {}; // [ruf] = { ok, fail } + let rufZaehler = 0; + let mapTimer = null; + + const $ = (id) => document.getElementById(id); + + /* ---- Helfer -------------------------------------------------------- */ + function sicher(t) { + if (t === null || t === undefined) return ""; + return String(t).replace(/[&<>"']/g, + (z) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[z])); + } + const euro = (n) => Math.round(n || 0).toLocaleString("de-DE") + " €"; + const kompakt = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + " Mio" + : n >= 1e3 ? (n / 1e3).toFixed(1) + "k" : "" + Math.round(n || 0); + const initialen = (n) => String(n || "?").replace(/\s*\(.*\)/, "").split(" ") + .map((w) => w[0]).slice(0, 2).join(""); + function avColor(n) { + let h = 0; for (const c of String(n || "")) h = (h * 31 + c.charCodeAt(0)) % 360; + return `hsl(${h} 45% 45%)`; + } + function zeit(w) { + if (!w) return "—"; + const d = new Date(String(w).replace(" ", "T")); + if (isNaN(d)) return sicher(w); + return d.toLocaleString("de-DE", + { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); + } + function darf(schluessel) { return zustand.rechte[schluessel] === true; } + + /* ---- Server-Brücke ------------------------------------------------- */ + function anClient(pfad, daten) { + if (!IST_FIVEM) return Promise.resolve({}); + return fetch(`https://${RESSOURCE}/${pfad}`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=UTF-8" }, + body: JSON.stringify(daten || {}), + }).then((a) => a.json()).catch(() => ({})); + } + + /** + * Ruft eine Serveraktion auf und wartet auf die Antwort. + * @returns {Promise} + */ + function aufruf(modul, aktion, daten) { + return new Promise((ok, fail) => { + if (!IST_FIVEM) { fail(new Error("Vorschaumodus — keine Serververbindung.")); return; } + const ruf = ++rufZaehler; + offeneRufe[ruf] = { ok, fail }; + anClient("aufruf", { modul, aktion, daten: daten || {}, ruf }); + setTimeout(() => { + if (offeneRufe[ruf]) { + delete offeneRufe[ruf]; + fail(new Error("Zeitüberschreitung — der Server hat nicht geantwortet.")); + } + }, 15000); + }); + } + + /* ---- Meldungen & Dialoge ------------------------------------------- */ + let toastTimer = null; + function toast(text, typ) { + const e = $("toast"); + e.textContent = text; + e.className = "toast show" + (typ ? " " + typ : ""); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => e.classList.remove("show"), 2600); + } + function modal(html, klein) { + $("overlay").innerHTML = `
${html}
`; + $("overlay").classList.add("show"); + } + function closeModal() { $("overlay").classList.remove("show"); $("overlay").innerHTML = ""; } + + /* ---- Kopf & Navigation --------------------------------------------- */ + function renderTop() { + $("top").innerHTML = + `
${sicher(zustand.serverName)}
+
+
${sicher(initialen(zustand.admin.name))} +
${sicher(zustand.admin.name)}
${sicher(zustand.admin.gruppe)}
`; + } + + function renderNav() { + $("nav").innerHTML = zustand.sektionen.map((it) => it.grp + ? `
${sicher(it.grp)}
` + : ``).join(""); + $("nav").querySelectorAll("[data-s]").forEach((b) => + b.addEventListener("click", () => oeffneSektion(b.dataset.s))); + } + + function oeffneSektion(id) { + clearInterval(mapTimer); mapTimer = null; + zustand.sektion = id; + renderNav(); + const rendern = sektionen[id] || sektionen.dashboard; + $("main").innerHTML = `
⏳
Lädt …
`; + Promise.resolve() + .then(() => rendern($("main"))) + .catch((f) => { + $("main").innerHTML = `

Fehler

+

${sicher(f.message)}

`; + }); + } + + /* ---- Öffnen / Nachrichten vom Client ------------------------------- */ + function zeigePanel(daten) { + zustand.admin = daten.admin || zustand.admin; + zustand.sektionen = daten.sektionen || []; + zustand.rechte = daten.rechte || {}; + zustand.media = daten.media || { aktiv: false }; + zustand.serverName = daten.serverName || "Aether RP"; + + $("panel").hidden = false; + document.body.style.display = ""; + renderTop(); + renderNav(); + // Erste sichtbare Sektion öffnen + const erste = zustand.sektionen.find((s) => s.id); + oeffneSektion(erste ? erste.id : "dashboard"); + } + + function schliessePanel() { + $("panel").hidden = true; + document.body.style.display = "none"; + closeModal(); + clearInterval(mapTimer); mapTimer = null; + } + + window.addEventListener("message", (e) => { + const d = e.data || {}; + switch (d.aktion) { + case "oeffnen": zeigePanel(d); break; + case "schliessen": schliessePanel(); break; + case "antwort": { + const offen = offeneRufe[d.ruf]; + if (!offen) break; + delete offeneRufe[d.ruf]; + if (d.fehler) offen.fail(new Error(d.fehler)); + else offen.ok(d.ergebnis || {}); + break; + } + } + }); + + document.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + if ($("overlay").classList.contains("show")) { closeModal(); return; } + schliessen(); + }); + + function schliessen() { anClient("schliessen", {}); schliessePanel(); } + + /* ---- Öffentliche Schnittstelle ------------------------------------- */ + return { + registriere(id, fn) { sektionen[id] = fn; }, + zustand, aufruf, anClient, toast, modal, closeModal, schliessen, + sicher, euro, kompakt, initialen, avColor, zeit, darf, + renderNav, oeffneSektion, $, + setMapTimer(t) { clearInterval(mapTimer); mapTimer = t; }, + start() { + if (IST_FIVEM) document.body.style.display = "none"; + else { + // Eigenständige Browser-Vorschau: nur der Rahmen, keine Daten + $("panel").hidden = false; + zustand.sektionen = [{ grp: "Vorschau" }, { id: "dashboard", label: "Dashboard", icon: "📊" }]; + renderTop(); renderNav(); + $("main").innerHTML = `
🖥️
+ Vorschaumodus — im Spiel mit F9 öffnen. Ohne Server keine Live-Daten.
`; + } + }, + }; +})(); + +App.start(); diff --git a/resources/[aether]/aether-admin/html/js/sections.js b/resources/[aether]/aether-admin/html/js/sections.js new file mode 100644 index 0000000..e7672b2 --- /dev/null +++ b/resources/[aether]/aether-admin/html/js/sections.js @@ -0,0 +1,488 @@ +/* ===================================================================== + aether-admin — Sektionen + + Jede Sektion lädt ihre Daten frisch vom Server und ruft für Eingriffe + `App.aufruf(...)`. Kein lokaler Spielzustand — was hier steht, kommt + vom Server, und was hier gedrückt wird, prüft der Server erneut. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, euro, kompakt, initialen, avColor, zeit, darf } = App; + const A = (m, a, d) => App.aufruf(m, a, d); + const neu = () => App.oeffneSektion(App.zustand.sektion); + const toast = App.toast, modal = App.modal, closeModal = App.closeModal; + const $ = App.$; + + const pingPill = (p) => `${p} ms`; + function flags(p) { + let f = ""; + if (p.god) f += 'GOD'; + if (p.frozen) f += 'FROZEN'; + if (p.muted) f += 'MUTE'; + if (p.warns > 0) f += `${p.warns}⚠`; + if (p.istTot) f += '†'; + return f || '—'; + } + const kopf = (titel, unter, rechts) => + `

${sicher(titel)}

${unter || ""}

${rechts || ""}
`; + + /* ===== Flächen-Diagramm ============================================= */ + function areaChart(werte, W, H, farbe) { + if (!werte || werte.length < 2) werte = [0, 0]; + const min = Math.min(...werte), max = Math.max(...werte), span = (max - min) || 1; + const x = (i) => 6 + (W - 12) * (i / (werte.length - 1)); + const y = (v) => 6 + (H - 12) * (1 - (v - min) / span); + let d = "M" + x(0).toFixed(1) + " " + y(werte[0]).toFixed(1); + for (let i = 1; i < werte.length; i++) d += " L" + x(i).toFixed(1) + " " + y(werte[i]).toFixed(1); + const uid = "g" + Math.random().toString(36).slice(2, 6); + return ` + + + `; + } + + /* ===== DASHBOARD ==================================================== */ + App.registriere("dashboard", async (main) => { + const d = await A("dashboard", "laden"); + if ($("top-live")) $("top-live").innerHTML = + `Spieler ${d.online}/${d.slots} + Reports ${d.offeneReports} + Ressourcen ${d.ressourcen.laufend}/${d.ressourcen.gesamt}`; + + main.innerHTML = kopf("Dashboard", "Serverüberblick in Echtzeit") + + `
+
👥 Spieler online
${d.online}/${d.slots}
${d.auslastung}% Auslastung
+
🎫 Offene Reports
${d.offeneReports}
${d.hoherPing} mit hohem Ping
+
🔨 Aktive Bans
${d.aktiveBans}
durchgesetzt
+
🧩 Ressourcen
${d.ressourcen.laufend}/${d.ressourcen.gesamt}
laufen
+
+
+
Spieler online · Verlauf jetzt ${d.online}
${areaChart(d.verlauf, 600, 150, "#5b8cff")}
+
Schnellaktionen
+ ${darf("broadcast.senden") ? '' : ""} + ${darf("welt.wetter") ? '' : ""} + ${darf("ressourcen.neustart") ? '' : ""} +
+
+
Letzte Admin-Aktionen
+ ${(d.letzteLogs || []).map((l) => `
${sicher(l.admin_name)}: ${sicher(l.aktion)}${l.ziel ? " · " + sicher(l.ziel) : ""}${zeit(l.erstellt_am)}
`).join("") || '
Noch nichts protokolliert.
'} +
`; + + main.querySelectorAll("[data-q]").forEach((b) => b.addEventListener("click", () => { + if (b.dataset.q === "restart") return neustartModal(); + App.oeffneSektion(b.dataset.q); + })); + }); + + function neustartModal() { + modal(`

Server-Neustart ankündigen

Alle Spieler werden benachrichtigt.
+ + + `, true); + $("rs-c").addEventListener("click", closeModal); + $("rs-ok").addEventListener("click", async () => { + try { + const r = await A("ressourcen", "neustart", { minuten: +$("rs-min").value, grund: $("rs-grund").value.trim() }); + closeModal(); toast(r.hinweis || "Angekündigt", "warn"); + } catch (f) { toast(f.message, "warn"); } + }); + } + + /* ===== SPIELER ===================================================== */ + let spielerSuche = "", spielerFilter = "alle"; + App.registriere("spieler", async (main) => { + const d = await A("spieler", "liste"); + main._spieler = d.spieler; main._jobs = d.jobs; + main.innerHTML = kopf("Spieler", `${d.spieler.length} online · Verwaltung & Aktionen`, + `
+ +
`) + + ` +
IDSpielerJob / FraktionRegionPingGeldStatus
`; + + const s = $("sp-such"); + s.addEventListener("input", () => { spielerSuche = s.value.trim().toLowerCase(); zeichne(); }); + main.querySelectorAll("#sp-filter [data-f]").forEach((b) => + b.addEventListener("click", () => { spielerFilter = b.dataset.f; neu(); })); + zeichne(); + + function liste() { + return main._spieler.filter((p) => { + const q = !spielerSuche || (p.name + " " + p.region).toLowerCase().includes(spielerSuche); + const f = spielerFilter === "alle" || (p.warns > 0 || p.ping >= 120 || p.muted || p.istTot); + return q && f; + }); + } + function zeichne() { + const el = $("sp-body"); if (!el) return; + el.innerHTML = liste().map((p) => ` + #${p.id} + ${sicher(initialen(p.name))}${sicher(p.name)} + ${sicher(p.job)}${p.fraktion ? ` ${sicher(p.fraktion)}` : ""} + ${sicher(p.region)}${pingPill(p.ping)} + ${euro(p.cash + p.bank)}${flags(p)} + `).join("") + || 'Keine Spieler.'; + el.querySelectorAll("[data-p]").forEach((b) => b.addEventListener("click", () => spielerModal(+b.dataset.p))); + } + }); + + async function spielerModal(id) { + let d; + try { d = await A("spieler", "details", { id }); } catch (f) { return toast(f.message, "warn"); } + const p = d.spieler; + const knopf = (a, icon, label, sub, extra) => darf("spieler." + a) + ? `` : ""; + + modal(`

${sicher(initialen(p.name))} ${sicher(p.name)} #${p.id}

+
${sicher(p.job)}${p.fraktion ? " · " + sicher(p.fraktion) : ""} · ${sicher(p.region)} · ${d.spielzeit || 0} Min · Gruppe ${sicher(d.gruppe)}
+
+
Bargeld${euro(p.cash)}
Bank${euro(p.bank)}
Ping${p.ping} ms
+
Leben${p.leben}
Verwarnungen${p.warns}
Godmode${p.god ? "an" : "aus"}
+
+
Teleport & Ansicht
+ ${knopf("goto", "🧭", "Zu Spieler", "Teleport hin")}${knopf("bring", "📥", "Herholen", "zu mir")} + ${knopf("spectate", "👁️", "Beobachten", "native")}${darf("kamera.oeffnen") ? '' : ""} +
Zustand
+ ${knopf("heal", "❤️", "Heilen", "volle HP")}${knopf("revive", "✨", "Wiederbeleben", "revive")}${knopf("armor", "🛡️", "Rüstung", "+Armor")} + ${knopf("freeze", "🧊", "Einfrieren", p.frozen ? "aktiv" : "toggle", p.frozen ? "on" : "")} + ${knopf("god", "⭐", "Godmode", p.god ? "aktiv" : "toggle", p.god ? "on" : "")} + ${knopf("mute", "🔇", "Stumm", p.muted ? "aktiv" : "toggle", p.muted ? "on" : "")} +
Geben & Setzen
+ ${knopf("geld", "💵", "Geld", "geben/nehmen")}${knopf("item", "🎁", "Item", "geben")}${knopf("fahrzeug", "🚗", "Fahrzeug", "spawnen")}${knopf("job", "💼", "Job/Fraktion", "setzen")} +
Moderation
+ ${knopf("warn", "⚠️", "Verwarnen", "Warn")}${knopf("notiz", "📝", "Notiz", "intern")} + ${knopf("kick", "👢", "Kicken", "trennen", "danger")}${knopf("ban", "🔨", "Bannen", "sperren", "danger")} +
+ ${d.warns.length ? `
Verwarnungen
${d.warns.map((w) => `
${sicher(w.grund)}${sicher(w.admin_name)} · ${zeit(w.erstellt_am)}
`).join("")}` : ""} + `); + $("pm-close").addEventListener("click", closeModal); + $("overlay").querySelectorAll("[data-a]").forEach((b) => b.addEventListener("click", () => spielerAktion(p, b.dataset.a, d))); + } + + async function spielerAktion(p, a, d) { + const einfach = { goto: 1, bring: 1, spectate: 1, heal: 1, revive: 1, armor: 1, freeze: 1, god: 1, mute: 1 }; + if (einfach[a]) { + try { await A("spieler", a, { id: p.id }); toast("Ausgeführt", "good"); spielerModal(p.id); neu(); } + catch (f) { toast(f.message, "warn"); } + return; + } + if (a === "kamera") { closeModal(); App.oeffneSektion("cams"); if (window.Kameras) window.Kameras.starte([p.id], p.name); return; } + if (a === "geld") return eingabeModal("Geld · " + p.name, + ` + `, + () => A("spieler", "geld", { id: p.id, konto: $("x-konto").value, betrag: +$("x-betrag").value }), p); + if (a === "item") return eingabeModal("Item geben · " + p.name, + ` + `, + () => A("spieler", "item", { id: p.id, item: $("x-item").value.trim(), menge: +$("x-menge").value }), p); + if (a === "fahrzeug") return eingabeModal("Fahrzeug spawnen · " + p.name, + ``, + () => A("spieler", "fahrzeug", { id: p.id, modell: $("x-modell").value.trim() }), p); + if (a === "job") return eingabeModal("Job / Fraktion · " + p.name, + ` + `, + () => A("spieler", "job", { id: p.id, charakterId: p.charakterId, job: $("x-job").value, fraktion: $("x-frak").value.trim() }), p); + if (a === "warn") return eingabeModal("Verwarnen · " + p.name, + ``, + () => A("spieler", "warn", { id: p.id, grund: $("x-grund").value.trim() }), p); + if (a === "notiz") return eingabeModal("Interne Notiz · " + p.name, + ``, + () => A("spieler", "notiz", { id: p.id, text: $("x-text").value.trim() }), p); + if (a === "kick") return eingabeModal("Kicken · " + p.name, + ``, + () => A("spieler", "kick", { id: p.id, grund: $("x-grund").value.trim() }), null, "red"); + if (a === "ban") return eingabeModal("Bannen · " + p.name, + ` + `, + () => A("spieler", "ban", { id: p.id, grund: $("x-grund").value.trim(), dauer: $("x-dauer").value }), null, "red"); + } + + /** Kleiner Eingabedialog; `zielRefresh` = Spieler neu öffnen, sonst nur Liste */ + function eingabeModal(titel, felder, aktion, zielRefresh, farbe) { + modal(`

${sicher(titel)}

${felder} + `, true); + $("e-c").addEventListener("click", () => zielRefresh ? spielerModal(zielRefresh.id) : closeModal()); + $("e-ok").addEventListener("click", async () => { + try { await aktion(); closeModal(); toast("Ausgeführt", "good"); if (zielRefresh) spielerModal(zielRefresh.id); neu(); } + catch (f) { toast(f.message, "warn"); } + }); + } + + /* ===== WELTKARTE =================================================== */ + const frakFarbe = (f) => ({ police: "#4aa8ff", ambulance: "#f0506e" }[f] || "#34d39a"); + App.registriere("weltkarte", async (main) => { + const d = await A("weltkarte", "laden"); + main.innerHTML = kopf("Weltkarte", `${d.spieler.length} Spieler live · Marker anklicken`) + + `
+
+
Vinewood
Downtown
Vespucci
+
+
Spieler ${d.spieler.length}
`; + zeichneMarker(d.spieler); + App.setMapTimer(setInterval(async () => { + try { const r = await A("weltkarte", "laden"); zeichneMarker(r.spieler); } catch (e) {} + }, 4000)); + + function zeichneMarker(sp) { + // GTA-Weltkoordinaten grob auf 0..100% abbilden + const zx = (x) => Math.max(2, Math.min(98, (x + 4000) / 8000 * 100)); + const zy = (y) => Math.max(2, Math.min(98, (4000 - y) / 8000 * 100)); + const mk = $("marker"); if (!mk) return; + mk.innerHTML = sp.map((p) => `
${sicher(initialen(p.name))}
${sicher(p.name.split(" ")[0])}
`).join(""); + const ml = $("mapliste"); + if (ml) ml.innerHTML = sp.map((p) => `
${sicher(initialen(p.name))}
${sicher(p.name)}
${p.ping} ms
`).join(""); + mk.querySelectorAll("[data-mk]").forEach((m) => m.addEventListener("click", () => spielerModal(+m.dataset.mk))); + if (ml) ml.querySelectorAll("[data-pq]").forEach((r) => r.addEventListener("click", () => spielerModal(+r.dataset.pq))); + } + }); + + /* ===== FAHRZEUGE ================================================== */ + App.registriere("fahrzeuge", async (main) => { + const d = await A("fahrzeuge", "laden"); + main.innerHTML = kopf("Fahrzeuge", `${d.anzahl} in der Welt`, + `
${darf("fahrzeuge.aufraeumen") ? '' : ""}${darf("fahrzeuge.spawn") ? '' : ""}
`) + + `
+ ${d.fahrzeuge.map((v) => ` + + + `).join("") || ''} +
KennzeichenPositionStatus
${sicher(v.plate || "—")}${v.x.toFixed(0)}, ${v.y.toFixed(0)}${v.leer ? 'leer' : 'besetzt'}${darf("fahrzeuge.reparieren") ? `` : ""}${darf("fahrzeuge.herholen") ? ` ` : ""}${darf("fahrzeuge.loeschen") ? ` ` : ""}
Keine Fahrzeuge.
`; + main._spieler = d.spieler; + + const bind = (sel, m, a, extra) => main.querySelectorAll(sel).forEach((b) => b.addEventListener("click", async () => { + try { const r = await A("fahrzeuge", a, Object.assign({ netId: +b.dataset[extra] }, {})); toast(r.entfernt !== undefined ? r.entfernt + " entfernt" : "Erledigt", "good"); neu(); } + catch (f) { toast(f.message, "warn"); } + })); + bind("[data-fix]", "fahrzeuge", "reparieren", "fix"); + bind("[data-her]", "fahrzeuge", "herholen", "her"); + bind("[data-del]", "fahrzeuge", "loeschen", "del"); + const cl = $("fz-clean"); if (cl) cl.addEventListener("click", async () => { try { const r = await A("fahrzeuge", "aufraeumen"); toast(r.entfernt + " entfernt", "good"); neu(); } catch (f) { toast(f.message, "warn"); } }); + const sp = $("fz-spawn"); if (sp) sp.addEventListener("click", () => { + modal(`

Fahrzeug spawnen

+ + `, true); + $("fs-c").addEventListener("click", closeModal); + $("fs-ok").addEventListener("click", async () => { + try { await A("fahrzeuge", "spawn", { ziel: $("fs-ziel").value ? +$("fs-ziel").value : null, modell: $("fs-modell").value.trim() }); closeModal(); toast("Gespawnt", "good"); neu(); } + catch (f) { toast(f.message, "warn"); } + }); + }); + }); + + /* ===== WELTSTEUERUNG ============================================== */ + App.registriere("welt", async (main) => { + const wetter = ["Klar", "Bewölkt", "Regen", "Gewitter", "Nebel", "Schnee", "Sturm"]; + main.innerHTML = kopf("Weltsteuerung", "Zeit, Wetter & Umgebung") + + `
+
🕒 Zeit
+ +
+
+
🌦️ Wetter
+
${wetter.map((w) => ``).join("")}
+
+
🧹 Bereinigung
+
`; + + const setze = async (m, a, dd, txt) => { try { await A(m, a, dd); toast(txt, "good"); } catch (f) { toast(f.message, "warn"); } }; + main.querySelectorAll("[data-h]").forEach((b) => b.addEventListener("click", () => { $("w-stunde").value = b.dataset.h; setze("welt", "zeit", { stunde: +b.dataset.h }, "Zeit " + b.dataset.h + ":00"); })); + $("w-zeitfix").addEventListener("click", () => setze("welt", "zeit", { stunde: +$("w-stunde").value, fix: true }, "Zeit eingefroren")); + main.querySelectorAll("[data-w]").forEach((b) => b.addEventListener("click", () => setze("welt", "wetter", { wetter: b.dataset.w }, "Wetter: " + b.dataset.w))); + $("w-blackout").addEventListener("click", () => setze("welt", "blackout", {}, "Blackout umgeschaltet")); + main.querySelectorAll("[data-clean]").forEach((b) => b.addEventListener("click", async () => { try { const r = await A("welt", "cleanup", { art: b.dataset.clean }); toast(r.entfernt + " entfernt", "good"); } catch (f) { toast(f.message, "warn"); } })); + }); + + /* ===== ANKÜNDIGUNGEN ============================================= */ + App.registriere("broadcast", async (main) => { + const d = await A("broadcast", "laden"); + main.innerHTML = kopf("Ankündigungen", "Nachricht an alle Spieler") + + `
+ + +
+
Verlauf
${(d.verlauf || []).map((b) => `
${sicher(b.typ)} ${sicher(b.nachricht)}${zeit(b.erstellt_am)}
`).join("") || '
Noch keine.
'}
`; + $("bc-send").addEventListener("click", async () => { + const m = $("bc-msg").value.trim(); if (!m) return toast("Nachricht eingeben", "warn"); + try { await A("broadcast", "senden", { typ: $("bc-typ").value, nachricht: m }); toast("Gesendet", "good"); neu(); } + catch (f) { toast(f.message, "warn"); } + }); + }); + + /* ===== MODERATION =============================================== */ + App.registriere("moderation", async (main) => { + const d = await A("moderation", "laden"); + main.innerHTML = kopf("Bans & Verwarnungen", `${d.bans.length} Bans · ${d.warns.length} Warns`, + darf("moderation.banManuell") ? '' : "") + + `
+
🔨 Aktive Bans
${d.bans.map((b) => `
${sicher(b.name)}
${sicher(b.grund)} · ${b.bis ? "bis " + zeit(b.bis) : "permanent"} · ${sicher(b.admin_name)}
${darf("moderation.unban") ? `` : ""}
`).join("") || '
Keine Bans.
'}
+
⚠️ Verwarnungen
${d.warns.map((w) => `
${sicher(w.name)}
${sicher(w.grund)} · ${sicher(w.admin_name)} · ${zeit(w.erstellt_am)}
${darf("moderation.warnWeg") ? `` : ""}
`).join("") || '
Keine Verwarnungen.
'}
`; + main.querySelectorAll("[data-unban]").forEach((b) => b.addEventListener("click", async () => { try { await A("moderation", "unban", { id: +b.dataset.unban }); toast("Entbannt", "good"); neu(); } catch (f) { toast(f.message, "warn"); } })); + main.querySelectorAll("[data-warn]").forEach((b) => b.addEventListener("click", async () => { try { await A("moderation", "warnWeg", { id: +b.dataset.warn }); toast("Gelöscht", "good"); neu(); } catch (f) { toast(f.message, "warn"); } })); + const mb = $("mod-ban"); if (mb) mb.addEventListener("click", () => { + modal(`

Manueller Ban

`, true); + $("mb-c").addEventListener("click", closeModal); + $("mb-ok").addEventListener("click", async () => { const n = $("mb-name").value.trim(); if (!n) return toast("Name angeben", "warn"); try { await A("moderation", "banManuell", { name: n, grund: $("mb-grund").value.trim(), dauer: $("mb-dauer").value }); closeModal(); toast("Gebannt", "warn"); neu(); } catch (f) { toast(f.message, "warn"); } }); + }); + }); + + /* ===== REPORTS ================================================== */ + App.registriere("reports", async (main) => { + const d = await A("reports", "laden"); + const prioP = { 1: ["Prio 1", "r"], 2: ["Prio 2", "a"], 3: ["Prio 3", "b"] }; + const stP = { offen: ["offen", "a"], bearbeitung: ["in Bearbeitung", "b"], geschlossen: ["geschlossen", "g"] }; + main.innerHTML = kopf("Reports / Support", `${d.reports.filter((r) => r.status !== "geschlossen").length} offen · ${d.reports.length} gesamt`) + + `
${d.reports.map((r) => { + const pp = prioP[r.prioritaet] || ["?", "m"], sp = stP[r.status] || ["?", "m"]; + return `
+
${sicher(r.betreff)}
+
${pp[0]}${sp[0]}von ${sicher(r.melder_name)}${r.bearbeiter ? " · " + sicher(r.bearbeiter) : ""}
+
${r.status === "geschlossen" ? `` : `${darf("reports.claim") ? `` : ""}${darf("reports.antwort") ? `` : ""}`}
`; + }).join("") || '
🎫
Keine Reports.
'}
`; + + main.querySelectorAll("[data-claim]").forEach((b) => b.addEventListener("click", async () => { try { await A("reports", "claim", { id: +b.dataset.claim }); toast("Übernommen", "good"); neu(); } catch (f) { toast(f.message, "warn"); } })); + main.querySelectorAll("[data-st]").forEach((b) => b.addEventListener("click", async () => { const [id, st] = b.dataset.st.split(":"); try { await A("reports", "status", { id: +id, status: st }); toast("Aktualisiert", "good"); neu(); } catch (f) { toast(f.message, "warn"); } })); + main.querySelectorAll("[data-ans]").forEach((b) => b.addEventListener("click", () => { + const [id, name] = b.dataset.ans.split(":"); + modal(`

Antwort · ${sicher(name)}

`, true); + $("ra-c").addEventListener("click", closeModal); + $("ra-ok").addEventListener("click", async () => { const t = $("ra").value.trim(); if (!t) return toast("Text eingeben", "warn"); try { await A("reports", "antwort", { id: +id, text: t }); closeModal(); toast("Gesendet", "good"); neu(); } catch (f) { toast(f.message, "warn"); } }); + })); + }); + + /* ===== WIRTSCHAFT =============================================== */ + App.registriere("wirtschaft", async (main) => { + const d = await A("wirtschaft", "laden"); + main.innerHTML = kopf("Wirtschaft", "Geldmenge, Staatskasse & Steuern") + + `
+
💶 Geld im Umlauf
${kompakt(d.umlauf)}
${d.charaktere} Charaktere
+
🏛️ Staatskasse
${kompakt(d.staatskasse)}
+
👤 Ø Vermögen
${kompakt(d.charaktere ? d.umlauf / d.charaktere : 0)}
+
📊 Steuersatz
${d.steuer}%
+ ${darf("wirtschaft.geldAlle") ? `
Aktionen
+
` : ""}`; + + const dialog = (titel, feld, aktion) => { modal(`

${titel}

${feld}`, true); $("w-c").addEventListener("click", closeModal); $("w-ok").addEventListener("click", async () => { try { await aktion(); closeModal(); toast("Gespeichert", "good"); neu(); } catch (f) { toast(f.message, "warn"); } }); }; + const eAll = $("e-all"); if (eAll) eAll.addEventListener("click", () => dialog("Geld an alle", ``, () => A("wirtschaft", "geldAlle", { betrag: +$("wx").value }))); + const eK = $("e-kasse"); if (eK) eK.addEventListener("click", () => dialog("Staatskasse", ``, () => A("wirtschaft", "kasse", { wert: +$("wx").value }))); + const eS = $("e-steuer"); if (eS) eS.addEventListener("click", () => dialog("Steuersatz", ``, () => A("wirtschaft", "steuer", { wert: +$("wx").value }))); + }); + + /* ===== FRAKTIONEN ============================================== */ + App.registriere("fraktionen", async (main) => { + const d = await A("fraktionen", "laden"); + main.innerHTML = kopf("Fraktionen", `${d.fraktionen.length} Fraktionen`, darf("fraktionen.speichern") ? '' : "") + + `
${d.fraktionen.map((f) => `
${sicher(initialen(f.name))}
${sicher(f.name)}
${f.mitglieder} Mitglieder · ${f.online} online
${f.gehalt ? euro(f.gehalt) + " /h" : "kein Payday"}
+
${darf("fraktionen.speichern") ? `` : ""}${darf("fraktionen.payday") && f.gehalt ? `` : ""}${darf("fraktionen.loeschen") ? `` : ""}
`).join("") || '
Keine Fraktionen.
'}
`; + main._fr = d.fraktionen; + + const frModal = (f) => { modal(`

${f ? "Fraktion bearbeiten" : "Neue Fraktion"}

`, true); $("f-c").addEventListener("click", closeModal); $("f-ok").addEventListener("click", async () => { const n = $("fn").value.trim(); if (!n) return toast("Name angeben", "warn"); try { await A("fraktionen", "speichern", { name: n, gehalt: +$("fg").value, farbe: $("ff").value.trim() }); closeModal(); toast("Gespeichert", "good"); neu(); } catch (e) { toast(e.message, "warn"); } }); }; + const frn = $("fr-neu"); if (frn) frn.addEventListener("click", () => frModal(null)); + main.querySelectorAll("[data-edit]").forEach((b) => b.addEventListener("click", () => frModal(main._fr.find((x) => x.name === b.dataset.edit)))); + main.querySelectorAll("[data-pay]").forEach((b) => b.addEventListener("click", async () => { try { const r = await A("fraktionen", "payday", { name: b.dataset.pay }); toast(r.anzahl + " ausgezahlt", "good"); } catch (f) { toast(f.message, "warn"); } })); + main.querySelectorAll("[data-del]").forEach((b) => b.addEventListener("click", async () => { try { await A("fraktionen", "loeschen", { name: b.dataset.del }); toast("Gelöscht", "warn"); neu(); } catch (f) { toast(f.message, "warn"); } })); + }); + + /* ===== IMMOBILIEN ============================================= */ + App.registriere("immobilien", async (main) => { + const d = await A("immobilien", "laden"); + main.innerHTML = kopf("Immobilien", `${d.immobilien.length} Objekte`) + + `
+ ${d.immobilien.map((im) => ` + `).join("") || ''} +
ObjektLageBesitzerWert
🏠 ${sicher(im.name)}${sicher(im.ort || "—")}${im.besitzerName ? sicher(im.besitzerName) : 'frei'}${euro(im.preis)}${darf("immobilien.zuweisen") ? `` : ""}${im.besitzer && darf("immobilien.raeumen") ? ` ` : ""}
Keine Objekte.
`; + main.querySelectorAll("[data-evict]").forEach((b) => b.addEventListener("click", async () => { try { await A("immobilien", "raeumen", { id: +b.dataset.evict }); toast("Geräumt", "warn"); neu(); } catch (f) { toast(f.message, "warn"); } })); + main.querySelectorAll("[data-assign]").forEach((b) => b.addEventListener("click", () => { + modal(`

Besitzer setzen

`, true); + $("im-c").addEventListener("click", closeModal); + $("im-ok").addEventListener("click", async () => { try { await A("immobilien", "zuweisen", { id: +b.dataset.assign, besitzer: $("im-o").value ? +$("im-o").value : null }); closeModal(); toast("Aktualisiert", "good"); neu(); } catch (f) { toast(f.message, "warn"); } }); + })); + }); + + /* ===== STAATSPROJEKTE ======================================== */ + App.registriere("staat", async (main) => { + const d = await A("staat", "laden"); + main.innerHTML = kopf("Staatsprojekte", `Abstimmungen · Staatskasse ${euro(d.staatskasse)}`) + + d.projekte.map((p) => { + const positiv = p.zustimmung >= d.zustimmungMin; + let status = p.aktiviert ? '✔ Umgesetzt' : p.offen ? 'Abstimmung läuft' : positiv ? 'bereit' : 'abgelehnt'; + let aktion = ""; + if (!p.aktiviert && p.offen && darf("staat.beenden")) aktion = ``; + else if (!p.aktiviert && !p.offen && positiv && darf("staat.aktivieren")) aktion = ``; + else if (!p.aktiviert && !p.offen && !positiv && darf("staat.archivieren")) aktion = ``; + return `
${sicher(p.titel)} ${status}
+
${sicher(p.beschreibung || "")} · Kosten: ${euro(p.kosten)}
+
+
${p.ja} Dafür · ${p.nein} Dagegen · ${p.zustimmung}% (nötig: ${d.zustimmungMin}%)
+
${aktion}
`; + }).join("") || '
Keine Projekte.
'; + const act = async (a, id) => { try { await A("staat", a, { id }); toast("Erledigt", "good"); neu(); } catch (f) { toast(f.message, "warn"); } }; + main.querySelectorAll("[data-end]").forEach((b) => b.addEventListener("click", () => act("beenden", +b.dataset.end))); + main.querySelectorAll("[data-act]").forEach((b) => b.addEventListener("click", () => act("aktivieren", +b.dataset.act))); + main.querySelectorAll("[data-arch]").forEach((b) => b.addEventListener("click", () => act("archivieren", +b.dataset.arch))); + }); + + /* ===== RESSOURCEN ============================================ */ + App.registriere("ressourcen", async (main) => { + const d = await A("ressourcen", "laden"); + main.innerHTML = kopf("Ressourcen", `${d.ressourcen.filter((r) => r.status === "started").length}/${d.ressourcen.length} aktiv`, + darf("ressourcen.neustart") ? '' : "") + + `
+ ${d.ressourcen.map((r, i) => ` + `).join("")} +
RessourceStatusVersion
🧩 ${sicher(r.name)}${r.status === "started" ? 'läuft' : '' + sicher(r.status) + ""}${sicher(r.version)}${darf("ressourcen.steuern") ? (r.status === "started" ? ` ` : ``) : ""}
`; + main.querySelectorAll("[data-r]").forEach((b) => b.addEventListener("click", async () => { const [name, aktion] = b.dataset.r.split(":"); try { await A("ressourcen", "steuern", { name, aktion }); toast(name + " " + aktion, "good"); neu(); } catch (f) { toast(f.message, "warn"); } })); + const rr = $("rz-restart"); if (rr) rr.addEventListener("click", neustartModal); + }); + + /* ===== AUDIT-LOG ============================================ */ + let logSuche = "", logAdmin = "alle"; + App.registriere("logs", async (main) => { + const d = await A("logs", "laden", { suche: logSuche, admin: logAdmin === "alle" ? "" : logAdmin }); + main.innerHTML = kopf("Audit-Log", `${d.eintraege.length} Einträge`) + + `
+ +
+
+ ${d.eintraege.map((l) => ``).join("") || ''} +
ZeitAdminAktionZielDetail
${zeit(l.erstellt_am)}${sicher(l.admin_name)}${sicher(l.aktion)}${sicher(l.ziel || "")}${sicher(l.detail || "")}
Keine Einträge.
`; + let t = null; + $("lg-such").addEventListener("input", (e) => { clearTimeout(t); t = setTimeout(() => { logSuche = e.target.value.trim(); neu(); }, 300); }); + $("lg-admin").addEventListener("change", (e) => { logAdmin = e.target.value; neu(); }); + }); + + /* ===== ADMIN-TEAM ========================================== */ + App.registriere("team", async (main) => { + const d = await A("team", "laden"); + const rolleP = { owner: "r", admin: "a", support: "b" }; + main.innerHTML = kopf("Admin-Team", `${d.team.filter((a) => a.online).length}/${d.team.length} online`, + darf("team.speichern") ? '' : "") + + `
${d.team.map((a) => `
${sicher(initialen(a.name))} +
${sicher(a.name)}
${sicher(a.admin_group)}
${a.online ? "online" : "offline"}
+ ${darf("team.entfernen") ? `
` : ""}
`).join("") || '
Kein Team.
'}
+
Die Rolle steht in aether_users.admin_group — der Core respektiert sie überall. owner › admin › support.
`; + main._rollen = d.rollen; + + const rollenModal = (lizenz, name) => { modal(`

Rolle · ${sicher(name || "neuer Benutzer")}

${lizenz ? "" : ''}`, true); $("tr-c").addEventListener("click", closeModal); $("tr-ok").addEventListener("click", async () => { try { await A("team", "speichern", { lizenz: lizenz || null, name: lizenz ? name : ($("tr-name") ? $("tr-name").value.trim() : ""), rolle: $("tr-rolle").value }); closeModal(); toast("Gespeichert", "good"); neu(); } catch (f) { toast(f.message, "warn"); } }); }; + const tn = $("tm-neu"); if (tn) tn.addEventListener("click", () => rollenModal(null, null)); + main.querySelectorAll("[data-role]").forEach((b) => b.addEventListener("click", () => { const [l, n] = b.dataset.role.split(":"); rollenModal(l, n); })); + main.querySelectorAll("[data-del]").forEach((b) => b.addEventListener("click", async () => { const [l, n] = b.dataset.del.split(":"); try { await A("team", "entfernen", { lizenz: l, name: n }); toast("Entfernt", "warn"); neu(); } catch (f) { toast(f.message, "warn"); } })); + }); + + /* ===== EINSTELLUNGEN ====================================== */ + App.registriere("settings", async (main) => { + const d = await A("settings", "laden"); + const sw = (k, label) => `
${label}
`; + main.innerHTML = kopf("Einstellungen", "Server-Konfiguration") + + `
Allgemein
+ + + +
+
Regeln & Systeme
${sw("pvp", "PvP")}${sw("whitelist", "Whitelist")}${sw("voice", "Voice-Chat")}${sw("economy", "Wirtschaft")}
`; + main._schalter = Object.assign({}, d.schalter); + main.querySelectorAll("[data-set]").forEach((b) => b.addEventListener("click", () => { main._schalter[b.dataset.set] = !main._schalter[b.dataset.set]; b.className = "btn " + (main._schalter[b.dataset.set] ? "green" : "ghost") + " sm"; b.textContent = main._schalter[b.dataset.set] ? "AN" : "AUS"; })); + $("st-save").addEventListener("click", async () => { try { await A("settings", "speichern", { servername: $("st-name").value.trim(), neustartStunden: +$("st-rs").value, schalter: main._schalter }); toast("Gespeichert", "good"); } catch (f) { toast(f.message, "warn"); } }); + }); +})(); diff --git a/resources/[aether]/aether-admin/html/js/webrtc.js b/resources/[aether]/aether-admin/html/js/webrtc.js new file mode 100644 index 0000000..a2de523 --- /dev/null +++ b/resources/[aether]/aether-admin/html/js/webrtc.js @@ -0,0 +1,247 @@ +/* ===================================================================== + aether-admin — Live-Kameras (echtes WebRTC) + + Zwei Rollen laufen in dieser Datei, beide über eine WebSocket- + Signalisierung direkt zum Media-Server (SFU) — NICHT über FiveM: + + • VIEWER — die geöffnete Panel-Ansicht. Empfängt die Streams der + beobachteten Spieler und zeigt sie als Video-Kacheln. + • PUBLISHER — läuft im Hintergrund auf dem Ziel-Client (auch ohne + geöffnetes Panel). Sendet seinen Aufnahme-Stream in den + Raum. + + Die EHRLICHE Stelle: Die NUI/CEF kann das Spielbild NICHT selbst + abgreifen. Der Publisher nutzt daher `window.__aetherCapture` (die + Anbindung einer externen Quelle wie OBS-Virtual-Cam). Ist keine da, + sendet er ein deutlich beschriftetes Platzhalterbild — so ist die + ganze WebRTC-Strecke live und verifizierbar, und man sieht genau, + wo die echte Quelle angeklemmt wird. +===================================================================== */ +"use strict"; + +(() => { + const EIS = [{ urls: "stun:stun.l.google.com:19302" }]; + + /* ================= PUBLISHER (Hintergrund, Ziel-Client) ============ */ + const Publisher = (() => { + const raeume = {}; // [raum] = { ws, pc, stream } + + /** + * Besorgt den zu sendenden MediaStream. + * Primärweg ist die Bildschirmfreigabe der NUI (getDisplayMedia) — echtes, + * flüssiges Video des Spielfensters, ganz ohne externes Werkzeug. Klappt sie + * (abhängig davon, ob das FiveM-CEF Bildschirmaufnahme erlaubt), sieht der + * Admin das Live-Bild. Sonst ein markierter Platzhalter. + * @returns {Promise<{stream:MediaStream, typ:string, fehler?:string}>} + */ + async function holeQuelle() { + // 1) Optionaler Override für Fortgeschrittene (z. B. bestimmtes Aufnahmegerät) + if (typeof window.__aetherCapture === "function") { + try { const s = await window.__aetherCapture(); if (s) return { stream: s, typ: "extern" }; } catch (e) {} + } + // 2) Bildschirmfreigabe — der eigentliche „keine externe Quelle"-Weg + try { + if (navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia) { + const s = await navigator.mediaDevices.getDisplayMedia({ + video: { frameRate: { ideal: 30, max: 60 } }, audio: false, + }); + return { stream: s, typ: "screen" }; + } + return { stream: platzhalterStream(), typ: "platzhalter", fehler: "getDisplayMedia nicht verfügbar" }; + } catch (e) { + // Häufig: das FiveM-CEF verweigert die Freigabe (kein Nutzergesten-Kontext) + return { stream: platzhalterStream(), typ: "platzhalter", fehler: String(e && e.name || e) }; + } + } + + function platzhalterStream() { + const c = document.createElement("canvas"); + c.width = 640; c.height = 360; + const ctx = c.getContext("2d"); + (function zeichne() { + ctx.fillStyle = "#0b0f16"; ctx.fillRect(0, 0, 640, 360); + ctx.fillStyle = "#f0506e"; ctx.font = "bold 22px sans-serif"; + ctx.fillText("Bildschirmfreigabe blockiert", 40, 150); + ctx.fillStyle = "#7e879b"; ctx.font = "14px sans-serif"; + ctx.fillText("Das FiveM-CEF dieses Clients erlaubt keine", 40, 185); + ctx.fillText("Bildschirmaufnahme (getDisplayMedia).", 40, 208); + ctx.fillStyle = "#34d39a"; ctx.font = "13px monospace"; + ctx.fillText(new Date().toLocaleTimeString("de-DE"), 40, 250); + requestAnimationFrame(zeichne); + })(); + return c.captureStream(12); + } + + async function starte(raum, url, token) { + if (raeume[raum]) return; // schon aktiv + const quelle = await holeQuelle(); + const stream = quelle.stream; + // Dem Admin zurückmelden, ob echte Bildschirmfreigabe oder Platzhalter + try { App.anClient("kameraStatus", { raum: raum, typ: quelle.typ, fehler: quelle.fehler || "" }); } catch (e) {} + + const pc = new RTCPeerConnection({ iceServers: EIS }); + stream.getTracks().forEach((t) => pc.addTrack(t, stream)); + + const ws = new WebSocket(url); + raeume[raum] = { ws, pc, stream }; + + pc.onicecandidate = (e) => { if (e.candidate) sende(ws, { t: "ice", candidate: e.candidate }); }; + ws.onopen = () => sende(ws, { t: "join", token: token }); + ws.onmessage = async (ev) => { + const m = JSON.parse(ev.data); + if (m.t === "ready") { + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + sende(ws, { t: "offer", sdp: pc.localDescription }); + } else if (m.t === "answer") { + await pc.setRemoteDescription(m.sdp); + } else if (m.t === "ice" && m.candidate) { + try { await pc.addIceCandidate(m.candidate); } catch (e) {} + } + }; + ws.onclose = () => stoppe(raum); + } + + function stoppe(raum) { + const r = raeume[raum]; if (!r) return; + try { r.pc.close(); } catch (e) {} + try { r.ws.close(); } catch (e) {} + try { r.stream.getTracks().forEach((t) => t.stop()); } catch (e) {} + delete raeume[raum]; + } + + function stoppeAlle() { Object.keys(raeume).forEach(stoppe); } + + return { starte, stoppe, stoppeAlle }; + })(); + + /* ================= VIEWER (Panel-Ansicht) ========================== */ + const Viewer = (() => { + let ws = null, pc = null, aktuellerRaum = null; + let aufTrack = null; // Rückruf, wenn ein Video-Track eintrifft + + function verbinde(url, token, raum, onTrack) { + trenne(); + aktuellerRaum = raum; aufTrack = onTrack; + pc = new RTCPeerConnection({ iceServers: EIS }); + pc.ontrack = (e) => { if (aufTrack) aufTrack(e.streams[0], e.track); }; + pc.onicecandidate = (e) => { if (e.candidate) sende(ws, { t: "ice", candidate: e.candidate }); }; + + ws = new WebSocket(url); + ws.onopen = () => sende(ws, { t: "join", token: token }); + ws.onmessage = async (ev) => { + const m = JSON.parse(ev.data); + if (m.t === "offer") { + await pc.setRemoteDescription(m.sdp); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + sende(ws, { t: "answer", sdp: pc.localDescription }); + } else if (m.t === "ice" && m.candidate) { + try { await pc.addIceCandidate(m.candidate); } catch (e) {} + } + }; + } + + function trenne() { + try { if (pc) pc.close(); } catch (e) {} + try { if (ws) ws.close(); } catch (e) {} + pc = null; ws = null; aktuellerRaum = null; + } + + return { verbinde, trenne, raum: () => aktuellerRaum }; + })(); + + function sende(ws, obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); } + + /* ================= Nachrichten vom Client (Publisher steuern) ====== */ + window.addEventListener("message", (e) => { + const d = e.data || {}; + if (d.aktion === "webrtcPublish") Publisher.starte(d.raum, d.url, d.token); + else if (d.aktion === "webrtcStop") Publisher.stoppe(d.raum); + else if (d.aktion === "webrtcStopAll") Publisher.stoppeAlle(); + else if (d.aktion === "kameraStatus") { + // Rückmeldung eines Ziel-Clients an den beobachtenden Admin + if (d.typ === "screen" || d.typ === "extern") App.toast(`📹 ${d.quelle || "Ziel"}: Bild live`, "good"); + else App.toast(`📹 ${d.quelle || "Ziel"}: Bildschirmfreigabe blockiert (${d.fehler || "CEF"})`, "warn"); + } + }); + + /* ================= Kamera-Sektion (Viewer-UI) ====================== */ + let aktiveView = null; // { raum, ziele:[{id,name}] } + + App.registriere("cams", async (main) => { + if (!App.zustand.media || !App.zustand.media.aktiv) { + main.innerHTML = `

Live-Kameras

WebRTC-Spectate

+
📹
+ Die Live-Kameras sind deaktiviert oder der Media-Server ist nicht konfiguriert.
+ Siehe media-server/README.md und die Convar aether_admin_media_secret.
`; + return; + } + + let spieler = []; + try { spieler = (await App.aufruf("spieler", "liste")).spieler || []; } catch (e) {} + + main.innerHTML = `

Live-Kameras

WebRTC-Spectate · Einzel- & Multi-Cam

+ ${aktiveView ? '' : ""}
+
+
Spieler wählen
+ ${spieler.map((p) => `
${App.sicher(App.initialen(p.name))}
${App.sicher(p.name)} · ${App.sicher(p.region)}
`).join("") || '
Niemand online.
'} +
+
${aktiveView ? "" : '
📹
Spieler links wählen und „View öffnen".
'}
`; + + const gewaehlt = new Set(); + main.querySelectorAll("[data-pv]").forEach((el) => el.addEventListener("click", () => { + const id = +el.dataset.pv; + if (gewaehlt.has(id)) { gewaehlt.delete(id); el.classList.remove("sel"); } + else { gewaehlt.add(id); el.classList.add("sel"); } + $("cam-start").disabled = gewaehlt.size === 0; + })); + + const $ = App.$; + $("cam-start").addEventListener("click", () => starteView([...gewaehlt], + spieler.filter((p) => gewaehlt.has(p.id)).map((p) => p.name).join(", "))); + const zu = $("cam-zu"); if (zu) zu.addEventListener("click", schliesseView); + + if (aktiveView) baueKacheln(spieler); + }); + + async function starteView(ids, titel) { + if (!ids.length) return; + let antwort; + try { antwort = await App.aufruf("kamera", "oeffnen", { ziele: ids }); } + catch (f) { return App.toast(f.message, "warn"); } + + aktiveView = { raum: antwort.raum, ziele: ids, titel: titel }; + App.oeffneSektion("cams"); // neu rendern mit aktiver View + App.toast(`View geöffnet · ${antwort.ziele} Ziel(e)`, "good"); + + Viewer.verbinde(antwort.url, antwort.token, antwort.raum, (stream) => { + // Neuen Stream als Kachel einhängen + const grid = App.$("cam-grid"); if (!grid) return; + const platz = grid.querySelector(".empty"); if (platz) platz.remove(); + const id = "cam_" + Math.random().toString(36).slice(2, 8); + const tile = document.createElement("div"); + tile.className = "camtile"; + tile.innerHTML = ` +
LIVE
${App.sicher(aktiveView.titel || "Kamera")}
`; + tile.querySelector("video").srcObject = stream; + grid.appendChild(tile); + }); + } + + async function schliesseView() { + if (!aktiveView) return; + Viewer.trenne(); + try { await App.aufruf("kamera", "schliessen", { raum: aktiveView.raum }); } catch (e) {} + aktiveView = null; + App.oeffneSektion("cams"); + } + + function baueKacheln() { + // Bereits laufende View nach einem Re-Render: die Video-Tracks kommen + // erneut über pc.ontrack; hier wird nur der leere Zustand entfernt. + } + + // Aus dem Spieler-Dialog aufrufbar: direkt eine View auf einen Spieler öffnen + window.Kameras = { starte: (ids, name) => starteView(ids, name) }; +})(); diff --git a/resources/[aether]/aether-admin/media-server/README.md b/resources/[aether]/aether-admin/media-server/README.md new file mode 100644 index 0000000..dc5fb45 --- /dev/null +++ b/resources/[aether]/aether-admin/media-server/README.md @@ -0,0 +1,79 @@ +# aether-admin — Media-Server (WebRTC-SFU) + +Router für die Live-Kameras des Admin-Panels. Er läuft **außerhalb** von +FiveM als eigener Node-Prozess und macht zweierlei: + +- **Signaling + Medien (WebSocket, Standard `:8787`)** — Publisher senden + ihren Stream, Viewer empfangen ihn. Der Server leitet die RTP-Pakete + weiter (Selective Forwarding Unit). +- **Token-Ausgabe (HTTP, Standard `:8788`)** — nur der FiveM-Server ruft + das auf, abgesichert über ein gemeinsames Geheimnis. + +Der FiveM-Server routet **kein Video** — er prüft nur Rechte, stellt Token +aus und weist Ziel-Clients an zu senden. + +## Einrichtung + +```bash +cd resources/[aether]/aether-admin/media-server +npm install +AETHER_MEDIA_SECRET="ein-langes-zufaelliges-geheimnis" node server.js +``` + +### Umgebungsvariablen + +| Variable | Standard | Bedeutung | +|---|---|---| +| `AETHER_MEDIA_SECRET` | *(leer)* | Gemeinsames Geheimnis. **Muss** gesetzt sein und exakt der FiveM-Convar `aether_admin_media_secret` entsprechen. | +| `AETHER_MEDIA_WS_PORT` | `8787` | WebSocket-Port (von den NUIs erreichbar). | +| `AETHER_MEDIA_HTTP_PORT` | `8788` | HTTP-Port für die Token-Ausgabe (nur vom FiveM-Server erreichbar). | + +### Passende FiveM-Convars (in `server.cfg`) + +```cfg +set aether_admin_media_secret "ein-langes-zufaelliges-geheimnis" +set aether_admin_media_http "http://127.0.0.1:8788" +``` + +Und in `resources/[aether]/aether-admin/shared/config.lua` die öffentliche +WebSocket-Adresse (die die Spieler-Clients erreichen): + +```lua +AdminConfig.Media.url = 'ws://DEINE-OEFFENTLICHE-IP:8787' +``` + +> Für den Produktivbetrieb hinter TLS (`wss://`) einen Reverse-Proxy +> (nginx/Caddy) vor den WS-Port setzen und die `url` auf `wss://…` zeigen. + +## Die Videoquelle + +Der Publisher in `html/js/webrtc.js` holt sich den Stream in dieser Reihenfolge: + +1. **`getDisplayMedia` — der Standardweg ohne externes Werkzeug.** Die NUI + gibt das Spielfenster über die Bildschirmfreigabe-API frei; das ist echtes, + flüssiges Video und braucht **kein OBS und keine Virtual-Cam**. +2. `window.__aetherCapture()` — optionaler Override, falls du eine bestimmte + Quelle erzwingen willst (z. B. ein Aufnahmegerät). +3. Ein rot beschrifteter **Platzhalter** — erscheint nur, wenn die + Bildschirmfreigabe abgelehnt/blockiert wurde, damit die Strecke trotzdem + nachweislich live ist. + +**Die entscheidende Abhängigkeit:** Ob `getDisplayMedia` klappt, hängt davon +ab, ob das **FiveM-CEF des Ziel-Clients** Bildschirmaufnahme aus einer +Hintergrund-NUI erlaubt (Nutzergesten-/Berechtigungskontext). Erlaubt es das, +siehst du das Live-Bild flüssig im Panel. Verweigert es das, kann kein +Resource das erzwingen — dann bleibt nur eine externe Quelle über +`__aetherCapture` oder die native Spectate-Funktion (Knopf „Beobachten"). + +Das Panel meldet pro Ziel-Client zurück, was gegriffen hat: eine Toast-Meldung +„Bild live" oder „Bildschirmfreigabe blockiert (Fehlergrund)". + +## Betrieb & Hinweise + +- Als Dienst betreiben (pm2/systemd), Neustart bei Absturz. +- **Live-Test nötig:** Ein WebRTC-SFU lässt sich nur im laufenden Betrieb + sinnvoll prüfen. Je nach `werift`-Version können Details der Signaling- + oder RTP-API minimal abweichen — der Code ist die klar strukturierte + Referenz und an einer Stelle (`publisherTrack`) leicht anzupassen. +- Ohne gesetztes Geheimnis lehnt der Server jede Token-Ausgabe ab; das + Panel zeigt dann den Hinweis „Media-Server nicht erreichbar". diff --git a/resources/[aether]/aether-admin/media-server/package.json b/resources/[aether]/aether-admin/media-server/package.json new file mode 100644 index 0000000..327e036 --- /dev/null +++ b/resources/[aether]/aether-admin/media-server/package.json @@ -0,0 +1,18 @@ +{ + "name": "aether-admin-media", + "version": "0.1.0", + "private": true, + "description": "WebRTC-SFU + Signaling für die Live-Kameras von aether-admin", + "type": "commonjs", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "werift": "^0.19.4", + "ws": "^8.16.0" + } +} diff --git a/resources/[aether]/aether-admin/media-server/server.js b/resources/[aether]/aether-admin/media-server/server.js new file mode 100644 index 0000000..79a93d6 --- /dev/null +++ b/resources/[aether]/aether-admin/media-server/server.js @@ -0,0 +1,198 @@ +/* ===================================================================== + aether-admin — Media-Server (WebRTC-SFU + Signaling) + + Zwei Ohren: + • HTTP (GRANT_PORT) — nur für den FiveM-Server. Der stellt hier + kurzlebige Zugangstoken aus (/grant) und räumt Räume ab (/revoke), + abgesichert über das gemeinsame Geheimnis ADMIN_SECRET. + • WebSocket (WS_PORT) — für die NUIs. Publisher senden ihren Stream, + Viewer empfangen ihn. Der Server routet die Medien (SFU): er nimmt + die RTP-Pakete eines Publishers und schreibt sie in Ausgangs-Tracks, + die an alle Viewer desselben Raums gehen. + + Signaling-Protokoll (JSON über WS), passend zu html/js/webrtc.js: + Client → Server: { t:"join", token } + { t:"offer", sdp } (Publisher ist Offerer) + { t:"answer", sdp } (Viewer antwortet auf Server-Offer) + { t:"ice", candidate } + Server → Client: { t:"ready" } (Publisher darf sein Offer senden) + { t:"offer", sdp } (Server offeriert dem Viewer) + { t:"answer", sdp } (Antwort an den Publisher) + { t:"ice", candidate } + + HINWEIS: WebRTC-SFUs lassen sich nur im laufenden Betrieb sinnvoll + prüfen. Dieser Server ist die Referenzimplementierung — je nach + werift-Version können Kleinigkeiten anzupassen sein (siehe README). +===================================================================== */ +"use strict"; + +const http = require("http"); +const { WebSocketServer } = require("ws"); +const { RTCPeerConnection, MediaStreamTrack } = require("werift"); + +const WS_PORT = parseInt(process.env.AETHER_MEDIA_WS_PORT || "8787", 10); +const GRANT_PORT = parseInt(process.env.AETHER_MEDIA_HTTP_PORT || "8788", 10); +const ADMIN_SECRET = process.env.AETHER_MEDIA_SECRET || ""; +const STUN = [{ urls: "stun:stun.l.google.com:19302" }]; + +if (!ADMIN_SECRET) { + console.warn("[media] WARNUNG: AETHER_MEDIA_SECRET ist leer — /grant lehnt alles ab."); +} + +/* ---- Zustand ------------------------------------------------------- */ +// token -> { raum, rolle, exp } +const grants = new Map(); +// raum -> { publishers:Set, viewers:Set, tracks:MediaStreamTrack[] } +const raeume = new Map(); + +function raumHolen(id) { + if (!raeume.has(id)) raeume.set(id, { publishers: new Set(), viewers: new Set(), tracks: [] }); + return raeume.get(id); +} + +// Abgelaufene Token regelmäßig entfernen +setInterval(() => { + const jetzt = Date.now(); + for (const [t, g] of grants) if (g.exp < jetzt) grants.delete(t); +}, 10000); + +/* ===================================================================== + HTTP: Token-Ausgabe (nur vom FiveM-Server) +===================================================================== */ +const httpServer = http.createServer((req, res) => { + if (req.method !== "POST") { res.writeHead(405).end(); return; } + if (req.headers["x-admin-secret"] !== ADMIN_SECRET || !ADMIN_SECRET) { + res.writeHead(403).end('{"ok":false}'); return; + } + + let body = ""; + req.on("data", (c) => { body += c; if (body.length > 1e5) req.destroy(); }); + req.on("end", () => { + let daten = {}; + try { daten = JSON.parse(body || "{}"); } catch (e) {} + + if (req.url === "/grant") { + const gueltig = Math.max(5, Math.min(300, +daten.gueltigSek || 30)); + grants.set(daten.token, { + raum: String(daten.raum || ""), + rolle: daten.rolle === "publisher" ? "publisher" : "viewer", + exp: Date.now() + gueltig * 1000, + }); + res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}'); + } else if (req.url === "/revoke") { + raumAufloesen(String(daten.raum || "")); + res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}'); + } else { + res.writeHead(404).end('{"ok":false}'); + } + }); +}); +httpServer.listen(GRANT_PORT, () => console.log(`[media] HTTP /grant auf :${GRANT_PORT}`)); + +/* ===================================================================== + WebSocket: Signaling + Medien +===================================================================== */ +const wss = new WebSocketServer({ port: WS_PORT }); +wss.on("listening", () => console.log(`[media] WebSocket auf :${WS_PORT}`)); + +wss.on("connection", (ws) => { + let sitzung = null; // wird nach gültigem join gesetzt + + ws.on("message", async (roh) => { + let m; try { m = JSON.parse(roh.toString()); } catch (e) { return; } + + // Erster Schritt: Beitritt mit gültigem Token + if (!sitzung) { + if (m.t !== "join") return; + const g = grants.get(m.token); + if (!g || g.exp < Date.now()) { ws.close(4001, "ungueltiges Token"); return; } + grants.delete(m.token); // Einmal-Token + + const raum = raumHolen(g.raum); + const pc = new RTCPeerConnection({ iceServers: STUN }); + sitzung = { rolle: g.rolle, raum: g.raum, pc, ws }; + + pc.onIceCandidate.subscribe((candidate) => { + sende(ws, { t: "ice", candidate: alsJson(candidate) }); + }); + + if (g.rolle === "publisher") { + raum.publishers.add(sitzung); + // Publisher ist Offerer → wir bitten ihn, sein Offer zu schicken + pc.onTrack.subscribe((track) => publisherTrack(g.raum, track)); + sende(ws, { t: "ready" }); + } else { + raum.viewers.add(sitzung); + // Viewer bekommt alle bereits laufenden Tracks und ein Offer + for (const track of raum.tracks) pc.addTransceiver(track, { direction: "sendonly" }); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + sende(ws, { t: "offer", sdp: pc.localDescription }); + } + return; + } + + // Laufende Verhandlung + const pc = sitzung.pc; + if (m.t === "offer") { // von einem Publisher + await pc.setRemoteDescription(m.sdp); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + sende(ws, { t: "answer", sdp: pc.localDescription }); + } else if (m.t === "answer") { // von einem Viewer + await pc.setRemoteDescription(m.sdp); + } else if (m.t === "ice" && m.candidate) { + try { await pc.addIceCandidate(m.candidate); } catch (e) {} + } + }); + + ws.on("close", () => { + if (!sitzung) return; + const raum = raeume.get(sitzung.raum); + try { sitzung.pc.close(); } catch (e) {} + if (raum) { raum.publishers.delete(sitzung); raum.viewers.delete(sitzung); } + }); +}); + +/* ---- Ein Publisher-Track kam an → an alle Viewer weiterreichen ----- */ +async function publisherTrack(raumId, track) { + const raum = raeume.get(raumId); + if (!raum) return; + + // Ausgangs-Track, in den wir die RTP-Pakete des Publishers schreiben + const fwd = new MediaStreamTrack({ kind: track.kind }); + track.onReceiveRtp.subscribe((rtp) => { try { fwd.writeRtp(rtp); } catch (e) {} }); + raum.tracks.push(fwd); + + // Jedem laufenden Viewer den neuen Track zusätzlich anbieten (Renegotiation) + for (const viewer of raum.viewers) { + try { + viewer.pc.addTransceiver(fwd, { direction: "sendonly" }); + const offer = await viewer.pc.createOffer(); + await viewer.pc.setLocalDescription(offer); + sende(viewer.ws, { t: "offer", sdp: viewer.pc.localDescription }); + } catch (e) { /* Viewer könnte gerade weg sein */ } + } +} + +function raumAufloesen(raumId) { + const raum = raeume.get(raumId); + if (!raum) return; + for (const s of raum.publishers) try { s.pc.close(); } catch (e) {} + for (const s of raum.viewers) try { s.pc.close(); } catch (e) {} + raeume.delete(raumId); +} + +/* ---- Kleine Helfer ------------------------------------------------- */ +function sende(ws, obj) { + if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); +} +function alsJson(candidate) { + if (!candidate) return null; + if (typeof candidate.toJSON === "function") return candidate.toJSON(); + return { + candidate: candidate.candidate, + sdpMid: candidate.sdpMid, + sdpMLineIndex: candidate.sdpMLineIndex, + }; +} diff --git a/resources/[aether]/aether-admin/server/auth.lua b/resources/[aether]/aether-admin/server/auth.lua new file mode 100644 index 0000000..9dc9a13 --- /dev/null +++ b/resources/[aether]/aether-admin/server/auth.lua @@ -0,0 +1,95 @@ +-- ===================================================================== +-- aether-admin — Rechteprüfung (Kopplung an aether-core) +-- +-- Die Wahrheit über die Gruppe eines Spielers steht in +-- `aether_users.admin_group` — gepflegt vom Core. Dieses Modul liest +-- sie, hält sie je Session vor und beantwortet die einzige Frage, die +-- der Rest des Codes stellt: „Darf diese Server-ID das?" +-- +-- Geprüft wird IMMER hier, nie in der Oberfläche. Was der Client +-- schickt, ist ein Wunsch, kein Nachweis. +-- ===================================================================== + +Admin = Admin or {} +Admin.Auth = {} + +-- Zwischenspeicher: [quelle] = gruppe +local gruppeCache = {} + +--- Liest die Rockstar-Lizenz einer Server-ID (ohne Präfix) +--- @param quelle number +--- @return string|nil +function Admin.Auth.Lizenz(quelle) + local lizenz = GetPlayerIdentifierByType(quelle, 'license') + if not lizenz then return nil end + return (lizenz:gsub('license:', '')) +end + +--- Anzeigename einer Server-ID +--- @param quelle number +--- @return string +function Admin.Auth.Name(quelle) + return GetPlayerName(quelle) or ('Spieler ' .. tostring(quelle)) +end + +--- Rechtegruppe einer Server-ID (mit Cache) +--- @param quelle number +--- @return string +function Admin.Auth.Gruppe(quelle) + if gruppeCache[quelle] then return gruppeCache[quelle] end + + local lizenz = Admin.Auth.Lizenz(quelle) + if not lizenz then return 'user' end + + local gruppe = Admin.DB.GruppeVonLizenz(lizenz) + gruppeCache[quelle] = gruppe + return gruppe +end + +--- Verwirft den Cache einer Server-ID (nach Rollenänderung / Disconnect) +--- @param quelle number +function Admin.Auth.CacheLeeren(quelle) + gruppeCache[quelle] = nil +end + +--- Verwirft den Cache einer Lizenz, egal unter welcher Server-ID sie online ist +--- @param lizenz string +function Admin.Auth.CacheLeerenNachLizenz(lizenz) + for quelle in pairs(gruppeCache) do + if Admin.Auth.Lizenz(quelle) == lizenz then + gruppeCache[quelle] = nil + end + end +end + +--- Erfüllt die Server-ID mindestens die geforderte Gruppe? +--- @param quelle number +--- @param benoetigt string +--- @return boolean +function Admin.Auth.Hat(quelle, benoetigt) + return AdminConfig.Erfuellt(Admin.Auth.Gruppe(quelle), benoetigt) +end + +--- Darf die Server-ID das Panel überhaupt öffnen? +--- @param quelle number +--- @return boolean +function Admin.Auth.DarfOeffnen(quelle) + return Admin.Auth.Hat(quelle, AdminConfig.MindestGruppe) +end + +--- Baut den „Handelnden"-Kontext, den Module und das Log erwarten +--- @param quelle number +--- @return table { quelle, lizenz, name, gruppe } +function Admin.Auth.Handelnder(quelle) + return { + quelle = quelle, + lizenz = Admin.Auth.Lizenz(quelle), + name = Admin.Auth.Name(quelle), + gruppe = Admin.Auth.Gruppe(quelle), + } +end + +-- Cache aufräumen, wenn jemand geht +AddEventHandler('playerDropped', function() + gruppeCache[source] = nil +end) diff --git a/resources/[aether]/aether-admin/server/db.lua b/resources/[aether]/aether-admin/server/db.lua new file mode 100644 index 0000000..d0e17c4 --- /dev/null +++ b/resources/[aether]/aether-admin/server/db.lua @@ -0,0 +1,150 @@ +-- ===================================================================== +-- aether-admin — Datenbankzugriff (oxmysql) +-- +-- Kapselt die wiederkehrenden Abfragen, die mehrere Fachmodule teilen: +-- Einstellungen, Benutzer-/Charakterdaten, Bans. Domänenspezifische +-- Abfragen (Reports, Warns, Immobilien …) liegen in ihren Modulen. +-- ===================================================================== + +Admin = Admin or {} +Admin.DB = {} + +-- --------------------------------------------------------------------- +-- Persistierte Einstellungen (Schlüssel/Wert) +-- --------------------------------------------------------------------- + +--- Liest eine Einstellung als Text +--- @param schluessel string +--- @param standard any Rückfallwert +--- @return string +function Admin.DB.Einstellung(schluessel, standard) + local wert = MySQL.scalar.await( + 'SELECT wert FROM aether_admin_settings WHERE schluessel = ?', { schluessel }) + if wert == nil then return standard end + return wert +end + +--- Liest eine Einstellung als Zahl +--- @param schluessel string +--- @param standard number +--- @return number +function Admin.DB.EinstellungZahl(schluessel, standard) + return tonumber(Admin.DB.Einstellung(schluessel, standard)) or standard or 0 +end + +--- Liest eine Einstellung als Schalter +--- @param schluessel string +--- @param standard boolean +--- @return boolean +function Admin.DB.EinstellungAn(schluessel, standard) + local wert = Admin.DB.Einstellung(schluessel, standard and '1' or '0') + return wert == '1' or wert == 'true' or wert == true +end + +--- Schreibt eine Einstellung +--- @param schluessel string +--- @param wert any +function Admin.DB.SetzeEinstellung(schluessel, wert) + MySQL.prepare.await([[ + INSERT INTO aether_admin_settings (schluessel, wert) VALUES (?, ?) + ON DUPLICATE KEY UPDATE wert = VALUES(wert) + ]], { schluessel, tostring(wert) }) +end + +-- --------------------------------------------------------------------- +-- Benutzer & Charaktere (aus aether-core) +-- --------------------------------------------------------------------- + +--- Rechtegruppe eines Benutzers direkt aus der Datenbank +--- @param lizenz string +--- @return string +function Admin.DB.GruppeVonLizenz(lizenz) + local wert = MySQL.scalar.await( + 'SELECT admin_group FROM aether_users WHERE license = ?', { lizenz }) + return wert or 'user' +end + +--- Setzt die Rechtegruppe eines Benutzers +--- @param lizenz string +--- @param gruppe string +--- @return boolean +function Admin.DB.SetzeGruppe(lizenz, gruppe) + local betroffen = MySQL.update.await( + 'UPDATE aether_users SET admin_group = ? WHERE license = ?', { gruppe, lizenz }) + return (betroffen or 0) > 0 +end + +--- Sucht einen Benutzer über Lizenz oder Namen +--- @param begriff string +--- @return table Liste +function Admin.DB.SucheBenutzer(begriff) + local muster = '%' .. begriff .. '%' + return MySQL.query.await([[ + SELECT license, name, admin_group, is_banned + FROM aether_users + WHERE license = ? OR name LIKE ? + ORDER BY last_seen DESC + LIMIT 25 + ]], { begriff, muster }) or {} +end + +-- --------------------------------------------------------------------- +-- Bans · Durchsetzung in aether_users, Gedächtnis in aether_admin_bans +-- --------------------------------------------------------------------- + +--- Sperrt einen Benutzer: setzt die Core-Felder und schreibt den Verlauf. +--- @param lizenz string|nil Lizenz (nil = nur Verlaufseintrag, Offline-Ban) +--- @param name string Anzeigename +--- @param grund string +--- @param bis string|nil DATETIME oder nil (dauerhaft) +--- @param adminLizenz string|nil +--- @param adminName string +--- @return number Ban-ID +function Admin.DB.Sperre(lizenz, name, grund, bis, adminLizenz, adminName) + if lizenz then + MySQL.update.await([[ + UPDATE aether_users SET is_banned = 1, ban_reason = ?, ban_until = ? + WHERE license = ? + ]], { grund, bis, lizenz }) + end + + return MySQL.insert.await([[ + INSERT INTO aether_admin_bans + (license, name, grund, admin_license, admin_name, bis, aktiv) + VALUES (?, ?, ?, ?, ?, ?, 1) + ]], { lizenz, name, grund, adminLizenz, adminName, bis }) +end + +--- Hebt einen Bann auf: löscht die Core-Sperre und markiert den Verlauf. +--- @param banId number +--- @param adminName string +--- @return boolean +function Admin.DB.Entsperre(banId, adminName) + local zeilen = MySQL.query.await( + 'SELECT license FROM aether_admin_bans WHERE id = ? AND aktiv = 1', { banId }) + if not zeilen or not zeilen[1] then return false end + + if zeilen[1].license then + MySQL.update.await([[ + UPDATE aether_users SET is_banned = 0, ban_reason = NULL, ban_until = NULL + WHERE license = ? + ]], { zeilen[1].license }) + end + + MySQL.update.await([[ + UPDATE aether_admin_bans + SET aktiv = 0, aufgehoben_am = NOW(), aufgehoben_von = ? + WHERE id = ? + ]], { adminName, banId }) + return true +end + +--- Liste aktiver Bans +--- @return table +function Admin.DB.AktiveBans() + return MySQL.query.await([[ + SELECT id, license, name, grund, admin_name, bis, erstellt_am + FROM aether_admin_bans WHERE aktiv = 1 + ORDER BY id DESC LIMIT 200 + ]]) or {} +end diff --git a/resources/[aether]/aether-admin/server/kamera.lua b/resources/[aether]/aether-admin/server/kamera.lua new file mode 100644 index 0000000..9c58f07 --- /dev/null +++ b/resources/[aether]/aether-admin/server/kamera.lua @@ -0,0 +1,218 @@ +-- ===================================================================== +-- aether-admin — Live-Kameras (WebRTC-Orchestrierung) +-- +-- Das eigentliche Video läuft NICHT über den FiveM-Server. Ein +-- separater Media-Server (SFU, siehe media-server/) routet die Streams. +-- Dieser Code ist nur der Türsteher und Vermittler: +-- +-- 1. Prüft, ob der Admin zusehen darf. +-- 2. Lässt sich vom Media-Server kurzlebige Zugangstoken ausstellen +-- (server-zu-server, abgesichert über ein geteiltes Geheimnis). +-- 3. Gibt dem Admin ein Zuschauer-Token und weist die Ziel-Clients an, +-- als Publisher in denselben Raum zu senden. +-- +-- Der FiveM-Server sieht kein Bild. Er entscheidet nur, WER wen sehen +-- darf — und protokolliert es. +-- ===================================================================== + +Admin = Admin or {} +Admin.Kamera = {} + +-- Laufende Räume: [raum] = { admin = quelle, ziele = { [serverId]=true }, gestartet } +local raeume = {} + +-- Server-Konfiguration aus Convars (Geheimnis NIE im geteilten Code!) +local function Secret() return GetConvar('aether_admin_media_secret', '') end +local function HttpBasis() return GetConvar('aether_admin_media_http', 'http://127.0.0.1:8788') end + +--- Ist die Kamera einsatzbereit (aktiv + Geheimnis gesetzt)? +--- @return boolean, string|nil Grund, falls nicht +local function Bereit() + if not AdminConfig.Media.aktiv then + return false, 'Live-Kameras sind in der Konfiguration deaktiviert.' + end + if Secret() == '' then + return false, 'Kein Media-Geheimnis gesetzt (Convar aether_admin_media_secret).' + end + return true +end + +--- Erzeugt eine zufällige, undurchsichtige Kennung +--- @param laenge number +--- @return string +local function Zufall(laenge) + local zeichen = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + local teile = {} + for i = 1, laenge do + local pos = math.random(#zeichen) + teile[i] = zeichen:sub(pos, pos) + end + return table.concat(teile) +end + +--- Ruft den Media-Server server-zu-server auf und wartet auf die Antwort +--- @param pfad string z. B. '/grant' +--- @param koerper table +--- @return table|nil { status, daten } +local function AnMediaServer(pfad, koerper) + local p = promise.new() + PerformHttpRequest(HttpBasis() .. pfad, function(status, text) + local ok, daten = pcall(json.decode, text or '') + p:resolve({ status = status, daten = ok and daten or nil }) + end, 'POST', json.encode(koerper), { + ['Content-Type'] = 'application/json', + ['x-admin-secret'] = Secret(), + }) + return Citizen.Await(p) +end + +--- Lässt ein Zugangstoken für einen Raum ausstellen +--- @param raum string +--- @param rolle string 'viewer' | 'publisher' +--- @param identitaet table { name, lizenz } +--- @return string|nil Token +local function StelleTokenAus(raum, rolle, identitaet) + local token = Zufall(40) + local antwort = AnMediaServer('/grant', { + token = token, + raum = raum, + rolle = rolle, + name = identitaet.name, + gueltigSek = AdminConfig.Media.tokenGueltig, + }) + if not antwort or antwort.status ~= 200 then + return nil + end + return token +end + +--- Weist einen Raum an, wieder abgebaut zu werden (best effort) +--- @param raum string +local function RaumAufloesen(raum) + local eintrag = raeume[raum] + if not eintrag then return end + + for serverId in pairs(eintrag.ziele) do + if GetPlayerName(serverId) then + TriggerClientEvent('aether-admin:kamera:stopp', serverId, raum) + end + end + + AnMediaServer('/revoke', { raum = raum }) + raeume[raum] = nil +end + +-- --------------------------------------------------------------------- +-- Aktionen aus dem Panel +-- --------------------------------------------------------------------- + +--- Öffnet eine Kamera-View auf eine oder mehrere Ziel-Server-IDs. +--- daten = { ziele = { serverId, ... }, name? } +Admin.Kern.Registriere('kamera', 'oeffnen', function(handelnder, daten) + local bereit, grund = Bereit() + if not bereit then return nil, grund end + + local ziele = type(daten.ziele) == 'table' and daten.ziele or {} + if #ziele == 0 then return nil, 'Keine Ziel-Spieler gewählt.' end + + local raum = 'r_' .. Zufall(12) + + -- Zuschauer-Token für den Admin + local viewerToken = StelleTokenAus(raum, 'viewer', { + name = handelnder.name, lizenz = handelnder.lizenz, + }) + if not viewerToken then + return nil, 'Media-Server nicht erreichbar. Läuft er? (siehe media-server/)' + end + + raeume[raum] = { admin = handelnder.quelle, ziele = {}, gestartet = os.time() } + + -- Jeden erreichbaren Ziel-Client zum Publizieren auffordern + local aufgefordert, namen = 0, {} + for _, roh in ipairs(ziele) do + local serverId = tonumber(roh) + if serverId and GetPlayerName(serverId) then + local pubToken = StelleTokenAus(raum, 'publisher', { + name = GetPlayerName(serverId), + }) + if pubToken then + raeume[raum].ziele[serverId] = true + aufgefordert = aufgefordert + 1 + namen[#namen + 1] = GetPlayerName(serverId) + TriggerClientEvent('aether-admin:kamera:publiziere', serverId, { + raum = raum, url = AdminConfig.Media.url, token = pubToken, + }) + end + end + end + + if aufgefordert == 0 then + RaumAufloesen(raum) + return nil, 'Kein Ziel ist erreichbar (offline?).' + end + + Admin.Log.Schreibe(handelnder, 'Live-Kamera geöffnet', + table.concat(namen, ', '), aufgefordert .. ' Ziel(e), Raum ' .. raum) + + return { + raum = raum, + url = AdminConfig.Media.url, + token = viewerToken, + ziele = aufgefordert, + } +end) + +--- Schließt eine Kamera-View +Admin.Kern.Registriere('kamera', 'schliessen', function(handelnder, daten) + local raum = tostring(daten.raum or '') + if not raeume[raum] then return { ok = true } end + if raeume[raum].admin ~= handelnder.quelle and not Admin.Auth.Hat(handelnder.quelle, 'owner') then + return nil, 'Diese View gehört einem anderen Admin.' + end + + RaumAufloesen(raum) + Admin.Log.Schreibe(handelnder, 'Live-Kamera geschlossen', nil, 'Raum ' .. raum) + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Statusmeldung eines Publishers an den beobachtenden Admin +-- (klappte die Bildschirmfreigabe oder nur der Platzhalter?) +-- --------------------------------------------------------------------- +RegisterNetEvent('aether-admin:kamera:status', function(raum, typ, fehler) + local quelle = source + local eintrag = raeume[tostring(raum or '')] + -- Nur echte Publisher dieses Raums dürfen melden + if not eintrag or not eintrag.ziele[quelle] then return end + if not eintrag.admin or not GetPlayerName(eintrag.admin) then return end + + TriggerClientEvent('aether-admin:kamera:status', eintrag.admin, { + quelle = GetPlayerName(quelle), + typ = tostring(typ or ''), + fehler = tostring(fehler or ''), + }) +end) + +-- --------------------------------------------------------------------- +-- Aufräumen +-- --------------------------------------------------------------------- + +-- Räume schließen, deren Admin oder alle Ziele weg sind +AddEventHandler('playerDropped', function() + local weg = source + for raum, eintrag in pairs(raeume) do + if eintrag.admin == weg then + RaumAufloesen(raum) + elseif eintrag.ziele[weg] then + eintrag.ziele[weg] = nil + local rest = 0 + for _ in pairs(eintrag.ziele) do rest = rest + 1 end + if rest == 0 then RaumAufloesen(raum) end + end + end +end) + +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + for raum in pairs(raeume) do RaumAufloesen(raum) end +end) diff --git a/resources/[aether]/aether-admin/server/kern.lua b/resources/[aether]/aether-admin/server/kern.lua new file mode 100644 index 0000000..b7f1547 --- /dev/null +++ b/resources/[aether]/aether-admin/server/kern.lua @@ -0,0 +1,89 @@ +-- ===================================================================== +-- aether-admin — Kern +-- +-- Ein einziger Weg führt von der Oberfläche zu einer Aktion: `aufruf`. +-- Der Kern nimmt ihn entgegen, prüft das Recht anhand der Rechte-Matrix +-- und ruft die registrierte Funktion. Jedes Fachmodul meldet nur seine +-- Aktionen an — die Prüfung passiert hier, an einer Stelle, für alle. +-- ===================================================================== + +Admin = Admin or {} +Admin.Kern = {} + +-- Registrierte Aktionen: ['modul.aktion'] = funktion +local aktionen = {} + +--- Registriert eine Aktion. +--- @param modul string Modulkennung (z. B. 'spieler') +--- @param aktion string Aktionsname (z. B. 'kick') +--- @param funktion function fun(handelnder, daten): ergebnis, fehler +function Admin.Kern.Registriere(modul, aktion, funktion) + aktionen[modul .. '.' .. aktion] = funktion +end + +--- Führt eine Aktion aus — inklusive Rechteprüfung. +--- @param quelle number Server-ID des Admins +--- @param modul string +--- @param aktion string +--- @param daten table Nutzdaten +--- @return any Ergebnis, string|nil Fehler +function Admin.Kern.FuehreAus(quelle, modul, aktion, daten) + local schluessel = tostring(modul) .. '.' .. tostring(aktion) + local funktion = aktionen[schluessel] + + if not funktion then + return nil, 'Unbekannte Aktion.' + end + + -- Panel-Grundrecht + if not Admin.Auth.DarfOeffnen(quelle) then + return nil, 'Kein Zugriff auf das Admin-Panel.' + end + + -- Recht auf genau diese Aktion + local benoetigt = AdminConfig.RechtFuer(schluessel) + if not Admin.Auth.Hat(quelle, benoetigt) then + return nil, ('Dafür fehlt dir die Berechtigung (%s).'):format(benoetigt) + end + + local handelnder = Admin.Auth.Handelnder(quelle) + local erfolg, ergebnis, fehler = pcall(funktion, handelnder, daten or {}) + if not erfolg then + print(('[aether-admin] Fehler in %s: %s'):format(schluessel, tostring(ergebnis))) + return nil, 'Interner Fehler in der Aktion.' + end + + return ergebnis, fehler +end + +--- Liefert die für einen Admin sichtbaren Sektionen (nach Gruppe gefiltert) +--- @param quelle number +--- @return table Liste { id, label, icon, grp } +function Admin.Kern.SichtbareSektionen(quelle) + local gruppe = Admin.Auth.Gruppe(quelle) + local liste = {} + for _, eintrag in ipairs(AdminConfig.Sektionen) do + if eintrag.grp then + liste[#liste + 1] = { grp = eintrag.grp } + elseif AdminConfig.Erfuellt(gruppe, eintrag.min) then + liste[#liste + 1] = { + id = eintrag.id, label = eintrag.label, icon = eintrag.icon, + } + end + end + return liste +end + +--- Baut das Rechte-Abbild für die Oberfläche: welche Aktionen darf dieser +--- Admin? Die UI blendet danach Knöpfe aus — die echte Prüfung bleibt aber +--- serverseitig. Das hier ist nur Kosmetik, kein Torwächter. +--- @param quelle number +--- @return table { ['modul.aktion'] = true, ... } +function Admin.Kern.RechteAbbild(quelle) + local gruppe = Admin.Auth.Gruppe(quelle) + local abbild = {} + for schluessel, benoetigt in pairs(AdminConfig.Rechte) do + abbild[schluessel] = AdminConfig.Erfuellt(gruppe, benoetigt) + end + return abbild +end diff --git a/resources/[aether]/aether-admin/server/log.lua b/resources/[aether]/aether-admin/server/log.lua new file mode 100644 index 0000000..9e526be --- /dev/null +++ b/resources/[aether]/aether-admin/server/log.lua @@ -0,0 +1,69 @@ +-- ===================================================================== +-- aether-admin — Prüfprotokoll +-- +-- Jede eingreifende Aktion hinterlässt eine Spur: wer, was, an wem, +-- mit welchem Detail. Einträge werden nur geschrieben und gelesen — +-- nie geändert oder entfernt. +-- ===================================================================== + +Admin = Admin or {} +Admin.Log = {} + +--- Schreibt einen Protokolleintrag +--- @param handelnder table|nil { lizenz, name } — der Admin +--- @param aktion string Was getan wurde +--- @param ziel string|nil An wem / woran +--- @param detail string|nil Zusatz +function Admin.Log.Schreibe(handelnder, aktion, ziel, detail) + MySQL.insert([[ + INSERT INTO aether_admin_log (admin_license, admin_name, aktion, ziel, detail) + VALUES (?, ?, ?, ?, ?) + ]], { + handelnder and handelnder.lizenz or nil, + handelnder and handelnder.name or 'System', + aktion, + ziel and tostring(ziel):sub(1, 96) or nil, + detail and tostring(detail):sub(1, 512) or nil, + }) +end + +--- Liest Protokolleinträge mit einfachen Filtern +--- @param filter table { suche?, admin?, limit? } +--- @return table +function Admin.Log.Lies(filter) + filter = filter or {} + local bedingungen, werte = {}, {} + + if filter.admin and filter.admin ~= '' then + bedingungen[#bedingungen + 1] = 'admin_name = ?' + werte[#werte + 1] = filter.admin + end + if filter.suche and filter.suche ~= '' then + local muster = '%' .. filter.suche .. '%' + bedingungen[#bedingungen + 1] = '(aktion LIKE ? OR ziel LIKE ? OR detail LIKE ?)' + werte[#werte + 1] = muster + werte[#werte + 1] = muster + werte[#werte + 1] = muster + end + + local wo = #bedingungen > 0 and ('WHERE ' .. table.concat(bedingungen, ' AND ')) or '' + werte[#werte + 1] = math.min(tonumber(filter.limit) or 200, 500) + + return MySQL.query.await(([[ + SELECT admin_name, aktion, ziel, detail, erstellt_am + FROM aether_admin_log + %s + ORDER BY id DESC + LIMIT ? + ]]):format(wo), werte) or {} +end + +--- Liste der Admins, die je etwas protokolliert haben (für den Filter) +--- @return table +function Admin.Log.Admins() + local zeilen = MySQL.query.await( + 'SELECT DISTINCT admin_name FROM aether_admin_log ORDER BY admin_name') or {} + local liste = {} + for _, z in ipairs(zeilen) do liste[#liste + 1] = z.admin_name end + return liste +end diff --git a/resources/[aether]/aether-admin/server/main.lua b/resources/[aether]/aether-admin/server/main.lua new file mode 100644 index 0000000..ccfdec9 --- /dev/null +++ b/resources/[aether]/aether-admin/server/main.lua @@ -0,0 +1,80 @@ +-- ===================================================================== +-- aether-admin — Server-Schnittstelle +-- +-- Ein einziges Netz-Event bedient das ganze Panel. Jede Anfrage +-- durchläuft die Rechteprüfung im Kern, bevor sie ein Modul erreicht. +-- ===================================================================== + +Admin = Admin or {} +Admin.Live = Admin.Live or { pos = {}, god = {}, frozen = {}, muted = {} } + +-- --------------------------------------------------------------------- +-- Panel öffnen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether-admin:oeffnen', function() + local quelle = source + + if not Admin.Auth.DarfOeffnen(quelle) then + TriggerClientEvent('aether-admin:verweigert', quelle, + 'Du hast keinen Zugriff auf das Admin-Panel.') + return + end + + local gruppe = Admin.Auth.Gruppe(quelle) + TriggerClientEvent('aether-admin:zeige', quelle, { + admin = { name = Admin.Auth.Name(quelle), gruppe = gruppe }, + sektionen = Admin.Kern.SichtbareSektionen(quelle), + rechte = Admin.Kern.RechteAbbild(quelle), + serverName = GetConvar('sv_hostname', 'Aether RP'), + media = { aktiv = AdminConfig.Media.aktiv }, + }) + Admin.Log.Schreibe(Admin.Auth.Handelnder(quelle), 'Panel geöffnet') +end) + +-- --------------------------------------------------------------------- +-- Zentraler Aufruf: die Oberfläche fragt, ein Modul antwortet +-- --------------------------------------------------------------------- +RegisterNetEvent('aether-admin:aufruf', function(modul, aktion, daten, ruf) + local quelle = source + local ergebnis, fehler = Admin.Kern.FuehreAus( + quelle, tostring(modul or ''), tostring(aktion or ''), daten or {}) + TriggerClientEvent('aether-admin:antwort', quelle, ruf, ergebnis, fehler) +end) + +-- --------------------------------------------------------------------- +-- Positions-Cache (für Weltkarte, Teleport, Spectate) +-- --------------------------------------------------------------------- +RegisterNetEvent('aether-admin:pos', function(pos) + if type(pos) == 'table' and pos.x then + Admin.Live.pos[source] = { x = pos.x + 0.0, y = pos.y + 0.0, z = pos.z + 0.0 } + end +end) + +-- --------------------------------------------------------------------- +-- Spieler-Report aus dem Spiel (/report) +-- --------------------------------------------------------------------- +RegisterNetEvent('aether-admin:report', function(betreff, nachricht) + local quelle = source + if type(betreff) ~= 'string' or #betreff < 2 then return end + Admin.ErstelleReport(quelle, betreff:sub(1, 160), nachricht) + TriggerClientEvent('aether-admin:client:hinweis', quelle, + '✅ Dein Report wurde an das Team übermittelt.') +end) + +-- --------------------------------------------------------------------- +-- Neuen Spielern den aktuellen Weltzustand mitgeben +-- --------------------------------------------------------------------- +AddEventHandler('aether:server:spielerGeladen', function(quelle) + if Admin.SendeWelt then Admin.SendeWelt(quelle) end +end) + +-- --------------------------------------------------------------------- +-- Aufräumen +-- --------------------------------------------------------------------- +AddEventHandler('playerDropped', function() + local weg = source + Admin.Live.pos[weg] = nil + Admin.Live.god[weg] = nil + Admin.Live.frozen[weg] = nil + Admin.Live.muted[weg] = nil +end) diff --git a/resources/[aether]/aether-admin/server/module/broadcast.lua b/resources/[aether]/aether-admin/server/module/broadcast.lua new file mode 100644 index 0000000..78db71a --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/broadcast.lua @@ -0,0 +1,32 @@ +-- ===================================================================== +-- aether-admin / Modul: Ankündigungen +-- +-- Eine Nachricht an den ganzen Server. Sie wird verschickt und der +-- Verlauf bleibt nachlesbar. +-- ===================================================================== + +Admin = Admin or {} + +Admin.Kern.Registriere('broadcast', 'laden', function(handelnder, daten) + return { + verlauf = MySQL.query.await([[ + SELECT typ, nachricht, admin_name, erstellt_am + FROM aether_admin_broadcasts ORDER BY id DESC LIMIT 30 + ]]) or {}, + } +end) + +Admin.Kern.Registriere('broadcast', 'senden', function(handelnder, daten) + local typ = tostring(daten.typ or 'info') + local text = tostring(daten.nachricht or ''):sub(1, 512) + if #text < 2 then return nil, 'Bitte eine Nachricht eingeben.' end + + MySQL.insert.await([[ + INSERT INTO aether_admin_broadcasts (typ, nachricht, admin_name) + VALUES (?, ?, ?) + ]], { typ, text, handelnder.name }) + + TriggerClientEvent('aether-admin:client:broadcast', -1, { typ = typ, text = text }) + Admin.Log.Schreibe(handelnder, 'Ankündigung', typ, text:sub(1, 80)) + return { ok = true } +end) diff --git a/resources/[aether]/aether-admin/server/module/dashboard.lua b/resources/[aether]/aether-admin/server/module/dashboard.lua new file mode 100644 index 0000000..993dafb --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/dashboard.lua @@ -0,0 +1,59 @@ +-- ===================================================================== +-- aether-admin / Modul: Dashboard +-- +-- Der Überblick. Jede Zahl kommt frisch — nichts wird geschätzt. Die +-- Spielerkurve ist ein echtes, über die Laufzeit gesammeltes Abbild +-- (kein erfundener Verlauf). +-- ===================================================================== + +Admin = Admin or {} + +-- Rollierender Verlauf der Spielerzahl (ein Wert je Sammelintervall) +local verlauf = {} +local MAX_PUNKTE = 24 + +CreateThread(function() + while true do + local anzahl = #(GetPlayers() or {}) + verlauf[#verlauf + 1] = anzahl + if #verlauf > MAX_PUNKTE then table.remove(verlauf, 1) end + Wait(60000) -- jede Minute ein Punkt + end +end) + +Admin.Kern.Registriere('dashboard', 'laden', function(handelnder, daten) + local online = #(GetPlayers() or {}) + local slots = GetConvarInt('sv_maxclients', 48) + + local offeneReports = tonumber(MySQL.scalar.await( + "SELECT COUNT(*) FROM aether_admin_reports WHERE status <> 'geschlossen'")) or 0 + local aktiveBans = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM aether_admin_bans WHERE aktiv = 1')) or 0 + + -- Ressourcen-Kurzstatus + local resAnzahl = GetNumResources() or 0 + local laufend = 0 + for i = 0, resAnzahl - 1 do + local r = GetResourceByFindIndex(i) + if r and GetResourceState(r) == 'started' then laufend = laufend + 1 end + end + + -- Spieler mit hohem Ping (grobe Health-Anzeige) + local hoherPing = 0 + for _, sid in ipairs(GetPlayers() or {}) do + if (GetPlayerPing(tonumber(sid)) or 0) >= 120 then hoherPing = hoherPing + 1 end + end + + return { + online = online, + slots = slots, + auslastung = slots > 0 and math.floor(online / slots * 100) or 0, + offeneReports = offeneReports, + aktiveBans = aktiveBans, + hoherPing = hoherPing, + ressourcen = { laufend = laufend, gesamt = resAnzahl }, + verlauf = #verlauf > 1 and verlauf or { online, online }, + letzteLogs = Admin.Log.Lies({ limit = 6 }), + serverName = GetConvar('sv_hostname', 'Aether RP'), + } +end) diff --git a/resources/[aether]/aether-admin/server/module/fahrzeuge.lua b/resources/[aether]/aether-admin/server/module/fahrzeuge.lua new file mode 100644 index 0000000..2e707b2 --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/fahrzeuge.lua @@ -0,0 +1,85 @@ +-- ===================================================================== +-- aether-admin / Modul: Fahrzeuge +-- +-- Die Fahrzeuge in der Welt — serverseitig über GetAllVehicles erfasst, +-- also echt und nicht simuliert. Löschen und Aufräumen geschehen direkt +-- serverseitig; Reparieren und Herholen brauchen einen Client in der +-- Nähe und werden dorthin weitergereicht. +-- ===================================================================== + +Admin = Admin or {} + +Admin.Kern.Registriere('fahrzeuge', 'laden', function(handelnder, daten) + local liste = {} + for _, veh in ipairs(GetAllVehicles() or {}) do + local pos = GetEntityCoords(veh) + local leer = GetVehicleNumberOfPassengers(veh) == 0 and IsVehicleSeatFree(veh, -1) + liste[#liste + 1] = { + netId = NetworkGetNetworkIdFromEntity(veh), + plate = (GetVehicleNumberPlateText(veh) or ''):gsub('%s+$', ''), + x = pos.x, y = pos.y, z = pos.z, + leer = leer, + } + if #liste >= 300 then break end + end + + -- Online-Spieler als mögliche Empfänger eines Spawns + local online = {} + for _, sid in ipairs(GetPlayers() or {}) do + online[#online + 1] = { id = tonumber(sid), name = GetPlayerName(tonumber(sid)) } + end + + return { fahrzeuge = liste, anzahl = #liste, spieler = online, + rechte = Admin.Kern.RechteAbbild(handelnder.quelle) } +end) + +Admin.Kern.Registriere('fahrzeuge', 'spawn', function(handelnder, daten) + local modell = tostring(daten.modell or ''):gsub('%s', '') + if modell == '' then return nil, 'Kein Modell gewählt.' end + local ziel = tonumber(daten.ziel) or handelnder.quelle + if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end + + TriggerClientEvent('aether-admin:client:fahrzeug', ziel, modell) + Admin.Log.Schreibe(handelnder, 'Fahrzeug gespawnt', GetPlayerName(ziel), modell) + return { ok = true } +end) + +Admin.Kern.Registriere('fahrzeuge', 'loeschen', function(handelnder, daten) + local netId = tonumber(daten.netId) + if not netId then return nil, 'Ungültige Kennung.' end + local veh = NetworkGetEntityFromNetworkId(netId) + if not veh or veh == 0 or not DoesEntityExist(veh) then + return nil, 'Fahrzeug existiert nicht mehr.' + end + DeleteEntity(veh) + Admin.Log.Schreibe(handelnder, 'Fahrzeug gelöscht', nil, 'net ' .. netId) + return { ok = true } +end) + +Admin.Kern.Registriere('fahrzeuge', 'reparieren', function(handelnder, daten) + local netId = tonumber(daten.netId) + if not netId then return nil, 'Ungültige Kennung.' end + -- Der Client des Admins repariert, sobald das Fahrzeug in Reichweite ist + TriggerClientEvent('aether-admin:client:fahrzeugFix', handelnder.quelle, netId) + Admin.Log.Schreibe(handelnder, 'Fahrzeug repariert', nil, 'net ' .. netId) + return { ok = true } +end) + +Admin.Kern.Registriere('fahrzeuge', 'herholen', function(handelnder, daten) + local netId = tonumber(daten.netId) + if not netId then return nil, 'Ungültige Kennung.' end + TriggerClientEvent('aether-admin:client:fahrzeugHer', handelnder.quelle, netId) + Admin.Log.Schreibe(handelnder, 'Fahrzeug hergeholt', nil, 'net ' .. netId) + return { ok = true } +end) + +Admin.Kern.Registriere('fahrzeuge', 'aufraeumen', function(handelnder, daten) + local entfernt = 0 + for _, veh in ipairs(GetAllVehicles() or {}) do + if GetVehicleNumberOfPassengers(veh) == 0 and IsVehicleSeatFree(veh, -1) then + DeleteEntity(veh); entfernt = entfernt + 1 + end + end + Admin.Log.Schreibe(handelnder, 'Fahrzeuge aufgeräumt', nil, entfernt .. ' entfernt') + return { ok = true, entfernt = entfernt } +end) diff --git a/resources/[aether]/aether-admin/server/module/fraktionen.lua b/resources/[aether]/aether-admin/server/module/fraktionen.lua new file mode 100644 index 0000000..f0db26d --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/fraktionen.lua @@ -0,0 +1,88 @@ +-- ===================================================================== +-- aether-admin / Modul: Fraktionen +-- +-- Mitgliedschaft ergibt sich aus aether_characters.faction. Hier liegen +-- nur die Zusatzangaben (Gehalt, Farbe). Der Payday zahlt das Gehalt an +-- alle Online-Mitglieder über den Core aus. +-- ===================================================================== + +Admin = Admin or {} + +Admin.Kern.Registriere('fraktionen', 'laden', function(handelnder, daten) + local meta = MySQL.query.await( + 'SELECT name, gehalt, farbe, aktiv FROM aether_admin_factions ORDER BY name') or {} + + -- Mitgliederzahl je Fraktion aus den Charakteren + local zaehl = MySQL.query.await([[ + SELECT faction AS name, COUNT(*) AS anzahl + FROM aether_characters WHERE deleted = 0 AND faction IS NOT NULL AND faction <> '' + GROUP BY faction + ]]) or {} + local nachName = {} + for _, z in ipairs(zaehl) do nachName[z.name] = tonumber(z.anzahl) end + + -- Online-Mitglieder + local online = {} + local alle = exports['aether-core']:HoleAlleSpieler() or {} + for _, d in pairs(alle) do + if d.fraktion then online[d.fraktion] = (online[d.fraktion] or 0) + 1 end + end + + for _, f in ipairs(meta) do + f.mitglieder = nachName[f.name] or 0 + f.online = online[f.name] or 0 + f.aktiv = tonumber(f.aktiv) == 1 + end + + return { fraktionen = meta, rechte = Admin.Kern.RechteAbbild(handelnder.quelle) } +end) + +Admin.Kern.Registriere('fraktionen', 'speichern', function(handelnder, daten) + local name = tostring(daten.name or ''):sub(1, 48) + if #name < 2 then return nil, 'Bitte einen Namen angeben.' end + local gehalt = math.max(0, math.floor(tonumber(daten.gehalt) or 0)) + local farbe = tostring(daten.farbe or '#34d39a'):sub(1, 9) + + MySQL.prepare.await([[ + INSERT INTO aether_admin_factions (name, gehalt, farbe) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE gehalt = VALUES(gehalt), farbe = VALUES(farbe) + ]], { name, gehalt, farbe }) + + Admin.Log.Schreibe(handelnder, 'Fraktion gespeichert', name, gehalt .. ' €/h') + return { ok = true } +end) + +Admin.Kern.Registriere('fraktionen', 'loeschen', function(handelnder, daten) + local name = tostring(daten.name or '') + if name == '' then return nil, 'Keine Fraktion angegeben.' end + + MySQL.update.await('DELETE FROM aether_admin_factions WHERE name = ?', { name }) + -- Mitgliedschaft der Charaktere lösen + MySQL.update.await( + 'UPDATE aether_characters SET faction = NULL WHERE faction = ?', { name }) + + Admin.Log.Schreibe(handelnder, 'Fraktion gelöscht', name) + return { ok = true } +end) + +Admin.Kern.Registriere('fraktionen', 'payday', function(handelnder, daten) + local name = tostring(daten.name or '') + local zeilen = MySQL.query.await( + 'SELECT gehalt FROM aether_admin_factions WHERE name = ?', { name }) + if not zeilen or not zeilen[1] then return nil, 'Fraktion nicht gefunden.' end + local gehalt = tonumber(zeilen[1].gehalt) or 0 + if gehalt <= 0 then return nil, 'Diese Fraktion zahlt kein Gehalt.' end + + local anzahl = 0 + local alle = exports['aether-core']:HoleAlleSpieler() or {} + for serverId, d in pairs(alle) do + if d.fraktion == name then + exports['aether-core']:FuegeGeldHinzu(tonumber(serverId), 'bank', gehalt, + 'Payday ' .. name) + anzahl = anzahl + 1 + end + end + + Admin.Log.Schreibe(handelnder, 'Payday', name, anzahl .. ' × ' .. gehalt .. ' €') + return { ok = true, anzahl = anzahl } +end) diff --git a/resources/[aether]/aether-admin/server/module/immobilien.lua b/resources/[aether]/aether-admin/server/module/immobilien.lua new file mode 100644 index 0000000..0d44bf8 --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/immobilien.lua @@ -0,0 +1,51 @@ +-- ===================================================================== +-- aether-admin / Modul: Immobilien +-- +-- Solange kein eigenständiges Immobilien-Resource läuft, verwaltet das +-- Panel die Objekte. Der Besitzer ist ein Charakter aus aether_characters. +-- ===================================================================== + +Admin = Admin or {} + +Admin.Kern.Registriere('immobilien', 'laden', function(handelnder, daten) + local liste = MySQL.query.await([[ + SELECT i.id, i.name, i.ort, i.preis, i.besitzer, + CONCAT(c.firstname, ' ', c.lastname) AS besitzerName + FROM aether_admin_properties i + LEFT JOIN aether_characters c ON c.id = i.besitzer + ORDER BY i.id + ]]) or {} + + -- Online-Charaktere zur Auswahl als Besitzer + local online = {} + local alle = exports['aether-core']:HoleAlleSpieler() or {} + for _, d in pairs(alle) do + online[#online + 1] = { + charakterId = d.charakterId, + name = (d.vorname or '?') .. ' ' .. (d.nachname or ''), + } + end + + return { immobilien = liste, spieler = online, + rechte = Admin.Kern.RechteAbbild(handelnder.quelle) } +end) + +Admin.Kern.Registriere('immobilien', 'zuweisen', function(handelnder, daten) + local id = tonumber(daten.id) + if not id then return nil, 'Ungültige Kennung.' end + local besitzer = tonumber(daten.besitzer) -- nil = frei + + MySQL.update.await('UPDATE aether_admin_properties SET besitzer = ? WHERE id = ?', + { besitzer, id }) + Admin.Log.Schreibe(handelnder, 'Immobilie zugewiesen', 'Objekt #' .. id, + besitzer and ('Charakter #' .. besitzer) or 'frei') + return { ok = true } +end) + +Admin.Kern.Registriere('immobilien', 'raeumen', function(handelnder, daten) + local id = tonumber(daten.id) + if not id then return nil, 'Ungültige Kennung.' end + MySQL.update.await('UPDATE aether_admin_properties SET besitzer = NULL WHERE id = ?', { id }) + Admin.Log.Schreibe(handelnder, 'Immobilie geräumt', 'Objekt #' .. id) + return { ok = true } +end) diff --git a/resources/[aether]/aether-admin/server/module/logs.lua b/resources/[aether]/aether-admin/server/module/logs.lua new file mode 100644 index 0000000..43a89c1 --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/logs.lua @@ -0,0 +1,17 @@ +-- ===================================================================== +-- aether-admin / Modul: Audit-Log +-- +-- Liest das Prüfprotokoll mit Filtern. Geschrieben wird es überall dort, +-- wo etwas geschieht — hier wird es nur sichtbar gemacht. +-- ===================================================================== + +Admin = Admin or {} + +Admin.Kern.Registriere('logs', 'laden', function(handelnder, daten) + return { + eintraege = Admin.Log.Lies({ + suche = daten.suche, admin = daten.admin, limit = daten.limit, + }), + admins = Admin.Log.Admins(), + } +end) diff --git a/resources/[aether]/aether-admin/server/module/moderation.lua b/resources/[aether]/aether-admin/server/module/moderation.lua new file mode 100644 index 0000000..b398afe --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/moderation.lua @@ -0,0 +1,59 @@ +-- ===================================================================== +-- aether-admin / Modul: Bans & Verwarnungen +-- +-- Die Durchsetzung der Bans liegt in aether_users (der Core sperrt +-- darüber beim Connect). Dieses Modul liest den Verlauf, hebt Bans auf +-- und erlaubt manuelle Sperren — auch für Offline-Spieler. +-- ===================================================================== + +Admin = Admin or {} + +Admin.Kern.Registriere('moderation', 'laden', function(handelnder, daten) + local bans = Admin.DB.AktiveBans() + local warns = MySQL.query.await([[ + SELECT id, name, grund, admin_name, erstellt_am + FROM aether_admin_warns ORDER BY id DESC LIMIT 100 + ]]) or {} + return { + bans = bans, + warns = warns, + rechte = Admin.Kern.RechteAbbild(handelnder.quelle), + } +end) + +Admin.Kern.Registriere('moderation', 'unban', function(handelnder, daten) + local banId = tonumber(daten.id) + if not banId then return nil, 'Ungültige Kennung.' end + if not Admin.DB.Entsperre(banId, handelnder.name) then + return nil, 'Bann nicht gefunden oder bereits aufgehoben.' + end + Admin.Log.Schreibe(handelnder, 'Entbannt', nil, 'Ban #' .. banId) + return { ok = true } +end) + +Admin.Kern.Registriere('moderation', 'warnWeg', function(handelnder, daten) + local warnId = tonumber(daten.id) + if not warnId then return nil, 'Ungültige Kennung.' end + MySQL.update.await('DELETE FROM aether_admin_warns WHERE id = ?', { warnId }) + Admin.Log.Schreibe(handelnder, 'Verwarnung gelöscht', nil, 'Warn #' .. warnId) + return { ok = true } +end) + +Admin.Kern.Registriere('moderation', 'banManuell', function(handelnder, daten) + local name = tostring(daten.name or ''):sub(1, 64) + if #name < 2 then return nil, 'Bitte Name oder Kennung angeben.' end + + local grund = tostring(daten.grund or 'Regelbruch'):sub(1, 255) + local dauer = tostring(daten.dauer or 'permanent') + local bis = Admin.BerechneBanEnde(dauer) + + -- Sieht die Kennung wie eine Lizenz aus, wird direkt gesperrt (greift beim Connect) + local lizenz = daten.lizenz and tostring(daten.lizenz) or nil + if not lizenz and name:match('^%x%x%x%x%x%x%x%x') and #name >= 32 then + lizenz = name + end + + Admin.DB.Sperre(lizenz, name, grund, bis, handelnder.lizenz, handelnder.name) + Admin.Log.Schreibe(handelnder, 'Manueller Ban (' .. dauer .. ')', name, grund) + return { ok = true } +end) diff --git a/resources/[aether]/aether-admin/server/module/reports.lua b/resources/[aether]/aether-admin/server/module/reports.lua new file mode 100644 index 0000000..4a1f4be --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/reports.lua @@ -0,0 +1,114 @@ +-- ===================================================================== +-- aether-admin / Modul: Reports / Support +-- +-- Spieler melden über `/report` — das Team übernimmt, antwortet und +-- schließt hier. Antworten gehen als Hinweis zurück an den Melder, +-- falls er online ist. +-- ===================================================================== + +Admin = Admin or {} + +--- Legt einen Report an (aus dem Spiel heraus aufgerufen) +--- @param quelle number Server-ID des Melders +--- @param betreff string +--- @param nachricht string|nil +--- @return number Report-ID +function Admin.ErstelleReport(quelle, betreff, nachricht) + local id = MySQL.insert.await([[ + INSERT INTO aether_admin_reports (melder_license, melder_name, betreff, nachricht) + VALUES (?, ?, ?, ?) + ]], { Admin.Auth.Lizenz(quelle), GetPlayerName(quelle), + tostring(betreff):sub(1, 160), nachricht and tostring(nachricht):sub(1, 2000) or nil }) + + -- Team im Dienst benachrichtigen + for _, sid in ipairs(GetPlayers() or {}) do + local s = tonumber(sid) + if Admin.Auth.Hat(s, 'support') then + TriggerClientEvent('aether-admin:client:hinweis', s, + '🎫 Neuer Report von ' .. GetPlayerName(quelle)) + end + end + return id +end + +Admin.Kern.Registriere('reports', 'laden', function(handelnder, daten) + local liste = MySQL.query.await([[ + SELECT id, melder_name, betreff, nachricht, prioritaet, status, bearbeiter, + erstellt_am, aktualisiert_am + FROM aether_admin_reports + ORDER BY (status = 'geschlossen'), prioritaet, id DESC + LIMIT 100 + ]]) or {} + + -- Für ein offenes Detail: Nachrichtenverlauf mitliefern + local detailId = tonumber(daten.detail) + local verlauf = {} + if detailId then + verlauf = MySQL.query.await([[ + SELECT autor, ist_admin, nachricht, erstellt_am + FROM aether_admin_report_msgs WHERE report = ? ORDER BY id + ]], { detailId }) or {} + end + + return { reports = liste, verlauf = verlauf, detail = detailId, + rechte = Admin.Kern.RechteAbbild(handelnder.quelle) } +end) + +Admin.Kern.Registriere('reports', 'claim', function(handelnder, daten) + local id = tonumber(daten.id) + if not id then return nil, 'Ungültige Kennung.' end + MySQL.update.await([[ + UPDATE aether_admin_reports SET status = 'bearbeitung', bearbeiter = ? + WHERE id = ? AND status <> 'geschlossen' + ]], { handelnder.name, id }) + Admin.Log.Schreibe(handelnder, 'Report übernommen', 'Report #' .. id) + return { ok = true } +end) + +Admin.Kern.Registriere('reports', 'status', function(handelnder, daten) + local id = tonumber(daten.id) + local status = tostring(daten.status or '') + local erlaubt = { offen = true, bearbeitung = true, geschlossen = true } + if not id or not erlaubt[status] then return nil, 'Ungültige Angabe.' end + + MySQL.update.await('UPDATE aether_admin_reports SET status = ? WHERE id = ?', { status, id }) + Admin.Log.Schreibe(handelnder, 'Report ' .. status, 'Report #' .. id) + return { ok = true } +end) + +Admin.Kern.Registriere('reports', 'antwort', function(handelnder, daten) + local id = tonumber(daten.id) + local text = tostring(daten.text or ''):sub(1, 2000) + if not id then return nil, 'Ungültige Kennung.' end + if #text < 2 then return nil, 'Bitte eine Antwort schreiben.' end + + local report = MySQL.query.await( + 'SELECT melder_license, status FROM aether_admin_reports WHERE id = ?', { id }) + if not report or not report[1] then return nil, 'Report nicht gefunden.' end + + MySQL.insert.await([[ + INSERT INTO aether_admin_report_msgs (report, autor, ist_admin, nachricht) + VALUES (?, ?, 1, ?) + ]], { id, handelnder.name, text }) + + if report[1].status == 'offen' then + MySQL.update.await( + "UPDATE aether_admin_reports SET status = 'bearbeitung', bearbeiter = ? WHERE id = ?", + { handelnder.name, id }) + end + + -- Melder benachrichtigen, falls online + local melderQuelle + for _, sid in ipairs(GetPlayers() or {}) do + if Admin.Auth.Lizenz(tonumber(sid)) == report[1].melder_license then + melderQuelle = tonumber(sid); break + end + end + if melderQuelle then + TriggerClientEvent('aether-admin:client:hinweis', melderQuelle, + '🎫 Antwort vom Team: ' .. text:sub(1, 120)) + end + + Admin.Log.Schreibe(handelnder, 'Report beantwortet', 'Report #' .. id) + return { ok = true } +end) diff --git a/resources/[aether]/aether-admin/server/module/ressourcen.lua b/resources/[aether]/aether-admin/server/module/ressourcen.lua new file mode 100644 index 0000000..8c83702 --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/ressourcen.lua @@ -0,0 +1,68 @@ +-- ===================================================================== +-- aether-admin / Modul: Ressourcen +-- +-- Zeigt die Serverressourcen und erlaubt Start/Stopp/Neustart. Ein +-- vollständiger Server-Neustart wird angekündigt und protokolliert, +-- aber NICHT blind ausgeführt — das Beenden des Prozesses überlässt +-- das Panel bewusst der Server-/txAdmin-Ebene. +-- ===================================================================== + +Admin = Admin or {} + +Admin.Kern.Registriere('ressourcen', 'laden', function(handelnder, daten) + local anzahl = GetNumResources() or 0 + local liste = {} + for i = 0, anzahl - 1 do + local name = GetResourceByFindIndex(i) + if name then + liste[#liste + 1] = { + name = name, + status = GetResourceState(name), + version = GetResourceMetadata(name, 'version', 0) or '—', + } + end + end + table.sort(liste, function(a, b) return a.name < b.name end) + return { ressourcen = liste, rechte = Admin.Kern.RechteAbbild(handelnder.quelle) } +end) + +Admin.Kern.Registriere('ressourcen', 'steuern', function(handelnder, daten) + local name = tostring(daten.name or '') + local aktion = tostring(daten.aktion or '') + if GetResourceState(name) == 'missing' then return nil, 'Ressource unbekannt.' end + if name == GetCurrentResourceName() then + return nil, 'Das Admin-Panel kann sich nicht selbst steuern.' + end + + if aktion == 'start' then StartResource(name) + elseif aktion == 'stop' then StopResource(name) + elseif aktion == 'restart' then + StopResource(name); Wait(150); StartResource(name) + else + return nil, 'Unbekannte Aktion.' + end + + Admin.Log.Schreibe(handelnder, 'Ressource ' .. aktion, name) + return { ok = true, status = GetResourceState(name) } +end) + +Admin.Kern.Registriere('ressourcen', 'neustart', function(handelnder, daten) + local minuten = math.max(0, math.floor(tonumber(daten.minuten) or 5)) + local grund = tostring(daten.grund or ''):sub(1, 120) + + TriggerClientEvent('aether-admin:client:broadcast', -1, { + typ = 'Neustart', + text = ('Server-Neustart in %d Minute(n)%s') + :format(minuten, grund ~= '' and (' — ' .. grund) or ''), + }) + Admin.Log.Schreibe(handelnder, 'Neustart angekündigt', nil, minuten .. ' min ' .. grund) + -- Bewusst kein automatisches `quit`: das entscheidet die Betriebsebene. + return { ok = true, hinweis = 'Angekündigt und protokolliert. Der eigentliche Neustart läuft über die Server-/txAdmin-Ebene.' } +end) + +Admin.Kern.Registriere('ressourcen', 'speichern', function(handelnder, daten) + -- Andere Module können darauf hören und ihren Stand sichern + TriggerEvent('aether-admin:speichereAlle') + Admin.Log.Schreibe(handelnder, 'Speichern ausgelöst') + return { ok = true } +end) diff --git a/resources/[aether]/aether-admin/server/module/settings.lua b/resources/[aether]/aether-admin/server/module/settings.lua new file mode 100644 index 0000000..845047b --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/settings.lua @@ -0,0 +1,48 @@ +-- ===================================================================== +-- aether-admin / Modul: Einstellungen +-- +-- Server-Grundeinstellungen, persistiert in aether_admin_settings. +-- Slots sind eine reine Anzeige (kommen aus der Serverkonfiguration). +-- ===================================================================== + +Admin = Admin or {} + +local SCHALTER = { 'pvp', 'whitelist', 'voice', 'economy' } + +Admin.Kern.Registriere('settings', 'laden', function(handelnder, daten) + local schalter = {} + for _, k in ipairs(SCHALTER) do schalter[k] = Admin.DB.EinstellungAn(k, true) end + + return { + servername = GetConvar('sv_hostname', 'Aether RP'), + slots = GetConvarInt('sv_maxclients', 48), + neustartStunden = Admin.DB.EinstellungZahl('neustart_stunden', 4), + schalter = schalter, + rechte = Admin.Kern.RechteAbbild(handelnder.quelle), + } +end) + +Admin.Kern.Registriere('settings', 'speichern', function(handelnder, daten) + if daten.servername then + local name = tostring(daten.servername):sub(1, 96) + Admin.DB.SetzeEinstellung('servername', name) + -- sv_hostname zur Laufzeit setzen (greift für die Serverliste) + ExecuteCommand('sets sv_hostname "' .. name:gsub('"', '') .. '"') + end + + if daten.neustartStunden ~= nil then + Admin.DB.SetzeEinstellung('neustart_stunden', + math.max(0, math.floor(tonumber(daten.neustartStunden) or 0))) + end + + if type(daten.schalter) == 'table' then + for _, k in ipairs(SCHALTER) do + if daten.schalter[k] ~= nil then + Admin.DB.SetzeEinstellung(k, daten.schalter[k] and '1' or '0') + end + end + end + + Admin.Log.Schreibe(handelnder, 'Einstellungen gespeichert') + return { ok = true } +end) diff --git a/resources/[aether]/aether-admin/server/module/spieler.lua b/resources/[aether]/aether-admin/server/module/spieler.lua new file mode 100644 index 0000000..d1c2784 --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/spieler.lua @@ -0,0 +1,360 @@ +-- ===================================================================== +-- aether-admin / Modul: Spieler +-- +-- Die Liste der Online-Spieler und alle Eingriffe an ihnen. Nichts +-- davon vertraut der Oberfläche: jeder Aufruf ist serverseitig +-- rechtegeprüft, Zielspieler werden über ihre echte Server-ID +-- aufgelöst, und alles Eingreifende landet im Prüfprotokoll. +-- ===================================================================== + +Admin = Admin or {} +Admin.Live = Admin.Live or { pos = {}, god = {}, frozen = {}, muted = {} } + +-- Grobe Ortsangabe aus Koordinaten (Annäherung, rein informativ) +local ORTE = { + { name = 'Legion Square', x = 195.0, y = -930.0 }, + { name = 'Mission Row', x = 428.0, y = -984.0 }, + { name = 'Vinewood', x = 300.0, y = 200.0 }, + { name = 'Vespucci', x = -1230.0, y = -1500.0 }, + { name = 'Del Perro', x = -1600.0, y = -300.0 }, + { name = 'Sandy Shores', x = 1960.0, y = 3740.0 }, + { name = 'Paleto Bay', x = -100.0, y = 6330.0 }, + { name = 'LS Airport', x = -1030.0, y = -2730.0 }, + { name = 'Grove Street', x = 90.0, y = -1930.0 }, +} + +--- Nächstgelegener benannter Ort zu einer Position +--- @param pos table|nil { x, y } +--- @return string +local function OrtVon(pos) + if not pos or not pos.x then return '—' end + local beste, abstand = '—', math.huge + for _, ort in ipairs(ORTE) do + local d = (ort.x - pos.x) ^ 2 + (ort.y - pos.y) ^ 2 + if d < abstand then abstand, beste = d, ort.name end + end + return beste +end + +--- Verwarnungen eines Benutzers zählen +--- @param lizenz string|nil +--- @return number +local function WarnAnzahl(lizenz) + if not lizenz then return 0 end + return tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM aether_admin_warns WHERE license = ?', { lizenz })) or 0 +end + +--- Baut den Anzeigedatensatz eines Online-Spielers +--- @param serverId number +--- @param daten table Spielerdaten aus aether-core +--- @return table +local function SpielerZeile(serverId, daten) + local lizenz = Admin.Auth.Lizenz(serverId) + return { + id = serverId, + name = (daten.vorname or '?') .. ' ' .. (daten.nachname or ''), + charakterId = daten.charakterId, + job = daten.job or 'unemployed', + jobRang = daten.jobRang or 0, + fraktion = daten.fraktion, + cash = math.floor(daten.bargeld or 0), + bank = math.floor(daten.konto or 0), + ping = GetPlayerPing(serverId) or 0, + region = OrtVon(daten.position), + leben = daten.leben or 200, + istTot = daten.istTot and true or false, + warns = WarnAnzahl(lizenz), + god = Admin.Live.god[serverId] and true or false, + frozen = Admin.Live.frozen[serverId] and true or false, + muted = Admin.Live.muted[serverId] and true or false, + } +end + +--- Sammelt alle Online-Spieler +--- @return table Liste +function Admin.SpielerListe() + local alle = exports['aether-core']:HoleAlleSpieler() or {} + local liste = {} + for serverId, daten in pairs(alle) do + liste[#liste + 1] = SpielerZeile(tonumber(serverId), daten) + end + table.sort(liste, function(a, b) return a.id < b.id end) + return liste +end + +-- ===================================================================== +-- LISTE & DETAILS +-- ===================================================================== + +Admin.Kern.Registriere('spieler', 'liste', function(handelnder, daten) + return { + spieler = Admin.SpielerListe(), + rechte = Admin.Kern.RechteAbbild(handelnder.quelle), + jobs = { 'unemployed','police','ambulance','mechanic','taxi','trucker','lawyer','reporter','barkeeper' }, + } +end) + +Admin.Kern.Registriere('spieler', 'details', function(handelnder, daten) + local serverId = tonumber(daten.id) + if not serverId then return nil, 'Ungültige Spieler-ID.' end + + local info = exports['aether-core']:HoleSpielerInfo(serverId) + if not info then return nil, 'Spieler ist nicht (mehr) online.' end + + local lizenz = Admin.Auth.Lizenz(serverId) + + local warns = lizenz and MySQL.query.await([[ + SELECT id, grund, admin_name, erstellt_am FROM aether_admin_warns + WHERE license = ? ORDER BY id DESC LIMIT 25 + ]], { lizenz }) or {} + + local notizen = lizenz and MySQL.query.await([[ + SELECT id, notiz, admin_name, erstellt_am FROM aether_admin_notes + WHERE license = ? ORDER BY id DESC LIMIT 25 + ]], { lizenz }) or {} + + return { + spieler = SpielerZeile(serverId, info), + lizenz = lizenz, + gruppe = Admin.Auth.Gruppe(serverId), + spielzeit = info.spielzeit, + warns = warns, + notizen = notizen, + rechte = Admin.Kern.RechteAbbild(handelnder.quelle), + jobs = { 'unemployed','police','ambulance','mechanic','taxi','trucker','lawyer','reporter','barkeeper' }, + } +end) + +-- ===================================================================== +-- TELEPORT & ANSICHT +-- ===================================================================== + +--- Auflösung: Position eines Spielers (aus dem Live-Cache) +local function PosVon(serverId) + return Admin.Live.pos[serverId] +end + +Admin.Kern.Registriere('spieler', 'goto', function(handelnder, daten) + local ziel = tonumber(daten.id) + local pos = PosVon(ziel) + if not pos then return nil, 'Position des Ziels ist noch nicht bekannt.' end + TriggerClientEvent('aether-admin:client:teleport', handelnder.quelle, pos) + Admin.Log.Schreibe(handelnder, 'Zu Spieler teleportiert', GetPlayerName(ziel)) + return { ok = true } +end) + +Admin.Kern.Registriere('spieler', 'bring', function(handelnder, daten) + local ziel = tonumber(daten.id) + if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end + local pos = PosVon(handelnder.quelle) + if not pos then return nil, 'Deine eigene Position ist noch nicht bekannt.' end + TriggerClientEvent('aether-admin:client:teleport', ziel, pos) + Admin.Log.Schreibe(handelnder, 'Spieler hergeholt', GetPlayerName(ziel)) + return { ok = true } +end) + +Admin.Kern.Registriere('spieler', 'spectate', function(handelnder, daten) + local ziel = tonumber(daten.id) + if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end + TriggerClientEvent('aether-admin:client:spectate', handelnder.quelle, { + ziel = ziel, pos = PosVon(ziel), + }) + Admin.Log.Schreibe(handelnder, 'Beobachtet', GetPlayerName(ziel)) + return { ok = true } +end) + +-- ===================================================================== +-- ZUSTAND +-- ===================================================================== + +--- Kleiner Baukasten für einfache „an Ziel weiterreichen"-Aktionen +--- @param aktion string Registrierter Aktionsname +--- @param event string Client-Event am Ziel +--- @param protokoll string Protokolltext +local function ZielAktion(aktion, event, protokoll) + Admin.Kern.Registriere('spieler', aktion, function(handelnder, daten) + local ziel = tonumber(daten.id) + if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end + TriggerClientEvent(event, ziel) + Admin.Log.Schreibe(handelnder, protokoll, GetPlayerName(ziel)) + return { ok = true } + end) +end + +ZielAktion('heal', 'aether-admin:client:heal', 'Spieler geheilt') +ZielAktion('revive', 'aether-admin:client:revive', 'Spieler wiederbelebt') +ZielAktion('armor', 'aether-admin:client:armor', 'Rüstung gegeben') + +--- Umschalt-Aktion mit gemerktem Zustand (Freeze/God/Mute) +--- @param aktion string +--- @param feld string Schlüssel in Admin.Live +--- @param event string|nil Client-Event am Ziel (nil = nur serverseitig, z. B. Mute) +--- @param nomen string Für das Protokoll +local function UmschaltAktion(aktion, feld, event, nomen) + Admin.Kern.Registriere('spieler', aktion, function(handelnder, daten) + local ziel = tonumber(daten.id) + if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end + local neu = not Admin.Live[feld][ziel] + Admin.Live[feld][ziel] = neu or nil + if event then TriggerClientEvent(event, ziel, neu) end + Admin.Log.Schreibe(handelnder, nomen .. (neu and ' an' or ' aus'), GetPlayerName(ziel)) + return { ok = true, aktiv = neu } + end) +end + +UmschaltAktion('freeze', 'frozen', 'aether-admin:client:freeze', 'Einfrieren') +UmschaltAktion('god', 'god', 'aether-admin:client:god', 'Godmode') +UmschaltAktion('mute', 'muted', nil, 'Stummschaltung') + +-- ===================================================================== +-- GEBEN & SETZEN +-- ===================================================================== + +Admin.Kern.Registriere('spieler', 'geld', function(handelnder, daten) + local ziel = tonumber(daten.id) + if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end + + local konto = daten.konto == 'cash' and 'cash' or 'bank' + local betrag = math.floor(tonumber(daten.betrag) or 0) + if betrag == 0 then return nil, 'Bitte einen Betrag angeben.' end + + local ok + if betrag > 0 then + ok = exports['aether-core']:FuegeGeldHinzu(ziel, konto, betrag, 'Admin: ' .. handelnder.name) + else + ok = exports['aether-core']:EntferneGeld(ziel, konto, -betrag, 'Admin: ' .. handelnder.name) + end + if not ok then return nil, 'Nicht möglich (Deckung?).' end + + Admin.Log.Schreibe(handelnder, 'Geld ' .. (betrag > 0 and '+' or '') .. betrag, + GetPlayerName(ziel), konto) + return { ok = true } +end) + +Admin.Kern.Registriere('spieler', 'item', function(handelnder, daten) + local ziel = tonumber(daten.id) + if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end + local item = tostring(daten.item or '') + local menge = math.max(1, math.floor(tonumber(daten.menge) or 1)) + if item == '' then return nil, 'Kein Item gewählt.' end + + -- Falls ein Inventar-Resource läuft, dorthin geben; sonst nur vermerken + local ok = pcall(function() + return exports['aether-inventory']:GebeItem(ziel, item, menge) + end) + + Admin.Log.Schreibe(handelnder, 'Item gegeben', GetPlayerName(ziel), menge .. '× ' .. item) + return { ok = true, zugestellt = ok } +end) + +Admin.Kern.Registriere('spieler', 'fahrzeug', function(handelnder, daten) + local ziel = tonumber(daten.id) + if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end + local modell = tostring(daten.modell or ''):gsub('%s', '') + if modell == '' then return nil, 'Kein Modell gewählt.' end + + TriggerClientEvent('aether-admin:client:fahrzeug', ziel, modell) + Admin.Log.Schreibe(handelnder, 'Fahrzeug gespawnt', GetPlayerName(ziel), modell) + return { ok = true } +end) + +Admin.Kern.Registriere('spieler', 'job', function(handelnder, daten) + local ziel = tonumber(daten.id) + local charakterId = tonumber(daten.charakterId) + if not charakterId then return nil, 'Charakter unbekannt.' end + + local job = tostring(daten.job or 'unemployed') + local fraktion = daten.fraktion ~= '' and tostring(daten.fraktion) or nil + + MySQL.update.await( + 'UPDATE aether_characters SET job = ?, faction = ? WHERE id = ?', + { job, fraktion, charakterId }) + + -- Falls online, den Client informieren (greift spätestens beim Nachladen) + if ziel and GetPlayerName(ziel) then + TriggerClientEvent('aether-admin:client:hinweis', ziel, + 'Deine Tätigkeit wurde vom Team angepasst: ' .. job) + end + + Admin.Log.Schreibe(handelnder, 'Job/Fraktion gesetzt', + GetPlayerName(ziel) or ('Charakter #' .. charakterId), + job .. (fraktion and (' / ' .. fraktion) or '')) + return { ok = true } +end) + +-- ===================================================================== +-- MODERATION AM SPIELER +-- ===================================================================== + +Admin.Kern.Registriere('spieler', 'warn', function(handelnder, daten) + local ziel = tonumber(daten.id) + if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end + local grund = tostring(daten.grund or ''):sub(1, 255) + if #grund < 2 then return nil, 'Bitte einen Grund angeben.' end + + local lizenz = Admin.Auth.Lizenz(ziel) + local charakterId = exports['aether-core']:HoleCharakterId(ziel) + + MySQL.insert.await([[ + INSERT INTO aether_admin_warns (license, `character`, name, grund, admin_license, admin_name) + VALUES (?, ?, ?, ?, ?, ?) + ]], { lizenz, charakterId, GetPlayerName(ziel), grund, handelnder.lizenz, handelnder.name }) + + TriggerClientEvent('aether-admin:client:hinweis', ziel, '⚠️ Verwarnung: ' .. grund) + Admin.Log.Schreibe(handelnder, 'Verwarnung', GetPlayerName(ziel), grund) + return { ok = true } +end) + +Admin.Kern.Registriere('spieler', 'notiz', function(handelnder, daten) + local ziel = tonumber(daten.id) + if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end + local text = tostring(daten.text or ''):sub(1, 1000) + if #text < 2 then return nil, 'Bitte einen Text eingeben.' end + + MySQL.insert.await([[ + INSERT INTO aether_admin_notes (license, name, notiz, admin_name) + VALUES (?, ?, ?, ?) + ]], { Admin.Auth.Lizenz(ziel), GetPlayerName(ziel), text, handelnder.name }) + + Admin.Log.Schreibe(handelnder, 'Notiz', GetPlayerName(ziel)) + return { ok = true } +end) + +Admin.Kern.Registriere('spieler', 'kick', function(handelnder, daten) + local ziel = tonumber(daten.id) + if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end + local grund = tostring(daten.grund or 'Kein Grund angegeben'):sub(1, 255) + local name = GetPlayerName(ziel) + + DropPlayer(ziel, 'Vom Team gekickt: ' .. grund) + Admin.Log.Schreibe(handelnder, 'Kick', name, grund) + return { ok = true } +end) + +Admin.Kern.Registriere('spieler', 'ban', function(handelnder, daten) + local ziel = tonumber(daten.id) + if not GetPlayerName(ziel) then return nil, 'Ziel ist offline.' end + local grund = tostring(daten.grund or 'Regelbruch'):sub(1, 255) + local dauer = tostring(daten.dauer or 'permanent') + local name = GetPlayerName(ziel) + local lizenz = Admin.Auth.Lizenz(ziel) + + -- Permanente Bans darf nur, wer 'ban' als owner-Recht hat — hier reicht das + -- Aktionsrecht; die Rechte-Matrix regelt, ob überhaupt gebannt werden darf. + local bis = Admin.BerechneBanEnde(dauer) + + Admin.DB.Sperre(lizenz, name, grund, bis, handelnder.lizenz, handelnder.name) + DropPlayer(ziel, 'Gebannt: ' .. grund) + Admin.Log.Schreibe(handelnder, 'Ban (' .. dauer .. ')', name, grund) + return { ok = true } +end) + +--- Rechnet eine Dauer-Angabe in ein DATETIME um (oder nil = permanent) +--- @param dauer string +--- @return string|nil +function Admin.BerechneBanEnde(dauer) + local tage = ({ ['1 Tag'] = 1, ['3 Tage'] = 3, ['7 Tage'] = 7, ['30 Tage'] = 30 })[dauer] + if not tage then return nil end + return os.date('%Y-%m-%d %H:%M:%S', os.time() + tage * 86400) +end diff --git a/resources/[aether]/aether-admin/server/module/staat.lua b/resources/[aether]/aether-admin/server/module/staat.lua new file mode 100644 index 0000000..5fd3cc0 --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/staat.lua @@ -0,0 +1,77 @@ +-- ===================================================================== +-- aether-admin / Modul: Staatsprojekte +-- +-- Projekte, über die (später aus einer Handy-App) abgestimmt wird. Ein +-- Projekt lässt sich umsetzen, sobald die Abstimmung beendet ist und +-- genug Zustimmung erreicht wurde — die Kosten kommen aus der Staatskasse. +-- ===================================================================== + +Admin = Admin or {} + +local ZUSTIMMUNG_MIN = 0.6 + +Admin.Kern.Registriere('staat', 'laden', function(handelnder, daten) + local liste = MySQL.query.await([[ + SELECT id, titel, beschreibung, kosten, ja, nein, abstimmung_offen, aktiviert, erstellt_am + FROM aether_admin_projects ORDER BY aktiviert, id DESC + ]]) or {} + + for _, p in ipairs(liste) do + local gesamt = (tonumber(p.ja) or 0) + (tonumber(p.nein) or 0) + p.zustimmung = gesamt > 0 and math.floor(tonumber(p.ja) / gesamt * 100) or 0 + p.offen = tonumber(p.abstimmung_offen) == 1 + p.aktiviert = tonumber(p.aktiviert) == 1 + end + + return { + projekte = liste, + staatskasse = Admin.DB.EinstellungZahl('staatskasse', 0), + zustimmungMin = ZUSTIMMUNG_MIN * 100, + rechte = Admin.Kern.RechteAbbild(handelnder.quelle), + } +end) + +Admin.Kern.Registriere('staat', 'beenden', function(handelnder, daten) + local id = tonumber(daten.id) + if not id then return nil, 'Ungültige Kennung.' end + MySQL.update.await('UPDATE aether_admin_projects SET abstimmung_offen = 0 WHERE id = ?', { id }) + Admin.Log.Schreibe(handelnder, 'Abstimmung beendet', 'Projekt #' .. id) + return { ok = true } +end) + +Admin.Kern.Registriere('staat', 'aktivieren', function(handelnder, daten) + local id = tonumber(daten.id) + if not id then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT titel, kosten, ja, nein, abstimmung_offen, aktiviert + FROM aether_admin_projects WHERE id = ? + ]], { id }) + if not zeilen or not zeilen[1] then return nil, 'Projekt nicht gefunden.' end + local p = zeilen[1] + + if tonumber(p.aktiviert) == 1 then return nil, 'Bereits umgesetzt.' end + if tonumber(p.abstimmung_offen) == 1 then return nil, 'Die Abstimmung läuft noch.' end + + local gesamt = (tonumber(p.ja) or 0) + (tonumber(p.nein) or 0) + local quote = gesamt > 0 and (tonumber(p.ja) / gesamt) or 0 + if quote < ZUSTIMMUNG_MIN then return nil, 'Nicht genug Zustimmung.' end + + local kosten = tonumber(p.kosten) or 0 + local kasse = Admin.DB.EinstellungZahl('staatskasse', 0) + if kosten > kasse then return nil, 'Staatskasse nicht gedeckt.' end + + Admin.DB.SetzeEinstellung('staatskasse', kasse - kosten) + MySQL.update.await('UPDATE aether_admin_projects SET aktiviert = 1 WHERE id = ?', { id }) + + Admin.Log.Schreibe(handelnder, 'Staatsprojekt aktiviert', p.titel, tostring(kosten)) + return { ok = true } +end) + +Admin.Kern.Registriere('staat', 'archivieren', function(handelnder, daten) + local id = tonumber(daten.id) + if not id then return nil, 'Ungültige Kennung.' end + MySQL.update.await('DELETE FROM aether_admin_projects WHERE id = ?', { id }) + Admin.Log.Schreibe(handelnder, 'Staatsprojekt archiviert', 'Projekt #' .. id) + return { ok = true } +end) diff --git a/resources/[aether]/aether-admin/server/module/team.lua b/resources/[aether]/aether-admin/server/module/team.lua new file mode 100644 index 0000000..106243c --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/team.lua @@ -0,0 +1,77 @@ +-- ===================================================================== +-- aether-admin / Modul: Admin-Team +-- +-- Das Team ist keine eigene Liste — es sind die Benutzer in +-- `aether_users` mit einer Gruppe über `user`. Rollen zu vergeben heißt +-- hier, die Gruppe eines Benutzers zu ändern; der Core respektiert sie +-- ab dann überall. +-- ===================================================================== + +Admin = Admin or {} + +-- Nur diese Gruppen sind vergebbar (owner nur von owner — via Rechte-Matrix) +local ROLLEN = { 'support', 'admin', 'owner' } + +--- Ist eine Gruppenkennung gültig? +local function GueltigeRolle(g) + for _, r in ipairs(ROLLEN) do if r == g then return true end end + return false +end + +Admin.Kern.Registriere('team', 'laden', function(handelnder, daten) + local zeilen = MySQL.query.await([[ + SELECT license, name, admin_group FROM aether_users + WHERE admin_group <> 'user' + ORDER BY FIELD(admin_group,'owner','admin','support'), name + ]]) or {} + + -- Wer davon ist online? + local onlineLizenz = {} + for _, sid in ipairs(GetPlayers() or {}) do + onlineLizenz[Admin.Auth.Lizenz(tonumber(sid))] = true + end + for _, z in ipairs(zeilen) do z.online = onlineLizenz[z.license] and true or false end + + return { team = zeilen, rollen = ROLLEN, + rechte = Admin.Kern.RechteAbbild(handelnder.quelle) } +end) + +Admin.Kern.Registriere('team', 'speichern', function(handelnder, daten) + local rolle = tostring(daten.rolle or '') + if not GueltigeRolle(rolle) then return nil, 'Unbekannte Rolle.' end + + -- Nur ein Owner darf Owner-Rechte vergeben + if rolle == 'owner' and not Admin.Auth.Hat(handelnder.quelle, 'owner') then + return nil, 'Owner-Rechte darf nur ein Owner vergeben.' + end + + -- Ziel über Lizenz oder Namenssuche bestimmen + local lizenz = daten.lizenz and tostring(daten.lizenz) or nil + if not lizenz then + local treffer = Admin.DB.SucheBenutzer(tostring(daten.name or '')) + if #treffer == 0 then return nil, 'Kein Benutzer gefunden.' end + if #treffer > 1 then return nil, 'Mehrdeutig — bitte die Lizenz angeben.' end + lizenz = treffer[1].license + end + + if not Admin.DB.SetzeGruppe(lizenz, rolle) then + return nil, 'Benutzer nicht gefunden.' + end + Admin.Auth.CacheLeerenNachLizenz(lizenz) + + Admin.Log.Schreibe(handelnder, 'Rolle vergeben', daten.name or lizenz, rolle) + return { ok = true } +end) + +Admin.Kern.Registriere('team', 'entfernen', function(handelnder, daten) + local lizenz = tostring(daten.lizenz or '') + if lizenz == '' then return nil, 'Keine Lizenz angegeben.' end + if lizenz == handelnder.lizenz then + return nil, 'Du kannst dir nicht selbst die Rechte entziehen.' + end + + Admin.DB.SetzeGruppe(lizenz, 'user') + Admin.Auth.CacheLeerenNachLizenz(lizenz) + Admin.Log.Schreibe(handelnder, 'Aus dem Team entfernt', daten.name or lizenz) + return { ok = true } +end) diff --git a/resources/[aether]/aether-admin/server/module/welt.lua b/resources/[aether]/aether-admin/server/module/welt.lua new file mode 100644 index 0000000..2021339 --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/welt.lua @@ -0,0 +1,87 @@ +-- ===================================================================== +-- aether-admin / Modul: Weltsteuerung +-- +-- Zeit, Wetter, Blackout und Bereinigung. Der Zustand wird an ALLE +-- Clients gespiegelt und neu verbundenen Spielern beim Laden geschickt, +-- damit die Welt für alle gleich aussieht. +-- ===================================================================== + +Admin = Admin or {} + +-- Gemeinsamer Weltzustand (Standard beim Serverstart) +Admin.Welt = Admin.Welt or { + stunde = 12, minute = 0, wetter = 'CLEAR', + zeitFix = false, wetterFix = false, blackout = false, +} + +local WETTER = { + ['Klar'] = 'CLEAR', ['Bewölkt'] = 'CLOUDS', ['Regen'] = 'RAIN', + ['Gewitter'] = 'THUNDER', ['Nebel'] = 'FOGGY', ['Schnee'] = 'XMAS', + ['Sturm'] = 'OVERCAST', +} + +--- Schickt den aktuellen Weltzustand an einen oder alle Clients +--- @param ziel number|nil Server-ID oder nil = alle +function Admin.SendeWelt(ziel) + TriggerClientEvent('aether-admin:client:welt', ziel or -1, Admin.Welt) +end + +Admin.Kern.Registriere('welt', 'zeit', function(handelnder, daten) + local stunde = math.max(0, math.min(23, math.floor(tonumber(daten.stunde) or 12))) + Admin.Welt.stunde = stunde + Admin.Welt.minute = 0 + if daten.fix ~= nil then Admin.Welt.zeitFix = daten.fix and true or false end + Admin.SendeWelt() + Admin.Log.Schreibe(handelnder, 'Zeit gesetzt', nil, ('%02d:00'):format(stunde)) + return { ok = true, welt = Admin.Welt } +end) + +Admin.Kern.Registriere('welt', 'wetter', function(handelnder, daten) + local anzeige = tostring(daten.wetter or 'Klar') + Admin.Welt.wetter = WETTER[anzeige] or 'CLEAR' + if daten.fix ~= nil then Admin.Welt.wetterFix = daten.fix and true or false end + Admin.SendeWelt() + Admin.Log.Schreibe(handelnder, 'Wetter gesetzt', nil, anzeige) + return { ok = true, welt = Admin.Welt } +end) + +Admin.Kern.Registriere('welt', 'blackout', function(handelnder, daten) + Admin.Welt.blackout = not Admin.Welt.blackout + Admin.SendeWelt() + Admin.Log.Schreibe(handelnder, 'Blackout ' .. (Admin.Welt.blackout and 'an' or 'aus')) + return { ok = true, blackout = Admin.Welt.blackout } +end) + +Admin.Kern.Registriere('welt', 'cleanup', function(handelnder, daten) + local art = tostring(daten.art or '') + local entfernt = 0 + + if art == 'veh' then + for _, veh in ipairs(GetAllVehicles() or {}) do + -- Nur leere Fahrzeuge entfernen (niemanden aus dem Sitz reißen) + if GetVehicleNumberOfPassengers(veh) == 0 and IsVehicleSeatFree(veh, -1) then + DeleteEntity(veh); entfernt = entfernt + 1 + end + end + elseif art == 'ped' then + for _, ped in ipairs(GetAllPeds() or {}) do + if not IsPedAPlayer(ped) then DeleteEntity(ped); entfernt = entfernt + 1 end + end + elseif art == 'obj' then + for _, obj in ipairs(GetAllObjects() or {}) do + DeleteEntity(obj); entfernt = entfernt + 1 + end + elseif art == 'ground' then + -- Herumliegende (tote) NPCs + for _, ped in ipairs(GetAllPeds() or {}) do + if not IsPedAPlayer(ped) and IsPedDeadOrDying(ped, true) then + DeleteEntity(ped); entfernt = entfernt + 1 + end + end + else + return nil, 'Unbekannte Bereinigung.' + end + + Admin.Log.Schreibe(handelnder, 'Bereinigt', art, entfernt .. ' entfernt') + return { ok = true, entfernt = entfernt } +end) diff --git a/resources/[aether]/aether-admin/server/module/weltkarte.lua b/resources/[aether]/aether-admin/server/module/weltkarte.lua new file mode 100644 index 0000000..9676fc0 --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/weltkarte.lua @@ -0,0 +1,31 @@ +-- ===================================================================== +-- aether-admin / Modul: Weltkarte +-- +-- Live-Standorte aller Spieler. Die Positionen stammen aus dem +-- Positions-Cache, den die Clients regelmäßig melden (siehe main.lua). +-- ===================================================================== + +Admin = Admin or {} +Admin.Live = Admin.Live or { pos = {}, god = {}, frozen = {}, muted = {} } + +Admin.Kern.Registriere('weltkarte', 'laden', function(handelnder, daten) + local alle = exports['aether-core']:HoleAlleSpieler() or {} + local liste = {} + + for serverId, d in pairs(alle) do + local sid = tonumber(serverId) + local pos = Admin.Live.pos[sid] or d.position + liste[#liste + 1] = { + id = sid, + name = (d.vorname or '?') .. ' ' .. (d.nachname or ''), + fraktion = d.fraktion, + ping = GetPlayerPing(sid) or 0, + tot = d.istTot and true or false, + x = pos and pos.x or 0.0, + y = pos and pos.y or 0.0, + } + end + + table.sort(liste, function(a, b) return a.id < b.id end) + return { spieler = liste, rechte = Admin.Kern.RechteAbbild(handelnder.quelle) } +end) diff --git a/resources/[aether]/aether-admin/server/module/wirtschaft.lua b/resources/[aether]/aether-admin/server/module/wirtschaft.lua new file mode 100644 index 0000000..f03c681 --- /dev/null +++ b/resources/[aether]/aether-admin/server/module/wirtschaft.lua @@ -0,0 +1,65 @@ +-- ===================================================================== +-- aether-admin / Modul: Wirtschaft +-- +-- Geldmenge, Staatskasse und Steuer. Die Geldmenge wird live aus allen +-- Charakteren summiert — keine Schätzung. +-- ===================================================================== + +Admin = Admin or {} + +Admin.Kern.Registriere('wirtschaft', 'laden', function(handelnder, daten) + local summe = MySQL.query.await([[ + SELECT COALESCE(SUM(cash), 0) AS bargeld, + COALESCE(SUM(bank), 0) AS konto, + COUNT(*) AS anzahl + FROM aether_characters WHERE deleted = 0 + ]])[1] or { bargeld = 0, konto = 0, anzahl = 0 } + + local umlauf = tonumber(summe.bargeld) + tonumber(summe.konto) + local online = #(GetPlayers() or {}) + + return { + umlauf = umlauf, + staatskasse = Admin.DB.EinstellungZahl('staatskasse', 0), + steuer = Admin.DB.EinstellungZahl('steuersatz', 8), + charaktere = tonumber(summe.anzahl), + online = online, + rechte = Admin.Kern.RechteAbbild(handelnder.quelle), + } +end) + +Admin.Kern.Registriere('wirtschaft', 'geldAlle', function(handelnder, daten) + local betrag = math.floor(tonumber(daten.betrag) or 0) + if betrag == 0 then return nil, 'Bitte einen Betrag angeben.' end + + local anzahl = 0 + for _, sid in ipairs(GetPlayers() or {}) do + local s = tonumber(sid) + if exports['aether-core']:HoleSpielerInfo(s) then + if betrag > 0 then + exports['aether-core']:FuegeGeldHinzu(s, 'bank', betrag, 'Admin-Auszahlung') + else + exports['aether-core']:EntferneGeld(s, 'bank', -betrag, 'Admin-Abzug') + end + anzahl = anzahl + 1 + end + end + + Admin.Log.Schreibe(handelnder, 'Geld an alle', anzahl .. ' Spieler', + (betrag > 0 and '+' or '') .. betrag) + return { ok = true, anzahl = anzahl } +end) + +Admin.Kern.Registriere('wirtschaft', 'kasse', function(handelnder, daten) + local wert = math.floor(tonumber(daten.wert) or 0) + Admin.DB.SetzeEinstellung('staatskasse', wert) + Admin.Log.Schreibe(handelnder, 'Staatskasse gesetzt', nil, tostring(wert)) + return { ok = true } +end) + +Admin.Kern.Registriere('wirtschaft', 'steuer', function(handelnder, daten) + local wert = math.max(0, math.min(100, math.floor(tonumber(daten.wert) or 0))) + Admin.DB.SetzeEinstellung('steuersatz', wert) + Admin.Log.Schreibe(handelnder, 'Steuersatz gesetzt', nil, wert .. '%') + return { ok = true } +end) diff --git a/resources/[aether]/aether-admin/shared/config.lua b/resources/[aether]/aether-admin/shared/config.lua new file mode 100644 index 0000000..e5df416 --- /dev/null +++ b/resources/[aether]/aether-admin/shared/config.lua @@ -0,0 +1,193 @@ +-- ===================================================================== +-- aether-admin — Konfiguration (Client + Server) +-- +-- Alles, was Betreiber anpassen wollen, ohne Code zu ändern. Kein Wert +-- hier vergibt selbst ein Recht — er beschreibt nur, welches Recht wofür +-- nötig ist. Die Prüfung selbst passiert immer serverseitig. +-- ===================================================================== + +AdminConfig = {} + +-- --------------------------------------------------------------------- +-- Rechtegruppen +-- +-- BEWUSST identisch zu Config.AdminGruppen aus aether-core. Die Hoheit +-- über die Gruppe eines Spielers liegt in `aether_users.admin_group`; +-- dieses Modul liest sie nur und rechnet mit derselben Rangordnung. +-- --------------------------------------------------------------------- +AdminConfig.Gruppen = { + ['user'] = 0, + ['support'] = 1, + ['admin'] = 2, + ['owner'] = 3, +} + +-- Ab welcher Gruppe das Panel überhaupt geöffnet werden darf +AdminConfig.MindestGruppe = 'support' + +-- --------------------------------------------------------------------- +-- Rechte-Matrix · [modul.aktion] = benötigte Gruppe +-- +-- Fehlt eine Aktion hier, gilt AdminConfig.StandardRecht. Lesen ist +-- günstig, Eingreifen teuer, Unwiderrufliches am teuersten. +-- --------------------------------------------------------------------- +AdminConfig.StandardRecht = 'admin' + +AdminConfig.Rechte = { + -- Überblick & Lesen + ['dashboard.laden'] = 'support', + ['spieler.liste'] = 'support', + ['spieler.details'] = 'support', + ['weltkarte.laden'] = 'support', + ['logs.laden'] = 'admin', + ['team.laden'] = 'support', + ['ressourcen.laden'] = 'admin', + ['wirtschaft.laden'] = 'admin', + ['fraktionen.laden'] = 'admin', + ['immobilien.laden'] = 'admin', + ['fahrzeuge.laden'] = 'admin', + ['staat.laden'] = 'admin', + ['moderation.laden'] = 'support', + ['reports.laden'] = 'support', + ['settings.laden'] = 'admin', + ['broadcast.laden'] = 'support', + + -- Spieler: Ansicht & Zustand + ['spieler.goto'] = 'admin', + ['spieler.bring'] = 'admin', + ['spieler.spectate'] = 'admin', + ['spieler.kamera'] = 'admin', + ['spieler.heal'] = 'admin', + ['spieler.revive'] = 'admin', + ['spieler.armor'] = 'admin', + ['spieler.freeze'] = 'admin', + ['spieler.god'] = 'admin', + ['spieler.mute'] = 'support', + ['spieler.notiz'] = 'support', + + -- Spieler: Geben & Setzen + ['spieler.geld'] = 'admin', + ['spieler.item'] = 'admin', + ['spieler.fahrzeug'] = 'admin', + ['spieler.job'] = 'admin', + + -- Moderation + ['spieler.warn'] = 'support', + ['spieler.kick'] = 'admin', + ['spieler.ban'] = 'admin', + ['moderation.unban'] = 'admin', + ['moderation.warnWeg'] = 'support', + ['moderation.banManuell']= 'admin', + + -- Reports + ['reports.claim'] = 'support', + ['reports.antwort'] = 'support', + ['reports.status'] = 'support', + + -- Welt + ['welt.zeit'] = 'admin', + ['welt.wetter'] = 'admin', + ['welt.blackout'] = 'admin', + ['welt.cleanup'] = 'admin', + ['broadcast.senden'] = 'admin', + + -- Fahrzeuge + ['fahrzeuge.spawn'] = 'admin', + ['fahrzeuge.reparieren'] = 'admin', + ['fahrzeuge.herholen'] = 'admin', + ['fahrzeuge.loeschen'] = 'admin', + ['fahrzeuge.aufraeumen'] = 'admin', + + -- Wirtschaft / Fraktionen / Immobilien / Staat + ['wirtschaft.geldAlle'] = 'owner', + ['wirtschaft.kasse'] = 'owner', + ['wirtschaft.steuer'] = 'owner', + ['fraktionen.speichern'] = 'admin', + ['fraktionen.loeschen'] = 'owner', + ['fraktionen.payday'] = 'admin', + ['immobilien.zuweisen'] = 'admin', + ['immobilien.raeumen'] = 'admin', + ['staat.beenden'] = 'admin', + ['staat.aktivieren'] = 'owner', + ['staat.archivieren'] = 'admin', + + -- System (nur Owner) + ['ressourcen.steuern'] = 'owner', + ['ressourcen.neustart'] = 'owner', + ['settings.speichern'] = 'owner', + ['team.speichern'] = 'owner', + ['team.entfernen'] = 'owner', + + -- Kameras (WebRTC) + ['kamera.oeffnen'] = 'admin', + ['kamera.schliessen'] = 'admin', +} + +-- --------------------------------------------------------------------- +-- Sektionen der Seitenleiste · min = ab welcher Gruppe sichtbar +-- Reihenfolge wie im Panel. `grp` gruppiert nur optisch. +-- --------------------------------------------------------------------- +AdminConfig.Sektionen = { + { grp = 'Überblick' }, + { id = 'dashboard', label = 'Dashboard', icon = '📊', min = 'support' }, + { id = 'weltkarte', label = 'Weltkarte', icon = '🗺️', min = 'support' }, + { grp = 'Verwaltung' }, + { id = 'spieler', label = 'Spieler', icon = '👥', min = 'support' }, + { id = 'fahrzeuge', label = 'Fahrzeuge', icon = '🚗', min = 'admin' }, + { id = 'immobilien', label = 'Immobilien', icon = '🏠', min = 'admin' }, + { id = 'fraktionen', label = 'Fraktionen', icon = '🚔', min = 'admin' }, + { id = 'wirtschaft', label = 'Wirtschaft', icon = '💰', min = 'admin' }, + { id = 'staat', label = 'Staatsprojekte', icon = '🏛️', min = 'admin' }, + { grp = 'Überwachung' }, + { id = 'cams', label = 'Live-Kameras', icon = '📹', min = 'admin' }, + { grp = 'Welt' }, + { id = 'welt', label = 'Weltsteuerung', icon = '🌍', min = 'admin' }, + { id = 'broadcast', label = 'Ankündigungen', icon = '📢', min = 'support' }, + { grp = 'Moderation' }, + { id = 'moderation', label = 'Bans & Warns', icon = '🔨', min = 'support' }, + { id = 'reports', label = 'Reports', icon = '🎫', min = 'support' }, + { grp = 'System' }, + { id = 'ressourcen', label = 'Ressourcen', icon = '🧩', min = 'admin' }, + { id = 'logs', label = 'Audit-Log', icon = '📜', min = 'admin' }, + { id = 'team', label = 'Admin-Team', icon = '⭐', min = 'support' }, + { id = 'settings', label = 'Einstellungen', icon = '⚙️', min = 'owner' }, +} + +-- --------------------------------------------------------------------- +-- Live-Kameras (WebRTC) +-- +-- Öffentliche Angaben. Das gemeinsame Geheimnis für die Token-Signatur +-- liegt NICHT hier, sondern serverseitig in der Convar `aether_admin_media_secret` +-- — sonst könnte jeder Client es aus dem geteilten Code lesen. +-- --------------------------------------------------------------------- +AdminConfig.Media = { + -- Ist die WebRTC-Kamera aktiv? Bei false zeigt das Panel nur den Hinweis. + aktiv = true, + -- WebSocket-Adresse des Media-Servers (Signaling + SFU), vom NUI erreichbar. + -- Auf den öffentlichen Host/Port des Media-Servers zeigen lassen. + url = 'ws://127.0.0.1:8787', + -- Wie lange ein ausgestelltes Zugangstoken gilt (Sekunden) + tokenGueltig = 30, +} + +--- Rechnet den Rang einer Gruppe aus (unbekannt = 0) +--- @param gruppe string|nil +--- @return number +function AdminConfig.Rang(gruppe) + return AdminConfig.Gruppen[gruppe or 'user'] or 0 +end + +--- Erfüllt `gruppe` mindestens `benoetigt`? +--- @param gruppe string|nil Gruppe des Spielers +--- @param benoetigt string|nil Geforderte Gruppe +--- @return boolean +function AdminConfig.Erfuellt(gruppe, benoetigt) + return AdminConfig.Rang(gruppe) >= AdminConfig.Rang(benoetigt) +end + +--- Welche Gruppe braucht eine Aktion? +--- @param schluessel string 'modul.aktion' +--- @return string +function AdminConfig.RechtFuer(schluessel) + return AdminConfig.Rechte[schluessel] or AdminConfig.StandardRecht +end diff --git a/resources/[aether]/aether-bank/client/main.lua b/resources/[aether]/aether-bank/client/main.lua new file mode 100644 index 0000000..3f1235d --- /dev/null +++ b/resources/[aether]/aether-bank/client/main.lua @@ -0,0 +1,180 @@ +-- ===================================================================== +-- Aether Bank — Clientlogik +-- Findet Geldautomaten in der Welt, oeffnet die Oberflaeche und reicht +-- alle Wuensche an den Server weiter. Entschieden wird dort. +-- ===================================================================== + +-- Ist der Automat gerade offen? +local istOffen = false +-- Ist der Spieler im Spiel? +local istGeladen = false + +-- Taste E zum Benutzen +local TASTE_BENUTZEN = 38 + +-- Modelle, die als Geldautomat gelten +local AUTOMATEN = { + 'prop_atm_01', + 'prop_atm_02', + 'prop_atm_03', + 'prop_fleeca_atm', +} + +-- Vorberechnete Hashes der Automaten-Modelle +local automatenHashes = {} +for _, name in ipairs(AUTOMATEN) do + automatenHashes[#automatenHashes + 1] = GetHashKey(name) +end + +--- Schickt eine Nachricht an die Oberflaeche +local function SendeAnNui(nachricht) + SendNuiMessage(json.encode(nachricht)) +end + +--- Zeigt eine Meldung oben links im Spiel +local function ZeigeSpielmeldung(text) + if not text or text == '' then return end + BeginTextCommandThefeedPost('STRING') + AddTextComponentSubstringPlayerName(text) + EndTextCommandThefeedPostTicker(false, true) +end + +--- Schliesst den Automaten +local function Schliessen() + if not istOffen then return end + istOffen = false + SetNuiFocus(false, false) + SendeAnNui({ aktion = 'schliessen' }) + TriggerServerEvent('aether:bank:schliessen') + pcall(function() exports['aether-hud']:SetzeSichtbar(true) end) +end + +-- --------------------------------------------------------------------- +-- Ereignisse +-- --------------------------------------------------------------------- +AddEventHandler('aether:client:bereit', function() + istGeladen = true +end) + +--- Server erlaubt das Oeffnen: Oberflaeche zeigen (PIN-Eingabe folgt) +RegisterNetEvent('aether:bank:zeige', function() + istOffen = true + SetNuiFocus(true, true) + pcall(function() exports['aether-hud']:SetzeSichtbar(false) end) + SendeAnNui({ aktion = 'oeffnen' }) +end) + +--- Ergebnis der PIN-Pruefung +RegisterNetEvent('aether:bank:pinErgebnis', function(erfolg, konto) + SendeAnNui({ aktion = 'pinErgebnis', erfolg = erfolg, konto = konto }) +end) + +--- Aktualisierter Kontostand nach einer Buchung +RegisterNetEvent('aether:bank:konto', function(konto) + konto.aktion = 'konto' + SendeAnNui(konto) +end) + +--- Erfolgsmeldung (wird als Abschlussbildschirm angezeigt) +RegisterNetEvent('aether:bank:erfolg', function(titel, text) + SendeAnNui({ aktion = 'erfolg', titel = titel, text = text }) +end) + +--- Ablehnung einer Aktion +RegisterNetEvent('aether:bank:fehler', function(text) + if istOffen then + SendeAnNui({ aktion = 'fehler', text = text }) + else + ZeigeSpielmeldung(text) + end +end) + +--- Benachrichtigung ausserhalb des Automaten (z. B. Geld erhalten) +RegisterNetEvent('aether:bank:benachrichtigung', function(text) + ZeigeSpielmeldung(text) +end) + +-- --------------------------------------------------------------------- +-- Automaten in der Naehe finden und benutzen +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + local pause = 700 + + if istGeladen and not istOffen then + local position = GetEntityCoords(PlayerPedId()) + + -- Naechstgelegenen Automaten suchen + local automat = nil + for _, hash in ipairs(automatenHashes) do + local objekt = GetClosestObjectOfType(position.x, position.y, position.z, + 1.6, hash, false, false, false) + if objekt ~= 0 then automat = objekt break end + end + + if automat then + pause = 0 -- fuer die Textanzeige jeden Frame laufen + + local automatPos = GetEntityCoords(automat) + SetTextFont(4) + SetTextScale(0.32, 0.32) + SetTextColour(255, 255, 255, 215) + SetTextCentre(true) + SetTextOutline() + SetTextEntry('STRING') + AddTextComponentString('[E] Geldautomat benutzen') + SetDrawOrigin(automatPos.x, automatPos.y, automatPos.z + 0.8, 0) + DrawText(0.0, 0.0) + ClearDrawOrigin() + + if IsControlJustReleased(0, TASTE_BENUTZEN) then + TriggerServerEvent('aether:bank:oeffnen') + end + end + end + + Wait(pause) + end +end) + +-- --------------------------------------------------------------------- +-- NUI-Callbacks +-- --------------------------------------------------------------------- +RegisterNUICallback('schliessen', function(_, rueckgabe) + Schliessen() + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('pinPruefen', function(daten, rueckgabe) + TriggerServerEvent('aether:bank:pinPruefen', tostring(daten.pin or '')) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('pinAendern', function(daten, rueckgabe) + TriggerServerEvent('aether:bank:pinAendern', tostring(daten.alt or ''), tostring(daten.neu or '')) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('auszahlen', function(daten, rueckgabe) + TriggerServerEvent('aether:bank:auszahlen', tonumber(daten.betrag) or 0) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('einzahlen', function(daten, rueckgabe) + TriggerServerEvent('aether:bank:einzahlen', tonumber(daten.betrag) or 0) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('ueberweisen', function(daten, rueckgabe) + TriggerServerEvent('aether:bank:ueberweisen', + tostring(daten.ziel or ''), tonumber(daten.betrag) or 0, tostring(daten.zweck or '')) + rueckgabe({ ok = true }) +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen +-- --------------------------------------------------------------------- +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + SetNuiFocus(false, false) +end) diff --git a/resources/[aether]/aether-bank/fxmanifest.lua b/resources/[aether]/aether-bank/fxmanifest.lua new file mode 100644 index 0000000..bddbe3c --- /dev/null +++ b/resources/[aether]/aether-bank/fxmanifest.lua @@ -0,0 +1,34 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +name 'aether-bank' +author 'Jerrit Fritzsche' +description 'Geldautomat: PIN, Ein-/Auszahlen, Überweisen, Kontoauszug' +version '0.1.0' + +ui_page 'html/index.html' + +files { + 'html/index.html', +} + +-- Core-Dateien mitladen (eigener Lua-State je Resource) +shared_scripts { + '@aether-core/shared/config.lua', + '@aether-core/shared/utils.lua', +} + +client_scripts { + 'client/main.lua', +} + +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/main.lua', +} + +dependencies { + 'aether-core', + 'oxmysql', +} diff --git a/resources/[aether]/aether-bank/html/index.html b/resources/[aether]/aether-bank/html/index.html new file mode 100644 index 0000000..7bc3a50 --- /dev/null +++ b/resources/[aether]/aether-bank/html/index.html @@ -0,0 +1,938 @@ + + + + + + + + +Fleeca — Bankautomat + + +
+
+
FLEECA
+
+ + +
+ Bewege die Maus auf den Automaten, um ihn zu aktivieren — verlässt du das Display, wird es transparent. + Test-PIN: 1234 +
+ + +
+
+ + +
+ +
+ + +
+
+
+ + Fleeca.
+
+
Guten Tag, Max WeberGirokonto · verfügbar
+
Kontostand
48.320 €
+
14:32
Di, 21. Juli 2026
+
+ +
+
+
+ + +
+
+
+
+ + + + + diff --git a/resources/[aether]/aether-bank/server/main.lua b/resources/[aether]/aether-bank/server/main.lua new file mode 100644 index 0000000..73d3585 --- /dev/null +++ b/resources/[aether]/aether-bank/server/main.lua @@ -0,0 +1,295 @@ +-- ===================================================================== +-- Aether Bank — Serverlogik +-- Alle Geldbewegungen und die PIN-Pruefung laufen ausschliesslich hier. +-- Die PIN verlaesst den Server niemals. +-- ===================================================================== + +local Core = exports['aether-core'] + +-- Wie viele Buchungen an die Oberflaeche geschickt werden +local MAX_BUCHUNGEN = 60 +-- Hoechstbetrag je Einzelvorgang (Schutz vor absurden Eingaben) +local MAX_BETRAG = 10000000 + +-- Merkt sich, wer sich an einem Automaten bereits mit PIN angemeldet hat +local angemeldet = {} + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Liefert die Charakter-ID eines Spielers +--- @param quelle number Server-ID +--- @return number|nil +local function CharakterVon(quelle) + return Core:HoleCharakterId(quelle) +end + +--- Prueft, ob ein Betrag eine gueltige, positive Ganzzahl ist +--- @param betrag any Eingabe +--- @return number|nil Bereinigter Betrag oder nil +local function PruefeBetrag(betrag) + if type(betrag) ~= 'number' then return nil end + betrag = math.floor(betrag) + if betrag < 1 or betrag > MAX_BETRAG then return nil end + return betrag +end + +--- Ordnet einer Buchungsart Symbol und Kategorie zu (nur fuer die Anzeige) +--- @param art string Buchungsart aus der Datenbank +--- @return string Symbol, string Kategorie +local function Darstellung(art) + local tabelle = { + deposit = { '💵', 'Einzahlung' }, + withdraw = { '🏧', 'Auszahlung' }, + transfer = { '↗', 'Transfer' }, + salary = { '💼', 'Einkommen' }, + fine = { '⚖️', 'Strafe' }, + } + local eintrag = tabelle[art] or { '•', 'Sonstiges' } + return eintrag[1], eintrag[2] +end + +--- Laedt die letzten Buchungen eines Charakters fuer die Anzeige +--- @param charakterId number Charakter-ID +--- @return table Liste von Buchungen +local function HoleBuchungen(charakterId) + local zeilen = MySQL.query.await([[ + SELECT type, amount, balance, label, created_at + FROM aether_transactions + WHERE `character` = ? + ORDER BY id DESC + LIMIT ? + ]], { charakterId, MAX_BUCHUNGEN }) or {} + + local liste = {} + for _, zeile in ipairs(zeilen) do + local icon, kategorie = Darstellung(zeile.type) + liste[#liste + 1] = { + titel = zeile.label ~= '' and zeile.label or kategorie, + betrag = zeile.amount, + saldo = zeile.balance, + zeit = tostring(zeile.created_at):sub(1, 16), + kat = kategorie, + icon = icon, + } + end + return liste +end + +--- Baut den Datensatz, den die Oberflaeche anzeigt (ohne PIN!) +--- @param quelle number Server-ID +--- @return table|nil +local function BaueKonto(quelle) + local charakterId = CharakterVon(quelle) + if not charakterId then return nil end + + local daten = Core:HoleSpielerInfo(quelle) + if not daten then return nil end + + return { + inhaber = (daten.vorname or '') .. ' ' .. (daten.nachname or ''), + iban = daten.iban or '—', + saldo = daten.konto or 0, + bargeld = daten.bargeld or 0, + transaktionen = HoleBuchungen(charakterId), + } +end + +--- Schickt den aktuellen Kontostand an die Oberflaeche +--- @param quelle number Server-ID +--- @param hinweis string|nil Rueckmeldung +local function SendeKonto(quelle, hinweis) + local konto = BaueKonto(quelle) + if not konto then return end + konto.hinweis = hinweis + TriggerClientEvent('aether:bank:konto', quelle, konto) +end + +--- Lehnt eine Aktion mit Begruendung ab +local function Ablehnen(quelle, grund) + TriggerClientEvent('aether:bank:fehler', quelle, grund) +end + +-- --------------------------------------------------------------------- +-- Automat oeffnen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:bank:oeffnen', function() + local quelle = source + if not CharakterVon(quelle) then return end + + angemeldet[quelle] = false -- jede Sitzung beginnt mit PIN-Eingabe + TriggerClientEvent('aether:bank:zeige', quelle) +end) + +-- --------------------------------------------------------------------- +-- PIN pruefen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:bank:pinPruefen', function(pin) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId or type(pin) ~= 'string' then return end + + local gespeichert = MySQL.scalar.await( + 'SELECT bank_pin FROM aether_characters WHERE id = ?', { charakterId }) + + if gespeichert ~= pin then + angemeldet[quelle] = false + TriggerClientEvent('aether:bank:pinErgebnis', quelle, false) + return + end + + angemeldet[quelle] = true + TriggerClientEvent('aether:bank:pinErgebnis', quelle, true, BaueKonto(quelle)) +end) + +-- --------------------------------------------------------------------- +-- PIN aendern +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:bank:pinAendern', function(alt, neu) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + if type(alt) ~= 'string' or type(neu) ~= 'string' then return end + + -- Neue PIN muss genau vier Ziffern haben + if not neu:match('^%d%d%d%d$') then + Ablehnen(quelle, 'Die neue PIN muss aus vier Ziffern bestehen.') + return + end + + local gespeichert = MySQL.scalar.await( + 'SELECT bank_pin FROM aether_characters WHERE id = ?', { charakterId }) + + if gespeichert ~= alt then + Ablehnen(quelle, 'Die aktuelle PIN ist falsch.') + return + end + + MySQL.update.await('UPDATE aether_characters SET bank_pin = ? WHERE id = ?', { neu, charakterId }) + TriggerClientEvent('aether:bank:erfolg', quelle, 'PIN geändert', 'Deine neue PIN ist ab sofort gültig.') +end) + +-- --------------------------------------------------------------------- +-- Auszahlen (Konto -> Bargeld) +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:bank:auszahlen', function(betrag) + local quelle = source + if not angemeldet[quelle] then return end + + betrag = PruefeBetrag(betrag) + if not betrag then Ablehnen(quelle, 'Ungültiger Betrag.') return end + + -- Erst abbuchen (prueft die Deckung), dann als Bargeld gutschreiben + if not Core:EntferneGeld(quelle, 'konto', betrag, 'Barauszahlung Automat') then + Ablehnen(quelle, 'Nicht genügend Guthaben.') + return + end + Core:FuegeGeldHinzu(quelle, 'bargeld', betrag, 'Barauszahlung') + + SendeKonto(quelle) + TriggerClientEvent('aether:bank:erfolg', quelle, + 'Bitte entnehmen Sie Ihr Geld', betrag .. ' € wurden ausgezahlt.') +end) + +-- --------------------------------------------------------------------- +-- Einzahlen (Bargeld -> Konto) +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:bank:einzahlen', function(betrag) + local quelle = source + if not angemeldet[quelle] then return end + + betrag = PruefeBetrag(betrag) + if not betrag then Ablehnen(quelle, 'Ungültiger Betrag.') return end + + if not Core:EntferneGeld(quelle, 'bargeld', betrag, 'Bareinzahlung') then + Ablehnen(quelle, 'Du hast nicht so viel Bargeld dabei.') + return + end + Core:FuegeGeldHinzu(quelle, 'konto', betrag, 'Bareinzahlung Automat') + + SendeKonto(quelle) + TriggerClientEvent('aether:bank:erfolg', quelle, + 'Einzahlung erfolgreich', betrag .. ' € wurden gutgeschrieben.') +end) + +-- --------------------------------------------------------------------- +-- Ueberweisen (Konto -> fremdes Konto, auch offline) +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:bank:ueberweisen', function(ziel, betrag, zweck) + local quelle = source + if not angemeldet[quelle] then return end + + local charakterId = CharakterVon(quelle) + if not charakterId then return end + + betrag = PruefeBetrag(betrag) + if not betrag then Ablehnen(quelle, 'Ungültiger Betrag.') return end + if type(ziel) ~= 'string' or #ziel < 3 then + Ablehnen(quelle, 'Bitte eine gültige IBAN angeben.') + return + end + + zweck = type(zweck) == 'string' and zweck:sub(1, 64) or '' + + -- Empfaenger ueber die IBAN suchen + local empfaenger = MySQL.query.await([[ + SELECT id, firstname, lastname, bank + FROM aether_characters + WHERE iban = ? AND deleted = 0 + LIMIT 1 + ]], { ziel }) + + if not empfaenger or not empfaenger[1] then + Ablehnen(quelle, 'Kein Konto mit dieser IBAN gefunden.') + return + end + + local ziel_ = empfaenger[1] + if ziel_.id == charakterId then + Ablehnen(quelle, 'Du kannst dir nicht selbst überweisen.') + return + end + + local empfaengerName = ziel_.firstname .. ' ' .. ziel_.lastname + local verwendung = zweck ~= '' and (' (' .. zweck .. ')') or '' + + -- Abbuchen (prueft die Deckung) + if not Core:EntferneGeld(quelle, 'konto', betrag, + 'Überweisung an ' .. empfaengerName .. verwendung) then + Ablehnen(quelle, 'Nicht genügend Guthaben.') + return + end + + -- Gutschrift: bei Online-Spielern ueber den Core, sonst direkt in der Datenbank + local zielQuelle = Core:HoleQuelleNachCharakter(ziel_.id) + if zielQuelle then + Core:FuegeGeldHinzu(zielQuelle, 'konto', betrag, + 'Überweisung von Konto' .. verwendung) + TriggerClientEvent('aether:bank:benachrichtigung', zielQuelle, + ('Du hast %d € erhalten.'):format(betrag)) + else + local neuerStand = (ziel_.bank or 0) + betrag + MySQL.update.await('UPDATE aether_characters SET bank = ? WHERE id = ?', + { neuerStand, ziel_.id }) + MySQL.insert('INSERT INTO aether_transactions (`character`, type, amount, balance, label) VALUES (?, ?, ?, ?, ?)', + { ziel_.id, 'transfer', betrag, neuerStand, 'Überweisung erhalten' .. verwendung }) + end + + Aether.Info(('Überweisung: Charakter #%d -> #%d, %d €'):format(charakterId, ziel_.id, betrag)) + + SendeKonto(quelle) + TriggerClientEvent('aether:bank:erfolg', quelle, + 'Überweisung ausgeführt', ('%d € an %s gesendet.'):format(betrag, empfaengerName)) +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:bank:schliessen', function() + angemeldet[source] = nil +end) + +AddEventHandler('playerDropped', function() + angemeldet[source] = nil +end) diff --git a/resources/[aether]/aether-character/client/aussehen.lua b/resources/[aether]/aether-character/client/aussehen.lua new file mode 100644 index 0000000..e1d2c9c --- /dev/null +++ b/resources/[aether]/aether-character/client/aussehen.lua @@ -0,0 +1,132 @@ +-- ===================================================================== +-- Aether Character — Aussehen auf den GTA-Ped uebertragen +-- +-- WICHTIG: Der Charakter-Ersteller arbeitet mit einer stilisierten +-- SVG-Vorschau und eigenen Werten (Paletten-Index, Slider 0–100). +-- GTA kennt diese Werte nicht — die folgenden Tabellen sind daher eine +-- *Annaeherung*. Sie sind bewusst als einfache Zuordnungstabellen +-- gebaut, damit du die Werte jederzeit nachjustieren kannst. +-- ===================================================================== + +AetherAussehen = {} + +-- --------------------------------------------------------------------- +-- Zuordnungstabellen (Index aus dem Ersteller -> GTA-Wert) +-- --------------------------------------------------------------------- + +-- Hautton-Index -> Elternteil-Haut fuer SetPedHeadBlendData (0–45) +-- Niedrige Werte = hellere Haut, hohe Werte = dunklere Haut +local HAUT_ZU_ELTERN = { [0]=0, [1]=1, [2]=2, [3]=3, [4]=4, [5]=5, [6]=6, [7]=7 } + +-- Frisur-Index -> Drawable der Komponente 2 (Haare), je Geschlecht +local FRISUR = { + maennlich = { [0]=1, [1]=2, [2]=6, [3]=4, [4]=10, [5]=0 }, -- Kurz/Mittel/Undercut/Lang/Zopf/Glatze + weiblich = { [0]=1, [1]=3, [2]=12, [3]=8, [4]=9, [5]=0 }, +} + +-- Haarfarben-Index -> GTA-Haarfarbe (0–63) +local HAARFARBE = { [0]=0, [1]=2, [2]=5, [3]=8, [4]=10, [5]=12, [6]=6, [7]=17, [8]=18, [9]=63 } + +-- Augenfarben-Index -> GTA-Augenfarbe (0–31) +local AUGENFARBE = { [0]=1, [1]=2, [2]=0, [3]=3, [4]=5, [5]=4 } + +-- Bart-Index -> Overlay-Drawable (Overlay 1 = Bart); -1 bedeutet rasiert +local BART = { [0]=-1, [1]=10, [2]=2, [3]=5 } + +-- Augenbrauen-Index (0/1/2) -> Overlay-Drawable (Overlay 2) +local AUGENBRAUEN = { [0]=4, [1]=1, [2]=6 } + +-- Kleidungsfarben-Index -> Textur-Variante der jeweiligen Komponente +local KLEID_TEXTUR = { [0]=0, [1]=1, [2]=2, [3]=3, [4]=4, [5]=5, [6]=6, [7]=7, [8]=8, [9]=9 } + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Rechnet einen Slider (0–100) in einen GTA-Gesichtswert (-1.0 bis 1.0) um +--- @param wert number Sliderwert 0–100 +--- @return number Gesichtswert +local function SliderZuGesicht(wert) + return ((tonumber(wert) or 50) - 50) / 50.0 +end + +--- Liefert den Schluessel fuer die geschlechtsabhaengigen Tabellen +--- @param geschlecht string 'männlich' | 'weiblich' | 'divers' +--- @return string +local function GeschlechtSchluessel(geschlecht) + return (geschlecht == 'weiblich') and 'weiblich' or 'maennlich' +end + +-- --------------------------------------------------------------------- +-- Anwendung +-- --------------------------------------------------------------------- + +--- Liefert den passenden Ped-Modellnamen zum Geschlecht +--- @param geschlecht string +--- @return string Modellname +function AetherAussehen.HoleModell(geschlecht) + if geschlecht == 'weiblich' then return 'mp_f_freemode_01' end + return 'mp_m_freemode_01' +end + +--- Uebertraegt ein Aussehen-Objekt des Erstellers auf einen Ped +--- @param ped number Ped-Handle +--- @param aussehen table Aussehen aus dem Charakter-Ersteller +function AetherAussehen.Anwenden(ped, aussehen) + if not aussehen or not DoesEntityExist(ped) then return end + + local schluessel = GeschlechtSchluessel(aussehen.geschlecht) + local hautEltern = HAUT_ZU_ELTERN[aussehen.skin or 1] or 1 + + -- Grundgesicht: Eltern und Hautton mischen + SetPedHeadBlendData(ped, + 0, 0, 0, -- Gesichtsform (Vater, Mutter, Mischung) + hautEltern, hautEltern, 0, + 0.5, 1.0, 0.0, -- Gewichtung Form / Haut / Drittwert + false) + + -- Gesichtszuege aus den Slidern (GTA-Index -> Sliderwert) + SetPedFaceFeature(ped, 0, SliderZuGesicht(aussehen.nase)) -- Nasenbreite + SetPedFaceFeature(ped, 6, SliderZuGesicht(aussehen.lippen)) -- Lippendicke + SetPedFaceFeature(ped, 7, SliderZuGesicht(aussehen.kiefer)) -- Kieferbreite + SetPedFaceFeature(ped, 11, SliderZuGesicht(aussehen.gesichtBreite)) -- Wangenbreite + + -- Haare + local frisur = (FRISUR[schluessel] or FRISUR.maennlich)[aussehen.hairStyle or 0] or 0 + SetPedComponentVariation(ped, 2, frisur, 0, 0) + local haarfarbe = HAARFARBE[aussehen.hairColor or 0] or 0 + SetPedHairColor(ped, haarfarbe, haarfarbe) + + -- Augenfarbe (Native ohne offiziellen Namen) + local augenfarbe = AUGENFARBE[aussehen.augen or 0] or 0 + Citizen.InvokeNative(0x50B56988B170AFDF, ped, augenfarbe) + + -- Augenbrauen (Overlay 2) + local braue = AUGENBRAUEN[math.floor((aussehen.augenbraue or 50) / 50)] or 1 + SetPedHeadOverlay(ped, 2, braue, 1.0) + SetPedHeadOverlayColor(ped, 2, 1, haarfarbe, haarfarbe) + + -- Bart (Overlay 1) — nur bei nicht-weiblichen Peds sinnvoll + local bart = BART[aussehen.bart or 0] or -1 + if bart >= 0 then + SetPedHeadOverlay(ped, 1, bart, 1.0) + SetPedHeadOverlayColor(ped, 1, 1, haarfarbe, haarfarbe) + else + SetPedHeadOverlay(ped, 1, 255, 0.0) -- 255 = kein Overlay + end + + -- Kleidung: Farbe wird als Texturvariante der Komponente gesetzt + local oberteil = KLEID_TEXTUR[aussehen.top or 0] or 0 + local hose = KLEID_TEXTUR[aussehen.hose or 0] or 0 + local schuhe = KLEID_TEXTUR[aussehen.schuhe or 0] or 0 + + SetPedComponentVariation(ped, 11, 1, oberteil, 0) -- Oberkoerper (Jacke/Shirt) + SetPedComponentVariation(ped, 8, 1, 0, 0) -- Unterhemd + SetPedComponentVariation(ped, 3, 1, 0, 0) -- Arme + SetPedComponentVariation(ped, 4, 1, hose, 0) -- Beine + SetPedComponentVariation(ped, 6, 1, schuhe, 0) -- Schuhe + + -- Koerperbau als Fettigkeits-/Muskel-Overlay (Overlay 10 = Sommersprossen frei, + -- daher nutzen wir Overlay 0 nicht; Statur ueber Gesichtszug 19 annaehern) + SetPedFaceFeature(ped, 19, SliderZuGesicht(aussehen.koerper)) +end diff --git a/resources/[aether]/aether-character/client/main.lua b/resources/[aether]/aether-character/client/main.lua new file mode 100644 index 0000000..444efe5 --- /dev/null +++ b/resources/[aether]/aether-character/client/main.lua @@ -0,0 +1,157 @@ +-- ===================================================================== +-- Aether Character — Clientlogik +-- Zeigt die Charakterauswahl, verwaltet die Kamera und meldet die +-- Auswahl an den Server. Der Core uebernimmt danach das Spawnen. +-- ===================================================================== + +-- Ist die Auswahl gerade offen? +local auswahlOffen = false + +-- Ort, an dem der Spieler waehrend der Auswahl geparkt wird +local PARKPLATZ = { x = -1037.7, y = -2737.6, z = 20.2 } + +-- --------------------------------------------------------------------- +-- Auswahl oeffnen / schliessen +-- --------------------------------------------------------------------- + +--- Oeffnet die Charakterauswahl mit der Liste vom Server +--- @param charaktere table Liste der Charaktere +--- @param maxSlots number Maximale Anzahl Charaktere +local function OeffneAuswahl(charaktere, maxSlots) + auswahlOffen = true + + -- Eigenen Spieler unsichtbar parken — sichtbar ist nur der Vorschau-Ped + local ped = PlayerPedId() + SetEntityVisible(ped, false, false) + FreezeEntityPosition(ped, true) + SetEntityCoordsNoOffset(ped, PARKPLATZ.x, PARKPLATZ.y, PARKPLATZ.z, false, false, false) + + -- 3D-Vorschau mit dem Aussehen des ersten Charakters starten + local erstes = charaktere and charaktere[1] or nil + Vorschau.Starten(erstes and erstes.aussehen or nil, 'koerper') + + -- Ladebildschirm beenden, damit die Auswahl sichtbar wird + ShutdownLoadingScreen() + ShutdownLoadingScreenNui() + DoScreenFadeIn(800) + + SetNuiFocus(true, true) + SendNuiMessage(json.encode({ + aktion = 'oeffnen', + charaktere = charaktere, + maxSlots = maxSlots, + })) +end + +--- Schliesst die Auswahl und gibt die Steuerung frei +local function SchliesseAuswahl() + if not auswahlOffen then return end + auswahlOffen = false + + SendNuiMessage(json.encode({ aktion = 'schliessen' })) + SetNuiFocus(false, false) + + DoScreenFadeOut(400) + Wait(400) + Vorschau.Beenden() +end + +-- --------------------------------------------------------------------- +-- Ereignisse vom Server +-- --------------------------------------------------------------------- + +--- Server fordert die Charakterauswahl an +RegisterNetEvent('aether:character:zeigeAuswahl', function(charaktere, maxSlots) + OeffneAuswahl(charaktere, maxSlots) +end) + +--- Aktualisierte Liste nach Erstellen oder Loeschen +RegisterNetEvent('aether:character:liste', function(charaktere, maxSlots, hinweis) + SendNuiMessage(json.encode({ + aktion = 'liste', + charaktere = charaktere, + maxSlots = maxSlots, + hinweis = hinweis, + })) +end) + +--- Server lehnt eine Aktion ab (z. B. Slot voll, ungueltiger Name) +RegisterNetEvent('aether:character:fehler', function(text) + SendNuiMessage(json.encode({ aktion = 'fehler', text = text })) +end) + +--- Charakter wurde gewaehlt: Auswahl sofort schliessen, damit der Core +--- ungestoert spawnen kann (Netz-Event kommt gleichzeitig beim Core an) +RegisterNetEvent('aether:client:spielerGeladen', function() + if auswahlOffen then + SchliesseAuswahl() + end +end) + +--- Der Core hat den Ped gesetzt und gespawnt: jetzt das Aussehen anwenden. +--- Eigenes Event, damit wir nicht mit dem Modellwechsel des Cores kollidieren. +AddEventHandler('aether:client:bereit', function(daten) + if daten and daten.aussehen then + AetherAussehen.Anwenden(PlayerPedId(), daten.aussehen) + end + + local ped = PlayerPedId() + FreezeEntityPosition(ped, false) + SetEntityVisible(ped, true, false) +end) + +-- --------------------------------------------------------------------- +-- NUI-Callbacks (Aktionen aus der Oberflaeche) +-- --------------------------------------------------------------------- + +--- Aussehen hat sich geaendert: sofort auf den Vorschau-Ped uebertragen +RegisterNUICallback('vorschau', function(daten, rueckgabe) + if daten.aussehen then + Vorschau.Anwenden(daten.aussehen) + end + if daten.modus then + Vorschau.SetzeModus(daten.modus) + end + rueckgabe({ ok = true }) +end) + +--- Ansicht wechseln (Gesicht / Koerper) +RegisterNUICallback('vorschauModus', function(daten, rueckgabe) + Vorschau.SetzeModus(daten.modus or 'koerper') + rueckgabe({ ok = true }) +end) + +--- Vorschau-Ped drehen +RegisterNUICallback('vorschauDrehen', function(daten, rueckgabe) + Vorschau.Drehen(tonumber(daten.grad) or 0.0) + rueckgabe({ ok = true }) +end) + +--- Spieler waehlt einen Charakter zum Spielen +RegisterNUICallback('spielen', function(daten, rueckgabe) + TriggerServerEvent('aether:character:spielen', daten.charakterId) + rueckgabe({ ok = true }) +end) + +--- Spieler legt einen neuen Charakter an +RegisterNUICallback('erstellen', function(daten, rueckgabe) + TriggerServerEvent('aether:character:erstellen', daten) + rueckgabe({ ok = true }) +end) + +--- Spieler loescht einen Charakter +RegisterNUICallback('loeschen', function(daten, rueckgabe) + TriggerServerEvent('aether:character:loeschen', daten.charakterId) + rueckgabe({ ok = true }) +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen beim Stoppen der Ressource +-- --------------------------------------------------------------------- +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + SetNuiFocus(false, false) + Vorschau.Beenden() + FreezeEntityPosition(PlayerPedId(), false) + SetEntityVisible(PlayerPedId(), true, false) +end) diff --git a/resources/[aether]/aether-character/client/vorschau.lua b/resources/[aether]/aether-character/client/vorschau.lua new file mode 100644 index 0000000..601a0a6 --- /dev/null +++ b/resources/[aether]/aether-character/client/vorschau.lua @@ -0,0 +1,189 @@ +-- ===================================================================== +-- Aether Character — 3D-Vorschau +-- Spawnt einen lokalen Ped als lebende Vorschau und richtet eine Kamera +-- darauf. Die NUI liegt mit einem transparenten Bereich darueber. +-- ===================================================================== + +Vorschau = {} + +-- Ort, an dem der Vorschau-Ped steht (ruhige Ecke am Spawnbereich) +local VORSCHAU_POS = { x = -1040.0, y = -2748.0, z = 21.36 } + +-- Kameraeinstellungen je Ansicht +-- abstand = Entfernung vor dem Ped, hoehe = Kamerahoehe, ziel = Blickpunkt am Ped +local MODI = { + gesicht = { abstand = 1.05, hoehe = 0.68, ziel = 0.68, sicht = 28.0 }, + koerper = { abstand = 2.90, hoehe = 0.25, ziel = 0.20, sicht = 40.0 }, +} + +-- Laufender Zustand der Vorschau +local vorschauPed = nil -- Handle des Vorschau-Peds +local vorschauKamera= nil -- Handle der Kamera +local aktuellesModell = nil -- Aktuell geladenes Ped-Modell (fuer Geschlechtswechsel) +local aktuelleDrehung = 0.0 -- Zusaetzliche Drehung durch den Spieler +local aktuellerModus = 'koerper' + +-- --------------------------------------------------------------------- +-- Interne Helfer +-- --------------------------------------------------------------------- + +--- Laedt ein Ped-Modell und wartet, bis es bereit ist +--- @param modellName string Name des Modells +--- @return number|nil Modell-Hash oder nil bei Fehlschlag +local function LadeModell(modellName) + local modell = GetHashKey(modellName) + RequestModel(modell) + + local versuche = 0 + while not HasModelLoaded(modell) and versuche < 100 do + Wait(50) + versuche = versuche + 1 + end + + if not HasModelLoaded(modell) then return nil end + return modell +end + +--- Setzt die Kamera auf die gewuenschte Ansicht +--- @param modus string 'gesicht' | 'koerper' +--- @param weich boolean|nil Sanft ueberblenden statt springen +local function SetzeKameraAuf(modus, weich) + if not vorschauKamera then return end + + local einstellung = MODI[modus] or MODI.koerper + aktuellerModus = modus + + -- Kamera steht noerdlich vor dem Ped; der Ped schaut sie an. + -- Fest im Raum verankert, damit das Drehen des Peds die Kamera nicht mitbewegt. + SetCamCoord(vorschauKamera, + VORSCHAU_POS.x, + VORSCHAU_POS.y + einstellung.abstand, + VORSCHAU_POS.z + einstellung.hoehe) + + PointCamAtCoord(vorschauKamera, + VORSCHAU_POS.x, VORSCHAU_POS.y, VORSCHAU_POS.z + einstellung.ziel) + + SetCamFov(vorschauKamera, einstellung.sicht) + + if weich then + -- Kurze Ueberblendung fuer einen weichen Wechsel + SetCamActiveWithInterp(vorschauKamera, vorschauKamera, 400, 1, 1) + end +end + +-- --------------------------------------------------------------------- +-- Oeffentliche Schnittstelle +-- --------------------------------------------------------------------- + +--- Startet die Vorschau: erzeugt Ped und Kamera +--- @param aussehen table|nil Startaussehen (bestimmt auch das Geschlecht) +--- @param modus string|nil Startansicht ('gesicht' | 'koerper') +function Vorschau.Starten(aussehen, modus) + -- Umgebung laden, damit der Ped nicht durch den Boden faellt + RequestCollisionAtCoord(VORSCHAU_POS.x, VORSCHAU_POS.y, VORSCHAU_POS.z) + + local modellName = AetherAussehen.HoleModell(aussehen and aussehen.geschlecht) + local modell = LadeModell(modellName) + if not modell then + print('^1[Aether]^7 Vorschau-Modell konnte nicht geladen werden: ' .. modellName) + return + end + + -- Ped nur lokal erzeugen (nicht im Netzwerk sichtbar) + vorschauPed = CreatePed(4, modell, VORSCHAU_POS.x, VORSCHAU_POS.y, VORSCHAU_POS.z, + 0.0, false, false) + aktuellesModell = modellName + aktuelleDrehung = 0.0 + + SetEntityHeading(vorschauPed, 0.0) -- schaut Richtung Kamera + FreezeEntityPosition(vorschauPed, true) + SetEntityInvincible(vorschauPed, true) + SetBlockingOfNonTemporaryEvents(vorschauPed, true) + SetModelAsNoLongerNeeded(modell) + + if aussehen then + AetherAussehen.Anwenden(vorschauPed, aussehen) + end + + -- Kamera erzeugen und aktivieren + vorschauKamera = CreateCam('DEFAULT_SCRIPTED_CAMERA', true) + SetzeKameraAuf(modus or 'koerper', false) + SetCamActive(vorschauKamera, true) + RenderScriptCams(true, false, 0, true, true) +end + +--- Uebertraegt ein Aussehen auf den Vorschau-Ped. +--- Wechselt das Geschlecht, wird der Ped neu erzeugt. +--- @param aussehen table Aussehen aus dem Ersteller +function Vorschau.Anwenden(aussehen) + if not aussehen then return end + + -- Geschlechtswechsel erfordert ein anderes Modell + local gewuenschtesModell = AetherAussehen.HoleModell(aussehen.geschlecht) + if gewuenschtesModell ~= aktuellesModell then + local drehung = aktuelleDrehung + local modus = aktuellerModus + + if vorschauPed and DoesEntityExist(vorschauPed) then + DeletePed(vorschauPed) + end + + local modell = LadeModell(gewuenschtesModell) + if not modell then return end + + vorschauPed = CreatePed(4, modell, VORSCHAU_POS.x, VORSCHAU_POS.y, VORSCHAU_POS.z, + 0.0, false, false) + aktuellesModell = gewuenschtesModell + + FreezeEntityPosition(vorschauPed, true) + SetEntityInvincible(vorschauPed, true) + SetBlockingOfNonTemporaryEvents(vorschauPed, true) + SetModelAsNoLongerNeeded(modell) + + -- Vorherige Drehung und Ansicht wiederherstellen + aktuelleDrehung = drehung + SetEntityHeading(vorschauPed, aktuelleDrehung) + SetzeKameraAuf(modus, false) + end + + if vorschauPed and DoesEntityExist(vorschauPed) then + AetherAussehen.Anwenden(vorschauPed, aussehen) + end +end + +--- Wechselt zwischen Gesichts- und Koerperansicht +--- @param modus string 'gesicht' | 'koerper' +function Vorschau.SetzeModus(modus) + SetzeKameraAuf(modus, true) +end + +--- Dreht den Vorschau-Ped um einen Winkel +--- @param grad number Drehung in Grad (positiv = nach rechts) +function Vorschau.Drehen(grad) + if not vorschauPed or not DoesEntityExist(vorschauPed) then return end + aktuelleDrehung = (aktuelleDrehung + (grad or 0.0)) % 360.0 + SetEntityHeading(vorschauPed, aktuelleDrehung) +end + +--- Beendet die Vorschau und raeumt Ped und Kamera auf +function Vorschau.Beenden() + if vorschauKamera then + RenderScriptCams(false, false, 0, true, true) + DestroyCam(vorschauKamera, false) + vorschauKamera = nil + end + + if vorschauPed and DoesEntityExist(vorschauPed) then + DeletePed(vorschauPed) + end + + vorschauPed = nil + aktuellesModell = nil + aktuelleDrehung = 0.0 +end + +--- Ist gerade eine Vorschau aktiv? +--- @return boolean +function Vorschau.IstAktiv() + return vorschauPed ~= nil +end diff --git a/resources/[aether]/aether-character/fxmanifest.lua b/resources/[aether]/aether-character/fxmanifest.lua new file mode 100644 index 0000000..9fcc2df --- /dev/null +++ b/resources/[aether]/aether-character/fxmanifest.lua @@ -0,0 +1,39 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +name 'aether-character' +author 'Jerrit Fritzsche' +description 'Multicharacter-Auswahl und Charakter-Ersteller' +version '0.1.0' + +-- Oberflaeche der Charakterauswahl +ui_page 'html/index.html' + +files { + 'html/index.html', +} + +-- Geteilte Dateien des Cores mitladen. +-- Noetig, weil jede FiveM-Resource einen eigenen Lua-State hat: +-- `Config` und `Aether.*` waeren sonst hier nicht verfuegbar. +shared_scripts { + '@aether-core/shared/config.lua', + '@aether-core/shared/utils.lua', +} + +client_scripts { + 'client/aussehen.lua', + 'client/vorschau.lua', + 'client/main.lua', +} + +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/main.lua', +} + +dependencies { + 'aether-core', + 'oxmysql', +} diff --git a/resources/[aether]/aether-character/html/index.html b/resources/[aether]/aether-character/html/index.html new file mode 100644 index 0000000..ff7fe18 --- /dev/null +++ b/resources/[aether]/aether-character/html/index.html @@ -0,0 +1,519 @@ + + + + + + + + +Aether — Charaktererstellung + + +
+ +
+

Charaktere

Wähle einen Charakter oder erstelle einen neuen.
+
+
+
+
+ + Drehen + +
+
+
+
+ + +
+
+
+
+
+
+ + Drehen + +
+
+
+

Charakter erstellen

Gestalte Aussehen und Identität

+
+
+
+
+
+ +
+
+ + + + + diff --git a/resources/[aether]/aether-character/server/main.lua b/resources/[aether]/aether-character/server/main.lua new file mode 100644 index 0000000..8f5aa17 --- /dev/null +++ b/resources/[aether]/aether-character/server/main.lua @@ -0,0 +1,221 @@ +-- ===================================================================== +-- Aether Character — Serverlogik +-- Liefert die Charakterliste, legt Charaktere an, loescht sie und +-- uebergibt die Auswahl an den Core. +-- +-- Grundsatz: Alles, was vom Client kommt, wird hier geprueft. +-- ===================================================================== + +local Core = exports['aether-core'] + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Liest die Rockstar-Lizenz eines Spielers aus +--- @param quelle number Server-ID +--- @return string|nil +local function HoleLizenz(quelle) + local lizenz = GetPlayerIdentifierByType(quelle, 'license') + if not lizenz then return nil end + return lizenz:gsub('license:', '') +end + +--- Wandelt ein Datum von TT.MM.JJJJ nach JJJJ-MM-TT (Datenbankformat) +--- @param text string Eingabe des Spielers +--- @return string|nil Datum im DB-Format oder nil bei ungueltiger Eingabe +local function DatumFuerDatenbank(text) + if type(text) ~= 'string' then return nil end + local tag, monat, jahr = text:match('^(%d%d)%.(%d%d)%.(%d%d%d%d)$') + if not tag then return nil end + + tag, monat, jahr = tonumber(tag), tonumber(monat), tonumber(jahr) + -- Grobe Plausibilitaetspruefung + if monat < 1 or monat > 12 or tag < 1 or tag > 31 then return nil end + if jahr < 1900 or jahr > 2010 then return nil end + + return ('%04d-%02d-%02d'):format(jahr, monat, tag) +end + +--- Wandelt ein Datum von JJJJ-MM-TT nach TT.MM.JJJJ (Anzeige) +--- @param wert any Wert aus der Datenbank +--- @return string +local function DatumFuerAnzeige(wert) + local text = tostring(wert or '') + local jahr, monat, tag = text:match('^(%d%d%d%d)-(%d%d)-(%d%d)') + if not jahr then return '—' end + return ('%s.%s.%s'):format(tag, monat, jahr) +end + +--- Kuerzt und saeubert einen Namen (nur Buchstaben, Leerzeichen, Bindestrich) +--- @param text any Eingabe +--- @return string|nil Bereinigter Name oder nil, wenn ungueltig +local function SaeubereName(text) + if type(text) ~= 'string' then return nil end + local name = text:gsub('^%s+', ''):gsub('%s+$', '') + if #name < 2 or #name > 32 then return nil end + -- Nur Buchstaben (inkl. Umlaute), Leerzeichen und Bindestriche zulassen + if name:find('[^%a%sÄÖÜäöüß%-]') then return nil end + return name +end + +--- Baut die Charakterliste in dem Format, das die Oberflaeche erwartet +--- @param lizenz string Rockstar-Lizenz +--- @return table Liste fuer die NUI +local function BaueListe(lizenz) + local datensaetze = MySQL.query.await([[ + SELECT id, firstname, lastname, gender, dob, nationality, + job, cash, bank, playtime, appearance, last_played + FROM aether_characters + WHERE license = ? AND deleted = 0 + ORDER BY last_played DESC + ]], { lizenz }) or {} + + local liste = {} + for _, datensatz in ipairs(datensaetze) do + -- Aussehen aus JSON zurueckwandeln + local aussehen = nil + if datensatz.appearance then + local erfolg, entschluesselt = pcall(json.decode, datensatz.appearance) + if erfolg then aussehen = entschluesselt end + end + + liste[#liste + 1] = { + id = datensatz.id, + vorname = datensatz.firstname, + nachname = datensatz.lastname, + geschlecht = datensatz.gender, + geburt = DatumFuerAnzeige(datensatz.dob), + herkunft = datensatz.nationality, + job = datensatz.job, + geld = (datensatz.cash or 0) + (datensatz.bank or 0), + stunden = math.floor((datensatz.playtime or 0) / 60), + zuletzt = tostring(datensatz.last_played or ''):sub(1, 16), + aussehen = aussehen, + } + end + return liste +end + +--- Schickt die aktuelle Liste an einen Spieler +--- @param quelle number Server-ID +--- @param hinweis string|nil Kurze Rueckmeldung fuer die Oberflaeche +local function SendeListe(quelle, hinweis) + local lizenz = HoleLizenz(quelle) + if not lizenz then return end + TriggerClientEvent('aether:character:liste', quelle, + BaueListe(lizenz), Config.MaxCharaktere, hinweis) +end + +-- --------------------------------------------------------------------- +-- Auswahl anzeigen (wird vom Core ausgeloest) +-- --------------------------------------------------------------------- +AddEventHandler('aether:server:charakterAuswahlNoetig', function(quelle) + local lizenz = HoleLizenz(quelle) + if not lizenz then return end + TriggerClientEvent('aether:character:zeigeAuswahl', quelle, + BaueListe(lizenz), Config.MaxCharaktere) +end) + +-- --------------------------------------------------------------------- +-- Charakter waehlen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:character:spielen', function(charakterId) + local quelle = source + local lizenz = HoleLizenz(quelle) + if not lizenz or type(charakterId) ~= 'number' then return end + + -- Gehoert der Charakter wirklich diesem Spieler? + local treffer = MySQL.query.await( + 'SELECT id FROM aether_characters WHERE id = ? AND license = ? AND deleted = 0', + { charakterId, lizenz }) + + if not treffer or not treffer[1] then + TriggerClientEvent('aether:character:fehler', quelle, 'Dieser Charakter gehört dir nicht.') + return + end + + -- Core laedt den Charakter und spawnt den Spieler + local erfolg = Core:LadeCharakter(quelle, charakterId) + if not erfolg then + TriggerClientEvent('aether:character:fehler', quelle, 'Charakter konnte nicht geladen werden.') + end +end) + +-- --------------------------------------------------------------------- +-- Charakter erstellen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:character:erstellen', function(daten) + local quelle = source + local lizenz = HoleLizenz(quelle) + if not lizenz or type(daten) ~= 'table' then return end + + -- Slotgrenze pruefen + local vorhandene = MySQL.scalar.await( + 'SELECT COUNT(*) FROM aether_characters WHERE license = ? AND deleted = 0', { lizenz }) + if (vorhandene or 0) >= Config.MaxCharaktere then + TriggerClientEvent('aether:character:fehler', quelle, + ('Du hast bereits %d Charaktere.'):format(Config.MaxCharaktere)) + return + end + + -- Namen pruefen + local vorname = SaeubereName(daten.vorname) + local nachname = SaeubereName(daten.nachname) + if not vorname or not nachname then + TriggerClientEvent('aether:character:fehler', quelle, + 'Bitte gültige Vor- und Nachnamen angeben (nur Buchstaben, 2–32 Zeichen).') + return + end + + -- Geburtsdatum pruefen + local geburt = DatumFuerDatenbank(daten.geburt) + if not geburt then + TriggerClientEvent('aether:character:fehler', quelle, + 'Ungültiges Geburtsdatum. Format: TT.MM.JJJJ (Jahr 1900–2010).') + return + end + + -- Geschlecht auf erlaubte Werte begrenzen + local geschlecht = daten.geschlecht + if geschlecht ~= 'männlich' and geschlecht ~= 'weiblich' and geschlecht ~= 'divers' then + geschlecht = 'männlich' + end + + -- Charakter anlegen (eigene Abfrage, da `Aether.DB` im Core-State liegt) + local neueId = MySQL.insert.await([[ + INSERT INTO aether_characters + (license, firstname, lastname, dob, gender, nationality, appearance, + cash, bank, iban, job, job_grade) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ]], { + lizenz, vorname, nachname, geburt, geschlecht, + tostring(daten.herkunft or 'Los Santos'):sub(1, 48), + type(daten.aussehen) == 'table' and json.encode(daten.aussehen) or nil, + Config.StartWerte.bargeld, + Config.StartWerte.konto, + Aether.ErzeugeIban(), + Config.StartWerte.job, + Config.StartWerte.jobRang, + }) + + Aether.Info(('Charakter #%d erstellt: %s %s'):format(neueId, vorname, nachname)) + SendeListe(quelle, ('%s %s wurde erstellt.'):format(vorname, nachname)) +end) + +-- --------------------------------------------------------------------- +-- Charakter loeschen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:character:loeschen', function(charakterId) + local quelle = source + local lizenz = HoleLizenz(quelle) + if not lizenz or type(charakterId) ~= 'number' then return end + + -- Weich loeschen; die Lizenzpruefung in der Abfrage sichert das Eigentum ab + MySQL.update.await( + 'UPDATE aether_characters SET deleted = 1 WHERE id = ? AND license = ?', + { charakterId, lizenz }) + Aether.Info(('Charakter #%d geloescht (Lizenz %s).'):format(charakterId, lizenz)) + + SendeListe(quelle, 'Charakter gelöscht.') +end) diff --git a/resources/[aether]/aether-core/client/main.lua b/resources/[aether]/aether-core/client/main.lua new file mode 100644 index 0000000..db26895 --- /dev/null +++ b/resources/[aether]/aether-core/client/main.lua @@ -0,0 +1,165 @@ +-- ===================================================================== +-- Aether Core — Clientlogik +-- Meldet sich beim Server an, spawnt den Charakter und haelt die Daten aktuell. +-- ===================================================================== + +Aether = Aether or {} + +-- Daten des eigenen Charakters (vom Server befuellt) +Aether.SpielerDaten = nil +-- Ist der Spieler fertig geladen und im Spiel? +Aether.IstGeladen = false + +-- --------------------------------------------------------------------- +-- Startvorgang +-- --------------------------------------------------------------------- +CreateThread(function() + -- Warten, bis die Spielwelt bereit ist + while not NetworkIsSessionStarted() do + Wait(100) + end + + -- Standard-Spawnverhalten von FiveM unterbinden. + -- Abgesichert, falls spawnmanager nicht laeuft (dann uebernimmt der Core allein). + local erfolg = pcall(function() + exports.spawnmanager:setAutoSpawn(false) + end) + if not erfolg then + Aether.Debug('spawnmanager nicht verfuegbar — Core spawnt eigenstaendig.') + end + + -- Bildschirm schwarz halten, bis der Charakter geladen ist + DoScreenFadeOut(0) + SetEntityVisible(PlayerPedId(), false, false) + + Aether.Debug('Client bereit — fordere Spielerdaten an.') + TriggerServerEvent('aether:server:spielerBereit') +end) + +-- --------------------------------------------------------------------- +-- Spieler wurde geladen: Ped setzen und spawnen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:client:spielerGeladen', function(daten) + Aether.SpielerDaten = daten + + -- Passendes Standardmodell laden (bis der Charakter-Ersteller migriert ist) + local modellName = Config.StandardPed[daten.geschlecht] or Config.StandardPed.m + local modell = GetHashKey(modellName) + + RequestModel(modell) + local versuche = 0 + while not HasModelLoaded(modell) and versuche < 100 do + Wait(50) + versuche = versuche + 1 + end + + if HasModelLoaded(modell) then + SetPlayerModel(PlayerId(), modell) + SetModelAsNoLongerNeeded(modell) + else + Aether.Fehler('Ped-Modell konnte nicht geladen werden: ' .. modellName) + end + + -- Spieler an die gespeicherte Position setzen + local ped = PlayerPedId() + local pos = daten.position or Config.SpawnPunkt + SetEntityCoordsNoOffset(ped, pos.x + 0.0, pos.y + 0.0, pos.z + 0.0, false, false, false) + SetEntityHeading(ped, pos.heading or 0.0) + + -- Zustand wiederherstellen + SetEntityHealth(ped, daten.leben or 200) + FreezeEntityPosition(ped, false) + SetEntityVisible(ped, true, false) + + -- Warten, bis die Umgebung geladen ist, dann Bild freigeben + local wartezeit = 0 + while not HasCollisionLoadedAroundEntity(ped) and wartezeit < 100 do + Wait(50) + wartezeit = wartezeit + 1 + end + + ShutdownLoadingScreen() + ShutdownLoadingScreenNui() + DoScreenFadeIn(1000) + + Aether.IstGeladen = true + Aether.Info(('Willkommen, %s %s!'):format(daten.vorname, daten.nachname)) + + -- Andere Ressourcen (HUD, Handy, Inventar) informieren. + -- WICHTIG: anderer Name als das Netz-Event, sonst ruft sich dieser + -- Handler endlos selbst auf. + TriggerEvent('aether:client:bereit', daten) +end) + +-- --------------------------------------------------------------------- +-- Aktualisierungen vom Server +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:client:geldAktualisiert', function(geld) + if not Aether.SpielerDaten then return end + Aether.SpielerDaten.bargeld = geld.bargeld + Aether.SpielerDaten.konto = geld.konto + Aether.SpielerDaten.schwarzgeld = geld.schwarzgeld + -- Weiterreichen ans HUD + TriggerEvent('aether:client:geldGeaendert', geld) +end) + +RegisterNetEvent('aether:client:zustandAktualisiert', function(zustand) + if not Aether.SpielerDaten then return end + Aether.SpielerDaten.hunger = zustand.hunger + Aether.SpielerDaten.durst = zustand.durst + Aether.SpielerDaten.ausdauer = zustand.ausdauer + -- Weiterreichen ans HUD + TriggerEvent('aether:client:zustandGeaendert', zustand) +end) + +--- Schaden durch Hunger/Durst zufuegen +RegisterNetEvent('aether:client:erleideSchaden', function(menge) + local ped = PlayerPedId() + local leben = GetEntityHealth(ped) + SetEntityHealth(ped, math.max(0, leben - (menge or 5))) +end) + +-- --------------------------------------------------------------------- +-- Position regelmaessig an den Server melden (fuer das Speichern) +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(10000) -- alle 10 Sekunden + if Aether.IstGeladen then + local ped = PlayerPedId() + local koordinaten = GetEntityCoords(ped) + TriggerServerEvent('aether:server:aktualisierePosition', { + x = Aether.Runde(koordinaten.x, 2), + y = Aether.Runde(koordinaten.y, 2), + z = Aether.Runde(koordinaten.z, 2), + heading = Aether.Runde(GetEntityHeading(ped), 2), + }) + -- Lebenswert mitschicken, damit er beim Speichern stimmt + TriggerServerEvent('aether:server:aktualisiereZustand', { + leben = GetEntityHealth(ped), + }) + end + end +end) + +-- --------------------------------------------------------------------- +-- Kein Charakter vorhanden (spaeter uebernimmt das aether-character) +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:client:keinCharakter', function() + Aether.Fehler('Kein Charakter vorhanden — Charaktererstellung ist noch nicht migriert.') +end) + +-- ===================================================================== +-- EXPORTS fuer andere Module +-- Beispiel: local daten = exports['aether-core']:HoleSpielerDaten() +-- ===================================================================== + +--- Liefert die Daten des eigenen Charakters +exports('HoleSpielerDaten', function() + return Aether.SpielerDaten +end) + +--- Ist der Spieler fertig geladen? +exports('IstGeladen', function() + return Aether.IstGeladen +end) diff --git a/resources/[aether]/aether-core/client/nui.lua b/resources/[aether]/aether-core/client/nui.lua new file mode 100644 index 0000000..56fb344 --- /dev/null +++ b/resources/[aether]/aether-core/client/nui.lua @@ -0,0 +1,76 @@ +-- ===================================================================== +-- Aether Core — NUI-Helfer +-- Einheitliches Oeffnen/Schliessen von Benutzeroberflaechen. +-- Damit muss nicht jedes Modul Fokus- und Cursor-Logik neu bauen. +-- ===================================================================== + +Aether = Aether or {} + +-- Name der Ressource, deren Oberflaeche gerade offen ist (nil = keine) +Aether.OffeneNui = nil + +--- Oeffnet eine NUI: setzt Fokus und Mauszeiger +--- @param ressource string Name der Ressource, die die Oberflaeche besitzt +--- @param nachricht table|nil Daten, die an die Oberflaeche geschickt werden +function Aether.OeffneNui(ressource, nachricht) + -- Es darf immer nur eine Oberflaeche gleichzeitig offen sein + if Aether.OffeneNui and Aether.OffeneNui ~= ressource then + Aether.SchliesseNui() + end + + Aether.OffeneNui = ressource + SetNuiFocus(true, true) + + if nachricht then + SendNuiMessage(json.encode(nachricht)) + end +end + +--- Schliesst die aktuell offene NUI und gibt die Steuerung zurueck +function Aether.SchliesseNui() + if not Aether.OffeneNui then return end + SetNuiFocus(false, false) + Aether.OffeneNui = nil +end + +--- Schickt Daten an die offene Oberflaeche, ohne den Fokus zu aendern +--- @param nachricht table Zu sendende Daten +function Aether.SendeAnNui(nachricht) + SendNuiMessage(json.encode(nachricht)) +end + +-- --------------------------------------------------------------------- +-- Sicherheitsnetz: bei ESC immer den Fokus freigeben +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + if Aether.OffeneNui then + Wait(0) + -- Taste 322 = ESC + if IsControlJustReleased(0, 322) then + Aether.SchliesseNui() + -- Der offenen Ressource Bescheid geben, damit sie aufraeumen kann + TriggerEvent('aether:client:nuiGeschlossen', Aether.OffeneNui) + end + else + Wait(250) + end + end +end) + +-- ===================================================================== +-- EXPORTS fuer andere Module +-- Beispiel: exports['aether-core']:OeffneNui('aether-phone', { aktion = 'oeffnen' }) +-- ===================================================================== + +exports('OeffneNui', function(ressource, nachricht) + Aether.OeffneNui(ressource, nachricht) +end) + +exports('SchliesseNui', function() + Aether.SchliesseNui() +end) + +exports('SendeAnNui', function(nachricht) + Aether.SendeAnNui(nachricht) +end) diff --git a/resources/[aether]/aether-core/fxmanifest.lua b/resources/[aether]/aether-core/fxmanifest.lua new file mode 100644 index 0000000..2dd76fd --- /dev/null +++ b/resources/[aether]/aether-core/fxmanifest.lua @@ -0,0 +1,32 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +name 'aether-core' +author 'Jerrit Fritzsche' +description 'Fundament des Aether-Frameworks: Spieler, Charaktere, Geld, Zustand' +version '0.1.0' + +-- Geteilter Code (laeuft auf Client und Server) +shared_scripts { + 'shared/config.lua', + 'shared/utils.lua', +} + +-- Clientseitiger Code +client_scripts { + 'client/main.lua', + 'client/nui.lua', +} + +-- Serverseitiger Code (MySQL.lua muss vor eigenem Code geladen werden) +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/database.lua', + 'server/player.lua', + 'server/main.lua', +} + +dependencies { + 'oxmysql', +} diff --git a/resources/[aether]/aether-core/server/database.lua b/resources/[aether]/aether-core/server/database.lua new file mode 100644 index 0000000..7f95fe2 --- /dev/null +++ b/resources/[aether]/aether-core/server/database.lua @@ -0,0 +1,131 @@ +-- ===================================================================== +-- Aether Core — Datenbankzugriff (oxmysql) +-- Kapselt alle SQL-Abfragen, damit der Rest des Codes kein SQL enthaelt. +-- ===================================================================== + +Aether = Aether or {} +Aether.DB = {} + +-- --------------------------------------------------------------------- +-- Benutzer +-- --------------------------------------------------------------------- + +--- Legt einen Benutzer an, falls er noch nicht existiert, und gibt ihn zurueck +--- @param lizenz string Rockstar-Lizenz des Spielers +--- @param name string Aktueller Spielername +--- @return table Benutzerdatensatz +function Aether.DB.HoleOderErstelleBenutzer(lizenz, name) + local vorhandene = MySQL.query.await( + 'SELECT * FROM aether_users WHERE license = ?', { lizenz }) + + if vorhandene and vorhandene[1] then + -- Namen und Zeitstempel aktualisieren + MySQL.update.await( + 'UPDATE aether_users SET name = ?, last_seen = NOW() WHERE license = ?', + { name, lizenz }) + return vorhandene[1] + end + + -- Neuen Benutzer anlegen + MySQL.insert.await( + 'INSERT INTO aether_users (license, name) VALUES (?, ?)', { lizenz, name }) + Aether.Info(('Neuer Benutzer angelegt: %s (%s)'):format(name, lizenz)) + + local neue = MySQL.query.await( + 'SELECT * FROM aether_users WHERE license = ?', { lizenz }) + return neue[1] +end + +-- --------------------------------------------------------------------- +-- Charaktere +-- --------------------------------------------------------------------- + +--- Laedt alle nicht geloeschten Charaktere eines Benutzers +--- @param lizenz string Rockstar-Lizenz +--- @return table Liste der Charaktere +function Aether.DB.HoleCharaktere(lizenz) + return MySQL.query.await( + 'SELECT * FROM aether_characters WHERE license = ? AND deleted = 0 ORDER BY last_played DESC', + { lizenz }) or {} +end + +--- Laedt einen einzelnen Charakter anhand seiner ID +--- @param charakterId number Charakter-ID +--- @return table|nil +function Aether.DB.HoleCharakter(charakterId) + local ergebnis = MySQL.query.await( + 'SELECT * FROM aether_characters WHERE id = ? AND deleted = 0', { charakterId }) + return ergebnis and ergebnis[1] or nil +end + +--- Legt einen neuen Charakter an und gibt dessen ID zurueck +--- @param lizenz string Rockstar-Lizenz +--- @param daten table Felder: firstname, lastname, dob, gender, nationality, appearance +--- @return number Neue Charakter-ID +function Aether.DB.ErstelleCharakter(lizenz, daten) + return MySQL.insert.await([[ + INSERT INTO aether_characters + (license, firstname, lastname, dob, gender, nationality, appearance, + cash, bank, iban, job, job_grade) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ]], { + lizenz, + daten.firstname or 'Max', + daten.lastname or 'Mustermann', + daten.dob or '1995-01-01', + daten.gender or 'm', + daten.nationality or 'Los Santos', + daten.appearance and json.encode(daten.appearance) or nil, + Config.StartWerte.bargeld, + Config.StartWerte.konto, + Aether.ErzeugeIban(), + Config.StartWerte.job, + Config.StartWerte.jobRang, + }) +end + +--- Speichert den aktuellen Stand eines Charakters zurueck in die Datenbank +--- @param charakterId number Charakter-ID +--- @param daten table Zu speichernde Werte +function Aether.DB.SpeichereCharakter(charakterId, daten) + MySQL.update([[ + UPDATE aether_characters SET + cash = ?, bank = ?, dirty_money = ?, + job = ?, job_grade = ?, faction = ?, faction_rank = ?, + health = ?, hunger = ?, thirst = ?, stamina = ?, + position = ?, is_dead = ?, playtime = ?, last_played = NOW() + WHERE id = ? + ]], { + daten.cash, daten.bank, daten.dirty_money, + daten.job, daten.job_grade, daten.faction, daten.faction_rank, + daten.health, daten.hunger, daten.thirst, daten.stamina, + json.encode(daten.position or Config.SpawnPunkt), + daten.is_dead and 1 or 0, + daten.playtime or 0, + charakterId, + }) +end + +--- Markiert einen Charakter als geloescht (Slot wird wieder frei) +--- @param charakterId number Charakter-ID +--- @param lizenz string Lizenz zur Sicherheit (nur eigene Charaktere loeschbar) +function Aether.DB.LoescheCharakter(charakterId, lizenz) + MySQL.update.await( + 'UPDATE aether_characters SET deleted = 1 WHERE id = ? AND license = ?', + { charakterId, lizenz }) +end + +-- --------------------------------------------------------------------- +-- Transaktionen (Bank-Modul) +-- --------------------------------------------------------------------- + +--- Schreibt eine Banktransaktion mit +--- @param charakterId number Charakter-ID +--- @param art string deposit/withdraw/transfer/salary/fine +--- @param betrag number Betrag (positiv = Eingang) +--- @param kontostand number Kontostand nach der Buchung +--- @param bezeichnung string Verwendungszweck +function Aether.DB.SchreibeTransaktion(charakterId, art, betrag, kontostand, bezeichnung) + MySQL.insert('INSERT INTO aether_transactions (`character`, type, amount, balance, label) VALUES (?, ?, ?, ?, ?)', + { charakterId, art, betrag, kontostand, bezeichnung or '' }) +end diff --git a/resources/[aether]/aether-core/server/main.lua b/resources/[aether]/aether-core/server/main.lua new file mode 100644 index 0000000..e8ece2c --- /dev/null +++ b/resources/[aether]/aether-core/server/main.lua @@ -0,0 +1,305 @@ +-- ===================================================================== +-- Aether Core — Serverlogik +-- Verbindungsaufbau, Laden/Speichern der Spieler und Exports fuer Module. +-- ===================================================================== + +Aether = Aether or {} + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Liest die Rockstar-Lizenz eines verbundenen Spielers aus +--- @param quelle number Server-ID +--- @return string|nil Lizenz (ohne Praefix) oder nil +local function HoleLizenz(quelle) + local lizenz = GetPlayerIdentifierByType(quelle, 'license') + if not lizenz then return nil end + return lizenz:gsub('license:', '') +end + +-- --------------------------------------------------------------------- +-- Verbindungsaufbau (Warteschlange / Bannpruefung) +-- --------------------------------------------------------------------- +AddEventHandler('playerConnecting', function(name, setKickReason, deferrals) + local quelle = source + deferrals.defer() + Wait(0) + + deferrals.update('Aether — Verbindung wird geprueft ...') + + local lizenz = HoleLizenz(quelle) + if not lizenz then + deferrals.done('Keine gueltige Rockstar-Lizenz gefunden. Bitte starte FiveM neu.') + return + end + + -- Benutzer anlegen oder laden + local benutzer = Aether.DB.HoleOderErstelleBenutzer(lizenz, name) + if not benutzer then + deferrals.done('Datenbankfehler beim Laden deines Profils. Bitte melde dich beim Team.') + return + end + + -- Bannpruefung + if benutzer.is_banned == 1 then + local text = benutzer.ban_reason or 'Kein Grund angegeben' + if benutzer.ban_until then + deferrals.done(('Du bist bis %s gesperrt.\nGrund: %s'):format(benutzer.ban_until, text)) + else + deferrals.done(('Du bist dauerhaft gesperrt.\nGrund: %s'):format(text)) + end + return + end + + deferrals.update('Aether — Willkommen zurueck!') + Wait(200) + deferrals.done() +end) + +-- --------------------------------------------------------------------- +-- Charakter in das Spiel laden +-- --------------------------------------------------------------------- + +--- Erzeugt das Spielerobjekt aus einem Charakter-Datensatz und spawnt den Spieler +--- @param quelle number Server-ID +--- @param lizenz string Rockstar-Lizenz +--- @param charakter table Datensatz aus aether_characters +--- @return table Spielerobjekt +local function LadeUndSpawne(quelle, lizenz, charakter) + local spieler = Aether.ErzeugeSpieler(quelle, lizenz, charakter) + + Aether.Info(('Spieler geladen: %s %s (ID %d, Charakter #%d)') + :format(spieler.vorname, spieler.nachname, quelle, spieler.charakterId)) + + -- Daten an den Client schicken, damit er spawnen kann + TriggerClientEvent('aether:client:spielerGeladen', quelle, spieler:HoleDaten()) + + -- Andere Ressourcen informieren (z. B. HUD, Inventar) + TriggerEvent('aether:server:spielerGeladen', quelle, spieler) + + return spieler +end + +-- --------------------------------------------------------------------- +-- Spieler ist bereit (wird vom Client ausgeloest) +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:server:spielerBereit', function() + local quelle = source + local lizenz = HoleLizenz(quelle) + if not lizenz then return end + + -- Bereits geladen? Dann nichts tun (schuetzt vor doppeltem Laden) + if Aether.Spieler[quelle] then return end + + -- Standardweg: `aether-character` zeigt die Charakterauswahl + if not Config.AutoCharakter then + TriggerEvent('aether:server:charakterAuswahlNoetig', quelle) + return + end + + -- Notbetrieb ohne aether-character: Startcharakter anlegen und direkt laden + local charaktere = Aether.DB.HoleCharaktere(lizenz) + if #charaktere == 0 then + local spielerName = GetPlayerName(quelle) + local neueId = Aether.DB.ErstelleCharakter(lizenz, { + firstname = spielerName:sub(1, 32), + lastname = 'Neuling', + }) + Aether.Info(('Startcharakter #%d fuer %s angelegt.'):format(neueId, spielerName)) + charaktere = Aether.DB.HoleCharaktere(lizenz) + end + + LadeUndSpawne(quelle, lizenz, charaktere[1]) +end) + +-- --------------------------------------------------------------------- +-- Position und Zustand vom Client uebernehmen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:server:aktualisierePosition', function(position) + local spieler = Aether.Spieler[source] + if not spieler or type(position) ~= 'table' then return end + spieler.position = position +end) + +RegisterNetEvent('aether:server:aktualisiereZustand', function(zustand) + local spieler = Aether.Spieler[source] + if not spieler or type(zustand) ~= 'table' then return end + -- Nur bekannte Felder uebernehmen (Client darf nichts Beliebiges setzen) + if zustand.leben then spieler.leben = zustand.leben end + if zustand.hunger then spieler.hunger = Aether.Begrenze(zustand.hunger, 0, 100) end + if zustand.durst then spieler.durst = Aether.Begrenze(zustand.durst, 0, 100) end + if zustand.ausdauer then spieler.ausdauer = Aether.Begrenze(zustand.ausdauer, 0, 100) end +end) + +-- --------------------------------------------------------------------- +-- Verbindungsabbruch: speichern und aufraeumen +-- --------------------------------------------------------------------- +AddEventHandler('playerDropped', function(grund) + local quelle = source + local spieler = Aether.Spieler[quelle] + if not spieler then return end + + spieler:Speichere() + Aether.Info(('Spieler gespeichert und entfernt: %s %s (Grund: %s)') + :format(spieler.vorname, spieler.nachname, grund)) + + TriggerEvent('aether:server:spielerEntfernt', quelle, spieler) + Aether.Spieler[quelle] = nil +end) + +-- --------------------------------------------------------------------- +-- Automatisches Speichern aller Spieler +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(Config.SpeicherIntervall) + local anzahl = 0 + for _, spieler in pairs(Aether.Spieler) do + spieler:Speichere() + anzahl = anzahl + 1 + end + if anzahl > 0 then + Aether.Debug(('Automatisch gespeichert: %d Spieler.'):format(anzahl)) + end + end +end) + +-- --------------------------------------------------------------------- +-- Hunger und Durst langsam senken +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(Config.Zustand.tickIntervall) + for _, spieler in pairs(Aether.Spieler) do + spieler:SetzeZustand('hunger', spieler.hunger - Config.Zustand.hungerProTick) + spieler:SetzeZustand('durst', spieler.durst - Config.Zustand.durstProTick) + + -- Bei leerem Magen oder Durst Schaden zufuegen + if spieler.hunger <= Config.Zustand.schadenAbWert + or spieler.durst <= Config.Zustand.schadenAbWert then + TriggerClientEvent('aether:client:erleideSchaden', spieler.quelle, + Config.Zustand.schadenProTick) + end + end + end +end) + +-- --------------------------------------------------------------------- +-- Beim Stoppen der Ressource alles sichern (z. B. bei /restart) +-- --------------------------------------------------------------------- +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + for _, spieler in pairs(Aether.Spieler) do + spieler:Speichere() + end + Aether.Info('Alle Spieler vor dem Stoppen gespeichert.') +end) + +-- ===================================================================== +-- EXPORTS — Schnittstelle fuer alle anderen Aether-Module +-- Beispiel: local spieler = exports['aether-core']:HoleSpieler(source) +-- ===================================================================== + +--- Liefert das Spielerobjekt zu einer Server-ID +exports('HoleSpieler', function(quelle) + return Aether.Spieler[quelle] +end) + +--- Liefert alle aktiven Spielerobjekte +exports('HoleAlleSpieler', function() + return Aether.Spieler +end) + +--- Laedt einen gewaehlten Charakter und spawnt den Spieler. +--- Wird von `aether-character` aufgerufen, nachdem der Spieler gewaehlt hat. +--- @param quelle number Server-ID +--- @param charakterId number Charakter-ID +--- @return boolean Erfolg +exports('LadeCharakter', function(quelle, charakterId) + local lizenz = HoleLizenz(quelle) + if not lizenz then return false end + + -- Doppeltes Laden verhindern + if Aether.Spieler[quelle] then return false end + + local charakter = Aether.DB.HoleCharakter(charakterId) + if not charakter then + Aether.Fehler(('Charakter #%s nicht gefunden.'):format(tostring(charakterId))) + return false + end + + -- Sicherheitspruefung: Der Charakter muss dem Spieler gehoeren + if charakter.license ~= lizenz then + Aether.Fehler(('Spieler %d wollte fremden Charakter #%d laden.'):format(quelle, charakterId)) + return false + end + + LadeUndSpawne(quelle, lizenz, charakter) + return true +end) + +--- Liefert das Spielerobjekt zu einer Charakter-ID +exports('HoleSpielerNachCharakter', function(charakterId) + for _, spieler in pairs(Aether.Spieler) do + if spieler.charakterId == charakterId then return spieler end + end + return nil +end) + +--- Liefert die Server-ID zu einer Charakter-ID, falls der Spieler online ist. +--- Bewusst schlank: Exports serialisieren, ganze Spielerobjekte waeren teuer. +--- @param charakterId number Charakter-ID +--- @return number|nil Server-ID oder nil +exports('HoleQuelleNachCharakter', function(charakterId) + for quelle, spieler in pairs(Aether.Spieler) do + if spieler.charakterId == charakterId then return quelle end + end + return nil +end) + +--- Liefert die Charakter-ID eines Spielers (oder nil) +--- @param quelle number Server-ID +--- @return number|nil +exports('HoleCharakterId', function(quelle) + local spieler = Aether.Spieler[quelle] + return spieler and spieler.charakterId or nil +end) + +--- Liefert die Daten eines Spielers als einfache Tabelle. +--- Wichtig: Exports serialisieren — Methoden ueberleben die Grenze nicht, +--- daher hier bewusst nur reine Werte. +--- @param quelle number Server-ID +--- @return table|nil +exports('HoleSpielerInfo', function(quelle) + local spieler = Aether.Spieler[quelle] + if not spieler then return nil end + return spieler:HoleDaten() +end) + +--- Aendert einen Zustandswert um einen Betrag (positiv oder negativ) +--- @param quelle number Server-ID +--- @param art string 'hunger' | 'durst' | 'ausdauer' +--- @param aenderung number Betrag +--- @return boolean Erfolg +exports('AendereZustand', function(quelle, art, aenderung) + local spieler = Aether.Spieler[quelle] + if not spieler then return false end + if art ~= 'hunger' and art ~= 'durst' and art ~= 'ausdauer' then return false end + spieler:SetzeZustand(art, (spieler[art] or 0) + aenderung) + return true +end) + +--- Fuegt einem Spieler Geld hinzu +exports('FuegeGeldHinzu', function(quelle, art, betrag, grund) + local spieler = Aether.Spieler[quelle] + if not spieler then return false end + return spieler:FuegeGeldHinzu(art, betrag, grund) +end) + +--- Zieht einem Spieler Geld ab (mit Deckungspruefung) +exports('EntferneGeld', function(quelle, art, betrag, grund) + local spieler = Aether.Spieler[quelle] + if not spieler then return false end + return spieler:EntferneGeld(art, betrag, grund) +end) diff --git a/resources/[aether]/aether-core/server/player.lua b/resources/[aether]/aether-core/server/player.lua new file mode 100644 index 0000000..7eb91f0 --- /dev/null +++ b/resources/[aether]/aether-core/server/player.lua @@ -0,0 +1,204 @@ +-- ===================================================================== +-- Aether Core — Spielerobjekt +-- Kapselt die Daten eines eingeloggten Charakters samt Methoden. +-- ===================================================================== + +Aether = Aether or {} + +-- Alle aktiven Spieler, indiziert nach Server-ID +Aether.Spieler = {} + +-- Methodentabelle (Vorlage fuer jedes Spielerobjekt) +local SpielerMethoden = {} +SpielerMethoden.__index = SpielerMethoden + +-- --------------------------------------------------------------------- +-- Erzeugung +-- --------------------------------------------------------------------- + +--- Erzeugt ein Spielerobjekt aus einem Charakter-Datensatz +--- @param quelle number Server-ID des Spielers +--- @param lizenz string Rockstar-Lizenz +--- @param charakter table Datensatz aus aether_characters +--- @return table Spielerobjekt +function Aether.ErzeugeSpieler(quelle, lizenz, charakter) + -- Gespeicherte Position einlesen (oder Standard-Spawn nutzen) + local position = Config.SpawnPunkt + if charakter.position then + local erfolg, entschluesselt = pcall(json.decode, charakter.position) + if erfolg and entschluesselt then position = entschluesselt end + end + + -- Aussehen einlesen (JSON aus dem Charakter-Ersteller) + local aussehen = nil + if charakter.appearance then + local erfolg, entschluesselt = pcall(json.decode, charakter.appearance) + if erfolg then aussehen = entschluesselt end + end + + local spieler = setmetatable({}, SpielerMethoden) + + spieler.quelle = quelle + spieler.lizenz = lizenz + spieler.charakterId = charakter.id + + -- Identitaet + spieler.vorname = charakter.firstname + spieler.nachname = charakter.lastname + spieler.geburtstag = charakter.dob + spieler.geschlecht = charakter.gender + spieler.herkunft = charakter.nationality + spieler.aussehen = aussehen + + -- Geld + spieler.bargeld = charakter.cash + spieler.konto = charakter.bank + spieler.schwarzgeld = charakter.dirty_money + spieler.iban = charakter.iban + + -- Arbeit und Zugehoerigkeit + spieler.job = charakter.job + spieler.jobRang = charakter.job_grade + spieler.fraktion = charakter.faction + spieler.fraktionRang= charakter.faction_rank + + -- Zustand + spieler.leben = charakter.health + spieler.hunger = charakter.hunger + spieler.durst = charakter.thirst + spieler.ausdauer = charakter.stamina + spieler.istTot = charakter.is_dead == 1 + spieler.position = position + spieler.spielzeit = charakter.playtime or 0 + + Aether.Spieler[quelle] = spieler + return spieler +end + +-- --------------------------------------------------------------------- +-- Geld +-- --------------------------------------------------------------------- + +--- Liefert den Kontostand einer Geldart +--- @param art string 'bargeld' | 'konto' | 'schwarzgeld' +--- @return number +function SpielerMethoden:HoleGeld(art) + return self[art] or 0 +end + +--- Fuegt Geld hinzu und meldet die Aenderung an den Client +--- @param art string 'bargeld' | 'konto' | 'schwarzgeld' +--- @param betrag number Positiver Betrag +--- @param grund string|nil Verwendungszweck fuer die Transaktionsliste +--- @return boolean Erfolg +function SpielerMethoden:FuegeGeldHinzu(art, betrag, grund) + if betrag <= 0 then return false end + self[art] = (self[art] or 0) + betrag + + -- Kontobewegungen zusaetzlich protokollieren + if art == 'konto' then + Aether.DB.SchreibeTransaktion(self.charakterId, 'deposit', betrag, self.konto, grund) + end + + self:SendeGeld() + return true +end + +--- Zieht Geld ab, sofern gedeckt +--- @param art string 'bargeld' | 'konto' | 'schwarzgeld' +--- @param betrag number Positiver Betrag +--- @param grund string|nil Verwendungszweck +--- @return boolean Erfolg (false = nicht genug Geld) +function SpielerMethoden:EntferneGeld(art, betrag, grund) + if betrag <= 0 then return false end + if (self[art] or 0) < betrag then return false end -- Deckungspruefung + + self[art] = self[art] - betrag + + if art == 'konto' then + Aether.DB.SchreibeTransaktion(self.charakterId, 'withdraw', -betrag, self.konto, grund) + end + + self:SendeGeld() + return true +end + +--- Schickt die aktuellen Geldwerte an den Client (fuer HUD und Handy) +function SpielerMethoden:SendeGeld() + TriggerClientEvent('aether:client:geldAktualisiert', self.quelle, { + bargeld = self.bargeld, + konto = self.konto, + schwarzgeld = self.schwarzgeld, + }) +end + +-- --------------------------------------------------------------------- +-- Zustand +-- --------------------------------------------------------------------- + +--- Setzt einen Zustandswert (Hunger/Durst/Ausdauer) und begrenzt ihn auf 0–100 +--- @param art string 'hunger' | 'durst' | 'ausdauer' +--- @param wert number Neuer Wert +function SpielerMethoden:SetzeZustand(art, wert) + self[art] = Aether.Begrenze(wert, 0, 100) + TriggerClientEvent('aether:client:zustandAktualisiert', self.quelle, { + hunger = self.hunger, + durst = self.durst, + ausdauer = self.ausdauer, + }) +end + +-- --------------------------------------------------------------------- +-- Daten fuer den Client +-- --------------------------------------------------------------------- + +--- Baut das Datenpaket, das der Client zum Spielen braucht +--- @return table +function SpielerMethoden:HoleDaten() + return { + charakterId = self.charakterId, + vorname = self.vorname, + nachname = self.nachname, + geburtstag = self.geburtstag, + geschlecht = self.geschlecht, + aussehen = self.aussehen, + bargeld = self.bargeld, + konto = self.konto, + schwarzgeld = self.schwarzgeld, + iban = self.iban, + job = self.job, + jobRang = self.jobRang, + fraktion = self.fraktion, + fraktionRang= self.fraktionRang, + leben = self.leben, + hunger = self.hunger, + durst = self.durst, + ausdauer = self.ausdauer, + istTot = self.istTot, + position = self.position, + } +end + +-- --------------------------------------------------------------------- +-- Speichern +-- --------------------------------------------------------------------- + +--- Schreibt den aktuellen Stand des Spielers in die Datenbank +function SpielerMethoden:Speichere() + Aether.DB.SpeichereCharakter(self.charakterId, { + cash = self.bargeld, + bank = self.konto, + dirty_money = self.schwarzgeld, + job = self.job, + job_grade = self.jobRang, + faction = self.fraktion, + faction_rank = self.fraktionRang, + health = self.leben, + hunger = math.floor(self.hunger), + thirst = math.floor(self.durst), + stamina = math.floor(self.ausdauer), + position = self.position, + is_dead = self.istTot, + playtime = self.spielzeit, + }) +end diff --git a/resources/[aether]/aether-core/shared/config.lua b/resources/[aether]/aether-core/shared/config.lua new file mode 100644 index 0000000..369ef75 --- /dev/null +++ b/resources/[aether]/aether-core/shared/config.lua @@ -0,0 +1,78 @@ +-- ===================================================================== +-- Aether Core — Zentrale Konfiguration +-- Wird auf Client und Server geladen und ist ueberall als `Config` verfuegbar. +-- ===================================================================== + +Config = {} + +-- Debug-Ausgaben in der Konsole (im Livebetrieb auf false setzen) +Config.Debug = true + +-- Sprache aller Systemmeldungen +Config.Sprache = 'de' + +-- --------------------------------------------------------------------- +-- Charaktere +-- --------------------------------------------------------------------- +-- Wie viele Charaktere darf ein Spieler gleichzeitig besitzen? +Config.MaxCharaktere = 5 + +-- Legt der Core selbst automatisch einen Startcharakter an? +-- false = `aether-character` uebernimmt die Auswahl und Erstellung (Standard). +-- true = Notbetrieb ohne aether-character (springt direkt ins Spiel). +Config.AutoCharakter = false + +-- Startwerte fuer einen frisch erstellten Charakter +Config.StartWerte = { + bargeld = 500, -- Startbargeld + konto = 2500, -- Startguthaben auf dem Konto + job = 'unemployed', + jobRang = 0, +} + +-- --------------------------------------------------------------------- +-- Spawn +-- --------------------------------------------------------------------- +-- Standardposition, wenn der Charakter noch keine gespeicherte Position hat +Config.SpawnPunkt = { + x = -1037.7, + y = -2737.6, + z = 20.2, + heading = 330.0, +} + +-- Standard-Ped-Modelle je Geschlecht (bis der Charakter-Ersteller migriert ist) +Config.StandardPed = { + m = 'mp_m_freemode_01', + w = 'mp_f_freemode_01', +} + +-- --------------------------------------------------------------------- +-- Zustandswerte (passend zu den HUD-Statusringen) +-- --------------------------------------------------------------------- +Config.Zustand = { + -- Intervall, in dem Hunger/Durst sinken (in Millisekunden) + tickIntervall = 60000, + -- Wie viel pro Tick abgezogen wird + hungerProTick = 1.0, + durstProTick = 1.4, + -- Ab welchem Wert Schaden genommen wird und wie viel + schadenAbWert = 0, + schadenProTick = 5, +} + +-- --------------------------------------------------------------------- +-- Speichern +-- --------------------------------------------------------------------- +-- Intervall des automatischen Speicherns aller Spieler (in Millisekunden) +Config.SpeicherIntervall = 300000 -- 5 Minuten + +-- --------------------------------------------------------------------- +-- Rechtegruppen (aufsteigend nach Befugnis) +-- --------------------------------------------------------------------- +Config.AdminGruppen = { + ['user'] = 0, + ['support'] = 1, + ['admin'] = 2, + ['owner'] = 3, +} diff --git a/resources/[aether]/aether-core/shared/utils.lua b/resources/[aether]/aether-core/shared/utils.lua new file mode 100644 index 0000000..d931038 --- /dev/null +++ b/resources/[aether]/aether-core/shared/utils.lua @@ -0,0 +1,97 @@ +-- ===================================================================== +-- Aether Core — Hilfsfunktionen (Client und Server) +-- Global als `Aether` verfuegbar. +-- ===================================================================== + +Aether = Aether or {} + +-- --------------------------------------------------------------------- +-- Konsolenausgaben mit einheitlichem Praefix +-- --------------------------------------------------------------------- + +--- Gibt eine Debug-Meldung aus (nur wenn Config.Debug aktiv ist) +--- @param nachricht string Auszugebender Text +function Aether.Debug(nachricht) + if not Config.Debug then return end + print(('^5[Aether]^7 %s'):format(nachricht)) +end + +--- Gibt eine Informationsmeldung aus +--- @param nachricht string Auszugebender Text +function Aether.Info(nachricht) + print(('^2[Aether]^7 %s'):format(nachricht)) +end + +--- Gibt eine Fehlermeldung aus +--- @param nachricht string Auszugebender Text +function Aether.Fehler(nachricht) + print(('^1[Aether FEHLER]^7 %s'):format(nachricht)) +end + +-- --------------------------------------------------------------------- +-- Allgemeine Helfer +-- --------------------------------------------------------------------- + +--- Begrenzt einen Wert auf einen Bereich +--- @param wert number Eingabewert +--- @param minimum number Untere Grenze +--- @param maximum number Obere Grenze +--- @return number +function Aether.Begrenze(wert, minimum, maximum) + if wert < minimum then return minimum end + if wert > maximum then return maximum end + return wert +end + +--- Rundet eine Zahl auf eine bestimmte Anzahl Nachkommastellen +--- @param wert number Eingabewert +--- @param stellen number|nil Nachkommastellen (Standard 0) +--- @return number +function Aether.Runde(wert, stellen) + local faktor = 10 ^ (stellen or 0) + return math.floor(wert * faktor + 0.5) / faktor +end + +--- Erzeugt eine zufaellige Zeichenkette nach einem Muster +--- A = Grossbuchstabe, 1 = Ziffer, alles andere bleibt stehen +--- @param muster string z. B. 'AA-1111' +--- @return string +function Aether.ZufallsText(muster) + local buchstaben = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + return (muster:gsub('.', function(zeichen) + if zeichen == 'A' then + local pos = math.random(#buchstaben) + return buchstaben:sub(pos, pos) + elseif zeichen == '1' then + return tostring(math.random(0, 9)) + end + return zeichen + end)) +end + +--- Erzeugt eine IBAN-artige Kontonummer fuer das Bank-Modul +--- @return string +function Aether.ErzeugeIban() + return Aether.ZufallsText('DE11 1111 1111 11') +end + +--- Formatiert einen Betrag als Euro-Text +--- @param betrag number Betrag +--- @return string +function Aether.Euro(betrag) + local text = tostring(math.floor(betrag or 0)) + -- Tausenderpunkte von hinten einfuegen + local formatiert = text:reverse():gsub('(%d%d%d)', '%1.'):reverse() + formatiert = formatiert:gsub('^%.', '') + return formatiert .. ' €' +end + +--- Prueft, ob eine Rechtegruppe mindestens die geforderte Stufe hat +--- @param gruppe string Gruppe des Spielers +--- @param benoetigt string Geforderte Gruppe +--- @return boolean +function Aether.HatRecht(gruppe, benoetigt) + local eigene = Config.AdminGruppen[gruppe or 'user'] or 0 + local noetig = Config.AdminGruppen[benoetigt or 'user'] or 0 + return eigene >= noetig +end diff --git a/resources/[aether]/aether-death/client/main.lua b/resources/[aether]/aether-death/client/main.lua new file mode 100644 index 0000000..9511aa7 --- /dev/null +++ b/resources/[aether]/aether-death/client/main.lua @@ -0,0 +1,187 @@ +-- ===================================================================== +-- Aether Death — Clientlogik +-- Erkennt den Tod, legt den Spieler zu Boden und zeigt den Screen. +-- ===================================================================== + +local istVerletzt = false +local istGeladen = false + +local function SendeAnNui(nachricht) + SendNuiMessage(json.encode(nachricht)) +end + +--- Legt den Spieler in den Verletzt-Zustand +local function LegeZuBoden() + local ped = PlayerPedId() + + -- Am Leben halten, aber bewegungsunfaehig — GTA soll nicht respawnen + SetEntityHealth(ped, 101) + SetEntityInvincible(ped, true) + NetworkSetFriendlyFireOption(false) + + -- Liegende Haltung + RequestAnimSet('move_m@injured') + SetPedMoveRateOverride(ped, 0.0) + SetPedToRagdoll(ped, 8000, 8000, 0, false, false, false) + + -- Steuerung einschraenken (nur Umsehen bleibt moeglich) + SetPlayerInvincible(PlayerId(), true) +end + +--- Hebt den Verletzt-Zustand wieder auf +--- @param leben number Lebensenergie nach dem Aufstehen +local function StelleWiederHer(leben) + local ped = PlayerPedId() + + ClearPedTasksImmediately(ped) + SetEntityHealth(ped, leben or 200) + SetEntityInvincible(ped, false) + SetPlayerInvincible(PlayerId(), false) + SetPedMoveRateOverride(ped, 1.0) + ResetPedMovementClipset(ped, 0.0) + + istVerletzt = false + SetNuiFocus(false, false) + SendeAnNui({ aktion = 'schliessen' }) + pcall(function() exports['aether-hud']:SetzeSichtbar(true) end) +end + +-- --------------------------------------------------------------------- +-- Todeserkennung +-- --------------------------------------------------------------------- +AddEventHandler('aether:client:bereit', function() + istGeladen = true +end) + +CreateThread(function() + while true do + Wait(300) + + if istGeladen and not istVerletzt then + local ped = PlayerPedId() + -- GTA meldet den Spieler als tot, sobald die Lebensenergie faellt + if IsEntityDead(ped) or GetEntityHealth(ped) <= 100 then + istVerletzt = true + LegeZuBoden() + TriggerServerEvent('aether:death:gefallen') + end + end + end +end) + +--- Verhindert, dass GTA den Spieler automatisch respawnt +CreateThread(function() + while true do + if istVerletzt then + Wait(0) + -- Steuerung sperren, Umsehen erlauben + DisableAllControlActions(0) + EnableControlAction(0, 1, true) -- Kamera horizontal + EnableControlAction(0, 2, true) -- Kamera vertikal + EnableControlAction(0, 245, true) -- Chat + + -- Automatischen Respawn unterbinden + local ped = PlayerPedId() + if GetEntityHealth(ped) < 101 then + SetEntityHealth(ped, 101) + end + IgnoreNextRestart(true) + SetFadeOutAfterDeath(false) + else + Wait(500) + end + end +end) + +-- --------------------------------------------------------------------- +-- Ereignisse vom Server +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:death:zeige', function(zustand) + istVerletzt = true + LegeZuBoden() + + SetNuiFocus(true, true) + pcall(function() exports['aether-hud']:SetzeSichtbar(false) end) + + zustand.aktion = 'oeffnen' + SendeAnNui(zustand) +end) + +RegisterNetEvent('aether:death:zustand', function(zustand) + zustand.aktion = 'zustand' + SendeAnNui(zustand) +end) + +RegisterNetEvent('aether:death:hinweis', function(text) + SendeAnNui({ aktion = 'hinweis', text = text }) +end) + +--- Ein Sanitaeter hat geholfen +RegisterNetEvent('aether:death:wiederbelebt', function(daten) + SendeAnNui({ aktion = 'behandelt' }) + Wait(2500) -- Ergebnisbildschirm kurz stehen lassen + StelleWiederHer(daten.leben) +end) + +--- Aufgegeben oder verblutet: im Krankenhaus aufwachen +RegisterNetEvent('aether:death:krankenhaus', function(daten) + SendeAnNui({ + aktion = 'krankenhaus', + klinik = daten.klinik, + kosten = daten.kosten, + bezahlt = daten.bezahlt, + grund = daten.grund, + }) + + DoScreenFadeOut(800) + Wait(1200) + + local ped = PlayerPedId() + local ziel = daten.position + SetEntityCoordsNoOffset(ped, ziel.x, ziel.y, ziel.z, false, false, false) + SetEntityHeading(ped, ziel.w or 0.0) + + StelleWiederHer(daten.leben) + + -- Warten, bis die Umgebung geladen ist + local versuche = 0 + while not HasCollisionLoadedAroundEntity(ped) and versuche < 60 do + Wait(50) + versuche = versuche + 1 + end + + DoScreenFadeIn(1000) +end) + +-- --------------------------------------------------------------------- +-- NUI-Callbacks +-- --------------------------------------------------------------------- +RegisterNUICallback('emsRufen', function(_, rueckgabe) + TriggerServerEvent('aether:death:emsRufen') + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('aufgeben', function(_, rueckgabe) + TriggerServerEvent('aether:death:aufgeben') + rueckgabe({ ok = true }) +end) + +--- Um Hilfe rufen: Umstehende hoeren es (rein oertlich) +RegisterNUICallback('hilfeRufen', function(_, rueckgabe) + local ped = PlayerPedId() + PlayPain(ped, 6, 0) + SendeAnNui({ aktion = 'hinweis', text = 'Du schreist um Hilfe …' }) + rueckgabe({ ok = true }) +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen +-- --------------------------------------------------------------------- +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + SetNuiFocus(false, false) + local ped = PlayerPedId() + SetEntityInvincible(ped, false) + SetPlayerInvincible(PlayerId(), false) + SetPedMoveRateOverride(ped, 1.0) +end) diff --git a/resources/[aether]/aether-death/fxmanifest.lua b/resources/[aether]/aether-death/fxmanifest.lua new file mode 100644 index 0000000..7e7dc3f --- /dev/null +++ b/resources/[aether]/aether-death/fxmanifest.lua @@ -0,0 +1,34 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +name 'aether-death' +author 'Jerrit Fritzsche' +description 'Verletzt-Zustand, Verbluten, Rettungsdienst und Krankenhaus' +version '0.1.0' + +ui_page 'html/index.html' + +files { + 'html/index.html', +} + +shared_scripts { + '@aether-core/shared/config.lua', + '@aether-core/shared/utils.lua', + 'shared/config.lua', +} + +client_scripts { + 'client/main.lua', +} + +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/main.lua', +} + +dependencies { + 'aether-core', + 'oxmysql', +} diff --git a/resources/[aether]/aether-death/html/index.html b/resources/[aether]/aether-death/html/index.html new file mode 100644 index 0000000..2a9f7c5 --- /dev/null +++ b/resources/[aether]/aether-death/html/index.html @@ -0,0 +1,405 @@ + + + + + + +Aether — Tod / Respawn + + + + + +
+
+ + +
+ + +
+ Demo + + +
+ + + + + diff --git a/resources/[aether]/aether-death/server/main.lua b/resources/[aether]/aether-death/server/main.lua new file mode 100644 index 0000000..273f1dd --- /dev/null +++ b/resources/[aether]/aether-death/server/main.lua @@ -0,0 +1,249 @@ +-- ===================================================================== +-- Aether Death — Serverlogik +-- Der Server fuehrt den Verblutungs-Countdown und entscheidet ueber +-- Wiederbelebung, Aufgeben und Krankenhausaufenthalt. +-- ===================================================================== + +local Core = exports['aether-core'] + +-- Verletzte Spieler: [quelle] = { seit, restBleed, sperre, emsGerufen, position } +local verletzte = {} + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +local function CharakterVon(quelle) + return Core:HoleCharakterId(quelle) +end + +--- Schickt den aktuellen Zustand an den verletzten Spieler +--- @param quelle number Server-ID +local function SendeZustand(quelle) + local eintrag = verletzte[quelle] + if not eintrag then return end + + TriggerClientEvent('aether:death:zustand', quelle, { + restBleed = eintrag.restBleed, + respawnSperre = eintrag.sperre, + emsGerufen = eintrag.emsGerufen, + bleedoutMax = Tod.BLEEDOUT, + }) +end + +--- Beendet den Verletzt-Zustand eines Spielers +--- @param quelle number Server-ID +local function BeendeVerletzung(quelle) + verletzte[quelle] = nil + local charakterId = CharakterVon(quelle) + if charakterId then + MySQL.update('UPDATE aether_characters SET is_dead = 0 WHERE id = ?', { charakterId }) + end +end + +-- --------------------------------------------------------------------- +-- Spieler geht zu Boden +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:death:gefallen', function() + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + if verletzte[quelle] then return end -- schon verletzt + + local position = GetEntityCoords(GetPlayerPed(quelle)) + + verletzte[quelle] = { + seit = os.time(), + restBleed = Tod.BLEEDOUT, + sperre = Tod.RESPAWN_SPERRE, + emsGerufen = false, + position = { x = position.x, y = position.y, z = position.z }, + } + + MySQL.update('UPDATE aether_characters SET is_dead = 1 WHERE id = ?', { charakterId }) + + TriggerClientEvent('aether:death:zeige', quelle, { + restBleed = Tod.BLEEDOUT, + respawnSperre = Tod.RESPAWN_SPERRE, + bleedoutMax = Tod.BLEEDOUT, + }) + + -- Andere Module informieren (z. B. Job beenden, HUD anpassen) + TriggerEvent('aether:death:spielerGefallen', quelle) +end) + +-- --------------------------------------------------------------------- +-- Rettungsdienst rufen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:death:emsRufen', function() + local quelle = source + local eintrag = verletzte[quelle] + if not eintrag or eintrag.emsGerufen then return end + + eintrag.emsGerufen = true + + local person = Core:HoleSpielerInfo(quelle) + local name = person and (person.vorname .. ' ' .. person.nachname) or 'Unbekannt' + + -- Notruf weiterreichen. Ein Rettungsdienst-Modul kann diesen Event + -- abfangen und daraus einen Einsatz erzeugen. + TriggerEvent('aether:death:notruf', quelle, { + name = name, + position = eintrag.position, + }) + + SendeZustand(quelle) + TriggerClientEvent('aether:death:hinweis', quelle, 'Notruf abgesetzt. Warte auf Hilfe.') +end) + +-- --------------------------------------------------------------------- +-- Aufgeben: im Krankenhaus aufwachen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:death:aufgeben', function() + local quelle = source + local eintrag = verletzte[quelle] + if not eintrag then return end + + -- Sperrzeit pruefen: nicht sofort aufgeben koennen + if eintrag.sperre > 0 then + TriggerClientEvent('aether:death:hinweis', quelle, + ('Noch %d Sekunden, bevor du aufgeben kannst.'):format(eintrag.sperre)) + return + end + + local klinik = Tod.NaechstesKrankenhaus( + vector3(eintrag.position.x, eintrag.position.y, eintrag.position.z)) + + -- Behandlungskosten abbuchen (erst Bargeld, dann Konto) + local bezahlt = Core:EntferneGeld(quelle, 'bargeld', Tod.KRANKENHAUS_KOSTEN, 'Behandlung Krankenhaus') + if not bezahlt then + bezahlt = Core:EntferneGeld(quelle, 'konto', Tod.KRANKENHAUS_KOSTEN, 'Behandlung Krankenhaus') + end + + BeendeVerletzung(quelle) + + TriggerClientEvent('aether:death:krankenhaus', quelle, { + klinik = klinik.name, + position = { x = klinik.pos.x, y = klinik.pos.y, z = klinik.pos.z, w = klinik.pos.w }, + leben = Tod.LEBEN_NACH_KLINIK, + kosten = bezahlt and Tod.KRANKENHAUS_KOSTEN or 0, + bezahlt = bezahlt, + grund = 'aufgegeben', + }) +end) + +-- --------------------------------------------------------------------- +-- Countdown: Verbluten und Sperrzeit +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(1000) + + for quelle, eintrag in pairs(verletzte) do + if eintrag.sperre > 0 then + eintrag.sperre = eintrag.sperre - 1 + end + + eintrag.restBleed = eintrag.restBleed - 1 + + -- Zeit abgelaufen: Der Spieler verblutet und wacht in der Klinik auf + if eintrag.restBleed <= 0 then + local klinik = Tod.NaechstesKrankenhaus( + vector3(eintrag.position.x, eintrag.position.y, eintrag.position.z)) + + local bezahlt = Core:EntferneGeld(quelle, 'bargeld', Tod.KRANKENHAUS_KOSTEN, 'Behandlung Krankenhaus') + if not bezahlt then + bezahlt = Core:EntferneGeld(quelle, 'konto', Tod.KRANKENHAUS_KOSTEN, 'Behandlung Krankenhaus') + end + + BeendeVerletzung(quelle) + + TriggerClientEvent('aether:death:krankenhaus', quelle, { + klinik = klinik.name, + position = { x = klinik.pos.x, y = klinik.pos.y, z = klinik.pos.z, w = klinik.pos.w }, + leben = Tod.LEBEN_NACH_KLINIK, + kosten = bezahlt and Tod.KRANKENHAUS_KOSTEN or 0, + bezahlt = bezahlt, + grund = 'verblutet', + }) + else + SendeZustand(quelle) + end + end + end +end) + +-- --------------------------------------------------------------------- +-- Position des Verletzten aktuell halten (er kann noch kriechen) +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(5000) + for quelle, eintrag in pairs(verletzte) do + local ped = GetPlayerPed(quelle) + if ped and ped ~= 0 then + local position = GetEntityCoords(ped) + eintrag.position = { x = position.x, y = position.y, z = position.z } + end + end + end +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen +-- --------------------------------------------------------------------- +AddEventHandler('playerDropped', function() + verletzte[source] = nil +end) + +-- ===================================================================== +-- EXPORTS — fuer den Rettungsdienst +-- ===================================================================== + +--- Belebt einen verletzten Spieler wieder. +--- Wird vom EMS aufgerufen; prueft die Entfernung des Helfers. +--- @param quelle number Server-ID des Verletzten +--- @param helfer number|nil Server-ID des Sanitaeters (nil = ohne Pruefung) +--- @return boolean Erfolg +exports('Wiederbeleben', function(quelle, helfer) + local eintrag = verletzte[quelle] + if not eintrag then return false end + + -- Entfernungspruefung, wenn ein Helfer angegeben ist + if helfer then + local hier = GetEntityCoords(GetPlayerPed(helfer)) + local dort = GetEntityCoords(GetPlayerPed(quelle)) + if #(hier - dort) > Tod.WIEDERBELEBUNG_WEITE then + return false + end + end + + BeendeVerletzung(quelle) + + TriggerClientEvent('aether:death:wiederbelebt', quelle, { + leben = Tod.LEBEN_NACH_HILFE, + }) + return true +end) + +--- Ist ein Spieler gerade verletzt am Boden? +--- @param quelle number Server-ID +--- @return boolean +exports('IstVerletzt', function(quelle) + return verletzte[quelle] ~= nil +end) + +--- Liefert alle verletzten Spieler mit Position (fuer die EMS-Karte) +--- @return table +exports('HoleVerletzte', function() + local liste = {} + for quelle, eintrag in pairs(verletzte) do + liste[#liste + 1] = { + quelle = quelle, + position = eintrag.position, + emsGerufen = eintrag.emsGerufen, + restBleed = eintrag.restBleed, + } + end + return liste +end) diff --git a/resources/[aether]/aether-death/shared/config.lua b/resources/[aether]/aether-death/shared/config.lua new file mode 100644 index 0000000..b44a6f8 --- /dev/null +++ b/resources/[aether]/aether-death/shared/config.lua @@ -0,0 +1,39 @@ +-- ===================================================================== +-- Aether Death — Einstellungen rund um Verletzung und Wiederbelebung +-- ===================================================================== + +Tod = Tod or {} + +-- Zeit bis zum Verbluten (Sekunden) +Tod.BLEEDOUT = 300 +-- Mindestwartezeit, bevor „Aufgeben" moeglich ist (Sekunden) +Tod.RESPAWN_SPERRE = 45 +-- Behandlungspauschale beim Aufwachen im Krankenhaus +Tod.KRANKENHAUS_KOSTEN = 1500 +-- Reichweite, in der ein Sanitaeter wiederbeleben kann (Meter) +Tod.WIEDERBELEBUNG_WEITE = 3.0 +-- Lebensenergie nach einer Wiederbelebung (GTA zaehlt 100–200) +Tod.LEBEN_NACH_HILFE = 150 +-- Lebensenergie nach dem Krankenhaus +Tod.LEBEN_NACH_KLINIK = 200 + +-- Krankenhaeuser: Aufwachpunkte nach dem Aufgeben oder Verbluten +Tod.Krankenhaeuser = { + { name = 'Pillbox Hill Medical', pos = vector4(298.6, -584.5, 43.3, 70.0) }, + { name = 'Sandy Shores Medical', pos = vector4(1839.6, 3672.9, 34.3, 210.0) }, + { name = 'Paleto Bay Medical', pos = vector4(-247.8, 6331.2, 32.4, 315.0) }, +} + +--- Liefert das naechstgelegene Krankenhaus zu einer Position +--- @param position vector3 Ausgangsposition +--- @return table Krankenhaus +function Tod.NaechstesKrankenhaus(position) + local bestes, besterAbstand = Tod.Krankenhaeuser[1], math.huge + for _, klinik in ipairs(Tod.Krankenhaeuser) do + local abstand = #(position - vector3(klinik.pos.x, klinik.pos.y, klinik.pos.z)) + if abstand < besterAbstand then + bestes, besterAbstand = klinik, abstand + end + end + return bestes +end diff --git a/resources/[aether]/aether-documents/client/main.lua b/resources/[aether]/aether-documents/client/main.lua new file mode 100644 index 0000000..8537823 --- /dev/null +++ b/resources/[aether]/aether-documents/client/main.lua @@ -0,0 +1,129 @@ +-- ===================================================================== +-- Aether Documents — Clientlogik +-- Öffnet die Börse und zeigt vorgezeigte Dokumente anderer Personen an. +-- ===================================================================== + +local istOffen = false +local istGeladen = false + +-- Taste zum Oeffnen der Boerse (F2) +local TASTE_BOERSE = 289 + +--- Schickt eine Nachricht an die Oberflaeche +local function SendeAnNui(nachricht) + SendNuiMessage(json.encode(nachricht)) +end + +--- Zeigt eine Meldung oben links im Spiel +local function ZeigeSpielmeldung(text) + if not text or text == '' then return end + BeginTextCommandThefeedPost('STRING') + AddTextComponentSubstringPlayerName(text) + EndTextCommandThefeedPostTicker(false, true) +end + +--- Schliesst die Boerse +local function Schliessen() + if not istOffen then return end + istOffen = false + SetNuiFocus(false, false) + SendeAnNui({ aktion = 'schliessen' }) + pcall(function() exports['aether-hud']:SetzeSichtbar(true) end) +end + +-- --------------------------------------------------------------------- +-- Ereignisse +-- --------------------------------------------------------------------- +AddEventHandler('aether:client:bereit', function() + istGeladen = true +end) + +--- Server schickt den Zustand und moechte, dass wir oeffnen +RegisterNetEvent('aether:documents:zeige', function(zustand) + istOffen = true + SetNuiFocus(true, true) + pcall(function() exports['aether-hud']:SetzeSichtbar(false) end) + zustand.aktion = 'oeffnen' + zustand.typen = Dokumente.Typen + SendeAnNui(zustand) +end) + +--- Aktualisierter Zustand nach einer Aktion +RegisterNetEvent('aether:documents:zustand', function(zustand) + if not istOffen then + ZeigeSpielmeldung(zustand.hinweis) + return + end + zustand.aktion = 'zustand' + zustand.typen = Dokumente.Typen + SendeAnNui(zustand) +end) + +--- Ablehnung einer Aktion +RegisterNetEvent('aether:documents:fehler', function(text) + if istOffen then + SendeAnNui({ aktion = 'fehler', text = text }) + else + ZeigeSpielmeldung(text) + end +end) + +--- Erfolgsmeldung +RegisterNetEvent('aether:documents:erfolg', function(text) + if istOffen then + SendeAnNui({ aktion = 'hinweis', text = text }) + else + ZeigeSpielmeldung(text) + end +end) + +--- Jemand zeigt uns ein Dokument: als Vollbild-Ansicht einblenden +RegisterNetEvent('aether:documents:gezeigt', function(dokument) + SetNuiFocus(false, false) + SendeAnNui({ aktion = 'vorgezeigt', dokument = dokument }) +end) + +-- --------------------------------------------------------------------- +-- Oeffnen per Taste +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(0) + if istGeladen and not istOffen and IsControlJustReleased(0, TASTE_BOERSE) then + if not IsEntityDead(PlayerPedId()) then + TriggerServerEvent('aether:documents:oeffnen') + end + end + end +end) + +-- --------------------------------------------------------------------- +-- NUI-Callbacks +-- --------------------------------------------------------------------- +RegisterNUICallback('schliessen', function(_, rueckgabe) + Schliessen() + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('verschieben', function(daten, rueckgabe) + TriggerServerEvent('aether:documents:verschieben', daten.dokumentId, daten.zielBoerse) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('vorzeigen', function(daten, rueckgabe) + TriggerServerEvent('aether:documents:vorzeigen', daten.dokumentId, daten.zielId) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('verlieren', function(daten, rueckgabe) + TriggerServerEvent('aether:documents:verlieren', daten.dokumentId) + rueckgabe({ ok = true }) +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen +-- --------------------------------------------------------------------- +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + SetNuiFocus(false, false) +end) diff --git a/resources/[aether]/aether-documents/fxmanifest.lua b/resources/[aether]/aether-documents/fxmanifest.lua new file mode 100644 index 0000000..e372692 --- /dev/null +++ b/resources/[aether]/aether-documents/fxmanifest.lua @@ -0,0 +1,36 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +name 'aether-documents' +author 'Jerrit Fritzsche' +description 'Börse mit Ausweis, Führerschein, Waffenschein und weiteren Dokumenten' +version '0.1.0' + +ui_page 'html/index.html' + +files { + 'html/index.html', +} + +-- Core-Dateien mitladen (eigener Lua-State je Resource) +shared_scripts { + '@aether-core/shared/config.lua', + '@aether-core/shared/utils.lua', + 'shared/config.lua', +} + +client_scripts { + 'client/main.lua', +} + +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/main.lua', +} + +dependencies { + 'aether-core', + 'aether-inventory', + 'oxmysql', +} diff --git a/resources/[aether]/aether-documents/html/index.html b/resources/[aether]/aether-documents/html/index.html new file mode 100644 index 0000000..fc64400 --- /dev/null +++ b/resources/[aether]/aether-documents/html/index.html @@ -0,0 +1,709 @@ + + + + + + + + +Aether — Börse & Dokumente + + +
+
+ +

BörseMax Weber · Dokumente & Karten

+
+
+
+
+
+
+ +
+
+
+ +
Karte wählen · Vorzeigen präsentiert das Dokument · Verlieren entfernt es aus der Börse
+ + + + + diff --git a/resources/[aether]/aether-documents/server/main.lua b/resources/[aether]/aether-documents/server/main.lua new file mode 100644 index 0000000..e498855 --- /dev/null +++ b/resources/[aether]/aether-documents/server/main.lua @@ -0,0 +1,386 @@ +-- ===================================================================== +-- Aether Documents — Serverlogik +-- Verwaltet Ausweise, Führerscheine und Waffenscheine. Behoerdliche +-- Dokumente kann ein Spieler sich nicht selbst ausstellen. +-- ===================================================================== + +local Core = exports['aether-core'] +local Inventory = exports['aether-inventory'] + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Erzeugt eine Dokumentnummer nach einem Muster +--- @param praefix string Vorsilbe, z. B. 'LSA' +--- @return string +local function ErzeugeNummer(praefix) + return ('%s-%04d-%04d'):format(praefix, math.random(1000, 9999), math.random(1000, 9999)) +end + +--- Rechnet eine Anzahl Jahre in ein Ablaufdatum um +--- @param jahre number|nil Gueltigkeit in Jahren +--- @return string|nil Datum im DB-Format oder nil +local function GueltigBis(jahre) + if not jahre then return nil end + return os.date('%Y-%m-%d', os.time() + jahre * 365 * 24 * 60 * 60) +end + +--- Wie viele Boersen besitzt der Spieler? (jede Boerse ist ein Item) +--- @param quelle number Server-ID +--- @return number +local function AnzahlBoersen(quelle) + local anzahl = Inventory:ZaehleItem(quelle, Dokumente.BOERSE_ITEM) or 0 + return math.max(0, anzahl) +end + +--- Laedt alle Dokumente eines Charakters und ergaenzt die Personendaten +--- @param charakterId number Charakter-ID +--- @return table Liste von Dokumenten +local function HoleDokumente(charakterId) + local zeilen = MySQL.query.await([[ + SELECT id, type, wallet, data, issued_by, issued_at, valid_until, revoked + FROM aether_documents + WHERE `character` = ? AND revoked = 0 + ORDER BY id ASC + ]], { charakterId }) or {} + + local liste = {} + for _, zeile in ipairs(zeilen) do + local daten = {} + if zeile.data then + local erfolg, entschluesselt = pcall(json.decode, zeile.data) + if erfolg and type(entschluesselt) == 'table' then daten = entschluesselt end + end + + local typ = Dokumente.Typen[zeile.type] + liste[#liste + 1] = { + id = zeile.id, + typ = zeile.type, + name = typ and typ.name or zeile.type, + icon = typ and typ.icon or '📄', + accent = typ and typ.accent or '#888', + boerse = zeile.wallet, + daten = daten, + aussteller= zeile.issued_by, + gueltigBis= zeile.valid_until and tostring(zeile.valid_until):sub(1, 10) or nil, + } + end + return liste +end + +--- Legt die automatisch vergebenen Dokumente an, falls sie fehlen +--- @param charakterId number Charakter-ID +--- @param person table Charakterdaten aus dem Core +local function ErstelleAutomatische(charakterId, person) + for typKennung, typ in pairs(Dokumente.Typen) do + if typ.automatisch then + local vorhanden = MySQL.scalar.await( + 'SELECT id FROM aether_documents WHERE `character` = ? AND type = ?', + { charakterId, typKennung }) + + if not vorhanden then + -- Typspezifische Daten erzeugen + local daten = {} + if typKennung == 'perso' then + daten = { + nummer = ErzeugeNummer('LSA'), + gebOrt = person.herkunft or 'Los Santos', + groesse = tostring(math.random(160, 195)) .. ' cm', + } + elseif typKennung == 'gk' then + daten = { + versicherung = 'San Andreas Gesundheit', + nummer = ErzeugeNummer('GK'), + } + elseif typKennung == 'vk' then + daten = { telefon = nil } -- wird vom Handy-Modul gefuellt + end + + MySQL.insert.await([[ + INSERT INTO aether_documents (`character`, type, wallet, data, issued_by, valid_until) + VALUES (?, ?, 0, ?, ?, ?) + ]], { + charakterId, typKennung, json.encode(daten), + 'Einwohnermeldeamt', GueltigBis(typ.gueltigJahre), + }) + end + end + end +end + +--- Baut den Zustand fuer die Oberflaeche +--- @param quelle number Server-ID +--- @return table|nil +local function BaueZustand(quelle) + local charakterId = Core:HoleCharakterId(quelle) + if not charakterId then return nil end + + local person = Core:HoleSpielerInfo(quelle) + if not person then return nil end + + -- Personen in Reichweite (bewusst ohne Namen — RP-Regel) + local hier = GetEntityCoords(GetPlayerPed(quelle)) + local nahe = {} + for _, andere in ipairs(GetPlayers()) do + andere = tonumber(andere) + if andere ~= quelle then + local dort = GetEntityCoords(GetPlayerPed(andere)) + if #(hier - dort) <= Dokumente.VORZEIG_WEITE then + nahe[#nahe + 1] = { id = andere, bekannt = false } + end + end + end + + return { + person = { + vorname = person.vorname, nachname = person.nachname, + geburt = person.geburtstag, geschlecht = person.geschlecht, + }, + dokumente = HoleDokumente(charakterId), + boersen = AnzahlBoersen(quelle), + maxSlots = Dokumente.MAX_SLOTS, + spieler = nahe, + } +end + +--- Schickt den Zustand an einen Spieler +local function SendeZustand(quelle, hinweis) + local zustand = BaueZustand(quelle) + if not zustand then return end + zustand.hinweis = hinweis + TriggerClientEvent('aether:documents:zustand', quelle, zustand) +end + +--- Lehnt eine Aktion ab +local function Ablehnen(quelle, grund) + TriggerClientEvent('aether:documents:fehler', quelle, grund) +end + +-- --------------------------------------------------------------------- +-- Beim Laden des Spielers: automatische Dokumente sicherstellen +-- --------------------------------------------------------------------- +AddEventHandler('aether:server:spielerGeladen', function(quelle, spieler) + ErstelleAutomatische(spieler.charakterId, { + herkunft = spieler.herkunft, + }) +end) + +-- --------------------------------------------------------------------- +-- Boerse oeffnen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:documents:oeffnen', function() + local quelle = source + if not Core:HoleCharakterId(quelle) then return end + + if AnzahlBoersen(quelle) < 1 then + Ablehnen(quelle, 'Du hast keine Börse dabei. Es gibt welche im 24/7.') + return + end + + local zustand = BaueZustand(quelle) + if zustand then + TriggerClientEvent('aether:documents:zeige', quelle, zustand) + end +end) + +-- --------------------------------------------------------------------- +-- Dokument in eine andere Boerse verschieben +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:documents:verschieben', function(dokumentId, zielBoerse) + local quelle = source + local charakterId = Core:HoleCharakterId(quelle) + if not charakterId then return end + if type(dokumentId) ~= 'number' or type(zielBoerse) ~= 'number' then return end + + zielBoerse = math.floor(zielBoerse) + if zielBoerse < 0 or zielBoerse >= AnzahlBoersen(quelle) then + Ablehnen(quelle, 'Diese Börse hast du nicht.') + return + end + + -- Gehoert das Dokument dem Spieler? + local gehoert = MySQL.scalar.await( + 'SELECT id FROM aether_documents WHERE id = ? AND `character` = ? AND revoked = 0', + { dokumentId, charakterId }) + if not gehoert then return end + + -- Platz in der Zielboerse pruefen + local belegt = MySQL.scalar.await( + 'SELECT COUNT(*) FROM aether_documents WHERE `character` = ? AND wallet = ? AND revoked = 0', + { charakterId, zielBoerse }) or 0 + if belegt >= Dokumente.MAX_SLOTS then + Ablehnen(quelle, 'Diese Börse ist voll.') + return + end + + MySQL.update.await('UPDATE aether_documents SET wallet = ? WHERE id = ?', { zielBoerse, dokumentId }) + SendeZustand(quelle, 'Dokument verschoben.') +end) + +-- --------------------------------------------------------------------- +-- Dokument vorzeigen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:documents:vorzeigen', function(dokumentId, zielQuelle) + local quelle = source + local charakterId = Core:HoleCharakterId(quelle) + if not charakterId or type(dokumentId) ~= 'number' then return end + + local dokumente = HoleDokumente(charakterId) + local gezeigt = nil + for _, dokument in ipairs(dokumente) do + if dokument.id == dokumentId then gezeigt = dokument break end + end + if not gezeigt then return end + + local person = Core:HoleSpielerInfo(quelle) + gezeigt.inhaber = (person.vorname or '') .. ' ' .. (person.nachname or '') + gezeigt.geburt = person.geburtstag + + -- Gezielt einer Person zeigen oder allen in Reichweite + local hier = GetEntityCoords(GetPlayerPed(quelle)) + local empfaenger = {} + + if type(zielQuelle) == 'number' then + local dort = GetEntityCoords(GetPlayerPed(zielQuelle)) + if #(hier - dort) <= Dokumente.VORZEIG_WEITE then + empfaenger[#empfaenger + 1] = zielQuelle + else + Ablehnen(quelle, 'Die Person ist zu weit weg.') + return + end + else + for _, andere in ipairs(GetPlayers()) do + andere = tonumber(andere) + if andere ~= quelle then + local dort = GetEntityCoords(GetPlayerPed(andere)) + if #(hier - dort) <= Dokumente.VORZEIG_WEITE then + empfaenger[#empfaenger + 1] = andere + end + end + end + end + + if #empfaenger == 0 then + Ablehnen(quelle, 'Niemand in der Nähe.') + return + end + + for _, andere in ipairs(empfaenger) do + TriggerClientEvent('aether:documents:gezeigt', andere, gezeigt) + end + TriggerClientEvent('aether:documents:erfolg', quelle, + ('%s vorgezeigt (%d Person%s).'):format(gezeigt.name, #empfaenger, #empfaenger == 1 and '' or 'en')) +end) + +-- --------------------------------------------------------------------- +-- Dokument verlieren +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:documents:verlieren', function(dokumentId) + local quelle = source + local charakterId = Core:HoleCharakterId(quelle) + if not charakterId or type(dokumentId) ~= 'number' then return end + + local geloescht = MySQL.update.await( + 'DELETE FROM aether_documents WHERE id = ? AND `character` = ?', + { dokumentId, charakterId }) + + if (geloescht or 0) > 0 then + SendeZustand(quelle, 'Dokument verloren.') + end +end) + +-- ===================================================================== +-- EXPORTS — Schnittstelle fuer andere Module +-- ===================================================================== + +--- Besitzt der Spieler einen gueltigen Waffenschein? +--- Wird vom Shop (Ammu-Nation) abgefragt. +--- @param quelle number Server-ID +--- @return boolean +exports('HatWaffenschein', function(quelle) + local charakterId = Core:HoleCharakterId(quelle) + if not charakterId then return false end + + local treffer = MySQL.scalar.await([[ + SELECT id FROM aether_documents + WHERE `character` = ? AND type = 'ws' AND revoked = 0 + AND (valid_until IS NULL OR valid_until >= CURDATE()) + ]], { charakterId }) + + return treffer ~= nil +end) + +--- Besitzt der Spieler ein bestimmtes gueltiges Dokument? +--- @param quelle number Server-ID +--- @param typ string Dokumenttyp +--- @return boolean +exports('HatDokument', function(quelle, typ) + local charakterId = Core:HoleCharakterId(quelle) + if not charakterId or not Dokumente.TypGueltig(typ) then return false end + + local treffer = MySQL.scalar.await([[ + SELECT id FROM aether_documents + WHERE `character` = ? AND type = ? AND revoked = 0 + AND (valid_until IS NULL OR valid_until >= CURDATE()) + ]], { charakterId, typ }) + + return treffer ~= nil +end) + +--- Stellt einem Charakter ein behoerdliches Dokument aus. +--- Gedacht fuer das Fraktions-CAD (Polizei stellt Waffenschein aus). +--- @param charakterId number Charakter-ID +--- @param typ string Dokumenttyp +--- @param daten table|nil Zusatzdaten (z. B. Fahrklassen, Waffenliste) +--- @param aussteller string|nil Ausstellende Stelle +--- @return boolean Erfolg +exports('StelleAus', function(charakterId, typ, daten, aussteller) + if not Dokumente.TypGueltig(typ) then return false end + local typDef = Dokumente.Typen[typ] + + local nummer = ErzeugeNummer(typ == 'ws' and 'WA' or 'DL') + daten = type(daten) == 'table' and daten or {} + daten.nummer = daten.nummer or nummer + + -- Vorhandenes Dokument auffrischen, sonst neu anlegen + MySQL.query.await([[ + INSERT INTO aether_documents (`character`, type, wallet, data, issued_by, valid_until, revoked) + VALUES (?, ?, 0, ?, ?, ?, 0) + ON DUPLICATE KEY UPDATE + data = VALUES(data), issued_by = VALUES(issued_by), + valid_until = VALUES(valid_until), revoked = 0, issued_at = NOW() + ]], { + charakterId, typ, json.encode(daten), + aussteller or 'Behörde', GueltigBis(typDef.gueltigJahre), + }) + + -- Falls der Spieler online ist, Ansicht auffrischen + local quelle = Core:HoleQuelleNachCharakter(charakterId) + if quelle then + SendeZustand(quelle, ('%s ausgestellt.'):format(typDef.name)) + end + + Aether.Info(('Dokument %s fuer Charakter #%d ausgestellt.'):format(typ, charakterId)) + return true +end) + +--- Entzieht ein behoerdliches Dokument +--- @param charakterId number Charakter-ID +--- @param typ string Dokumenttyp +--- @return boolean Erfolg +exports('Entziehe', function(charakterId, typ) + if not Dokumente.TypGueltig(typ) then return false end + + MySQL.update.await( + 'UPDATE aether_documents SET revoked = 1 WHERE `character` = ? AND type = ?', + { charakterId, typ }) + + local quelle = Core:HoleQuelleNachCharakter(charakterId) + if quelle then + SendeZustand(quelle, ('%s wurde entzogen.'):format(Dokumente.Typen[typ].name)) + end + + Aether.Info(('Dokument %s fuer Charakter #%d entzogen.'):format(typ, charakterId)) + return true +end) diff --git a/resources/[aether]/aether-documents/shared/config.lua b/resources/[aether]/aether-documents/shared/config.lua new file mode 100644 index 0000000..187d436 --- /dev/null +++ b/resources/[aether]/aether-documents/shared/config.lua @@ -0,0 +1,49 @@ +-- ===================================================================== +-- Aether Documents — Dokumenttypen und Grenzen +-- ===================================================================== + +Dokumente = Dokumente or {} + +-- Wie viele Dokumente passen in eine Boerse? +Dokumente.MAX_SLOTS = 10 + +-- Reichweite zum Vorzeigen (Meter) +Dokumente.VORZEIG_WEITE = 4.0 + +-- Item, das als Boerse dient (aus aether-inventory) +Dokumente.BOERSE_ITEM = 'boerse' + +-- --------------------------------------------------------------------- +-- Dokumenttypen +-- automatisch = wird bei der Charaktererstellung angelegt +-- behoerde = kann nur von einer Behoerde ausgestellt werden +-- --------------------------------------------------------------------- +Dokumente.Typen = { + perso = { + name = 'Personalausweis', icon = '🪪', accent = '#2f9e73', + automatisch = true, gueltigJahre = 10, + }, + gk = { + name = 'Gesundheitskarte', icon = '➕', accent = '#2fb0c4', + automatisch = true, gueltigJahre = 6, + }, + vk = { + name = 'Visitenkarte', icon = '💼', accent = '#f5a623', + automatisch = true, + }, + fs = { + name = 'Führerschein', icon = '🚗', accent = '#d5638a', + behoerde = true, gueltigJahre = 15, + }, + ws = { + name = 'Waffenschein', icon = '🔫', accent = '#e0503f', + behoerde = true, gueltigJahre = 3, + }, +} + +--- Prueft, ob ein Typ gueltig ist +--- @param typ any Zu pruefender Wert +--- @return boolean +function Dokumente.TypGueltig(typ) + return type(typ) == 'string' and Dokumente.Typen[typ] ~= nil +end diff --git a/resources/[aether]/aether-garage/client/main.lua b/resources/[aether]/aether-garage/client/main.lua new file mode 100644 index 0000000..6bc9430 --- /dev/null +++ b/resources/[aether]/aether-garage/client/main.lua @@ -0,0 +1,330 @@ +-- ===================================================================== +-- Aether Garage — Clientlogik +-- Erzeugt Fahrzeuge erst nach Freigabe durch den Server und meldet +-- beim Einparken den tatsaechlichen Zustand zurueck. +-- ===================================================================== + +local istOffen = false +local istGeladen = false +local aktuelleGarage = nil -- Garage, an der der Spieler gerade steht + +-- Taste E +local TASTE_BENUTZEN = 38 + +--- Schickt eine Nachricht an die Oberflaeche +local function SendeAnNui(nachricht) + SendNuiMessage(json.encode(nachricht)) +end + +--- Zeigt eine Meldung oben links im Spiel +local function ZeigeSpielmeldung(text) + if not text or text == '' then return end + BeginTextCommandThefeedPost('STRING') + AddTextComponentSubstringPlayerName(text) + EndTextCommandThefeedPostTicker(false, true) +end + +--- Schliesst die Garage +local function Schliessen() + if not istOffen then return end + istOffen = false + SetNuiFocus(false, false) + SendeAnNui({ aktion = 'schliessen' }) + pcall(function() exports['aether-hud']:SetzeSichtbar(true) end) +end + +--- Wandelt eine Hex-Farbe in RGB-Werte um +--- @param hex string z. B. '#3563a8' +--- @return number, number, number +local function HexZuRgb(hex) + hex = (hex or '#20232a'):gsub('#', '') + return tonumber(hex:sub(1, 2), 16) or 32, + tonumber(hex:sub(3, 4), 16) or 35, + tonumber(hex:sub(5, 6), 16) or 42 +end + +--- Sucht ein Fahrzeug mit einem bestimmten Kennzeichen in der Naehe +--- @param kennzeichen string Kennzeichen +--- @return number|nil Fahrzeug-Handle +local function FindeFahrzeug(kennzeichen) + local position = GetEntityCoords(PlayerPedId()) + for fahrzeug in EnumerateVehicles() do + if DoesEntityExist(fahrzeug) then + local schild = GetVehicleNumberPlateText(fahrzeug) + if schild and schild:gsub('%s', '') == kennzeichen:gsub('%s', '') then + if #(position - GetEntityCoords(fahrzeug)) < 30.0 then + return fahrzeug + end + end + end + end + return nil +end + +--- Durchlaeuft alle Fahrzeuge in der Naehe +function EnumerateVehicles() + return coroutine.wrap(function() + local handle, fahrzeug = FindFirstVehicle() + local erfolgreich + repeat + coroutine.yield(fahrzeug) + erfolgreich, fahrzeug = FindNextVehicle(handle) + until not erfolgreich + EndFindVehicle(handle) + end) +end + +-- --------------------------------------------------------------------- +-- Karten-Markierungen +-- --------------------------------------------------------------------- +CreateThread(function() + for _, garage in pairs(Garage.Garagen) do + if garage.typ == 'oeffentlich' then + local blip = AddBlipForCoord(garage.position.x, garage.position.y, garage.position.z) + SetBlipSprite(blip, 357) + SetBlipColour(blip, 3) + SetBlipScale(blip, 0.75) + SetBlipAsShortRange(blip, true) + BeginTextCommandSetBlipName('STRING') + AddTextComponentString('Garage ' .. garage.name) + EndTextCommandSetBlipName(blip) + end + end +end) + +-- --------------------------------------------------------------------- +-- Ereignisse +-- --------------------------------------------------------------------- +AddEventHandler('aether:client:bereit', function() + istGeladen = true +end) + +RegisterNetEvent('aether:garage:zeige', function(zustand) + istOffen = true + SetNuiFocus(true, true) + pcall(function() exports['aether-hud']:SetzeSichtbar(false) end) + zustand.aktion = 'oeffnen' + SendeAnNui(zustand) +end) + +RegisterNetEvent('aether:garage:zustand', function(zustand) + if not istOffen then + ZeigeSpielmeldung(zustand.hinweis) + return + end + zustand.aktion = 'zustand' + SendeAnNui(zustand) +end) + +RegisterNetEvent('aether:garage:fehler', function(text) + if istOffen then + SendeAnNui({ aktion = 'fehler', text = text }) + else + ZeigeSpielmeldung(text) + end +end) + +-- --------------------------------------------------------------------- +-- Eigenschaften anwenden, die nur clientseitig gesetzt werden koennen +-- Der Server erzeugt das Fahrzeug und legt die Werte im State Bag ab; +-- hier werden sie uebernommen, sobald das Fahrzeug beim Spieler auftaucht. +-- --------------------------------------------------------------------- +AddStateBagChangeHandler('aether', nil, function(bagName, _, werte) + if type(werte) ~= 'table' then return end + + local netId = tonumber(bagName:match('entity:(%d+)')) + if not netId then return end + + CreateThread(function() + -- Warten, bis die Entitaet lokal existiert + local versuche = 0 + while not NetworkDoesEntityExistWithNetworkId(netId) and versuche < 100 do + Wait(50) + versuche = versuche + 1 + end + if not NetworkDoesEntityExistWithNetworkId(netId) then return end + + local fahrzeug = NetToVeh(netId) + if not fahrzeug or fahrzeug == 0 or not DoesEntityExist(fahrzeug) then return end + + if werte.kennzeichen then + SetVehicleNumberPlateText(fahrzeug, werte.kennzeichen) + end + if werte.farbe then + local r, g, b = HexZuRgb(werte.farbe) + SetVehicleCustomPrimaryColour(fahrzeug, r, g, b) + SetVehicleCustomSecondaryColour(fahrzeug, r, g, b) + end + if werte.tank then SetVehicleFuelLevel(fahrzeug, werte.tank + 0.0) end + if werte.motor then SetVehicleEngineHealth(fahrzeug, werte.motor + 0.0) end + if werte.karosserie then SetVehicleBodyHealth(fahrzeug, werte.karosserie + 0.0) end + end) +end) + +--- Server hat das Fahrzeug erzeugt: einsteigen +RegisterNetEvent('aether:garage:einsteigen', function(netId, kennzeichen) + Schliessen() + + -- Warten, bis das Fahrzeug beim Spieler angekommen ist + local versuche = 0 + while not NetworkDoesEntityExistWithNetworkId(netId) and versuche < 100 do + Wait(50) + versuche = versuche + 1 + end + if not NetworkDoesEntityExistWithNetworkId(netId) then + ZeigeSpielmeldung('Fahrzeug konnte nicht geladen werden.') + return + end + + local fahrzeug = NetToVeh(netId) + if fahrzeug and DoesEntityExist(fahrzeug) then + SetVehicleEngineOn(fahrzeug, true, true, false) + SetPedIntoVehicle(PlayerPedId(), fahrzeug, -1) + ZeigeSpielmeldung('Fahrzeug ausgeparkt · ' .. kennzeichen) + end +end) + +-- --------------------------------------------------------------------- +-- Kilometerzaehler +-- GTA fuehrt keinen Tachostand, deshalb zaehlen wir die gefahrene +-- Strecke je Kennzeichen selbst mit (in Metern). +-- --------------------------------------------------------------------- +local gefahreneMeter = {} -- [kennzeichen] = Meter seit dem Ausparken +local letztePosition = nil -- Position im letzten Durchlauf +local seitMeldung = 0 -- Sekunden seit der letzten Zustandsmeldung + +CreateThread(function() + while true do + Wait(1000) + + local ped = PlayerPedId() + if istGeladen and IsPedInAnyVehicle(ped, false) then + local fahrzeug = GetVehiclePedIsIn(ped, false) + local position = GetEntityCoords(fahrzeug) + local kennzeichen = (GetVehicleNumberPlateText(fahrzeug) or ''):gsub('%s', '') + + if letztePosition and kennzeichen ~= '' then + local strecke = #(position - letztePosition) + -- Sprünge durch Teleport oder Nachladen ignorieren + if strecke < 200.0 then + gefahreneMeter[kennzeichen] = (gefahreneMeter[kennzeichen] or 0) + strecke + end + end + + -- Zustand regelmaessig melden, damit er auch bei einem + -- unerwarteten Serverstopp nicht verloren geht + seitMeldung = seitMeldung + 1 + if seitMeldung >= 30 and kennzeichen ~= '' then + seitMeldung = 0 + TriggerServerEvent('aether:garage:zustandMelden', kennzeichen, { + tank = GetVehicleFuelLevel(fahrzeug), + motor = GetVehicleEngineHealth(fahrzeug), + karosserie = GetVehicleBodyHealth(fahrzeug), + }) + end + + letztePosition = position + else + letztePosition = nil + end + end +end) + +-- --------------------------------------------------------------------- +-- Garage in der Naehe erkennen +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + local pause = 800 + aktuelleGarage = nil + + if istGeladen and not istOffen then + local position = GetEntityCoords(PlayerPedId()) + + for garageId, garage in pairs(Garage.Garagen) do + if #(position - garage.position) <= Garage.INTERAKTION_WEITE then + aktuelleGarage = garageId + pause = 0 + + SetTextFont(4) + SetTextScale(0.34, 0.34) + SetTextColour(255, 255, 255, 215) + SetTextCentre(true) + SetTextOutline() + SetTextEntry('STRING') + AddTextComponentString('[E] Garage ' .. garage.name) + SetDrawOrigin(garage.position.x, garage.position.y, garage.position.z + 0.9, 0) + DrawText(0.0, 0.0) + ClearDrawOrigin() + + if IsControlJustReleased(0, TASTE_BENUTZEN) then + TriggerServerEvent('aether:garage:oeffnen', garageId) + end + break + end + end + end + + Wait(pause) + end +end) + +-- --------------------------------------------------------------------- +-- NUI-Callbacks +-- --------------------------------------------------------------------- +RegisterNUICallback('schliessen', function(_, rueckgabe) + Schliessen() + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('ausparken', function(daten, rueckgabe) + TriggerServerEvent('aether:garage:ausparken', daten.kennzeichen, daten.standort) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('einparken', function(daten, rueckgabe) + -- Tatsaechlichen Zustand des Fahrzeugs ermitteln und mitschicken + local fahrzeug = FindeFahrzeug(daten.kennzeichen) + local zustand = {} + + if fahrzeug and DoesEntityExist(fahrzeug) then + local schluessel = (daten.kennzeichen or ''):gsub('%s', '') + zustand.tank = GetVehicleFuelLevel(fahrzeug) + zustand.motor = GetVehicleEngineHealth(fahrzeug) + zustand.karosserie = GetVehicleBodyHealth(fahrzeug) + zustand.meter = math.floor(gefahreneMeter[schluessel] or 0) + gefahreneMeter[schluessel] = 0 -- Zaehler nach dem Einparken zuruecksetzen + else + SendeAnNui({ aktion = 'fehler', text = 'Das Fahrzeug ist nicht in der Nähe.' }) + rueckgabe({ ok = false }) + return + end + + TriggerServerEvent('aether:garage:einparken', daten.kennzeichen, daten.standort, zustand) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('ausloesen', function(daten, rueckgabe) + TriggerServerEvent('aether:garage:ausloesen', daten.kennzeichen, daten.standort) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('orten', function(daten, rueckgabe) + local fahrzeug = FindeFahrzeug(daten.kennzeichen) + if fahrzeug and DoesEntityExist(fahrzeug) then + local position = GetEntityCoords(fahrzeug) + SetNewWaypoint(position.x, position.y) + SendeAnNui({ aktion = 'hinweis', text = 'Markierung auf der Karte gesetzt.' }) + else + SendeAnNui({ aktion = 'fehler', text = 'Das Fahrzeug ist nicht in der Nähe.' }) + end + rueckgabe({ ok = true }) +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen +-- --------------------------------------------------------------------- +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + SetNuiFocus(false, false) +end) diff --git a/resources/[aether]/aether-garage/fxmanifest.lua b/resources/[aether]/aether-garage/fxmanifest.lua new file mode 100644 index 0000000..39c64f7 --- /dev/null +++ b/resources/[aether]/aether-garage/fxmanifest.lua @@ -0,0 +1,34 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +name 'aether-garage' +author 'Jerrit Fritzsche' +description 'Garagen: Ein- und Ausparken, Fahrzeugzustand, Kennzeichen' +version '0.1.0' + +ui_page 'html/index.html' + +files { + 'html/index.html', +} + +shared_scripts { + '@aether-core/shared/config.lua', + '@aether-core/shared/utils.lua', + 'shared/garagen.lua', +} + +client_scripts { + 'client/main.lua', +} + +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/main.lua', +} + +dependencies { + 'aether-core', + 'oxmysql', +} diff --git a/resources/[aether]/aether-garage/html/index.html b/resources/[aether]/aether-garage/html/index.html new file mode 100644 index 0000000..037c501 --- /dev/null +++ b/resources/[aether]/aether-garage/html/index.html @@ -0,0 +1,330 @@ + + + + + + + + +Aether — Garage + + +
+

GarageLegion Square

+
+
+
+
+
+
+
+
+
+
+ + + + + diff --git a/resources/[aether]/aether-garage/server/main.lua b/resources/[aether]/aether-garage/server/main.lua new file mode 100644 index 0000000..cf9d1e6 --- /dev/null +++ b/resources/[aether]/aether-garage/server/main.lua @@ -0,0 +1,511 @@ +-- ===================================================================== +-- Aether Garage — Serverlogik +-- Verwaltet Fahrzeugbesitz, Ein- und Ausparken. Der Client darf ein +-- Fahrzeug erst erzeugen, nachdem der Server es freigegeben hat. +-- ===================================================================== + +local Core = exports['aether-core'] + +-- Fahrzeuge, die gerade ausgeparkt werden duerfen: [quelle] = kennzeichen +local freigaben = {} + +-- Aktuell in der Welt stehende Fahrzeuge: [kennzeichen] = Entity-Handle +-- Sie werden vom Server erzeugt und ueberleben dadurch auch, wenn kein +-- Spieler in der Naehe ist. +local aktiveFahrzeuge = {} + +-- --------------------------------------------------------------------- +-- Fahrzeuge in der Welt erzeugen und entfernen +-- --------------------------------------------------------------------- + +--- Erzeugt ein Fahrzeug serverseitig an einer Position +--- @param datensatz table Zeile aus aether_vehicles +--- @param x number +--- @param y number +--- @param z number +--- @param heading number +--- @return number|nil Entity-Handle +local function ErzeugeFahrzeug(datensatz, x, y, z, heading) + -- Bereits vorhandenes Fahrzeug mit diesem Kennzeichen zuerst entfernen + local vorhandenes = aktiveFahrzeuge[datensatz.plate] + if vorhandenes and DoesEntityExist(vorhandenes) then + DeleteEntity(vorhandenes) + end + + local fahrzeug = CreateVehicle(GetHashKey(datensatz.model), x, y, z, heading or 0.0, true, true) + if not fahrzeug or fahrzeug == 0 then return nil end + + -- Warten, bis die Entitaet wirklich existiert + local versuche = 0 + while not DoesEntityExist(fahrzeug) and versuche < 50 do + Wait(10) + versuche = versuche + 1 + end + if not DoesEntityExist(fahrzeug) then return nil end + + local props = {} + if datensatz.props then + local erfolg, entschluesselt = pcall(json.decode, datensatz.props) + if erfolg and type(entschluesselt) == 'table' then props = entschluesselt end + end + + -- Alles, was nur clientseitig gesetzt werden kann, ueber den State Bag + -- weitergeben. Der Client wendet es an, sobald das Fahrzeug bei ihm + -- auftaucht (siehe client/main.lua). + Entity(fahrzeug).state:set('aether', { + kennzeichen = datensatz.plate, + farbe = props.farbe, + tank = datensatz.fuel, + motor = datensatz.engine, + karosserie = datensatz.body, + }, true) + + aktiveFahrzeuge[datensatz.plate] = fahrzeug + return fahrzeug +end + +--- Entfernt ein Fahrzeug aus der Welt +--- @param kennzeichen string Kennzeichen +local function EntferneFahrzeug(kennzeichen) + local fahrzeug = aktiveFahrzeuge[kennzeichen] + if fahrzeug and DoesEntityExist(fahrzeug) then + DeleteEntity(fahrzeug) + end + aktiveFahrzeuge[kennzeichen] = nil +end + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Liefert die Charakter-ID eines Spielers +local function CharakterVon(quelle) + return Core:HoleCharakterId(quelle) +end + +--- Prueft, ob ein Spieler Zugang zu einer Garage hat. +--- Haus-Garagen werden spaeter ueber `aether-immobilien` geprueft. +--- @param quelle number Server-ID +--- @param garageId string Kennung der Garage +--- @return boolean +local function HatZugang(quelle, garageId) + local garage = Garage.Garagen[garageId] + if not garage then return false end + if garage.typ == 'oeffentlich' then return true end + + -- Optionale Abfrage beim Immobilien-Modul + local erfolg, ergebnis = pcall(function() + return exports['aether-immobilien']:HatGaragenZugang(quelle, garageId) + end) + if erfolg and type(ergebnis) == 'boolean' then return ergebnis end + + -- Modul noch nicht vorhanden: Haus-Garagen bleiben verschlossen + return false +end + +--- Laedt alle Fahrzeuge eines Charakters +--- @param charakterId number Charakter-ID +--- @return table Liste von Fahrzeugen +local function HoleFahrzeuge(charakterId) + local zeilen = MySQL.query.await([[ + SELECT plate, model, props, garage, state, fuel, engine, body, mileage + FROM aether_vehicles WHERE owner = ? + ]], { charakterId }) or {} + + local liste = {} + for _, zeile in ipairs(zeilen) do + local props = {} + if zeile.props then + local erfolg, entschluesselt = pcall(json.decode, zeile.props) + if erfolg and type(entschluesselt) == 'table' then props = entschluesselt end + end + + liste[#liste + 1] = { + kennzeichen = zeile.plate, + modell = zeile.model, + name = props.name or zeile.model, + klasse = props.klasse or 'Fahrzeug', + typ = props.typ or 'sedan', + farbe = props.farbe or '#20232a', + plateStyle = props.plateStyle or 'sa', + garage = zeile.garage, + status = zeile.state, -- in | out | impound + tank = math.floor(zeile.fuel or 0), + motor = math.floor((zeile.engine or 1000) / 10), + karosserie = math.floor((zeile.body or 1000) / 10), + km = zeile.mileage or 0, + } + end + return liste +end + +--- Baut den Zustand fuer die Oberflaeche +--- @param quelle number Server-ID +--- @param standort string Garage, an der der Spieler steht +--- @return table|nil +local function BaueZustand(quelle, standort) + local charakterId = CharakterVon(quelle) + if not charakterId then return nil end + + -- Nur zugaengliche Garagen anzeigen + local garagen = {} + for garageId, garage in pairs(Garage.Garagen) do + if HatZugang(quelle, garageId) then + garagen[#garagen + 1] = { + id = garageId, name = garage.name, typ = garage.typ, + } + end + end + + return { + garagen = garagen, + fahrzeuge = HoleFahrzeuge(charakterId), + standort = standort, + gebuehr = Garage.AUSLOESE_GEBUEHR, + } +end + +--- Schickt den Zustand an einen Spieler +local function SendeZustand(quelle, standort, hinweis) + local zustand = BaueZustand(quelle, standort) + if not zustand then return end + zustand.hinweis = hinweis + TriggerClientEvent('aether:garage:zustand', quelle, zustand) +end + +--- Lehnt eine Aktion ab +local function Ablehnen(quelle, grund) + TriggerClientEvent('aether:garage:fehler', quelle, grund) +end + +--- Liest ein Fahrzeug und prueft den Besitz +--- @param charakterId number Charakter-ID +--- @param kennzeichen string Kennzeichen +--- @return table|nil +local function HoleEigenes(charakterId, kennzeichen) + local treffer = MySQL.query.await( + 'SELECT * FROM aether_vehicles WHERE plate = ? AND owner = ?', + { kennzeichen, charakterId }) + return treffer and treffer[1] or nil +end + +-- --------------------------------------------------------------------- +-- Ausgeparkte Fahrzeuge beim Serverstart wiederherstellen +-- --------------------------------------------------------------------- +CreateThread(function() + Wait(2000) -- warten, bis Datenbank und Spielwelt bereit sind + + local zeilen = MySQL.query.await([[ + SELECT * FROM aether_vehicles + WHERE state = 'out' AND pos_x IS NOT NULL + ]]) or {} + + local wiederhergestellt = 0 + for _, zeile in ipairs(zeilen) do + local entitaet = ErzeugeFahrzeug(zeile, zeile.pos_x, zeile.pos_y, zeile.pos_z, zeile.heading) + if entitaet then + wiederhergestellt = wiederhergestellt + 1 + else + -- Konnte es nicht erzeugt werden (z. B. Modell entfernt), + -- wandert es zurueck in seine Garage statt verloren zu gehen. + MySQL.update("UPDATE aether_vehicles SET state = 'in' WHERE plate = ?", { zeile.plate }) + Aether.Fehler(('Fahrzeug %s konnte nicht gesetzt werden — zurueck in die Garage.') + :format(zeile.plate)) + end + Wait(50) -- Erzeugung entzerren + end + + if wiederhergestellt > 0 then + Aether.Info(('%d ausgeparkte Fahrzeuge wiederhergestellt.'):format(wiederhergestellt)) + end +end) + +-- --------------------------------------------------------------------- +-- Position der ausgeparkten Fahrzeuge regelmaessig sichern +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(30000) -- alle 30 Sekunden + + for kennzeichen, fahrzeug in pairs(aktiveFahrzeuge) do + if DoesEntityExist(fahrzeug) then + local position = GetEntityCoords(fahrzeug) + MySQL.update([[ + UPDATE aether_vehicles SET pos_x = ?, pos_y = ?, pos_z = ?, heading = ? + WHERE plate = ? + ]], { position.x, position.y, position.z, GetEntityHeading(fahrzeug), kennzeichen }) + else + -- Entitaet ist verschwunden (z. B. zerstoert): Eintrag aufraeumen + aktiveFahrzeuge[kennzeichen] = nil + end + end + end +end) + +--- Beim Stoppen der Ressource die letzten Positionen sichern +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + + for kennzeichen, fahrzeug in pairs(aktiveFahrzeuge) do + if DoesEntityExist(fahrzeug) then + local position = GetEntityCoords(fahrzeug) + MySQL.update.await([[ + UPDATE aether_vehicles SET pos_x = ?, pos_y = ?, pos_z = ?, heading = ? + WHERE plate = ? + ]], { position.x, position.y, position.z, GetEntityHeading(fahrzeug), kennzeichen }) + end + end + Aether.Info('Fahrzeugpositionen gesichert.') +end) + +-- --------------------------------------------------------------------- +-- Zustand eines fahrenden Fahrzeugs uebernehmen (vom Client gemeldet) +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:garage:zustandMelden', function(kennzeichen, zustand) + if type(kennzeichen) ~= 'string' or type(zustand) ~= 'table' then return end + if not aktiveFahrzeuge[kennzeichen] then return end -- nur bekannte Fahrzeuge + + local tank = math.max(0, math.min(100, tonumber(zustand.tank) or 100)) + local motor = math.max(0, math.min(1000, tonumber(zustand.motor) or 1000)) + local body = math.max(0, math.min(1000, tonumber(zustand.karosserie) or 1000)) + + MySQL.update('UPDATE aether_vehicles SET fuel = ?, engine = ?, body = ? WHERE plate = ?', + { tank, motor, body, kennzeichen }) +end) + +-- --------------------------------------------------------------------- +-- Startfahrzeug fuer neue Charaktere +-- --------------------------------------------------------------------- +AddEventHandler('aether:server:spielerGeladen', function(quelle, spieler) + if not Garage.STARTFAHRZEUG then return end + + local vorhanden = MySQL.scalar.await( + 'SELECT COUNT(*) FROM aether_vehicles WHERE owner = ?', { spieler.charakterId }) + if (vorhanden or 0) > 0 then return end + + local kennzeichen = Garage.ErzeugeKennzeichen() + MySQL.insert.await([[ + INSERT INTO aether_vehicles (plate, owner, model, props, garage, state) + VALUES (?, ?, ?, ?, ?, 'in') + ]], { + kennzeichen, spieler.charakterId, Garage.STARTFAHRZEUG.modell, + json.encode({ name = 'Blista', klasse = 'Kleinwagen', typ = 'compact', + farbe = '#3563a8', plateStyle = 'sa' }), + Garage.STARTFAHRZEUG.garage, + }) + Aether.Info(('Startfahrzeug %s fuer Charakter #%d angelegt.') + :format(kennzeichen, spieler.charakterId)) +end) + +-- --------------------------------------------------------------------- +-- Garage oeffnen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:garage:oeffnen', function(garageId) + local quelle = source + if not CharakterVon(quelle) then return end + if type(garageId) ~= 'string' or not Garage.Garagen[garageId] then return end + + if not HatZugang(quelle, garageId) then + Ablehnen(quelle, 'Zu dieser Garage hast du keinen Zugang.') + return + end + + -- Entfernungspruefung + local position = GetEntityCoords(GetPlayerPed(quelle)) + if #(position - Garage.Garagen[garageId].position) > (Garage.INTERAKTION_WEITE + 3.0) then + Ablehnen(quelle, 'Du bist nicht an dieser Garage.') + return + end + + local zustand = BaueZustand(quelle, garageId) + if zustand then + TriggerClientEvent('aether:garage:zeige', quelle, zustand) + end +end) + +-- --------------------------------------------------------------------- +-- Ausparken +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:garage:ausparken', function(kennzeichen, standort) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + if type(kennzeichen) ~= 'string' or type(standort) ~= 'string' then return end + + local garage = Garage.Garagen[standort] + if not garage or not HatZugang(quelle, standort) then return end + + local fahrzeug = HoleEigenes(charakterId, kennzeichen) + if not fahrzeug then Ablehnen(quelle, 'Das ist nicht dein Fahrzeug.') return end + + if fahrzeug.state == 'impound' then + Ablehnen(quelle, 'Das Fahrzeug ist beschlagnahmt.') + return + end + if fahrzeug.state == 'out' then + Ablehnen(quelle, 'Das Fahrzeug steht bereits draußen.') + return + end + + -- Standort-Regel: nur an der Garage, wo das Fahrzeug wirklich steht + if fahrzeug.garage ~= standort then + local wo = Garage.Garagen[fahrzeug.garage] + Ablehnen(quelle, ('Das Fahrzeug steht in %s.'):format(wo and wo.name or fahrzeug.garage)) + return + end + + -- Fahrzeug serverseitig erzeugen, damit es unabhaengig vom Spieler + -- (und ueber Neustarts hinweg) in der Welt bleibt + local spawn = garage.spawn + local entitaet = ErzeugeFahrzeug(fahrzeug, spawn.x, spawn.y, spawn.z, spawn.w) + if not entitaet then + Ablehnen(quelle, 'Fahrzeug konnte nicht erzeugt werden.') + return + end + + MySQL.update.await([[ + UPDATE aether_vehicles + SET state = 'out', pos_x = ?, pos_y = ?, pos_z = ?, heading = ? + WHERE plate = ? + ]], { spawn.x, spawn.y, spawn.z, spawn.w, kennzeichen }) + + freigaben[quelle] = kennzeichen + + -- Dem Client sagen, in welches Fahrzeug er einsteigen soll + TriggerClientEvent('aether:garage:einsteigen', quelle, + NetworkGetNetworkIdFromEntity(entitaet), kennzeichen) +end) + +-- --------------------------------------------------------------------- +-- Einparken +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:garage:einparken', function(kennzeichen, standort, zustand) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + if type(kennzeichen) ~= 'string' or type(standort) ~= 'string' then return end + + local garage = Garage.Garagen[standort] + if not garage or not HatZugang(quelle, standort) then return end + + local fahrzeug = HoleEigenes(charakterId, kennzeichen) + if not fahrzeug then Ablehnen(quelle, 'Das ist nicht dein Fahrzeug.') return end + + -- Entfernung des Spielers zur Garage pruefen + local position = GetEntityCoords(GetPlayerPed(quelle)) + if #(position - garage.position) > Garage.EINPARK_WEITE then + Ablehnen(quelle, 'Du bist zu weit von der Garage entfernt.') + return + end + + -- Zustandswerte vom Client uebernehmen, aber begrenzen + zustand = type(zustand) == 'table' and zustand or {} + local tank = math.max(0, math.min(100, tonumber(zustand.tank) or fahrzeug.fuel or 100)) + local motor = math.max(0, math.min(1000, tonumber(zustand.motor) or fahrzeug.engine or 1000)) + local body = math.max(0, math.min(1000, tonumber(zustand.karosserie) or fahrzeug.body or 1000)) + + -- Kilometerstand: gefahrene Meter aufaddieren (nie verringern). + -- Nach oben begrenzt, damit manipulierte Werte nichts kaputtmachen. + local meter = math.max(0, math.min(1000000, math.floor(tonumber(zustand.meter) or 0))) + local km = (fahrzeug.mileage or 0) + math.floor(meter / 1000) + + MySQL.update.await([[ + UPDATE aether_vehicles + SET state = 'in', garage = ?, fuel = ?, engine = ?, body = ?, mileage = ?, + pos_x = NULL, pos_y = NULL, pos_z = NULL, heading = NULL + WHERE plate = ? + ]], { standort, tank, motor, body, km, kennzeichen }) + + -- Fahrzeug aus der Welt nehmen (serverseitig, damit es bei allen verschwindet) + EntferneFahrzeug(kennzeichen) + + freigaben[quelle] = nil + SendeZustand(quelle, standort, ('Fahrzeug in %s eingelagert.'):format(garage.name)) +end) + +-- --------------------------------------------------------------------- +-- Beschlagnahmtes Fahrzeug ausloesen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:garage:ausloesen', function(kennzeichen, standort) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + if type(kennzeichen) ~= 'string' or type(standort) ~= 'string' then return end + if not Garage.Garagen[standort] or not HatZugang(quelle, standort) then return end + + local fahrzeug = HoleEigenes(charakterId, kennzeichen) + if not fahrzeug or fahrzeug.state ~= 'impound' then return end + + if not Core:EntferneGeld(quelle, 'bargeld', Garage.AUSLOESE_GEBUEHR, 'Fahrzeug ausgelöst') then + Ablehnen(quelle, ('Du brauchst %d € in bar.'):format(Garage.AUSLOESE_GEBUEHR)) + return + end + + MySQL.update.await( + 'UPDATE aether_vehicles SET state = ?, garage = ? WHERE plate = ?', + { 'in', standort, kennzeichen }) + + SendeZustand(quelle, standort, ('Fahrzeug für %d € ausgelöst.'):format(Garage.AUSLOESE_GEBUEHR)) +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen +-- --------------------------------------------------------------------- +AddEventHandler('playerDropped', function() + freigaben[source] = nil +end) + +-- ===================================================================== +-- EXPORTS +-- ===================================================================== + +--- Gibt einem Charakter ein Fahrzeug +--- @param charakterId number Charakter-ID +--- @param modell string Spawnname des Modells +--- @param garageId string Garage, in der es steht +--- @param props table|nil Anzeigedaten (Name, Klasse, Farbe ...) +--- @return string|nil Kennzeichen +exports('GebeFahrzeug', function(charakterId, modell, garageId, props) + if type(charakterId) ~= 'number' or type(modell) ~= 'string' then return nil end + + local kennzeichen = Garage.ErzeugeKennzeichen() + MySQL.insert.await([[ + INSERT INTO aether_vehicles (plate, owner, model, props, garage, state) + VALUES (?, ?, ?, ?, ?, 'in') + ]], { + kennzeichen, charakterId, modell, + json.encode(props or { name = modell }), + garageId or 'legion', + }) + return kennzeichen +end) + +--- Beschlagnahmt ein Fahrzeug (fuer das Polizei-CAD) +--- @param kennzeichen string Kennzeichen +--- @return boolean Erfolg +exports('Beschlagnahmen', function(kennzeichen) + if type(kennzeichen) ~= 'string' then return false end + MySQL.update.await( + "UPDATE aether_vehicles SET state = 'impound' WHERE plate = ?", { kennzeichen }) + return true +end) + +--- Sucht den Besitzer eines Kennzeichens (fuer die Kennzeichenabfrage im CAD) +--- @param kennzeichen string Kennzeichen +--- @return table|nil { charakterId, vorname, nachname, modell } +exports('HoleHalter', function(kennzeichen) + local treffer = MySQL.query.await([[ + SELECT v.owner, v.model, c.firstname, c.lastname + FROM aether_vehicles v + JOIN aether_characters c ON c.id = v.owner + WHERE v.plate = ? + ]], { kennzeichen }) + + if not treffer or not treffer[1] then return nil end + return { + charakterId = treffer[1].owner, + vorname = treffer[1].firstname, + nachname = treffer[1].lastname, + modell = treffer[1].model, + } +end) diff --git a/resources/[aether]/aether-garage/shared/garagen.lua b/resources/[aether]/aether-garage/shared/garagen.lua new file mode 100644 index 0000000..a303ae5 --- /dev/null +++ b/resources/[aether]/aether-garage/shared/garagen.lua @@ -0,0 +1,93 @@ +-- ===================================================================== +-- Aether Garage — Garagen, Standorte und Einstellungen +-- ===================================================================== + +Garage = Garage or {} + +Garage.INTERAKTION_WEITE = 3.0 -- Reichweite zum Oeffnen (Meter) +Garage.EINPARK_WEITE = 12.0 -- Wie nah das Fahrzeug an der Garage sein muss +Garage.AUSLOESE_GEBUEHR = 500 -- Kosten fuer beschlagnahmte Fahrzeuge + +-- Bekommt ein neuer Charakter ein Startfahrzeug? +Garage.STARTFAHRZEUG = { modell = 'blista', garage = 'legion' } + +-- --------------------------------------------------------------------- +-- Garagen +-- typ 'oeffentlich' = fuer alle zugaenglich +-- typ 'haus' = nur mit Zugang (spaeter ueber aether-immobilien) +-- position = Ort der Bedienung, spawn = wo das Fahrzeug erscheint +-- --------------------------------------------------------------------- +Garage.Garagen = { + legion = { + name = 'Legion Square', typ = 'oeffentlich', + position = vector3(215.9, -810.0, 30.7), + spawn = vector4(228.6, -800.5, 30.6, 158.0), + }, + sandy = { + name = 'Sandy Shores', typ = 'oeffentlich', + position = vector3(1735.4, 3710.6, 34.1), + spawn = vector4(1729.6, 3716.3, 34.1, 20.0), + }, + delperro = { + name = 'Del Perro', typ = 'oeffentlich', + position = vector3(-1160.0, -736.5, 20.4), + spawn = vector4(-1170.5, -737.7, 19.9, 108.0), + }, + paleto = { + name = 'Paleto Bay', typ = 'oeffentlich', + position = vector3(105.9, 6613.9, 32.0), + spawn = vector4(112.6, 6608.9, 31.8, 45.0), + }, + -- Haus-Garagen: sichtbar nur mit Zugang + villa = { + name = 'Villa Vinewood Hills', typ = 'haus', + position = vector3(-174.3, 502.1, 137.4), + spawn = vector4(-165.9, 496.3, 137.1, 190.0), + }, +} + +-- --------------------------------------------------------------------- +-- Fahrzeugklassen fuer die Anzeige +-- Ordnet dem GTA-Klassenindex einen Namen und einen Vorschautyp zu. +-- --------------------------------------------------------------------- +Garage.Klassen = { + [0] = { name = 'Kleinwagen', typ = 'compact' }, + [1] = { name = 'Limousine', typ = 'sedan' }, + [2] = { name = 'SUV', typ = 'suv' }, + [3] = { name = 'Coupé', typ = 'sedan' }, + [4] = { name = 'Muscle', typ = 'muscle' }, + [5] = { name = 'Sportwagen', typ = 'sport' }, + [6] = { name = 'Sportwagen', typ = 'sport' }, + [7] = { name = 'Supersport', typ = 'sport' }, + [8] = { name = 'Motorrad', typ = 'bike' }, + [9] = { name = 'Geländewagen', typ = 'suv' }, + [12]= { name = 'Transporter', typ = 'pickup' }, + [13]= { name = 'Fahrrad', typ = 'bike' }, + [14]= { name = 'Boot', typ = 'sedan' }, + [15]= { name = 'Helikopter', typ = 'sedan' }, + [16]= { name = 'Flugzeug', typ = 'sedan' }, + [17]= { name = 'Service', typ = 'pickup' }, + [18]= { name = 'Einsatz', typ = 'suv' }, + [19]= { name = 'Militär', typ = 'suv' }, + [20]= { name = 'LKW', typ = 'pickup' }, +} + +--- Liefert Klassenname und Vorschautyp zu einem GTA-Klassenindex +--- @param index number Klassenindex +--- @return string Name, string Vorschautyp +function Garage.Klasse(index) + local eintrag = Garage.Klassen[index or -1] or { name = 'Fahrzeug', typ = 'sedan' } + return eintrag.name, eintrag.typ +end + +--- Erzeugt ein Kennzeichen im San-Andreas-Format (8 Zeichen) +--- @return string +function Garage.ErzeugeKennzeichen() + local buchstaben = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + local kennzeichen = ('%02d'):format(math.random(0, 99)) + for _ = 1, 3 do + local pos = math.random(#buchstaben) + kennzeichen = kennzeichen .. buchstaben:sub(pos, pos) + end + return kennzeichen .. ('%03d'):format(math.random(0, 999)) +end diff --git a/resources/[aether]/aether-hud/client/fahrzeug.lua b/resources/[aether]/aether-hud/client/fahrzeug.lua new file mode 100644 index 0000000..16d45fc --- /dev/null +++ b/resources/[aether]/aether-hud/client/fahrzeug.lua @@ -0,0 +1,77 @@ +-- ===================================================================== +-- Aether HUD — Fahrzeugdaten und Sicherheitsgurt +-- Liefert Tempo, Gang, Tank und Motorzustand fuer die Tachoanzeige. +-- Der Gurt ist eine reine Skriptmechanik (GTA kennt ihn nicht). +-- ===================================================================== + +Fahrzeug = {} + +-- Ist der Gurt gerade angelegt? +local gurtAngelegt = false +-- Merkt sich, ob der Spieler im letzten Durchlauf im Fahrzeug sass +local warImFahrzeug = false + +-- Taste zum Anlegen/Loesen des Gurts (B) +local TASTE_GURT = 29 + +--- Liefert den aktuellen Gurtzustand +--- @return boolean +function Fahrzeug.HatGurt() + return gurtAngelegt +end + +--- Sammelt alle Anzeigewerte des Fahrzeugs, in dem der Spieler sitzt +--- @return table|nil Werte oder nil, wenn der Spieler nicht faehrt +function Fahrzeug.HoleDaten() + local ped = PlayerPedId() + if not IsPedInAnyVehicle(ped, false) then return nil end + + local fahrzeug = GetVehiclePedIsIn(ped, false) + if not fahrzeug or fahrzeug == 0 then return nil end + + -- Geschwindigkeit von m/s in km/h umrechnen + local tempo = GetEntitySpeed(fahrzeug) * 3.6 + + return { + imFahrzeug = true, + tempo = math.floor(tempo + 0.5), + tank = math.floor(GetVehicleFuelLevel(fahrzeug) + 0.5), + motor = GetIsVehicleEngineRunning(fahrzeug), + gurt = gurtAngelegt, + } +end + +-- --------------------------------------------------------------------- +-- Gurt-Steuerung +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + local ped = PlayerPedId() + local imFahrzeug = IsPedInAnyVehicle(ped, false) + + if imFahrzeug then + Wait(0) + + -- Gurt per Taste umschalten (nur als Fahrer oder Beifahrer sinnvoll) + if IsControlJustReleased(0, TASTE_GURT) then + gurtAngelegt = not gurtAngelegt + PlaySoundFrontend(-1, 'SELECT', 'HUD_FRONTEND_DEFAULT_SOUNDSET', true) + end + + -- Mit Gurt: Herausschleudern bei Unfaellen verhindern + local fahrzeug = GetVehiclePedIsIn(ped, false) + if fahrzeug ~= 0 then + SetPedConfigFlag(ped, 32, not gurtAngelegt) -- 32 = kann durch Scheibe fliegen + end + + warImFahrzeug = true + else + -- Beim Aussteigen den Gurt automatisch loesen + if warImFahrzeug then + gurtAngelegt = false + warImFahrzeug = false + end + Wait(500) + end + end +end) diff --git a/resources/[aether]/aether-hud/client/main.lua b/resources/[aether]/aether-hud/client/main.lua new file mode 100644 index 0000000..c692d2c --- /dev/null +++ b/resources/[aether]/aether-hud/client/main.lua @@ -0,0 +1,195 @@ +-- ===================================================================== +-- Aether HUD — Clientlogik +-- Versorgt die Oberflaeche mit Status, Geld, Voice und Fahrzeugwerten. +-- Es wird nur gesendet, wenn sich ein Wert tatsaechlich geaendert hat. +-- ===================================================================== + +-- Ist der Spieler fertig geladen? (vorher kein HUD zeigen) +local istGeladen = false +-- Soll das HUD sichtbar sein? (z. B. bei offenen Menues ausblendbar) +local istSichtbar = true + +-- Zuletzt gesendete Werte, um unnoetige Nachrichten zu vermeiden +local letzterStatus = {} +local letztesFahrzeug = {} +local letzteVoice = {} + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Schickt eine Nachricht an die Oberflaeche +--- @param nachricht table Zu sendende Daten +local function SendeAnHud(nachricht) + SendNuiMessage(json.encode(nachricht)) +end + +--- Prueft, ob sich mindestens ein Feld gegenueber dem Zwischenspeicher geaendert hat +--- @param neu table Neue Werte +--- @param alt table Zwischenspeicher (wird aktualisiert) +--- @return boolean true, wenn sich etwas geaendert hat +local function HatSichGeaendert(neu, alt) + local geaendert = false + for schluessel, wert in pairs(neu) do + if alt[schluessel] ~= wert then + alt[schluessel] = wert + geaendert = true + end + end + return geaendert +end + +--- Rechnet die Lebensenergie (100–200) in Prozent (0–100) um. +--- GTA zaehlt Leben ab 100; darunter ist der Spieler tot. +--- @param ped number Ped-Handle +--- @return number Prozentwert 0–100 +local function LebenInProzent(ped) + local leben = GetEntityHealth(ped) + if leben <= 100 then return 0 end + return Aether.Begrenze((leben - 100) / 100 * 100, 0, 100) +end + +-- --------------------------------------------------------------------- +-- Ereignisse vom Core +-- --------------------------------------------------------------------- + +--- Spieler ist im Spiel: HUD mit den Startwerten fuellen +AddEventHandler('aether:client:bereit', function(daten) + istGeladen = true + + SendeAnHud({ + aktion = 'geld', + bargeld = daten.bargeld, + konto = daten.konto, + schwarzgeld = daten.schwarzgeld, + -- Schwarzgeld nur zeigen, wenn welches vorhanden ist + schwarzgeldSichtbar = (daten.schwarzgeld or 0) > 0, + }) + + SendeAnHud({ + aktion = 'status', + leben = LebenInProzent(PlayerPedId()), + hunger = daten.hunger, + durst = daten.durst, + ausdauer = daten.ausdauer, + }) + + SendeAnHud({ aktion = 'sichtbar', an = true }) +end) + +--- Geld hat sich geaendert +AddEventHandler('aether:client:geldGeaendert', function(geld) + SendeAnHud({ + aktion = 'geld', + bargeld = geld.bargeld, + konto = geld.konto, + schwarzgeld = geld.schwarzgeld, + schwarzgeldSichtbar = (geld.schwarzgeld or 0) > 0, + }) +end) + +--- Hunger/Durst/Ausdauer haben sich geaendert +AddEventHandler('aether:client:zustandGeaendert', function(zustand) + SendeAnHud({ + aktion = 'status', + hunger = zustand.hunger, + durst = zustand.durst, + ausdauer = zustand.ausdauer, + }) +end) + +-- --------------------------------------------------------------------- +-- Laufende Erfassung: Leben und Ausdauer +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(500) + if istGeladen then + local ped = PlayerPedId() + + -- Ausdauer aus dem Sprint-Vorrat ableiten (100 = ausgeruht) + local ausdauer = 100.0 - GetPlayerSprintStaminaRemaining(PlayerId()) + + local neu = { + leben = math.floor(LebenInProzent(ped)), + ausdauer = math.floor(Aether.Begrenze(ausdauer, 0, 100)), + } + + if HatSichGeaendert(neu, letzterStatus) then + neu.aktion = 'status' + SendeAnHud(neu) + end + end + end +end) + +-- --------------------------------------------------------------------- +-- Laufende Erfassung: Voice +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(200) + if istGeladen then + -- Sprechen erkennen (funktioniert mit dem eingebauten Mumble-Voice) + local redet = NetworkIsPlayerTalking(PlayerId()) + + -- Reichweite aus der Mumble-Einstellung ableiten + local reichweite = MumbleGetTalkerProximity() or 0.0 + local stufe = 1 -- 0 = Fluestern, 1 = Normal, 2 = Rufen + if reichweite <= 3.5 then stufe = 0 + elseif reichweite >= 12.0 then stufe = 2 end + + local neu = { redet = redet, voiceStufe = stufe } + if HatSichGeaendert(neu, letzteVoice) then + neu.aktion = 'voice' + SendeAnHud(neu) + end + end + end +end) + +-- --------------------------------------------------------------------- +-- Laufende Erfassung: Fahrzeug +-- Im Fahrzeug haeufiger abfragen, damit der Tacho fluessig laeuft. +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + local pause = 500 + + if istGeladen then + local daten = Fahrzeug.HoleDaten() + + if daten then + pause = 100 -- fluessige Tachoanzeige + if HatSichGeaendert(daten, letztesFahrzeug) then + daten.aktion = 'fahrzeug' + SendeAnHud(daten) + end + elseif letztesFahrzeug.imFahrzeug ~= false then + -- Gerade ausgestiegen: Fahrzeuganzeige ausblenden + letztesFahrzeug = { imFahrzeug = false } + SendeAnHud({ aktion = 'fahrzeug', imFahrzeug = false, tempo = 0 }) + end + end + + Wait(pause) + end +end) + +-- ===================================================================== +-- EXPORTS — andere Module koennen das HUD aus- und einblenden +-- Beispiel: exports['aether-hud']:SetzeSichtbar(false) +-- ===================================================================== + +--- Blendet das HUD ein oder aus +--- @param an boolean true = sichtbar +exports('SetzeSichtbar', function(an) + istSichtbar = an and true or false + SendeAnHud({ aktion = 'sichtbar', an = istSichtbar }) +end) + +--- Ist das HUD gerade sichtbar? +--- @return boolean +exports('IstSichtbar', function() + return istSichtbar +end) diff --git a/resources/[aether]/aether-hud/fxmanifest.lua b/resources/[aether]/aether-hud/fxmanifest.lua new file mode 100644 index 0000000..c8167b2 --- /dev/null +++ b/resources/[aether]/aether-hud/fxmanifest.lua @@ -0,0 +1,30 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +name 'aether-hud' +author 'Jerrit Fritzsche' +description 'In-Game-HUD: Status, Voice, Geld und Fahrzeuganzeige' +version '0.1.0' + +-- Das HUD ist ein dauerhaftes Overlay (kein Fokus, keine Maus) +ui_page 'html/index.html' + +files { + 'html/index.html', +} + +-- Geteilte Dateien des Cores mitladen (eigener Lua-State je Resource) +shared_scripts { + '@aether-core/shared/config.lua', + '@aether-core/shared/utils.lua', +} + +client_scripts { + 'client/fahrzeug.lua', + 'client/main.lua', +} + +dependencies { + 'aether-core', +} diff --git a/resources/[aether]/aether-hud/html/index.html b/resources/[aether]/aether-hud/html/index.html new file mode 100644 index 0000000..20d6f0b --- /dev/null +++ b/resources/[aether]/aether-hud/html/index.html @@ -0,0 +1,484 @@ + + + + + + +Aether — HUD + + + + + +
+
+
+
+ + +
+ +
+ 🎙️ +
+ Normal +
+ + +
+
+
+ + +
+
+
Gang N
+
+
🎗️
+
⚙️
+
+
+
+
+ + + + + +
0
KM/H
+
+
+ + +
+ + +
+
+
Demo-SteuerungNur Prototyp — Werte simulieren
+
▾
+
+
+
+
Status (0–100)
+
100
+
82
+
74
+
100
+
+
+
+
Aktionen
+
+ + + + + + +
+
+
+
+
Geld
+
+ + + +
+
+
+
+
Fahrzeug
+
+
0
+
68
+
+ + +
+
+
+
+
+ + + + + diff --git a/resources/[aether]/aether-inventory/client/drops.lua b/resources/[aether]/aether-inventory/client/drops.lua new file mode 100644 index 0000000..ef41a8a --- /dev/null +++ b/resources/[aether]/aether-inventory/client/drops.lua @@ -0,0 +1,164 @@ +-- ===================================================================== +-- Aether Inventory — Sichtbare Boden-Drops +-- Fuer jedes fallengelassene Item steht ein Karton in der Welt. +-- Die Props werden nur lokal erzeugt und nach Entfernung ein-/ausgeblendet. +-- ===================================================================== + +Drops = {} + +-- Bekannte Drops vom Server: [id] = { id, item, menge, x, y, z, prop } +local bekannteDrops = {} +-- Erzeugte Prop-Objekte: [id] = Objekt-Handle +local propObjekte = {} + +-- Taste E zum Aufheben +local TASTE_AUFHEBEN = 38 + +-- --------------------------------------------------------------------- +-- Prop erzeugen und entfernen +-- --------------------------------------------------------------------- + +--- Erzeugt den Karton eines Drops in der Welt +--- @param drop table Datensatz vom Server +local function ErzeugeProp(drop) + if propObjekte[drop.id] then return end + + local modell = GetHashKey(drop.prop or 'prop_cardbordbox_03a') + RequestModel(modell) + + local versuche = 0 + while not HasModelLoaded(modell) and versuche < 60 do + Wait(50) + versuche = versuche + 1 + end + if not HasModelLoaded(modell) then return end + + -- Nur lokal erzeugen: Jeder Client setzt seinen eigenen Karton + local objekt = CreateObject(modell, drop.x, drop.y, drop.z, false, false, false) + PlaceObjectOnGroundProperly(objekt) + FreezeEntityPosition(objekt, true) + SetEntityCollision(objekt, false, true) -- nicht wegschiebbar, aber sichtbar + SetModelAsNoLongerNeeded(modell) + + propObjekte[drop.id] = objekt +end + +--- Entfernt den Karton eines Drops wieder +--- @param id number Drop-ID +local function EntferneProp(id) + local objekt = propObjekte[id] + if objekt and DoesEntityExist(objekt) then + DeleteObject(objekt) + end + propObjekte[id] = nil +end + +--- Entfernt alle erzeugten Kartons (beim Stoppen der Ressource) +function Drops.AlleEntfernen() + for id in pairs(propObjekte) do EntferneProp(id) end +end + +-- --------------------------------------------------------------------- +-- Ereignisse vom Server +-- --------------------------------------------------------------------- + +--- Vollstaendige Liste beim Einsteigen ins Spiel +RegisterNetEvent('aether:inventory:dropListe', function(liste) + bekannteDrops = {} + for _, drop in pairs(liste or {}) do + bekannteDrops[drop.id] = drop + end +end) + +--- Ein neues Item wurde fallengelassen +RegisterNetEvent('aether:inventory:dropHinzu', function(drop) + if not drop or not drop.id then return end + bekannteDrops[drop.id] = drop +end) + +--- Ein Item wurde aufgehoben +RegisterNetEvent('aether:inventory:dropWeg', function(id) + bekannteDrops[id] = nil + EntferneProp(id) +end) + +-- --------------------------------------------------------------------- +-- Kartons je nach Entfernung ein- und ausblenden +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(1000) + + local position = GetEntityCoords(PlayerPedId()) + + for id, drop in pairs(bekannteDrops) do + local abstand = #(position - vector3(drop.x, drop.y, drop.z)) + + if abstand <= Inventar.DROP_SPAWN_WEITE then + ErzeugeProp(drop) + elseif propObjekte[id] then + EntferneProp(id) + end + end + + -- Kartons entfernen, deren Drop es nicht mehr gibt + for id in pairs(propObjekte) do + if not bekannteDrops[id] then EntferneProp(id) end + end + end +end) + +-- --------------------------------------------------------------------- +-- Aufheben per Taste E am naechsten Karton +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + local pause = 500 + local position = GetEntityCoords(PlayerPedId()) + + -- Naechstgelegenen Drop in Reichweite suchen + local naechsteId, naechsterAbstand, naechsterDrop = nil, Inventar.DROP_HINWEIS, nil + for id, drop in pairs(bekannteDrops) do + local abstand = #(position - vector3(drop.x, drop.y, drop.z)) + if abstand < naechsterAbstand then + naechsteId, naechsterAbstand, naechsterDrop = id, abstand, drop + end + end + + if naechsteId then + pause = 0 -- fuer die Anzeige jeden Frame laufen + + -- Hinweis ueber dem Karton einblenden + local def = Inventar.Items[naechsterDrop.item] + local beschriftung = def and def.name or naechsterDrop.item + if (naechsterDrop.menge or 1) > 1 then + beschriftung = beschriftung .. ' ×' .. naechsterDrop.menge + end + + SetTextFont(4) + SetTextScale(0.32, 0.32) + SetTextColour(255, 255, 255, 215) + SetTextCentre(true) + SetTextOutline() + SetTextEntry('STRING') + AddTextComponentString('[E] ' .. beschriftung .. ' aufheben') + SetDrawOrigin(naechsterDrop.x, naechsterDrop.y, naechsterDrop.z + 0.6, 0) + DrawText(0.0, 0.0) + ClearDrawOrigin() + + if IsControlJustReleased(0, TASTE_AUFHEBEN) then + TriggerServerEvent('aether:inventory:aufheben', naechsteId) + end + end + + Wait(pause) + end +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen +-- --------------------------------------------------------------------- +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + Drops.AlleEntfernen() +end) diff --git a/resources/[aether]/aether-inventory/client/main.lua b/resources/[aether]/aether-inventory/client/main.lua new file mode 100644 index 0000000..527bb1b --- /dev/null +++ b/resources/[aether]/aether-inventory/client/main.lua @@ -0,0 +1,151 @@ +-- ===================================================================== +-- Aether Inventory — Clientlogik +-- Oeffnet die Oberflaeche und reicht Wuensche an den Server weiter. +-- Es wird bewusst nichts lokal entschieden. +-- ===================================================================== + +-- Ist das Inventar gerade offen? +local istOffen = false +-- Ist der Spieler im Spiel? +local istGeladen = false + +-- Taste zum Oeffnen (I) +local TASTE_INVENTAR = 311 + +--- Schickt eine Nachricht an die Oberflaeche +--- @param nachricht table Daten +local function SendeAnNui(nachricht) + SendNuiMessage(json.encode(nachricht)) +end + +--- Schliesst das Inventar und gibt die Steuerung zurueck +local function Schliessen() + if not istOffen then return end + istOffen = false + SetNuiFocus(false, false) + SendeAnNui({ aktion = 'schliessen' }) + + -- HUD wieder einblenden, falls vorhanden + pcall(function() exports['aether-hud']:SetzeSichtbar(true) end) +end + +-- --------------------------------------------------------------------- +-- Ereignisse vom Core und Server +-- --------------------------------------------------------------------- +AddEventHandler('aether:client:bereit', function() + istGeladen = true +end) + +--- Server schickt den Zustand und moechte, dass wir oeffnen +RegisterNetEvent('aether:inventory:zeige', function(zustand) + istOffen = true + SetNuiFocus(true, true) + + -- HUD ausblenden, solange das Inventar offen ist + pcall(function() exports['aether-hud']:SetzeSichtbar(false) end) + + zustand.aktion = 'oeffnen' + zustand.items = Inventar.Items -- Registry zum Anzeigen mitschicken + zustand.equipSlots = Inventar.EQUIP_SLOTS + zustand.bereiche = Inventar.BEREICH + SendeAnNui(zustand) +end) + +--- Zeigt eine Meldung oben links im Spiel (wenn die Oberflaeche zu ist) +--- @param text string Meldungstext +local function ZeigeSpielmeldung(text) + if not text or text == '' then return end + BeginTextCommandThefeedPost('STRING') + AddTextComponentSubstringPlayerName(text) + EndTextCommandThefeedPostTicker(false, true) +end + +--- Server schickt einen aktualisierten Zustand (nach jeder Aktion) +RegisterNetEvent('aether:inventory:zustand', function(zustand) + if not istOffen then + -- Inventar zu (z. B. Aufheben per Taste E): kurz im Spiel melden + ZeigeSpielmeldung(zustand.hinweis) + return + end + zustand.aktion = 'zustand' + SendeAnNui(zustand) +end) + +--- Kurze Rueckmeldung (z. B. Ablehnung einer Aktion) +RegisterNetEvent('aether:inventory:hinweis', function(text) + if istOffen then + SendeAnNui({ aktion = 'hinweis', text = text }) + else + ZeigeSpielmeldung(text) + end +end) + +--- Heilung durch ein benutztes Item +RegisterNetEvent('aether:inventory:heilen', function(menge) + local ped = PlayerPedId() + local leben = GetEntityHealth(ped) + SetEntityHealth(ped, math.min(200, leben + (menge or 0))) +end) + +-- --------------------------------------------------------------------- +-- Oeffnen per Taste +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(0) + if istGeladen and not istOffen and IsControlJustReleased(0, TASTE_INVENTAR) then + -- Nicht oeffnen, waehrend man tot ist oder schwimmt + local ped = PlayerPedId() + if not IsEntityDead(ped) then + TriggerServerEvent('aether:inventory:oeffnen') + end + end + end +end) + +-- --------------------------------------------------------------------- +-- NUI-Callbacks — jede Aktion geht ungeprueft an den Server, +-- der sie dann validiert. +-- --------------------------------------------------------------------- +RegisterNUICallback('schliessen', function(_, rueckgabe) + Schliessen() + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('verschieben', function(daten, rueckgabe) + TriggerServerEvent('aether:inventory:verschieben', daten.von, daten.nach) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('splitten', function(daten, rueckgabe) + TriggerServerEvent('aether:inventory:splitten', daten.slot, daten.menge) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('fallenlassen', function(daten, rueckgabe) + TriggerServerEvent('aether:inventory:fallenlassen', daten.slot, daten.menge) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('aufheben', function(daten, rueckgabe) + TriggerServerEvent('aether:inventory:aufheben', daten.bodenId) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('geben', function(daten, rueckgabe) + TriggerServerEvent('aether:inventory:geben', daten.slot, daten.menge, daten.zielId) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('benutzen', function(daten, rueckgabe) + TriggerServerEvent('aether:inventory:benutzen', daten.slot) + rueckgabe({ ok = true }) +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen +-- --------------------------------------------------------------------- +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + SetNuiFocus(false, false) +end) diff --git a/resources/[aether]/aether-inventory/fxmanifest.lua b/resources/[aether]/aether-inventory/fxmanifest.lua new file mode 100644 index 0000000..fae7d74 --- /dev/null +++ b/resources/[aether]/aether-inventory/fxmanifest.lua @@ -0,0 +1,38 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +name 'aether-inventory' +author 'Jerrit Fritzsche' +description 'Server-geprüftes Inventar mit Drag & Drop, Gewicht und Boden-Items' +version '0.1.0' + +ui_page 'html/index.html' + +files { + 'html/index.html', +} + +-- Item-Registry teilen sich Client und Server. +-- Core-Dateien mitladen, da jede Resource einen eigenen Lua-State hat. +shared_scripts { + '@aether-core/shared/config.lua', + '@aether-core/shared/utils.lua', + 'shared/items.lua', +} + +client_scripts { + 'client/drops.lua', + 'client/main.lua', +} + +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/inventar.lua', + 'server/main.lua', +} + +dependencies { + 'aether-core', + 'oxmysql', +} diff --git a/resources/[aether]/aether-inventory/html/index.html b/resources/[aether]/aether-inventory/html/index.html new file mode 100644 index 0000000..0a00aa6 --- /dev/null +++ b/resources/[aether]/aether-inventory/html/index.html @@ -0,0 +1,772 @@ + + + + + + + + +Aether — Inventar + + +
+
+ +
InventarMax Weber · Rucksack
+
+
+
0 / 30 kg
+
+
+ +
+ +
+
Spieler in der Nähe
+
+
Boden
+
+
+ + +
+
Tasche
+
+
+ + +
+
Charakter & Ausrüstung
+
+
+ + + + + + + + + + + + +
Helm
+
Schmuck
+
Brust
+
Hose
+
Schuhe
+
+
Schnellzugriff (Hotbar)
+
+
+
+ + +
+
+
+
+ +
+ Ziehen zum Verschieben · auf Boden = fallenlassen · auf Spieler = geben · Rechtsklick für Menü & Splitten · Hover für Details +
+ +
+ + + + + diff --git a/resources/[aether]/aether-inventory/server/inventar.lua b/resources/[aether]/aether-inventory/server/inventar.lua new file mode 100644 index 0000000..fbd5dbb --- /dev/null +++ b/resources/[aether]/aether-inventory/server/inventar.lua @@ -0,0 +1,369 @@ +-- ===================================================================== +-- Aether Inventory — Datenschicht +-- Haelt die Inventare aktiver Spieler im Speicher und schreibt sie +-- in die Datenbank zurueck. Enthaelt keine Ereignisse — nur Logik. +-- ===================================================================== + +Lager = {} + +-- Inventare aktiver Charaktere: [charakterId] = { [slot] = {item, menge, meta} } +local inventare = {} + +-- Items am Boden: [bodenId] = { id, item, menge, meta, x, y, z, prop } +-- Die IDs kommen aus der Datenbank, damit Drops Neustarts ueberleben. +local bodenItems = {} + +-- --------------------------------------------------------------------- +-- Laden und Speichern +-- --------------------------------------------------------------------- + +--- Laedt das Inventar eines Charakters aus der Datenbank +--- @param charakterId number Charakter-ID +--- @return table Inventar (slot -> Eintrag) +function Lager.Laden(charakterId) + local zeilen = MySQL.query.await( + 'SELECT slot, item, amount, metadata FROM aether_inventory WHERE owner_type = ? AND owner_id = ?', + { 'character', tostring(charakterId) }) or {} + + local inventar = {} + for _, zeile in ipairs(zeilen) do + -- Unbekannte Items ueberspringen (z. B. nach Entfernen aus der Registry) + if Inventar.Items[zeile.item] then + local meta = nil + if zeile.metadata then + local erfolg, entschluesselt = pcall(json.decode, zeile.metadata) + if erfolg then meta = entschluesselt end + end + inventar[zeile.slot] = { item = zeile.item, menge = zeile.amount, meta = meta } + end + end + + inventare[charakterId] = inventar + return inventar +end + +--- Schreibt das Inventar eines Charakters komplett zurueck +--- @param charakterId number Charakter-ID +function Lager.Speichern(charakterId) + local inventar = inventare[charakterId] + if not inventar then return end + + -- Einfach und sicher: alten Stand loeschen, aktuellen neu schreiben + MySQL.query.await('DELETE FROM aether_inventory WHERE owner_type = ? AND owner_id = ?', + { 'character', tostring(charakterId) }) + + local werte = {} + for slot, eintrag in pairs(inventar) do + werte[#werte + 1] = { + 'character', tostring(charakterId), slot, eintrag.item, eintrag.menge, + eintrag.meta and json.encode(eintrag.meta) or nil, + } + end + + if #werte > 0 then + MySQL.prepare.await( + 'INSERT INTO aether_inventory (owner_type, owner_id, slot, item, amount, metadata) VALUES (?, ?, ?, ?, ?, ?)', + werte) + end +end + +--- Entfernt ein Inventar aus dem Speicher (nach dem Speichern) +--- @param charakterId number Charakter-ID +function Lager.Entladen(charakterId) + inventare[charakterId] = nil +end + +--- Legt die Startausruestung fuer einen neuen Charakter an +--- @param charakterId number Charakter-ID +function Lager.ErstelleStart(charakterId) + local inventar = {} + for _, eintrag in ipairs(Inventar.StartItems) do + inventar[eintrag.slot] = { item = eintrag.item, menge = eintrag.menge, meta = nil } + end + inventare[charakterId] = inventar + Lager.Speichern(charakterId) +end + +-- --------------------------------------------------------------------- +-- Zugriff +-- --------------------------------------------------------------------- + +--- Liefert das Inventar eines Charakters (oder nil) +--- @param charakterId number Charakter-ID +--- @return table|nil +function Lager.Hole(charakterId) + return inventare[charakterId] +end + +--- Berechnet das getragene Gesamtgewicht +--- @param charakterId number Charakter-ID +--- @return number Gewicht in Kilogramm +function Lager.Gewicht(charakterId) + local inventar = inventare[charakterId] + if not inventar then return 0 end + + local gewicht = 0 + for _, eintrag in pairs(inventar) do + local def = Inventar.Items[eintrag.item] + if def then gewicht = gewicht + (def.gewicht or 0) * eintrag.menge end + end + return gewicht +end + +--- Sucht den ersten freien Platz in der Tasche +--- @param charakterId number Charakter-ID +--- @return number|nil Slotnummer oder nil, wenn voll +function Lager.FreierSlot(charakterId) + local inventar = inventare[charakterId] + if not inventar then return nil end + + for slot = Inventar.BEREICH.tasche.von, Inventar.BEREICH.tasche.bis do + if not inventar[slot] then return slot end + end + return nil +end + +--- Prueft, ob eine zusaetzliche Menge noch getragen werden kann +--- @param charakterId number Charakter-ID +--- @param kennung string Item-Kennung +--- @param menge number Stueckzahl +--- @return boolean +function Lager.PasstNoch(charakterId, kennung, menge) + local def = Inventar.Items[kennung] + if not def then return false end + local zusatz = (def.gewicht or 0) * menge + return (Lager.Gewicht(charakterId) + zusatz) <= Inventar.MAX_GEWICHT +end + +-- --------------------------------------------------------------------- +-- Aenderungen am Inventar +-- --------------------------------------------------------------------- + +--- Fuegt ein Item hinzu (stapelt, wenn moeglich) +--- @param charakterId number Charakter-ID +--- @param kennung string Item-Kennung +--- @param menge number Stueckzahl +--- @param meta table|nil Zusatzdaten +--- @return boolean Erfolg, string|nil Fehlergrund +function Lager.Hinzufuegen(charakterId, kennung, menge, meta) + local inventar = inventare[charakterId] + local def = Inventar.Items[kennung] + if not inventar or not def then return false, 'Unbekanntes Item' end + if not Lager.PasstNoch(charakterId, kennung, menge) then return false, 'Zu schwer' end + + -- Stapelbare Items ohne Zusatzdaten auf vorhandene Stapel legen + if def.stapelbar and not meta then + for slot = Inventar.BEREICH.tasche.von, Inventar.BEREICH.tasche.bis do + local eintrag = inventar[slot] + if eintrag and eintrag.item == kennung and not eintrag.meta then + eintrag.menge = eintrag.menge + menge + return true + end + end + end + + local frei = Lager.FreierSlot(charakterId) + if not frei then return false, 'Kein Platz frei' end + + inventar[frei] = { item = kennung, menge = menge, meta = meta } + return true +end + +--- Entfernt eine Menge aus einem bestimmten Slot +--- @param charakterId number Charakter-ID +--- @param slot number Slotnummer +--- @param menge number Stueckzahl +--- @return table|nil Entfernter Eintrag {item, menge, meta} oder nil +function Lager.EntferneAusSlot(charakterId, slot, menge) + local inventar = inventare[charakterId] + if not inventar then return nil end + + local eintrag = inventar[slot] + if not eintrag then return nil end + + menge = math.min(menge or eintrag.menge, eintrag.menge) + if menge <= 0 then return nil end + + local entfernt = { item = eintrag.item, menge = menge, meta = eintrag.meta } + + if menge >= eintrag.menge then + inventar[slot] = nil + else + eintrag.menge = eintrag.menge - menge + end + + return entfernt +end + +--- Verschiebt oder tauscht zwei Slots (inkl. Stapeln) +--- @param charakterId number Charakter-ID +--- @param vonSlot number Quelle +--- @param nachSlot number Ziel +--- @return boolean Erfolg, string|nil Fehlergrund +function Lager.Verschieben(charakterId, vonSlot, nachSlot) + local inventar = inventare[charakterId] + if not inventar or vonSlot == nachSlot then return false end + + local quelle = inventar[vonSlot] + if not quelle then return false, 'Kein Item' end + + local quellDef = Inventar.Items[quelle.item] + local ziel = inventar[nachSlot] + + -- Ausruestungs-Slot: nur passende Items zulassen + local zielSchluessel = Inventar.EquipSchluessel(nachSlot) + if zielSchluessel and quellDef.slot ~= zielSchluessel then + return false, 'Passt nicht in diesen Ausrüstungsplatz' + end + + -- Beim Tauschen muss auch das Zielitem in die Quelle passen + local quellSchluessel = Inventar.EquipSchluessel(vonSlot) + if ziel and quellSchluessel then + local zielDef = Inventar.Items[ziel.item] + if zielDef.slot ~= quellSchluessel then + return false, 'Tauschen nicht möglich' + end + end + + -- Stapeln, wenn identisches stapelbares Item ohne Zusatzdaten + if ziel and ziel.item == quelle.item and quellDef.stapelbar + and not quelle.meta and not ziel.meta then + ziel.menge = ziel.menge + quelle.menge + inventar[vonSlot] = nil + return true + end + + -- Verschieben oder Tauschen + inventar[nachSlot] = quelle + inventar[vonSlot] = ziel -- nil, wenn das Ziel leer war + return true +end + +--- Teilt einen Stapel auf einen freien Taschenplatz auf +--- @param charakterId number Charakter-ID +--- @param slot number Quellslot +--- @param menge number Abzuspaltende Menge +--- @return boolean Erfolg, string|nil Fehlergrund +function Lager.Splitten(charakterId, slot, menge) + local inventar = inventare[charakterId] + if not inventar then return false end + + local eintrag = inventar[slot] + if not eintrag then return false, 'Kein Item' end + if menge < 1 or menge >= eintrag.menge then return false, 'Ungültige Menge' end + + local frei = Lager.FreierSlot(charakterId) + if not frei then return false, 'Kein Platz frei' end + + eintrag.menge = eintrag.menge - menge + inventar[frei] = { item = eintrag.item, menge = menge, meta = eintrag.meta } + return true +end + +-- --------------------------------------------------------------------- +-- Items am Boden +-- --------------------------------------------------------------------- + +--- Laedt alle fallengelassenen Items aus der Datenbank (beim Serverstart) +--- und raeumt dabei zu alte Drops auf. +--- @return table Alle geladenen Drops +function Lager.LadeBoden() + -- Alte Drops entfernen, damit die Welt nicht zumuellt + if Inventar.DROP_MAX_STUNDEN and Inventar.DROP_MAX_STUNDEN > 0 then + MySQL.query.await( + 'DELETE FROM aether_drops WHERE created_at < DATE_SUB(NOW(), INTERVAL ? HOUR)', + { Inventar.DROP_MAX_STUNDEN }) + end + + local zeilen = MySQL.query.await( + 'SELECT id, item, amount, metadata, x, y, z, prop FROM aether_drops') or {} + + bodenItems = {} + for _, zeile in ipairs(zeilen) do + -- Items, die es nicht mehr gibt, werden uebersprungen + if Inventar.Items[zeile.item] then + local meta = nil + if zeile.metadata then + local erfolg, entschluesselt = pcall(json.decode, zeile.metadata) + if erfolg then meta = entschluesselt end + end + bodenItems[zeile.id] = { + id = zeile.id, item = zeile.item, menge = zeile.amount, meta = meta, + x = zeile.x, y = zeile.y, z = zeile.z, prop = zeile.prop, + } + end + end + + return bodenItems +end + +--- Liefert alle bekannten Boden-Items (fuer die Uebertragung an Clients) +--- @return table +function Lager.AlleBodenItems() + return bodenItems +end + +--- Legt ein Item an einer Position auf den Boden und speichert es dauerhaft +--- @param eintrag table {item, menge, meta} +--- @param koordinaten table {x, y, z} +--- @return table|nil Datensatz mit id und prop +function Lager.AufBodenLegen(eintrag, koordinaten) + -- Zufaelligen Karton waehlen, damit alle Drops einheitlich aussehen + local prop = Inventar.DROP_PROPS[math.random(#Inventar.DROP_PROPS)] + + local id = MySQL.insert.await([[ + INSERT INTO aether_drops (item, amount, metadata, x, y, z, prop) + VALUES (?, ?, ?, ?, ?, ?, ?) + ]], { + eintrag.item, eintrag.menge, + eintrag.meta and json.encode(eintrag.meta) or nil, + koordinaten.x, koordinaten.y, koordinaten.z, prop, + }) + + if not id then return nil end + + local datensatz = { + id = id, item = eintrag.item, menge = eintrag.menge, meta = eintrag.meta, + x = koordinaten.x, y = koordinaten.y, z = koordinaten.z, prop = prop, + } + bodenItems[id] = datensatz + return datensatz +end + +--- Nimmt ein Item vom Boden (und loescht es dauerhaft) +--- @param bodenId number Boden-ID +--- @return table|nil Eintrag oder nil +function Lager.VomBodenNehmen(bodenId) + local eintrag = bodenItems[bodenId] + if not eintrag then return nil end + + bodenItems[bodenId] = nil + MySQL.query('DELETE FROM aether_drops WHERE id = ?', { bodenId }) + return eintrag +end + +--- Liefert ein Item am Boden, ohne es zu entfernen +--- @param bodenId number Boden-ID +--- @return table|nil +function Lager.HoleBodenItem(bodenId) + return bodenItems[bodenId] +end + +--- Liefert alle Boden-Items in Reichweite einer Position +--- @param koordinaten table {x, y, z} +--- @param weite number Sichtweite in Metern +--- @return table Liste mit {id, item, menge, meta} +function Lager.BodenInDerNaehe(koordinaten, weite) + local liste = {} + for id, eintrag in pairs(bodenItems) do + local dx = eintrag.x - koordinaten.x + local dy = eintrag.y - koordinaten.y + local dz = eintrag.z - koordinaten.z + if (dx * dx + dy * dy + dz * dz) <= (weite * weite) then + liste[#liste + 1] = { + id = id, item = eintrag.item, menge = eintrag.menge, meta = eintrag.meta, + } + end + end + return liste +end diff --git a/resources/[aether]/aether-inventory/server/main.lua b/resources/[aether]/aether-inventory/server/main.lua new file mode 100644 index 0000000..5a6c4cc --- /dev/null +++ b/resources/[aether]/aether-inventory/server/main.lua @@ -0,0 +1,403 @@ +-- ===================================================================== +-- Aether Inventory — Serverlogik +-- Jede Bewegung wird hier geprueft. Der Client sagt nur, was er moechte; +-- entschieden wird ausschliesslich auf dem Server. +-- ===================================================================== + +local Core = exports['aether-core'] + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Liefert die Charakter-ID eines Spielers +--- @param quelle number Server-ID +--- @return number|nil +local function CharakterVon(quelle) + return Core:HoleCharakterId(quelle) +end + +--- Prueft, ob eine Slotnummer ueberhaupt gueltig ist +--- @param slot any Zu pruefender Wert +--- @return boolean +local function SlotGueltig(slot) + if type(slot) ~= 'number' or slot ~= math.floor(slot) then return false end + return Inventar.BereichVon(slot) ~= nil +end + +--- Baut den Zustand, den die Oberflaeche zum Anzeigen braucht +--- @param quelle number Server-ID +--- @param charakterId number Charakter-ID +--- @return table +local function BaueZustand(quelle, charakterId) + local koordinaten = GetEntityCoords(GetPlayerPed(quelle)) + local position = { x = koordinaten.x, y = koordinaten.y, z = koordinaten.z } + + -- Spieler in Reichweite (anonym, solange nicht bekannt) + local nahe = {} + for _, andereQuelle in ipairs(GetPlayers()) do + andereQuelle = tonumber(andereQuelle) + if andereQuelle ~= quelle then + local andererPed = GetPlayerPed(andereQuelle) + local andereKoordinaten = GetEntityCoords(andererPed) + if #(koordinaten - andereKoordinaten) <= Inventar.GEBEN_WEITE then + nahe[#nahe + 1] = { + id = andereQuelle, + -- Namen bewusst nicht mitschicken: Im Rollenspiel kennt man + -- eine Person erst nach Ausweis/Visitenkarte. + bekannt = false, + } + end + end + end + + return { + inventar = Lager.Hole(charakterId) or {}, + gewicht = Lager.Gewicht(charakterId), + maxGewicht = Inventar.MAX_GEWICHT, + boden = Lager.BodenInDerNaehe(position, Inventar.BODEN_SICHT), + spieler = nahe, + } +end + +--- Schickt den aktuellen Zustand an einen Spieler +--- @param quelle number Server-ID +--- @param hinweis string|nil Kurze Rueckmeldung +local function SendeZustand(quelle, hinweis) + local charakterId = CharakterVon(quelle) + if not charakterId then return end + + local zustand = BaueZustand(quelle, charakterId) + zustand.hinweis = hinweis + TriggerClientEvent('aether:inventory:zustand', quelle, zustand) +end + +--- Meldet dem Spieler, dass eine Aktion nicht moeglich war +--- @param quelle number Server-ID +--- @param grund string Begruendung +local function Ablehnen(quelle, grund) + TriggerClientEvent('aether:inventory:hinweis', quelle, grund or 'Nicht möglich') +end + +-- --------------------------------------------------------------------- +-- Fallengelassene Items beim Serverstart laden +-- --------------------------------------------------------------------- +CreateThread(function() + Wait(1000) -- kurz warten, bis die Datenbankverbindung steht + local drops = Lager.LadeBoden() + + local anzahl = 0 + for _ in pairs(drops) do anzahl = anzahl + 1 end + Aether.Info(('%d fallengelassene Items geladen.'):format(anzahl)) +end) + +-- --------------------------------------------------------------------- +-- Laden und Speichern im Spielerlebenszyklus +-- --------------------------------------------------------------------- + +--- Spieler wurde geladen: Inventar aus der Datenbank holen +AddEventHandler('aether:server:spielerGeladen', function(quelle, spieler) + local charakterId = spieler.charakterId + + local vorhanden = MySQL.scalar.await( + 'SELECT COUNT(*) FROM aether_inventory WHERE owner_type = ? AND owner_id = ?', + { 'character', tostring(charakterId) }) + + if (vorhanden or 0) == 0 then + -- Frischer Charakter: Startausruestung anlegen + Lager.ErstelleStart(charakterId) + Aether.Info(('Startausruestung fuer Charakter #%d angelegt.'):format(charakterId)) + else + Lager.Laden(charakterId) + end + + -- Alle bekannten Boden-Items schicken, damit der Client die Kartons setzt + TriggerClientEvent('aether:inventory:dropListe', quelle, Lager.AlleBodenItems()) +end) + +--- Spieler hat den Server verlassen: Inventar sichern +AddEventHandler('aether:server:spielerEntfernt', function(quelle, spieler) + Lager.Speichern(spieler.charakterId) + Lager.Entladen(spieler.charakterId) +end) + +--- Beim Stoppen der Ressource alle Inventare sichern +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + for _, quelle in ipairs(GetPlayers()) do + local charakterId = CharakterVon(tonumber(quelle)) + if charakterId then Lager.Speichern(charakterId) end + end +end) + +-- --------------------------------------------------------------------- +-- Oeffnen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:inventory:oeffnen', function() + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + + -- Sicherstellen, dass das Inventar geladen ist + if not Lager.Hole(charakterId) then Lager.Laden(charakterId) end + + TriggerClientEvent('aether:inventory:zeige', quelle, BaueZustand(quelle, charakterId)) +end) + +-- --------------------------------------------------------------------- +-- Verschieben / Tauschen / Stapeln +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:inventory:verschieben', function(vonSlot, nachSlot) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + if not SlotGueltig(vonSlot) or not SlotGueltig(nachSlot) then return end + + local erfolg, grund = Lager.Verschieben(charakterId, vonSlot, nachSlot) + if not erfolg and grund then Ablehnen(quelle, grund) end + SendeZustand(quelle) +end) + +-- --------------------------------------------------------------------- +-- Stapel teilen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:inventory:splitten', function(slot, menge) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + if not SlotGueltig(slot) or type(menge) ~= 'number' then return end + + local erfolg, grund = Lager.Splitten(charakterId, slot, math.floor(menge)) + if not erfolg and grund then Ablehnen(quelle, grund) end + SendeZustand(quelle) +end) + +-- --------------------------------------------------------------------- +-- Fallenlassen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:inventory:fallenlassen', function(slot, menge) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + if not SlotGueltig(slot) then return end + + local entfernt = Lager.EntferneAusSlot(charakterId, slot, math.floor(menge or 0)) + if not entfernt then Ablehnen(quelle, 'Kein Item') return end + + local koordinaten = GetEntityCoords(GetPlayerPed(quelle)) + local datensatz = Lager.AufBodenLegen(entfernt, + { x = koordinaten.x, y = koordinaten.y, z = koordinaten.z }) + + if not datensatz then + -- Speichern fehlgeschlagen: Item zurueckgeben, damit es nicht verschwindet + Lager.Hinzufuegen(charakterId, entfernt.item, entfernt.menge, entfernt.meta) + Ablehnen(quelle, 'Fallenlassen fehlgeschlagen') + SendeZustand(quelle) + return + end + + -- Alle Clients erzeugen den Karton an dieser Stelle + TriggerClientEvent('aether:inventory:dropHinzu', -1, datensatz) + + local def = Inventar.Items[entfernt.item] + SendeZustand(quelle, (def and def.name or entfernt.item) .. ' fallengelassen') +end) + +-- --------------------------------------------------------------------- +-- Aufheben +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:inventory:aufheben', function(bodenId) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId or type(bodenId) ~= 'number' then return end + + local amBoden = Lager.HoleBodenItem(bodenId) + if not amBoden then Ablehnen(quelle, 'Item ist nicht mehr da') return end + + -- Entfernungspruefung: niemand darf quer über die Karte aufheben + local koordinaten = GetEntityCoords(GetPlayerPed(quelle)) + local abstand = #(koordinaten - vector3(amBoden.x, amBoden.y, amBoden.z)) + if abstand > Inventar.AUFHEB_WEITE then + Ablehnen(quelle, 'Zu weit entfernt') + return + end + + if not Lager.PasstNoch(charakterId, amBoden.item, amBoden.menge) then + Ablehnen(quelle, 'Zu schwer') + return + end + + -- Erst jetzt vom Boden nehmen, damit es bei Ablehnung liegen bleibt + local genommen = Lager.VomBodenNehmen(bodenId) + if not genommen then Ablehnen(quelle, 'Item ist nicht mehr da') return end + + -- Karton bei allen Clients entfernen + TriggerClientEvent('aether:inventory:dropWeg', -1, bodenId) + + local erfolg, grund = Lager.Hinzufuegen(charakterId, genommen.item, genommen.menge, genommen.meta) + if not erfolg then + -- Zurueck auf den Boden legen, damit nichts verloren geht + local neu = Lager.AufBodenLegen(genommen, { x = amBoden.x, y = amBoden.y, z = amBoden.z }) + if neu then TriggerClientEvent('aether:inventory:dropHinzu', -1, neu) end + Ablehnen(quelle, grund or 'Aufheben nicht möglich') + return + end + + local def = Inventar.Items[genommen.item] + SendeZustand(quelle, (def and def.name or genommen.item) .. ' aufgehoben') +end) + +-- --------------------------------------------------------------------- +-- Geben +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:inventory:geben', function(slot, menge, zielQuelle) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + if not SlotGueltig(slot) or type(zielQuelle) ~= 'number' then return end + if zielQuelle == quelle then return end + + local zielCharakter = CharakterVon(zielQuelle) + if not zielCharakter then Ablehnen(quelle, 'Person nicht erreichbar') return end + + -- Entfernungspruefung + local hier = GetEntityCoords(GetPlayerPed(quelle)) + local dort = GetEntityCoords(GetPlayerPed(zielQuelle)) + if #(hier - dort) > Inventar.GEBEN_WEITE then + Ablehnen(quelle, 'Zu weit entfernt') + return + end + + -- Erst pruefen, ob der Empfaenger es tragen kann + local inventar = Lager.Hole(charakterId) + local eintrag = inventar and inventar[slot] + if not eintrag then Ablehnen(quelle, 'Kein Item') return end + + local abgabe = math.min(math.floor(menge or eintrag.menge), eintrag.menge) + if abgabe < 1 then return end + + if not Lager.PasstNoch(zielCharakter, eintrag.item, abgabe) then + Ablehnen(quelle, 'Die Person kann das nicht tragen') + return + end + + local entfernt = Lager.EntferneAusSlot(charakterId, slot, abgabe) + if not entfernt then return end + + local erfolg = Lager.Hinzufuegen(zielCharakter, entfernt.item, entfernt.menge, entfernt.meta) + if not erfolg then + -- Rueckgabe an den Absender, damit nichts verschwindet + Lager.Hinzufuegen(charakterId, entfernt.item, entfernt.menge, entfernt.meta) + Ablehnen(quelle, 'Übergabe fehlgeschlagen') + SendeZustand(quelle) + return + end + + local def = Inventar.Items[entfernt.item] + local bezeichnung = def and def.name or entfernt.item + SendeZustand(quelle, bezeichnung .. ' übergeben') + TriggerClientEvent('aether:inventory:hinweis', zielQuelle, + ('Du hast %s ×%d erhalten'):format(bezeichnung, entfernt.menge)) + SendeZustand(zielQuelle) +end) + +-- --------------------------------------------------------------------- +-- Benutzen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:inventory:benutzen', function(slot) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId or not SlotGueltig(slot) then return end + + local inventar = Lager.Hole(charakterId) + local eintrag = inventar and inventar[slot] + if not eintrag then return end + + local def = Inventar.Items[eintrag.item] + if not def or not def.nutzbar then Ablehnen(quelle, 'Das lässt sich nicht benutzen') return end + + -- Wirkung anwenden + if def.wirkung then + if def.wirkung.art == 'leben' then + TriggerClientEvent('aether:inventory:heilen', quelle, def.wirkung.menge) + else + Core:AendereZustand(quelle, def.wirkung.art, def.wirkung.menge) + end + end + + -- Verbrauchbare Items aufbrauchen + if def.verbrauchbar then + Lager.EntferneAusSlot(charakterId, slot, 1) + end + + -- Andere Module informieren (z. B. Handy oeffnen) + TriggerEvent('aether:inventory:itemBenutzt', quelle, eintrag.item, eintrag.meta) + TriggerClientEvent('aether:inventory:itemBenutzt', quelle, eintrag.item, eintrag.meta) + + SendeZustand(quelle, def.name .. ' benutzt') +end) + +-- ===================================================================== +-- EXPORTS — Schnittstelle fuer andere Module (Shop, Jobs, Dokumente ...) +-- ===================================================================== + +--- Gibt einem Spieler ein Item +exports('GebeItem', function(quelle, kennung, menge, meta) + local charakterId = CharakterVon(quelle) + if not charakterId then return false end + local erfolg = Lager.Hinzufuegen(charakterId, kennung, menge or 1, meta) + if erfolg then SendeZustand(quelle) end + return erfolg +end) + +--- Prueft, ob ein Spieler eine zusaetzliche Menge noch tragen kann +--- @param quelle number Server-ID +--- @param kennung string Item-Kennung +--- @param menge number Stueckzahl +--- @return boolean +exports('PasstNoch', function(quelle, kennung, menge) + local charakterId = CharakterVon(quelle) + if not charakterId then return false end + return Lager.PasstNoch(charakterId, kennung, menge or 1) +end) + +--- Zaehlt, wie viele Stueck eines Items ein Spieler besitzt +exports('ZaehleItem', function(quelle, kennung) + local charakterId = CharakterVon(quelle) + local inventar = charakterId and Lager.Hole(charakterId) + if not inventar then return 0 end + + local summe = 0 + for _, eintrag in pairs(inventar) do + if eintrag.item == kennung then summe = summe + eintrag.menge end + end + return summe +end) + +--- Entfernt eine Menge eines Items (ueber beliebige Slots verteilt) +exports('EntferneItem', function(quelle, kennung, menge) + local charakterId = CharakterVon(quelle) + local inventar = charakterId and Lager.Hole(charakterId) + if not inventar then return false end + + -- Erst pruefen, ob genug vorhanden ist + local vorhanden = 0 + for _, eintrag in pairs(inventar) do + if eintrag.item == kennung then vorhanden = vorhanden + eintrag.menge end + end + if vorhanden < menge then return false end + + -- Dann abziehen + local offen = menge + for slot, eintrag in pairs(inventar) do + if offen <= 0 then break end + if eintrag.item == kennung then + local abzug = math.min(offen, eintrag.menge) + Lager.EntferneAusSlot(charakterId, slot, abzug) + offen = offen - abzug + end + end + + SendeZustand(quelle) + return true +end) diff --git a/resources/[aether]/aether-inventory/shared/items.lua b/resources/[aether]/aether-inventory/shared/items.lua new file mode 100644 index 0000000..e992058 --- /dev/null +++ b/resources/[aether]/aether-inventory/shared/items.lua @@ -0,0 +1,264 @@ +-- ===================================================================== +-- Aether Inventory — Item-Registry und Slot-Aufteilung +-- Wird auf Client und Server geladen. Der Server rechnet damit +-- (Gewicht, Stapelbarkeit), der Client zeigt sie nur an. +-- ===================================================================== + +Inventar = Inventar or {} + +-- --------------------------------------------------------------------- +-- Grenzen +-- --------------------------------------------------------------------- +Inventar.MAX_GEWICHT = 30.0 -- Traglast in Kilogramm +Inventar.TASCHE_SLOTS = 25 -- Plaetze in der Tasche +Inventar.HOTBAR_SLOTS = 5 -- Plaetze in der Schnellleiste +Inventar.AUFHEB_WEITE = 3.0 -- Reichweite zum Aufheben (Meter) +Inventar.GEBEN_WEITE = 3.0 -- Reichweite zum Geben (Meter) +Inventar.BODEN_SICHT = 15.0 -- Sichtweite fuer Items in der Inventarliste (Meter) + +-- --------------------------------------------------------------------- +-- Fallengelassene Items (sichtbare Kartons in der Welt) +-- --------------------------------------------------------------------- +-- Einheitliche Props: Beim Fallenlassen wird zufaellig einer davon gewaehlt +Inventar.DROP_PROPS = { + 'prop_cardbordbox_03a', + 'prop_cardbordbox_04a', + 'prop_cardbordbox_05a', +} + +Inventar.DROP_SPAWN_WEITE = 30.0 -- Ab dieser Entfernung wird der Karton erzeugt +Inventar.DROP_HINWEIS = 1.8 -- Entfernung, ab der „E zum Aufheben" erscheint +Inventar.DROP_MAX_STUNDEN = 48 -- Nach dieser Zeit werden Drops aufgeraeumt (0 = nie) + +-- --------------------------------------------------------------------- +-- Slot-Aufteilung +-- Alles liegt in einer Tabelle; die Slotnummer bestimmt den Bereich. +-- --------------------------------------------------------------------- +Inventar.BEREICH = { + tasche = { von = 0, bis = 24 }, -- 0–24 + hotbar = { von = 100, bis = 104 }, -- 100–104 + equip = { von = 200, bis = 204 }, -- 200–204 +} + +-- Zuordnung der Ausruestungs-Slots zu ihren Slotnummern +Inventar.EQUIP_SLOTS = { + helm = 200, + brust = 201, + hose = 202, + schuhe = 203, + schmuck = 204, +} + +--- Liefert den Bereichsnamen zu einer Slotnummer +--- @param slot number Slotnummer +--- @return string|nil 'tasche' | 'hotbar' | 'equip' +function Inventar.BereichVon(slot) + for name, bereich in pairs(Inventar.BEREICH) do + if slot >= bereich.von and slot <= bereich.bis then return name end + end + return nil +end + +--- Liefert den Ausruestungs-Schluessel zu einer Slotnummer (z. B. 'brust') +--- @param slot number Slotnummer +--- @return string|nil +function Inventar.EquipSchluessel(slot) + for schluessel, nummer in pairs(Inventar.EQUIP_SLOTS) do + if nummer == slot then return schluessel end + end + return nil +end + +-- --------------------------------------------------------------------- +-- Item-Registry +-- gewicht = Kilogramm pro Stueck +-- stapelbar = mehrere Stueck in einem Slot +-- slot = passt nur in diesen Ausruestungs-Slot +-- nutzbar = kann benutzt werden +-- wirkung = was beim Benutzen passiert (vom Server ausgewertet) +-- --------------------------------------------------------------------- +Inventar.Items = { + pistole = { + name = 'Pistole P99', icon = '🔫', kat = 'Waffe', gewicht = 1.2, rar = 'epic', + waffe = true, beschreibung = 'Halbautomatische 9mm-Pistole. Registrierte Feuerwaffe.', + }, + munition9 = { + name = '9mm Munition', icon = '🧨', kat = 'Munition', gewicht = 0.01, rar = 'common', + stapelbar = true, beschreibung = 'Standard-Patronen für 9mm-Waffen.', + }, + handy = { + name = 'Smartphone', icon = '📱', kat = 'Elektronik', gewicht = 0.3, rar = 'rare', + komponenten = { 'SIM-Karte', 'Akku' }, nutzbar = true, + beschreibung = 'Modernes Smartphone. Enthält verbaute Komponenten.', + }, + wasser = { + name = 'Wasserflasche', icon = '🥤', kat = 'Nahrung', gewicht = 0.5, rar = 'common', + stapelbar = true, verbrauchbar = true, nutzbar = true, + wirkung = { art = 'durst', menge = 25 }, + beschreibung = 'Löscht den Durst. +25 Durst.', + }, + sandwich = { + name = 'Sandwich', icon = '🥪', kat = 'Nahrung', gewicht = 0.2, rar = 'common', + stapelbar = true, verbrauchbar = true, nutzbar = true, + wirkung = { art = 'hunger', menge = 30 }, + beschreibung = 'Stillt den Hunger. +30 Hunger.', + }, + verband = { + name = 'Verband', icon = '🩹', kat = 'Medizin', gewicht = 0.1, rar = 'uncommon', + stapelbar = true, verbrauchbar = true, nutzbar = true, + wirkung = { art = 'leben', menge = 25 }, + beschreibung = 'Stoppt Blutungen und heilt leichte Wunden.', + }, + tablette = { + name = 'Schmerztabletten', icon = '💊', kat = 'Medizin', gewicht = 0.02, rar = 'common', + stapelbar = true, verbrauchbar = true, nutzbar = true, + wirkung = { art = 'leben', menge = 10 }, + beschreibung = 'Lindert Schmerzen kurzfristig.', + }, + funk = { + name = 'Funkgerät', icon = '📻', kat = 'Elektronik', gewicht = 0.4, rar = 'uncommon', + nutzbar = true, beschreibung = 'Kurzstrecken-Funk für die Kommunikation.', + }, + dietrich = { + name = 'Dietrich', icon = '🗝️', kat = 'Werkzeug', gewicht = 0.05, rar = 'uncommon', + stapelbar = true, beschreibung = 'Zum Öffnen einfacher Schlösser. Kann brechen.', + }, + reparaturkit = { + name = 'Reparaturkit', icon = '🧰', kat = 'Werkzeug', gewicht = 2.0, rar = 'uncommon', + nutzbar = true, beschreibung = 'Repariert Fahrzeuge notdürftig.', + }, + bargeld = { + name = 'Bargeld', icon = '💵', kat = 'Wertsache', gewicht = 0, rar = 'legendary', + stapelbar = true, beschreibung = 'Sauberes Bargeld.', + }, + goldkette = { + name = 'Goldkette', icon = '📿', kat = 'Schmuck', gewicht = 0.15, rar = 'epic', + slot = 'schmuck', beschreibung = 'Massive Goldkette. Ein echter Hingucker.', + }, + helm = { + name = 'Ballistikhelm', icon = '⛑️', kat = 'Ausrüstung', gewicht = 1.0, rar = 'uncommon', + slot = 'helm', beschreibung = 'Schützt den Kopf vor Treffern.', + }, + weste = { + name = 'Schutzweste', icon = '🦺', kat = 'Ausrüstung', gewicht = 3.5, rar = 'rare', + slot = 'brust', beschreibung = 'Kugelsichere Weste. Reduziert Schaden am Oberkörper.', + }, + jeans = { + name = 'Jeans', icon = '👖', kat = 'Kleidung', gewicht = 0.6, rar = 'common', + slot = 'hose', beschreibung = 'Robuste Jeans.', + }, + sneaker = { + name = 'Sneaker', icon = '👟', kat = 'Kleidung', gewicht = 0.8, rar = 'common', + slot = 'schuhe', beschreibung = 'Bequeme Sneaker.', + }, + + -- --- Weitere Waren aus dem Laden ------------------------------------- + cola = { + name = 'Cola', icon = '🥫', kat = 'Nahrung', gewicht = 0.4, rar = 'common', + stapelbar = true, verbrauchbar = true, nutzbar = true, + wirkung = { art = 'durst', menge = 20 }, + beschreibung = 'Eiskalt, koffeinhaltig.', + }, + energy = { + name = 'Energydrink', icon = '⚡', kat = 'Nahrung', gewicht = 0.4, rar = 'common', + stapelbar = true, verbrauchbar = true, nutzbar = true, + wirkung = { art = 'ausdauer', menge = 40 }, + beschreibung = 'Gibt Extra-Ausdauer.', + }, + kaffee = { + name = 'Kaffee to go', icon = '☕', kat = 'Nahrung', gewicht = 0.3, rar = 'common', + stapelbar = true, verbrauchbar = true, nutzbar = true, + wirkung = { art = 'ausdauer', menge = 25 }, + beschreibung = 'Wach werden.', + }, + chips = { + name = 'Chips', icon = '🍟', kat = 'Nahrung', gewicht = 0.2, rar = 'common', + stapelbar = true, verbrauchbar = true, nutzbar = true, + wirkung = { art = 'hunger', menge = 15 }, + beschreibung = 'Knuspriger Snack.', + }, + schoko = { + name = 'Schokoriegel', icon = '🍫', kat = 'Nahrung', gewicht = 0.1, rar = 'common', + stapelbar = true, verbrauchbar = true, nutzbar = true, + wirkung = { art = 'hunger', menge = 12 }, + beschreibung = 'Süße Energie.', + }, + burger = { + name = 'Burger', icon = '🍔', kat = 'Nahrung', gewicht = 0.3, rar = 'common', + stapelbar = true, verbrauchbar = true, nutzbar = true, + wirkung = { art = 'hunger', menge = 45 }, + beschreibung = 'Deftige Mahlzeit.', + }, + medkit = { + name = 'Erste-Hilfe-Kit', icon = '🧳', kat = 'Medizin', gewicht = 1.5, rar = 'rare', + stapelbar = true, verbrauchbar = true, nutzbar = true, + wirkung = { art = 'leben', menge = 60 }, + beschreibung = 'Behandelt schwere Wunden.', + }, + kabel = { + name = 'Kabelbinder', icon = '🔗', kat = 'Werkzeug', gewicht = 0.1, rar = 'common', + stapelbar = true, beschreibung = 'Vielseitig einsetzbar.', + }, + lampe = { + name = 'Taschenlampe', icon = '🔦', kat = 'Werkzeug', gewicht = 0.3, rar = 'common', + nutzbar = true, beschreibung = 'Leuchtet den Weg.', + }, + sim = { + name = 'SIM-Karte', icon = '📶', kat = 'Elektronik', gewicht = 0.01, rar = 'common', + stapelbar = true, beschreibung = 'Wird fürs Handy benötigt.', + }, + akku = { + name = 'Akku', icon = '🔋', kat = 'Elektronik', gewicht = 0.1, rar = 'common', + stapelbar = true, beschreibung = 'Ersatz-Akku fürs Handy.', + }, + boerse = { + name = 'Börse', icon = '👛', kat = 'Zubehör', gewicht = 0.2, rar = 'uncommon', + beschreibung = 'Fasst bis zu 10 Dokumente.', + }, + rucksack = { + name = 'Rucksack', icon = '🎒', kat = 'Zubehör', gewicht = 0.6, rar = 'uncommon', + beschreibung = 'Erhöht die Tragekapazität.', + }, + + -- --- Waffen und Zubehoer (Ammu-Nation) ------------------------------- + smg = { + name = 'MP5 (MPX)', icon = '🔫', kat = 'Waffe', gewicht = 3.0, rar = 'legendary', + waffe = true, beschreibung = 'Maschinenpistole. Registrierte Feuerwaffe.', + }, + flinte = { + name = 'Schrotflinte 870', icon = '🔫', kat = 'Waffe', gewicht = 3.6, rar = 'epic', + waffe = true, beschreibung = 'Pump-Action Shotgun. Registrierte Feuerwaffe.', + }, + schrot12 = { + name = 'Schrot 12/70', icon = '🧨', kat = 'Munition', gewicht = 0.04, rar = 'common', + stapelbar = true, beschreibung = 'Schrotpatronen für Flinten.', + }, + schalldaempfer = { + name = 'Schalldämpfer', icon = '🔇', kat = 'Aufsatz', gewicht = 0.2, rar = 'rare', + beschreibung = 'Dämpft den Mündungsknall.', + }, + waffenlampe = { + name = 'Waffenlampe', icon = '🔦', kat = 'Aufsatz', gewicht = 0.15, rar = 'uncommon', + beschreibung = 'Taktische Beleuchtung.', + }, + laservisier = { + name = 'Laservisier', icon = '🎯', kat = 'Aufsatz', gewicht = 0.15, rar = 'uncommon', + beschreibung = 'Bessere Zielerfassung.', + }, +} + +-- Startausruestung eines frisch erstellten Charakters +Inventar.StartItems = { + { item = 'handy', menge = 1, slot = 0 }, + { item = 'wasser', menge = 2, slot = 1 }, + { item = 'sandwich', menge = 1, slot = 2 }, + { item = 'jeans', menge = 1, slot = Inventar.EQUIP_SLOTS.hose }, + { item = 'sneaker', menge = 1, slot = Inventar.EQUIP_SLOTS.schuhe }, +} + +--- Liefert die Definition eines Items +--- @param kennung string Item-Kennung +--- @return table|nil +function Inventar.HoleItem(kennung) + return Inventar.Items[kennung] +end diff --git a/resources/[aether]/aether-jobs/client/main.lua b/resources/[aether]/aether-jobs/client/main.lua new file mode 100644 index 0000000..acca874 --- /dev/null +++ b/resources/[aether]/aether-jobs/client/main.lua @@ -0,0 +1,344 @@ +-- ===================================================================== +-- Aether Jobs — Clientlogik +-- Die Arbeit findet in der Welt statt: Marker, Route, Animation. +-- Der Server prueft Ort und Dauer, bevor er bezahlt. +-- ===================================================================== + +local istOffen = false +local istGeladen = false + +-- Aktuelle Schicht (vom Server gespiegelt) +local schicht = nil -- { jobId, schrittIdx, auftrag, schritte, farbe, name } +local arbeitetGerade = false -- laeuft gerade eine Handlung? +local zielBlip = nil -- Markierung des aktuellen Arbeitsorts + +local TASTE_BENUTZEN = 38 -- E + +local function SendeAnNui(nachricht) + SendNuiMessage(json.encode(nachricht)) +end + +local function ZeigeSpielmeldung(text) + if not text or text == '' then return end + BeginTextCommandThefeedPost('STRING') + AddTextComponentSubstringPlayerName(text) + EndTextCommandThefeedPostTicker(false, true) +end + +local function Schliessen() + if not istOffen then return end + istOffen = false + SetNuiFocus(false, false) + SendeAnNui({ aktion = 'schliessen' }) + TriggerServerEvent('aether:jobs:schliessen') + pcall(function() exports['aether-hud']:SetzeSichtbar(true) end) +end + +-- --------------------------------------------------------------------- +-- Wegpunkt zum aktuellen Arbeitsort +-- --------------------------------------------------------------------- + +--- Liefert den Ort des aktuellen Arbeitsschritts +--- @return table|nil { position, name } +local function AktuellerOrt() + if not schicht or not schicht.auftrag or not schicht.auftrag.orte then return nil end + return schicht.auftrag.orte[schicht.schrittIdx + 1] +end + +--- Setzt die Kartenmarkierung auf den aktuellen Arbeitsort +local function AktualisiereZielBlip() + if zielBlip then + RemoveBlip(zielBlip) + zielBlip = nil + end + + local ort = AktuellerOrt() + if not ort then return end + + zielBlip = AddBlipForCoord(ort.position.x, ort.position.y, ort.position.z) + SetBlipSprite(zielBlip, 1) + SetBlipColour(zielBlip, 5) + SetBlipScale(zielBlip, 0.9) + SetBlipRoute(zielBlip, true) -- Route auf der Karte anzeigen + SetBlipRouteColour(zielBlip, 5) + BeginTextCommandSetBlipName('STRING') + AddTextComponentString('Arbeitsort: ' .. (ort.name or '')) + EndTextCommandSetBlipName(zielBlip) +end + +--- Entfernt die Zielmarkierung +local function EntferneZielBlip() + if zielBlip then + RemoveBlip(zielBlip) + zielBlip = nil + end +end + +-- --------------------------------------------------------------------- +-- Arbeitshandlung ausfuehren +-- --------------------------------------------------------------------- + +--- Spielt die Handlung ab und meldet sie danach dem Server +--- @param schrittDef table Schrittdefinition +local function FuehreHandlungAus(schrittDef) + if arbeitetGerade then return end + arbeitetGerade = true + + local ped = PlayerPedId() + local dauer = schrittDef.dauer or 4000 + + -- Passendes Szenario abspielen, damit die Handlung sichtbar ist + if schrittDef.szenario then + TaskStartScenarioInPlace(ped, schrittDef.szenario, 0, true) + end + FreezeEntityPosition(ped, true) + + -- Fortschrittsbalken anzeigen + local start = GetGameTimer() + CreateThread(function() + while arbeitetGerade do + local vergangen = GetGameTimer() - start + local anteil = math.min(1.0, vergangen / dauer) + + -- Balken am unteren Bildschirmrand + DrawRect(0.5, 0.86, 0.24, 0.028, 0, 0, 0, 170) + DrawRect(0.38 + 0.12 * anteil, 0.86, 0.24 * anteil, 0.028, 90, 200, 120, 220) + + SetTextFont(4) + SetTextScale(0.34, 0.34) + SetTextColour(255, 255, 255, 230) + SetTextCentre(true) + SetTextEntry('STRING') + AddTextComponentString(schrittDef.text or 'Arbeite …') + SetTextOutline() + DrawText(0.5, 0.895) + + Wait(0) + end + end) + + Wait(dauer) + + ClearPedTasks(ped) + FreezeEntityPosition(ped, false) + arbeitetGerade = false + + -- Erst jetzt meldet der Client den Schritt; der Server prueft Ort und Zeit + TriggerServerEvent('aether:jobs:schritt') +end + +-- --------------------------------------------------------------------- +-- Ereignisse vom Server +-- --------------------------------------------------------------------- +AddEventHandler('aether:client:bereit', function() + istGeladen = true +end) + +RegisterNetEvent('aether:jobs:zeige', function(zustand) + istOffen = true + SetNuiFocus(true, true) + pcall(function() exports['aether-hud']:SetzeSichtbar(false) end) + zustand.aktion = 'oeffnen' + SendeAnNui(zustand) +end) + +RegisterNetEvent('aether:jobs:zustand', function(zustand) + -- Schicht lokal spiegeln, damit Marker und Anzeige stimmen + if zustand.aktiv then + local job = nil + for _, j in ipairs(zustand.jobs or {}) do + if j.id == zustand.aktiv.jobId then job = j break end + end + schicht = { + jobId = zustand.aktiv.jobId, + schrittIdx = zustand.aktiv.schrittIdx, + auftrag = zustand.aktiv.aktuell, + schritte = job and job.schritte or {}, + name = job and job.name or '', + } + AktualisiereZielBlip() + else + schicht = nil + EntferneZielBlip() + end + + if istOffen then + zustand.aktion = 'zustand' + SendeAnNui(zustand) + elseif zustand.hinweis then + ZeigeSpielmeldung(zustand.hinweis) + end +end) + +RegisterNetEvent('aether:jobs:fehler', function(text) + if istOffen then + SendeAnNui({ aktion = 'fehler', text = text }) + else + ZeigeSpielmeldung(text) + end +end) + +--- Dienstfahrzeug steht bereit +RegisterNetEvent('aether:jobs:fahrzeugBereit', function(netId, spawn) + Schliessen() + + local versuche = 0 + while not NetworkDoesEntityExistWithNetworkId(netId) and versuche < 100 do + Wait(50) + versuche = versuche + 1 + end + if not NetworkDoesEntityExistWithNetworkId(netId) then return end + + local fahrzeug = NetToVeh(netId) + if fahrzeug and DoesEntityExist(fahrzeug) then + SetVehicleNumberPlateText(fahrzeug, 'DIENST') + ZeigeSpielmeldung('~g~Dienstfahrzeug~s~ steht bereit — folge der Markierung.') + + -- Wegpunkt zum Fahrzeug setzen, damit man es findet + SetNewWaypoint(spawn.x, spawn.y) + end +end) + +-- --------------------------------------------------------------------- +-- Jobcenter in der Naehe erkennen +-- --------------------------------------------------------------------- +CreateThread(function() + local blip = AddBlipForCoord(Jobs.JOBCENTER.x, Jobs.JOBCENTER.y, Jobs.JOBCENTER.z) + SetBlipSprite(blip, 407) + SetBlipColour(blip, 5) + SetBlipScale(blip, 0.8) + SetBlipAsShortRange(blip, true) + BeginTextCommandSetBlipName('STRING') + AddTextComponentString('Jobcenter') + EndTextCommandSetBlipName(blip) + + while true do + local pause = 800 + + if istGeladen and not istOffen and not schicht then + local position = GetEntityCoords(PlayerPedId()) + + if #(position - Jobs.JOBCENTER) <= Jobs.INTERAKTION_WEITE then + pause = 0 + SetTextFont(4) + SetTextScale(0.34, 0.34) + SetTextColour(255, 255, 255, 215) + SetTextCentre(true) + SetTextOutline() + SetTextEntry('STRING') + AddTextComponentString('[E] Jobcenter') + SetDrawOrigin(Jobs.JOBCENTER.x, Jobs.JOBCENTER.y, Jobs.JOBCENTER.z + 0.9, 0) + DrawText(0.0, 0.0) + ClearDrawOrigin() + + if IsControlJustReleased(0, TASTE_BENUTZEN) then + TriggerServerEvent('aether:jobs:oeffnen') + end + end + end + + Wait(pause) + end +end) + +-- --------------------------------------------------------------------- +-- Arbeitsort: Marker, Aufgabenanzeige und Aktion +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + local pause = 500 + + if schicht and not istOffen and not arbeitetGerade then + local ort = AktuellerOrt() + local schrittDef = schicht.schritte[schicht.schrittIdx + 1] + + if ort and schrittDef then + pause = 0 + local position = GetEntityCoords(PlayerPedId()) + local ziel = vector3(ort.position.x, ort.position.y, ort.position.z) + local abstand = #(position - ziel) + + -- Aufgabenanzeige oben links + SetTextFont(4) + SetTextScale(0.36, 0.36) + SetTextColour(255, 255, 255, 220) + SetTextEntry('STRING') + AddTextComponentString(('~y~%s~s~ %s'):format(schicht.name, schrittDef.text)) + SetTextOutline() + DrawText(0.012, 0.62) + + SetTextFont(4) + SetTextScale(0.31, 0.31) + SetTextColour(200, 200, 210, 190) + SetTextEntry('STRING') + AddTextComponentString(('Ziel: %s · %d m'):format(ort.name or '', math.floor(abstand))) + SetTextOutline() + DrawText(0.012, 0.655) + + -- Marker am Arbeitsort, sobald er in Sichtweite ist + if abstand < 60.0 then + DrawMarker(1, ziel.x, ziel.y, ziel.z - 0.95, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, + 1.6, 1.6, 0.7, 90, 200, 120, 120, false, false, 2, false, nil, nil, false) + end + + -- In Reichweite: Aktion anbieten + if abstand <= Jobs.ORT_RADIUS then + SetTextFont(4) + SetTextScale(0.34, 0.34) + SetTextColour(255, 255, 255, 220) + SetTextCentre(true) + SetTextOutline() + SetTextEntry('STRING') + AddTextComponentString('[E] ' .. (schrittDef.btn or 'Arbeiten')) + SetDrawOrigin(ziel.x, ziel.y, ziel.z + 0.9, 0) + DrawText(0.0, 0.0) + ClearDrawOrigin() + + if IsControlJustReleased(0, TASTE_BENUTZEN) then + FuehreHandlungAus(schrittDef) + end + end + end + end + + Wait(pause) + end +end) + +--- Taste J: Arbeitsansicht oeffnen +RegisterCommand('job', function() + if istGeladen and not istOffen then + TriggerServerEvent('aether:jobs:oeffnen') + end +end, false) +RegisterKeyMapping('job', 'Jobcenter / Arbeitsansicht öffnen', 'keyboard', 'J') + +-- --------------------------------------------------------------------- +-- NUI-Callbacks +-- --------------------------------------------------------------------- +RegisterNUICallback('schliessen', function(_, rueckgabe) + Schliessen() + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('antreten', function(daten, rueckgabe) + TriggerServerEvent('aether:jobs:antreten', tostring(daten.jobId or '')) + Schliessen() -- gearbeitet wird in der Welt, nicht im Menue + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('feierabend', function(_, rueckgabe) + TriggerServerEvent('aether:jobs:feierabend') + rueckgabe({ ok = true }) +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen +-- --------------------------------------------------------------------- +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + SetNuiFocus(false, false) + EntferneZielBlip() + ClearPedTasks(PlayerPedId()) + FreezeEntityPosition(PlayerPedId(), false) +end) diff --git a/resources/[aether]/aether-jobs/fxmanifest.lua b/resources/[aether]/aether-jobs/fxmanifest.lua new file mode 100644 index 0000000..ed8513a --- /dev/null +++ b/resources/[aether]/aether-jobs/fxmanifest.lua @@ -0,0 +1,34 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +name 'aether-jobs' +author 'Jerrit Fritzsche' +description 'Jobcenter mit acht Jobs, Level/XP und Auftrags-Loop' +version '0.1.0' + +ui_page 'html/index.html' + +files { + 'html/index.html', +} + +shared_scripts { + '@aether-core/shared/config.lua', + '@aether-core/shared/utils.lua', + 'shared/jobs.lua', +} + +client_scripts { + 'client/main.lua', +} + +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/main.lua', +} + +dependencies { + 'aether-core', + 'oxmysql', +} diff --git a/resources/[aether]/aether-jobs/html/index.html b/resources/[aether]/aether-jobs/html/index.html new file mode 100644 index 0000000..bc76534 --- /dev/null +++ b/resources/[aether]/aether-jobs/html/index.html @@ -0,0 +1,340 @@ + + + + + + + + +Aether — Jobs + + +
+
💼
Aether JobsArbeitsvermittlung
+
+ +
+
+
+ + + + + diff --git a/resources/[aether]/aether-jobs/server/main.lua b/resources/[aether]/aether-jobs/server/main.lua new file mode 100644 index 0000000..ed3e3b2 --- /dev/null +++ b/resources/[aether]/aether-jobs/server/main.lua @@ -0,0 +1,344 @@ +-- ===================================================================== +-- Aether Jobs — Serverlogik +-- Der Server vergibt die Auftraege, bestimmt den Lohn und prueft die +-- Zeit je Arbeitsschritt. Der Client kann nichts davon beeinflussen. +-- ===================================================================== + +local Core = exports['aether-core'] + +-- Laufende Schichten: [quelle] = { +-- jobId, auftrag, schrittIdx, schrittSeit, dienstBeginn, verdient, auftraege, fahrzeug +-- } +local schichten = {} + +-- --------------------------------------------------------------------- +-- Dienstfahrzeuge +-- --------------------------------------------------------------------- + +--- Stellt das Dienstfahrzeug eines Jobs bereit +--- @param quelle number Server-ID +--- @param job table Jobdefinition +--- @return number|nil Entity-Handle +local function StelleFahrzeug(quelle, job) + if not job.fahrzeug then return nil end + + local spawn = job.fahrzeug.spawn + local fahrzeug = CreateVehicle(GetHashKey(job.fahrzeug.modell), + spawn.x, spawn.y, spawn.z, spawn.w, true, true) + + local versuche = 0 + while not DoesEntityExist(fahrzeug) and versuche < 50 do + Wait(10) + versuche = versuche + 1 + end + if not DoesEntityExist(fahrzeug) then return nil end + + -- Kennzeichen als Dienstfahrzeug markieren + Entity(fahrzeug).state:set('aetherJob', { + job = job.id, besitzer = quelle, + }, true) + + return fahrzeug +end + +--- Entfernt das Dienstfahrzeug einer Schicht +local function EntferneFahrzeug(schicht) + if schicht and schicht.fahrzeug and DoesEntityExist(schicht.fahrzeug) then + DeleteEntity(schicht.fahrzeug) + end +end + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +local function CharakterVon(quelle) + return Core:HoleCharakterId(quelle) +end + +--- Laedt den Fortschritt aller Jobs eines Charakters +--- @param charakterId number Charakter-ID +--- @return table [jobId] = { level, xp, verdient, auftraege } +local function HoleFortschritt(charakterId) + local zeilen = MySQL.query.await( + 'SELECT job, level, xp, verdient, auftraege FROM aether_jobs WHERE `character` = ?', + { charakterId }) or {} + + local fortschritt = {} + -- Standardwerte fuer alle bekannten Jobs + for _, job in ipairs(Jobs.Liste) do + fortschritt[job.id] = { level = 1, xp = 0, verdient = 0, auftraege = 0 } + end + for _, zeile in ipairs(zeilen) do + if fortschritt[zeile.job] then + fortschritt[zeile.job] = { + level = zeile.level, xp = zeile.xp, + verdient = zeile.verdient, auftraege = zeile.auftraege, + } + end + end + return fortschritt +end + +--- Schreibt den Fortschritt eines Jobs zurueck +local function SpeichereFortschritt(charakterId, jobId, werte) + MySQL.query([[ + INSERT INTO aether_jobs (`character`, job, level, xp, verdient, auftraege) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + level = VALUES(level), xp = VALUES(xp), + verdient = VALUES(verdient), auftraege = VALUES(auftraege) + ]], { charakterId, jobId, werte.level, werte.xp, werte.verdient, werte.auftraege }) +end + +--- Baut den Zustand fuer die Oberflaeche +local function BaueZustand(quelle) + local charakterId = CharakterVon(quelle) + if not charakterId then return nil end + + local schicht = schichten[quelle] + local aktiv = nil + + if schicht then + local job = Jobs.Hole(schicht.jobId) + aktiv = { + jobId = schicht.jobId, + schrittIdx = schicht.schrittIdx, + aktuell = schicht.auftrag, + verdient = schicht.verdient, + auftraege = schicht.auftraege, + dienstzeit = math.floor((os.time() - schicht.dienstBeginn)), + schritte = job and job.schritte or {}, + } + end + + local person = Core:HoleSpielerInfo(quelle) + + return { + jobs = Jobs.Liste, + fortschritt = HoleFortschritt(charakterId), + aktiv = aktiv, + xpProLevel = Jobs.XP_PRO_LEVEL, + schrittDauer= Jobs.SCHRITT_DAUER, + geld = { + bargeld = person and person.bargeld or 0, + konto = person and person.konto or 0, + }, + } +end + +local function SendeZustand(quelle, hinweis) + local zustand = BaueZustand(quelle) + if not zustand then return end + zustand.hinweis = hinweis + TriggerClientEvent('aether:jobs:zustand', quelle, zustand) +end + +local function Ablehnen(quelle, grund) + TriggerClientEvent('aether:jobs:fehler', quelle, grund) +end + +-- --------------------------------------------------------------------- +-- Jobcenter oeffnen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:jobs:oeffnen', function() + local quelle = source + if not CharakterVon(quelle) then return end + + -- Am Jobcenter oder bereits im Dienst (dann von ueberall einsehbar) + if not schichten[quelle] then + local position = GetEntityCoords(GetPlayerPed(quelle)) + if #(position - Jobs.JOBCENTER) > (Jobs.INTERAKTION_WEITE + 3.0) then + Ablehnen(quelle, 'Du bist nicht am Jobcenter.') + return + end + end + + local zustand = BaueZustand(quelle) + if zustand then + TriggerClientEvent('aether:jobs:zeige', quelle, zustand) + end +end) + +-- --------------------------------------------------------------------- +-- Dienst antreten +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:jobs:antreten', function(jobId) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId or type(jobId) ~= 'string' then return end + + local job = Jobs.Hole(jobId) + if not job then return end + + if schichten[quelle] then + Ablehnen(quelle, 'Du bist bereits im Dienst. Mach erst Feierabend.') + return + end + + local fortschritt = HoleFortschritt(charakterId) + local level = fortschritt[jobId].level + + schichten[quelle] = { + jobId = jobId, + auftrag = Jobs.ErzeugeAuftrag(jobId, level), + schrittIdx = 0, + schrittSeit = 0, + dienstBeginn = os.time(), + verdient = 0, + auftraege = 0, + fahrzeug = nil, + } + + -- Dienstfahrzeug bereitstellen, sofern der Job eines vorsieht + if job.fahrzeug then + schichten[quelle].fahrzeug = StelleFahrzeug(quelle, job) + if schichten[quelle].fahrzeug then + TriggerClientEvent('aether:jobs:fahrzeugBereit', quelle, + NetworkGetNetworkIdFromEntity(schichten[quelle].fahrzeug), + job.fahrzeug.spawn) + end + end + + SendeZustand(quelle, 'Dienst angetreten: ' .. job.name) +end) + +-- --------------------------------------------------------------------- +-- Arbeitsschritt ausfuehren +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:jobs:schritt', function() + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + + local schicht = schichten[quelle] + if not schicht then Ablehnen(quelle, 'Du bist nicht im Dienst.') return end + + local job = Jobs.Hole(schicht.jobId) + if not job then return end + + local schrittDef = job.schritte[schicht.schrittIdx + 1] + if not schrittDef then return end + + -- ---- Ortspruefung: Der Spieler muss wirklich dort stehen ---- + local ziel = schicht.auftrag.orte and schicht.auftrag.orte[schicht.schrittIdx + 1] + if not ziel then return end + + local position = GetEntityCoords(GetPlayerPed(quelle)) + local abstand = #(position - vector3(ziel.position.x, ziel.position.y, ziel.position.z)) + if abstand > (Jobs.ORT_RADIUS + 2.0) then + Ablehnen(quelle, ('Du bist nicht am Arbeitsort (%s).'):format(ziel.name)) + return + end + + -- ---- Zeitpruefung: Die Handlung braucht ihre vorgesehene Dauer ---- + local dauer = schrittDef.dauer or 4000 + local jetzt = GetGameTimer() + if schicht.schrittSeit > 0 and (jetzt - schicht.schrittSeit) < (dauer - 500) then + Ablehnen(quelle, 'Zu schnell — arbeite den Schritt ab.') + return + end + schicht.schrittSeit = jetzt + + schicht.schrittIdx = schicht.schrittIdx + 1 + + -- Noch nicht der letzte Schritt? Dann nur weiterzaehlen. + if schicht.schrittIdx < #job.schritte then + SendeZustand(quelle) + return + end + + -- ---- Auftrag abgeschlossen: bezahlen und Erfahrung gutschreiben ---- + local auftrag = schicht.auftrag + local fortschritt = HoleFortschritt(charakterId) + local werte = fortschritt[schicht.jobId] + + -- Lohn kommt aus dem serverseitig erzeugten Auftrag + Core:FuegeGeldHinzu(quelle, 'bargeld', auftrag.geld, 'Lohn ' .. job.name) + + werte.xp = werte.xp + auftrag.xp + werte.verdient = werte.verdient + auftrag.geld + werte.auftraege = werte.auftraege + 1 + + local aufgestiegen = false + while werte.xp >= Jobs.XP_PRO_LEVEL and werte.level < Jobs.MAX_LEVEL do + werte.xp = werte.xp - Jobs.XP_PRO_LEVEL + werte.level = werte.level + 1 + aufgestiegen = true + end + -- Auf Hoechstlevel keine Erfahrung mehr ansammeln + if werte.level >= Jobs.MAX_LEVEL then werte.xp = 0 end + + SpeichereFortschritt(charakterId, schicht.jobId, werte) + + schicht.verdient = schicht.verdient + auftrag.geld + schicht.auftraege = schicht.auftraege + 1 + schicht.schrittIdx = 0 + schicht.auftrag = Jobs.ErzeugeAuftrag(schicht.jobId, werte.level) + + local hinweis = ('Auftrag erledigt · +%d €'):format(auftrag.geld) + if aufgestiegen then + hinweis = hinweis .. (' · Level %d erreicht!'):format(werte.level) + end + SendeZustand(quelle, hinweis) +end) + +-- --------------------------------------------------------------------- +-- Feierabend +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:jobs:feierabend', function() + local quelle = source + local schicht = schichten[quelle] + if not schicht then return end + + local job = Jobs.Hole(schicht.jobId) + local dauer = math.floor((os.time() - schicht.dienstBeginn) / 60) + + EntferneFahrzeug(schicht) + schichten[quelle] = nil + SendeZustand(quelle, ('Feierabend · %d € in %d Minuten verdient.') + :format(schicht.verdient, dauer)) + + if job then + Aether.Debug(('%s hat den Dienst als %s beendet (%d €).') + :format(GetPlayerName(quelle), job.name, schicht.verdient)) + end +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:jobs:schliessen', function() + -- Die Schicht laeuft weiter, nur die Oberflaeche wird geschlossen +end) + +AddEventHandler('playerDropped', function() + EntferneFahrzeug(schichten[source]) + schichten[source] = nil +end) + +--- Beim Stoppen der Ressource alle Dienstfahrzeuge entfernen +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + for _, schicht in pairs(schichten) do + EntferneFahrzeug(schicht) + end +end) + +-- ===================================================================== +-- EXPORTS +-- ===================================================================== + +--- Liefert den aktuellen Job eines Spielers (oder nil) +exports('HoleAktivenJob', function(quelle) + local schicht = schichten[quelle] + return schicht and schicht.jobId or nil +end) + +--- Liefert das Level eines Spielers in einem Job +exports('HoleLevel', function(quelle, jobId) + local charakterId = CharakterVon(quelle) + if not charakterId then return 0 end + local fortschritt = HoleFortschritt(charakterId) + return fortschritt[jobId] and fortschritt[jobId].level or 1 +end) diff --git a/resources/[aether]/aether-jobs/shared/jobs.lua b/resources/[aether]/aether-jobs/shared/jobs.lua new file mode 100644 index 0000000..e7202da --- /dev/null +++ b/resources/[aether]/aether-jobs/shared/jobs.lua @@ -0,0 +1,271 @@ +-- ===================================================================== +-- Aether Jobs — Jobdefinitionen, Arbeitsorte und Auftragsgenerator +-- +-- Jeder Arbeitsschritt hat einen ECHTEN Ort in der Welt. Der Spieler +-- muss hinfahren; der Server prueft die Entfernung, bevor er bezahlt. +-- ===================================================================== + +Jobs = Jobs or {} + +Jobs.XP_PRO_LEVEL = 120 -- Erfahrung bis zum naechsten Level +Jobs.MAX_LEVEL = 20 -- Hoechstlevel je Job + +-- Wie nah man am Arbeitsort stehen muss (Meter) +Jobs.ORT_RADIUS = 4.0 +-- Reichweite zum Jobcenter (Meter) +Jobs.INTERAKTION_WEITE = 2.0 + +-- Standort des Jobcenters (Innenstadt) +Jobs.JOBCENTER = vector3(-265.0, -963.5, 31.2) + +-- --------------------------------------------------------------------- +-- Abgabeorte im Stadtgebiet +-- Werden fuer Zustellungen, Taxifahrten und Frachten zufaellig gewaehlt. +-- --------------------------------------------------------------------- +Jobs.Abgabeorte = { + { name = 'Legion Square', pos = vector3(195.9, -933.2, 30.7) }, + { name = 'Vinewood Blvd', pos = vector3(297.4, 180.6, 104.4) }, + { name = 'Vespucci Beach', pos = vector3(-1233.6, -1490.3, 4.4) }, + { name = 'Grove Street', pos = vector3(105.8, -1940.0, 21.3) }, + { name = 'Sandy Shores', pos = vector3(1961.0, 3740.5, 32.3) }, + { name = 'Del Perro', pos = vector3(-1305.6, -394.2, 36.7) }, + { name = 'Mirror Park', pos = vector3(1136.5, -982.3, 46.0) }, + { name = 'Downtown', pos = vector3(-247.5, -883.4, 31.2) }, + { name = 'Rockford Hills', pos = vector3(-1108.5, -455.2, 36.2) }, + { name = 'Paleto Bay', pos = vector3(-127.0, 6386.0, 31.5) }, +} + +-- Zufallsdaten fuer Fang und Erz +Jobs.Fische = { + { n = 'alter Stiefel', w = 5 }, { n = 'Hering', w = 55 }, { n = 'Makrele', w = 85 }, + { n = 'Wolfsbarsch', w = 130 }, { n = 'Lachs', w = 190 }, { n = 'Thunfisch', w = 340 }, +} +Jobs.Erze = { + { n = 'Kohle', w = 45 }, { n = 'Eisenerz', w = 80 }, { n = 'Kupfererz', w = 120 }, + { n = 'Silbererz', w = 220 }, { n = 'Goldnugget', w = 480 }, +} + +-- --------------------------------------------------------------------- +-- Jobs +-- +-- Je Schritt: +-- text Aufgabenbeschreibung +-- btn Beschriftung der Aktion +-- ort fester vector3 ODER 'zufaellig' (aus Jobs.Abgabeorte) +-- ortName Anzeigename bei festem Ort +-- dauer Wie lange die Handlung dauert (ms) +-- szenario GTA-Szenario, das dabei gespielt wird (optional) +-- +-- fahrzeug: Dienstfahrzeug, das beim Antreten gestellt wird +-- --------------------------------------------------------------------- +Jobs.Liste = { + { + id = 'liefer', name = 'Lieferdienst', icon = '📦', kat = 'Service', farbe = '#4aa8ff', + beschreibung = 'Pakete am Depot laden und im Stadtgebiet ausliefern.', + lohnVon = 120, lohnBis = 180, xp = 30, + fahrzeug = { modell = 'boxville2', spawn = vector4(-424.9, -2789.5, 6.0, 90.0) }, + schritte = { + { text = 'Fahre zum Depot und lade das Paket ein.', btn = '📦 Paket laden', + ort = vector3(-419.5, -2792.3, 6.0), ortName = 'Depot Terminal', + dauer = 5000, szenario = 'PROP_HUMAN_BUM_BIN' }, + { text = 'Liefere das Paket beim Kunden aus.', btn = '✅ Zustellen', + ort = 'zufaellig', dauer = 4000, szenario = 'WORLD_HUMAN_CLIPBOARD' }, + }, + }, + { + id = 'muell', name = 'Müllabfuhr', icon = '🗑️', kat = 'Service', farbe = '#7bc96f', + beschreibung = 'Mülltonnen einsammeln und am Wertstoffhof entladen.', + lohnVon = 90, lohnBis = 140, xp = 26, + fahrzeug = { modell = 'trash', spawn = vector4(-343.1, -1560.2, 25.5, 270.0) }, + schritte = { + { text = 'Fahre die Route ab und sammle die Tonnen ein.', btn = '🗑️ Tonnen leeren', + ort = 'zufaellig', dauer = 6000, szenario = 'PROP_HUMAN_BUM_BIN' }, + { text = 'Entlade am Wertstoffhof.', btn = '♻️ Entladen', + ort = vector3(-351.6, -1546.1, 25.2), ortName = 'Wertstoffhof', + dauer = 5000, szenario = 'WORLD_HUMAN_CLIPBOARD' }, + }, + }, + { + id = 'taxi', name = 'Taxi', icon = '🚕', kat = 'Transport', farbe = '#facc15', + beschreibung = 'Fahrgäste abholen und ans Ziel bringen.', + lohnVon = 80, lohnBis = 250, xp = 22, + fahrzeug = { modell = 'taxi', spawn = vector4(895.8, -179.1, 74.7, 240.0) }, + schritte = { + { text = 'Hole den Fahrgast ab.', btn = '🧍 Fahrgast aufnehmen', + ort = 'zufaellig', dauer = 3000 }, + { text = 'Bringe den Fahrgast ans Ziel.', btn = '🏁 Fahrgast absetzen', + ort = 'zufaellig', dauer = 3000 }, + }, + }, + { + id = 'trucker', name = 'Trucker', icon = '🚛', kat = 'Transport', farbe = '#f0684a', + beschreibung = 'Fracht über Land transportieren — gut bezahlt.', + lohnVon = 420, lohnBis = 680, xp = 45, + fahrzeug = { modell = 'mule3', spawn = vector4(151.6, -3211.0, 5.9, 90.0) }, + schritte = { + { text = 'Beladung am Lagerhaus vorbereiten.', btn = '📦 Beladen', + ort = vector3(158.7, -3212.5, 5.9), ortName = 'Hafenlager', + dauer = 8000, szenario = 'WORLD_HUMAN_CLIPBOARD' }, + { text = 'Fahre die Fracht zum Zielort.', btn = '🛣️ Ausliefern', + ort = 'zufaellig', dauer = 6000, szenario = 'WORLD_HUMAN_CLIPBOARD' }, + }, + }, + { + id = 'tankwart', name = 'Tankwart', icon = '⛽', kat = 'Service', farbe = '#f5a742', + beschreibung = 'Kunden an der Zapfsäule bedienen und abkassieren.', + lohnVon = 100, lohnBis = 160, xp = 24, + schritte = { + { text = 'Bediene die Zapfsäule für den Kunden.', btn = '⛽ Betanken', + ort = vector3(265.1, -1261.3, 29.3), ortName = 'Tankstelle Strawberry', + dauer = 6000, szenario = 'WORLD_HUMAN_VEHICLE_MECHANIC' }, + { text = 'Kassiere an der Kasse.', btn = '💳 Kassieren', + ort = vector3(263.9, -1256.4, 29.6), ortName = 'Kasse', + dauer = 3000, szenario = 'WORLD_HUMAN_CLIPBOARD' }, + }, + }, + { + id = 'angeln', name = 'Angeln', icon = '🎣', kat = 'Natur', farbe = '#3fc7e0', + beschreibung = 'Am Pier angeln und den Fang am Stand verkaufen.', + lohnVon = 5, lohnBis = 340, xp = 18, + schritte = { + { text = 'Wirf die Angel aus und warte auf einen Biss.', btn = '🎣 Angeln', + ort = vector3(-1850.1, -1245.9, 8.6), ortName = 'Pier Vespucci', + dauer = 12000, szenario = 'WORLD_HUMAN_STAND_FISHING' }, + { text = 'Verkaufe den Fang am Fischstand.', btn = '💰 Verkaufen', + ort = vector3(-1817.5, -1193.6, 14.3), ortName = 'Fischstand', + dauer = 3000, szenario = 'WORLD_HUMAN_CLIPBOARD' }, + }, + }, + { + id = 'holz', name = 'Holzfäller', icon = '🪓', kat = 'Natur', farbe = '#a3703f', + beschreibung = 'Bäume fällen und die Stämme zum Sägewerk bringen.', + lohnVon = 105, lohnBis = 280, xp = 28, + schritte = { + { text = 'Fälle den markierten Baum.', btn = '🪓 Baum fällen', + ort = vector3(-552.6, 5324.9, 73.6), ortName = 'Wald bei Paleto', + dauer = 10000, szenario = 'WORLD_HUMAN_HAMMERING' }, + { text = 'Bringe die Stämme zum Sägewerk.', btn = '🪵 Abliefern', + ort = vector3(-501.3, 5305.5, 79.5), ortName = 'Sägewerk', + dauer = 5000, szenario = 'WORLD_HUMAN_CLIPBOARD' }, + }, + }, + { + id = 'bergbau', name = 'Bergbau', icon = '⛏️', kat = 'Natur', farbe = '#9a9aa0', + beschreibung = 'Erz in der Mine abbauen, einschmelzen und verkaufen.', + lohnVon = 45, lohnBis = 480, xp = 30, + schritte = { + { text = 'Baue das Erz in der Mine ab.', btn = '⛏️ Erz abbauen', + ort = vector3(2949.6, 2792.2, 40.9), ortName = 'Mine Sandy Shores', + dauer = 12000, szenario = 'WORLD_HUMAN_HAMMERING' }, + { text = 'Schmelze das Erz ein und verkaufe es.', btn = '🔥 Einschmelzen', + ort = vector3(1109.4, -2007.9, 31.0), ortName = 'Schmelze Cypress Flats', + dauer = 8000, szenario = 'WORLD_HUMAN_WELDING' }, + }, + }, +} + +--- Liefert die Definition eines Jobs +--- @param jobId string Job-Kennung +--- @return table|nil +function Jobs.Hole(jobId) + for _, job in ipairs(Jobs.Liste) do + if job.id == jobId then return job end + end + return nil +end + +local function zufall(a, b) return math.random(a, b) end +local function waehle(liste) return liste[math.random(#liste)] end + +--- Loest die Orte aller Schritte eines Auftrags zu echten Koordinaten auf. +--- Wird nur serverseitig aufgerufen. +--- @param job table Jobdefinition +--- @return table Liste { position = {x,y,z}, name = ... } je Schritt +local function LoeseOrteAuf(job) + local orte = {} + local bereitsGewaehlt = nil + + for _, schritt in ipairs(job.schritte) do + if schritt.ort == 'zufaellig' then + -- Nicht zweimal denselben Ort im selben Auftrag verwenden + local ziel + repeat ziel = waehle(Jobs.Abgabeorte) + until not bereitsGewaehlt or ziel.name ~= bereitsGewaehlt or #Jobs.Abgabeorte < 2 + bereitsGewaehlt = ziel.name + orte[#orte + 1] = { + position = { x = ziel.pos.x, y = ziel.pos.y, z = ziel.pos.z }, + name = ziel.name, + } + else + orte[#orte + 1] = { + position = { x = schritt.ort.x, y = schritt.ort.y, z = schritt.ort.z }, + name = schritt.ortName or 'Arbeitsort', + } + end + end + return orte +end + +--- Erzeugt einen Auftrag fuer einen Job. +--- Wird ausschliesslich serverseitig aufgerufen — Lohn und Orte duerfen +--- nie vom Client kommen. +--- @param jobId string Job-Kennung +--- @param level number Aktuelles Level des Spielers +--- @return table|nil Auftrag { ziel, geld, xp, info?, fang?, orte } +function Jobs.ErzeugeAuftrag(jobId, level) + local job = Jobs.Hole(jobId) + if not job then return nil end + + local bonus = 1 + (math.max(1, level) - 1) * 0.05 + local orte = LoeseOrteAuf(job) + + local auftrag + if jobId == 'taxi' then + auftrag = { + ziel = orte[1].name .. ' → ' .. orte[2].name, + geld = math.floor(zufall(80, 250) * bonus + 0.5), + xp = job.xp, + } + elseif jobId == 'angeln' then + local fisch = waehle(Jobs.Fische) + auftrag = { + ziel = 'Pier Vespucci', + geld = math.floor(fisch.w * (1 + (level - 1) * 0.06) + 0.5), + xp = job.xp, + fang = fisch.n, + } + elseif jobId == 'holz' then + local anzahl = zufall(3, 8) + auftrag = { + ziel = 'Wald bei Paleto', + geld = math.floor(anzahl * 35 * bonus + 0.5), + xp = job.xp, + fang = anzahl .. ' Stämme', + } + elseif jobId == 'bergbau' then + local erz = waehle(Jobs.Erze) + local anzahl = zufall(2, 6) + auftrag = { + ziel = 'Mine Sandy Shores', + geld = math.floor(erz.w * anzahl * 0.5 * bonus + 0.5), + xp = job.xp, + fang = anzahl .. '× ' .. erz.n, + } + else + local vorsatz = { + liefer = 'Zustellung: ', + muell = 'Route: ', + trucker = 'Fracht nach ', + tankwart = 'Schicht an der ', + } + -- Beim Standardauftrag benennt der letzte Ort das Ziel + auftrag = { + ziel = (vorsatz[jobId] or '') .. orte[#orte].name, + geld = math.floor(zufall(job.lohnVon, job.lohnBis) * bonus + 0.5), + xp = job.xp, + } + end + + auftrag.orte = orte + return auftrag +end diff --git a/resources/[aether]/aether-loading/fxmanifest.lua b/resources/[aether]/aether-loading/fxmanifest.lua new file mode 100644 index 0000000..4a0cdcc --- /dev/null +++ b/resources/[aether]/aether-loading/fxmanifest.lua @@ -0,0 +1,19 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +name 'aether-loading' +author 'Jerrit Fritzsche' +description 'Ladebildschirm des Aether-Servers' +version '0.1.0' + +-- Diese Datei wird beim Verbinden als Ladebildschirm angezeigt +loadscreen 'html/index.html' + +-- Der Ladebildschirm bleibt sichtbar, bis der Core ihn per +-- ShutdownLoadingScreen() beendet (nachdem der Charakter gespawnt ist) +loadscreen_manual_shutdown 'yes' + +files { + 'html/index.html', +} diff --git a/resources/[aether]/aether-loading/html/index.html b/resources/[aether]/aether-loading/html/index.html new file mode 100644 index 0000000..9c7519e --- /dev/null +++ b/resources/[aether]/aether-loading/html/index.html @@ -0,0 +1,380 @@ + + + + + + +Aether — Ladebildschirm + + + + + +
+
+ +
+ +
+
+ ◆ +
AETHERLos Santos Roleplay
+
+
+
+ + +
+
+
Willkommen zurück
+

Deine
Geschichte
beginnt.

+

Steig ein in das Leben von Los Santos — dein Charakter, deine Entscheidungen, deine Regeln. Bitte warte, während die Welt geladen wird.

+
+ +
+ + +
+
+
+
+
Verbinde mit dem Server…
+
0%
+
+
+
+
+
♪
+
+
Aether Ambient
+
Lobby · Loop
+
+ + + +
+
+
+
+ + + + + diff --git a/resources/[aether]/aether-medical/client/main.lua b/resources/[aether]/aether-medical/client/main.lua new file mode 100644 index 0000000..2eb62e8 --- /dev/null +++ b/resources/[aether]/aether-medical/client/main.lua @@ -0,0 +1,261 @@ +-- ===================================================================== +-- aether-medical — Clientlogik +-- +-- Öffnet die Kliniksoftware und vermittelt zwischen Oberfläche und +-- Server. Der Client führt nichts Medizinisches aus — er zeigt an, +-- meldet Tatsachen (Fahrzeugtelemetrie) und setzt Wegpunkte. +-- ===================================================================== + +local istOffen = false +local istGeladen = false +local imDienst = false + +local function SendeAnNui(nachricht) + SendNuiMessage(json.encode(nachricht)) +end + +local function ZeigeSpielmeldung(text) + if not text or text == '' then return end + BeginTextCommandThefeedPost('STRING') + AddTextComponentSubstringPlayerName(text) + EndTextCommandThefeedPostTicker(false, true) +end + +local function Schliessen() + if not istOffen then return end + istOffen = false + SetNuiFocus(false, false) + SendeAnNui({ aktion = 'schliessen' }) + pcall(function() exports['aether-hud']:SetzeSichtbar(true) end) +end + +-- --------------------------------------------------------------------- +-- Ereignisse vom Server +-- --------------------------------------------------------------------- +AddEventHandler('aether:client:bereit', function() + istGeladen = true +end) + +--- Oberfläche mit vollem Zustand anzeigen +RegisterNetEvent('aether:medical:zeige', function(daten) + istOffen = true + imDienst = true + SetNuiFocus(true, true) + pcall(function() exports['aether-hud']:SetzeSichtbar(false) end) + daten.aktion = 'oeffnen' + SendeAnNui(daten) +end) + +--- Anmeldebildschirm (noch nicht im Dienst) +RegisterNetEvent('aether:medical:anmeldung', function(daten) + istOffen = true + SetNuiFocus(true, true) + pcall(function() exports['aether-hud']:SetzeSichtbar(false) end) + daten.aktion = 'anmeldung' + SendeAnNui(daten) +end) + +RegisterNetEvent('aether:medical:schliessen', function() + imDienst = false + Schliessen() + ZeigeSpielmeldung('Dienst beendet.') +end) + +--- Antwort auf einen Modul-Aufruf +RegisterNetEvent('aether:medical:antwort', function(ruf, ergebnis, fehler) + SendeAnNui({ aktion = 'antwort', ruf = ruf, ergebnis = ergebnis, fehler = fehler }) +end) + +--- Dienstliste (wer ist im Dienst, für Dashboard und Leitstelle) +RegisterNetEvent('aether:medical:dienstliste', function(liste) + if istOffen then + SendeAnNui({ aktion = 'dienstliste', mitarbeiter = liste }) + end +end) + +--- Hinweis / Erinnerung +RegisterNetEvent('aether:medical:hinweis', function(hinweis) + if istOffen then + SendeAnNui({ aktion = 'hinweis', hinweis = hinweis }) + else + -- Auch ausserhalb der Oberfläche erreichbar, damit Fristen nicht untergehen + local praefix = hinweis.prioritaet == 'kritisch' and '~r~' or + hinweis.prioritaet == 'hoch' and '~o~' or '~b~' + ZeigeSpielmeldung(praefix .. hinweis.titel .. '~s~' .. + (hinweis.text and ('\n' .. hinweis.text) or '')) + end +end) + +RegisterNetEvent('aether:medical:fehler', function(text) + if istOffen then + SendeAnNui({ aktion = 'fehler', text = text }) + else + ZeigeSpielmeldung(text) + end +end) + +--- Es hat sich etwas an den Einsätzen geändert +RegisterNetEvent('aether:medical:einsatzAenderung', function() + if istOffen then + SendeAnNui({ aktion = 'einsatzAenderung' }) + end +end) + +--- Die Leitstelle bittet um Übernahme eines Einsatzes +RegisterNetEvent('aether:medical:einsatzZuweisung', function(daten) + PlaySoundFrontend(-1, 'Event_Start_Text', 'GTAO_FM_Events_Soundset', true) + + if daten.position then + SetNewWaypoint(daten.position.x, daten.position.y) + end + + if istOffen then + SendeAnNui({ aktion = 'einsatzZuweisung', daten = daten }) + else + ZeigeSpielmeldung(('~b~Zuweisung von %s~s~\n%s — %s') + :format(daten.von, daten.nummer, daten.titel)) + end +end) + +-- --------------------------------------------------------------------- +-- Fahrzeug-Telemetrie +-- +-- Meldet Tatsachen über das gefahrene Rettungsfahrzeug: Position, +-- Tankstand, Zustand. Der Server verwirft die Meldung, wenn das +-- Kennzeichen nicht zum Fuhrpark gehört. +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(30000) -- alle 30 Sekunden genügt + + if imDienst then + local ped = PlayerPedId() + if IsPedInAnyVehicle(ped, false) then + local fahrzeug = GetVehiclePedIsIn(ped, false) + -- Nur melden, wenn man selbst am Steuer sitzt + if GetPedInVehicleSeat(fahrzeug, -1) == ped then + local position = GetEntityCoords(fahrzeug) + TriggerServerEvent('aether:medical:fahrzeugMelden', { + kennzeichen = GetVehicleNumberPlateText(fahrzeug), + tank = GetVehicleFuelLevel(fahrzeug), + zustand = GetVehicleBodyHealth(fahrzeug), + x = position.x, y = position.y, z = position.z, + }) + end + end + end + end +end) + +-- --------------------------------------------------------------------- +-- Terminals in der Klinik +-- --------------------------------------------------------------------- +CreateThread(function() + for _, terminal in ipairs(Medizin.Config.Terminals) do + local blip = AddBlipForCoord(terminal.pos.x, terminal.pos.y, terminal.pos.z) + SetBlipSprite(blip, 61) + SetBlipColour(blip, 2) + SetBlipScale(blip, 0.8) + SetBlipAsShortRange(blip, true) + BeginTextCommandSetBlipName('STRING') + AddTextComponentString('Klinik ' .. terminal.name) + EndTextCommandSetBlipName(blip) + end + + while true do + local pause = 800 + + if istGeladen and not istOffen then + local position = GetEntityCoords(PlayerPedId()) + + for _, terminal in ipairs(Medizin.Config.Terminals) do + if #(position - terminal.pos) <= Medizin.Config.TerminalWeite then + pause = 0 + SetTextFont(4) + SetTextScale(0.34, 0.34) + SetTextColour(255, 255, 255, 215) + SetTextCentre(true) + SetTextOutline() + SetTextEntry('STRING') + AddTextComponentString('[E] Klinik-PC ' .. terminal.name) + SetDrawOrigin(terminal.pos.x, terminal.pos.y, terminal.pos.z + 0.9, 0) + DrawText(0.0, 0.0) + ClearDrawOrigin() + + if IsControlJustReleased(0, 38) then + TriggerServerEvent('aether:medical:oeffnen') + end + break + end + end + end + + Wait(pause) + end +end) + +--- Tablet jederzeit öffnen, sobald man im Dienst ist +RegisterCommand('kis', function() + if istGeladen and not istOffen then + TriggerServerEvent('aether:medical:oeffnen') + end +end, false) +RegisterKeyMapping('kis', 'Klinik-Tablet öffnen', 'keyboard', 'F6') + +-- --------------------------------------------------------------------- +-- NUI-Callbacks +-- --------------------------------------------------------------------- +RegisterNUICallback('schliessen', function(_, rueckgabe) + Schliessen() + rueckgabe({ ok = true }) +end) + +--- Zentraler Modul-Aufruf: die Oberfläche fragt, der Server antwortet +RegisterNUICallback('aufruf', function(daten, rueckgabe) + TriggerServerEvent('aether:medical:aufruf', + tostring(daten.modul or ''), tostring(daten.aktion or ''), + daten.daten or {}, daten.ruf) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('dienstAntreten', function(daten, rueckgabe) + TriggerServerEvent('aether:medical:dienstAntreten', tostring(daten.funktion or 'station')) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('dienstBeenden', function(_, rueckgabe) + TriggerServerEvent('aether:medical:dienstBeenden') + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('status', function(daten, rueckgabe) + TriggerServerEvent('aether:medical:status', tostring(daten.status or '')) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('funktion', function(daten, rueckgabe) + TriggerServerEvent('aether:medical:funktion', tostring(daten.funktion or '')) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('leitstelle', function(daten, rueckgabe) + TriggerServerEvent('aether:medical:leitstelle', tonumber(daten.quelle)) + rueckgabe({ ok = true }) +end) + +--- Wegpunkt auf der Karte setzen (z. B. zu einem Einsatzort) +RegisterNUICallback('wegpunkt', function(daten, rueckgabe) + if daten.x and daten.y then + SetNewWaypoint(daten.x + 0.0, daten.y + 0.0) + ZeigeSpielmeldung('Wegpunkt gesetzt.') + end + rueckgabe({ ok = true }) +end) + +-- --------------------------------------------------------------------- +-- Aufräumen +-- --------------------------------------------------------------------- +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + SetNuiFocus(false, false) +end) diff --git a/resources/[aether]/aether-medical/fxmanifest.lua b/resources/[aether]/aether-medical/fxmanifest.lua new file mode 100644 index 0000000..a884e1b --- /dev/null +++ b/resources/[aether]/aether-medical/fxmanifest.lua @@ -0,0 +1,76 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +name 'aether-medical' +author 'Jerrit Fritzsche' +description 'Medizin-Managementsystem — Klinik- und Rettungsdienstsoftware' +version '0.1.0' + +ui_page 'html/index.html' + +files { + 'html/index.html', + 'html/css/app.css', + 'html/js/app.js', + 'html/js/bausteine.js', + 'html/js/module/dashboard.js', + 'html/js/module/mdt.js', + 'html/js/module/patienten.js', + 'html/js/module/einsatz.js', + 'html/js/module/behandlung.js', + 'html/js/module/labor.js', + 'html/js/module/operation.js', + 'html/js/module/medikament.js', + 'html/js/module/station.js', + 'html/js/module/intensiv.js', + 'html/js/module/blutbank.js', + 'html/js/module/fahrzeug.js', + 'html/js/module/kommunikation.js', + 'html/js/module/bericht.js', + 'html/js/module/vorlage.js', +} + +shared_scripts { + '@aether-core/shared/config.lua', + '@aether-core/shared/utils.lua', + 'shared/config.lua', + 'shared/rollen.lua', +} + +client_scripts { + 'client/main.lua', +} + +-- Reihenfolge ist wichtig: Kern und Basisdienste vor den Fachmodulen. +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/kern.lua', + 'server/rechte.lua', + 'server/protokoll.lua', + 'server/dienst.lua', + 'server/hinweise.lua', + -- --- Fachmodule (hier neue Module ergänzen) --- + 'server/module/dashboard.lua', + 'server/module/mdt.lua', + 'server/module/patienten.lua', + 'server/module/einsatz.lua', + 'server/module/behandlung.lua', + 'server/module/labor.lua', + 'server/module/operation.lua', + 'server/module/medikament.lua', + 'server/module/station.lua', + 'server/module/intensiv.lua', + 'server/module/blutbank.lua', + 'server/module/fahrzeug.lua', + 'server/module/kommunikation.lua', + 'server/module/bericht.lua', + 'server/module/vorlage.lua', + -- --- Schnittstelle zuletzt --- + 'server/main.lua', +} + +dependencies { + 'aether-core', + 'oxmysql', +} diff --git a/resources/[aether]/aether-medical/html/css/app.css b/resources/[aether]/aether-medical/html/css/app.css new file mode 100644 index 0000000..6f8be6b --- /dev/null +++ b/resources/[aether]/aether-medical/html/css/app.css @@ -0,0 +1,1156 @@ +/* ===================================================================== + aether-medical — Oberfläche + + Optik eines Krankenhausinformationssystems: ruhig, sachlich, hohe + Informationsdichte, klare Trennung zwischen Anzeige und Entscheidung. + Dunkelmodus ist Standard, helles Design über .thema-hell. +===================================================================== */ + +/* ---- Farbwelt ---- */ +.thema-dunkel { + --hg: #080d12; + --flaeche: #10171e; + --flaeche-2: #17202a; + --flaeche-3: #202c38; + --rand: rgba(255,255,255,0.09); + --rand-stark: rgba(255,255,255,0.16); + --text: #e6eef2; + --text-leise: #8697a4; + --akzent: #14b8a6; + --akzent-weich: rgba(20,184,166,0.14); + --gut: #34d399; + --warn: #f59e0b; + --kritisch: #ef4444; + --info: #38bdf8; +} + +.thema-hell { + --hg: #eef3f4; + --flaeche: #ffffff; + --flaeche-2: #f4f8f9; + --flaeche-3: #e6eef0; + --rand: rgba(15,42,42,0.10); + --rand-stark: rgba(15,42,42,0.20); + --text: #0d1f26; + --text-leise: #55707c; + --akzent: #0d9488; + --akzent-weich: rgba(13,148,136,0.10); + --gut: #059669; + --warn: #d97706; + --kritisch: #dc2626; + --info: #0284c7; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; height: 100%; overflow: hidden; + background: transparent; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: var(--text); + font-size: 14px; +} + +button, input, select, textarea { font-family: inherit; color: inherit; } +[hidden] { display: none !important; } + +/* ===================================================================== + Anmeldung +===================================================================== */ +.anmeldung { + position: fixed; inset: 0; z-index: 20; + display: grid; place-items: center; + background: radial-gradient(circle at 50% 30%, #14323a 0%, var(--hg) 65%); +} +.anmeldung-karte { + width: min(430px, 92%); padding: 30px 28px 24px; text-align: center; + background: var(--flaeche); border: 1px solid var(--rand-stark); + border-radius: 18px; box-shadow: 0 40px 90px -30px rgba(0,0,0,0.8); +} +.wappen { + width: 58px; height: 58px; margin: 0 auto 14px; border-radius: 16px; + display: grid; place-items: center; font-size: 28px; font-weight: 800; + background: var(--akzent-weich); color: var(--akzent); + border: 1px solid rgba(20,184,166,0.3); +} +.an-klinik { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; + color: var(--text-leise); font-weight: 800; +} +.anmeldung-karte h1 { margin: 6px 0 20px; font-size: 22px; font-weight: 800; } +.an-zeile { + display: flex; justify-content: space-between; align-items: baseline; + padding: 8px 0; border-bottom: 1px solid var(--rand); font-size: 13px; +} +.an-zeile span { color: var(--text-leise); } +.an-zeile b { font-weight: 700; } + +.an-funktion { margin-top: 18px; text-align: left; } +.an-funktion > label { + display: block; font-size: 10.5px; font-weight: 800; color: var(--text-leise); + text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; +} +.an-funktionen { display: flex; flex-wrap: wrap; gap: 6px; } +.an-funktion-knopf { + padding: 8px 12px; border-radius: 9px; cursor: pointer; transition: .12s; + background: var(--flaeche-2); border: 1px solid var(--rand); + color: var(--text-leise); font-size: 12px; font-weight: 700; +} +.an-funktion-knopf:hover { color: var(--text); border-color: var(--akzent); } +.an-funktion-knopf.gewaehlt { + background: var(--akzent); border-color: var(--akzent); color: #04211e; +} + +.an-fuss { + margin: 18px 0 0; font-size: 11px; line-height: 1.5; color: var(--text-leise); +} + +/* ===================================================================== + Grundgerüst +===================================================================== */ +.tablet { + position: fixed; inset: 0; z-index: 10; + display: grid; grid-template-columns: 232px 1fr; + background: var(--hg); +} + +/* ---- Seitenleiste ---- */ +.seitenleiste { + display: grid; grid-template-rows: auto 1fr auto; + background: var(--flaeche); border-right: 1px solid var(--rand); + min-height: 0; +} +.marke { + display: flex; align-items: center; gap: 11px; + padding: 17px 16px; border-bottom: 1px solid var(--rand); +} +.marke-wappen { + width: 34px; height: 34px; border-radius: 10px; flex: none; + display: grid; place-items: center; font-size: 18px; font-weight: 800; + background: var(--akzent-weich); color: var(--akzent); +} +.marke-text b { display: block; font-size: 14px; font-weight: 800; letter-spacing: 0.02em; } +.marke-text small { display: block; font-size: 10.5px; color: var(--text-leise); } + +.navigation { padding: 10px 9px; overflow-y: auto; min-height: 0; } +.navigation button { + width: 100%; display: flex; align-items: center; gap: 11px; + padding: 9px 11px; margin-bottom: 2px; border-radius: 9px; + background: transparent; border: none; cursor: pointer; + color: var(--text-leise); font-size: 13px; font-weight: 600; + text-align: left; transition: .12s; +} +.navigation button:hover { background: var(--flaeche-2); color: var(--text); } +.navigation button.aktiv { + background: var(--akzent-weich); color: var(--akzent); font-weight: 800; +} +.nav-icon { font-size: 15px; width: 20px; text-align: center; flex: none; } +.nav-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.seitenleiste-fuss { + display: flex; gap: 7px; padding: 12px 14px; border-top: 1px solid var(--rand); +} + +/* ---- Hauptbereich ---- */ +.hauptbereich { + display: grid; grid-template-rows: auto 1fr; min-width: 0; min-height: 0; + position: relative; +} + +.kopfleiste { + display: flex; align-items: center; gap: 14px; + padding: 11px 18px; border-bottom: 1px solid var(--rand); + background: var(--flaeche); +} + +.suche-feld { position: relative; flex: 1; max-width: 560px; } +.suche-symbol { + position: absolute; left: 12px; top: 50%; transform: translateY(-50%); + color: var(--text-leise); font-size: 15px; +} +.suche-feld input { + width: 100%; padding: 9px 13px 9px 34px; border-radius: 9px; + background: var(--flaeche-2); border: 1px solid var(--rand); + font-size: 12.5px; outline: none; +} +.suche-feld input:focus { border-color: var(--akzent); } + +.kopf-rechts { display: flex; align-items: center; gap: 9px; margin-left: auto; } + +/* Status- und Funktionswähler */ +.status-waehler, .funktion-waehler { position: relative; } +.status-knopf, .funktion-knopf { + display: flex; align-items: center; gap: 8px; + padding: 8px 13px; border-radius: 9px; cursor: pointer; + background: var(--flaeche-2); border: 1px solid var(--rand); + font-size: 12.5px; font-weight: 700; +} +.status-knopf:hover, .funktion-knopf:hover { border-color: var(--rand-stark); } +.status-punkt { + width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--gut); +} +.status-liste, .funktion-liste { + position: absolute; top: calc(100% + 6px); right: 0; z-index: 16; + min-width: 190px; padding: 5px; border-radius: 11px; + background: var(--flaeche); border: 1px solid var(--rand-stark); + box-shadow: 0 24px 50px -20px rgba(0,0,0,0.7); +} +.status-liste button, .funktion-liste button { + width: 100%; display: flex; align-items: center; gap: 9px; + padding: 8px 10px; border-radius: 8px; border: none; background: transparent; + cursor: pointer; font-size: 12.5px; text-align: left; +} +.status-liste button:hover, .funktion-liste button:hover { background: var(--flaeche-2); } + +/* Mitarbeiterchip */ +.person-chip { + display: flex; align-items: center; gap: 11px; + padding: 6px 12px; border-radius: 9px; + background: var(--flaeche-2); border: 1px solid var(--rand); +} +.person-text b { display: block; font-size: 12.5px; font-weight: 700; } +.person-text small { display: block; font-size: 10.5px; color: var(--text-leise); } +.person-nummer { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11px; font-weight: 700; color: var(--akzent); +} + +/* Inhaltsbereich */ +.inhalt { padding: 20px 22px; overflow-y: auto; min-height: 0; } +.inhalt::-webkit-scrollbar, .navigation::-webkit-scrollbar, +.schublade-inhalt::-webkit-scrollbar { width: 8px; } +.inhalt::-webkit-scrollbar-thumb, .navigation::-webkit-scrollbar-thumb, +.schublade-inhalt::-webkit-scrollbar-thumb { + background: var(--rand-stark); border-radius: 4px; +} + +/* ===================================================================== + Wiederverwendbare Bausteine +===================================================================== */ +.seiten-kopf { + display: flex; align-items: flex-end; justify-content: space-between; + gap: 14px; margin-bottom: 18px; +} +.seiten-kopf h2 { margin: 0; font-size: 20px; font-weight: 800; letter-spacing: -0.01em; } +.seiten-kopf p { margin: 3px 0 0; font-size: 12.5px; color: var(--text-leise); max-width: 74ch; } + +.karte { + background: var(--flaeche); border: 1px solid var(--rand); + border-radius: 14px; padding: 16px; +} +.karte-kopf { + display: flex; align-items: center; justify-content: space-between; + gap: 10px; margin-bottom: 12px; +} +.karte-titel { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.07em; + color: var(--text-leise); font-weight: 800; +} + +/* Raster — eine Schreibweise, überall gleich */ +.raster { display: grid; gap: 14px; margin-bottom: 16px; } +.raster.k2 { grid-template-columns: repeat(2, 1fr); } +.raster.k3 { grid-template-columns: repeat(3, 1fr); } +.raster.k4 { grid-template-columns: repeat(4, 1fr); } +.raster.k5 { grid-template-columns: repeat(5, 1fr); } +.raster.k6 { grid-template-columns: repeat(6, 1fr); } +@media (max-width: 1500px) { + .raster.k6, .raster.k5 { grid-template-columns: repeat(3, 1fr); } +} +@media (max-width: 1150px) { + .raster.k4, .raster.k3 { grid-template-columns: repeat(2, 1fr); } + .tablet { grid-template-columns: 62px 1fr; } + .marke-text, .nav-text { display: none; } + .navigation button { justify-content: center; } +} + +/* Kennzahl */ +.kennzahl { + background: var(--flaeche); border: 1px solid var(--rand); + border-radius: 12px; padding: 14px 16px; +} +.kennzahl .kz-titel { + font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; + color: var(--text-leise); font-weight: 800; +} +.kennzahl .kz-wert { + font-size: 26px; font-weight: 800; margin-top: 4px; font-variant-numeric: tabular-nums; + line-height: 1.15; +} +.kennzahl .kz-zusatz { font-size: 11.5px; color: var(--text-leise); margin-top: 2px; } +.kennzahl.hoch .kz-wert { color: var(--kritisch); } +.kennzahl.warn .kz-wert { color: var(--warn); } +.kennzahl.gut .kz-wert { color: var(--gut); } +.kennzahl.klickbar { cursor: pointer; transition: .12s; } +.kennzahl.klickbar:hover { border-color: var(--akzent); } + +/* Listen */ +.liste { display: flex; flex-direction: column; } +.liste-zeile { + display: flex; align-items: center; gap: 12px; + padding: 10px 2px; border-bottom: 1px solid var(--rand); +} +.liste-zeile:last-child { border-bottom: none; } +.liste-zeile[data-oeffne], .liste-zeile.klickbar { cursor: pointer; } +.liste-zeile[data-oeffne]:hover, .liste-zeile.klickbar:hover { background: var(--flaeche-2); } +.liste-zeile .lz-haupt { flex: 1; min-width: 0; } +.liste-zeile .lz-titel { font-size: 13px; font-weight: 700; } +.liste-zeile .lz-unter { font-size: 11.5px; color: var(--text-leise); margin-top: 1px; } +.liste-leer { + padding: 22px; text-align: center; font-size: 12.5px; color: var(--text-leise); +} + +/* Marken / Etiketten */ +.marker { + font-size: 10px; font-weight: 800; padding: 3px 8px; border-radius: 999px; + display: inline-block; white-space: nowrap; +} +.marker.gut { background: rgba(52,211,153,0.15); color: var(--gut); } +.marker.warn { background: rgba(245,158,11,0.15); color: var(--warn); } +.marker.kritisch { background: rgba(239,68,68,0.15); color: var(--kritisch); } +.marker.info { background: rgba(56,189,248,0.15); color: var(--info); } +.marker.neutral { background: var(--flaeche-3); color: var(--text-leise); } + +/* Prioritätsstreifen */ +.prio { width: 3px; align-self: stretch; border-radius: 2px; flex: none; min-height: 30px; } +.prio.p1 { background: var(--kritisch); } +.prio.p2 { background: var(--warn); } +.prio.p3 { background: var(--akzent); } +.prio.p4 { background: var(--text-leise); } + +/* Knöpfe */ +.knopf { + padding: 10px 16px; border-radius: 9px; border: 1px solid transparent; + background: var(--akzent); color: #04211e; + font-size: 13px; font-weight: 800; cursor: pointer; transition: .12s; +} +.knopf:hover { filter: brightness(1.08); } +.knopf:active { transform: scale(0.98); } +.knopf:disabled { opacity: .45; cursor: not-allowed; } +.knopf.leise { + background: var(--flaeche-2); color: var(--text); + border-color: var(--rand); font-weight: 700; +} +.knopf.leise:hover { background: var(--flaeche-3); filter: none; } +.knopf.gefahr { background: var(--kritisch); color: #fff; } +.knopf.gross { width: 100%; padding: 13px; font-size: 14px; margin-top: 18px; } +.knopf.klein { padding: 7px 10px; font-size: 12px; } +.knopf.symbol { padding: 8px 11px; font-size: 15px; position: relative; } + +.zaehler { + position: absolute; top: 2px; right: 2px; + background: var(--kritisch); color: #fff; + font-size: 9px; font-weight: 800; min-width: 15px; height: 15px; + border-radius: 999px; display: grid; place-items: center; padding: 0 3px; +} + +/* Warnhinweise */ +.warnung { + display: flex; align-items: center; gap: 11px; + padding: 11px 14px; border-radius: 10px; margin-bottom: 8px; + border: 1px solid var(--rand); background: var(--flaeche); + font-size: 12.5px; line-height: 1.5; +} +.warnung.klickbar { cursor: pointer; } +.warnung .w-symbol { font-size: 16px; flex: none; } +.warnung.kritisch { border-color: rgba(239,68,68,0.4); background: rgba(239,68,68,0.07); } +.warnung.warn { border-color: rgba(245,158,11,0.35); background: rgba(245,158,11,0.06); } +.warnung b { color: var(--text); } + +/* ===================================================================== + Mitteilungs-Schublade +===================================================================== */ +.schublade { + position: absolute; top: 0; right: 0; bottom: 0; width: 360px; z-index: 15; + background: var(--flaeche); border-left: 1px solid var(--rand-stark); + display: grid; grid-template-rows: auto 1fr; + box-shadow: -20px 0 50px -20px rgba(0,0,0,0.5); + animation: schubladeRein .18s ease; +} +@keyframes schubladeRein { from { transform: translateX(24px); opacity: 0; } } +.schublade-kopf { + display: flex; align-items: center; justify-content: space-between; + padding: 15px 16px; border-bottom: 1px solid var(--rand); +} +.schublade-inhalt { overflow-y: auto; padding: 10px 12px; } +.hinweis-eintrag { + padding: 11px 12px; border-radius: 10px; margin-bottom: 7px; + background: var(--flaeche-2); border: 1px solid var(--rand); cursor: pointer; +} +.hinweis-eintrag.ungelesen { border-left: 3px solid var(--akzent); } +.hinweis-eintrag.prio-hoch.ungelesen { border-left-color: var(--warn); } +.hinweis-eintrag.prio-kritisch.ungelesen { border-left-color: var(--kritisch); } +.hinweis-eintrag .he-kopf { + display: flex; align-items: center; justify-content: space-between; gap: 8px; +} +.hinweis-eintrag .he-titel { font-size: 12.5px; font-weight: 700; } +.hinweis-eintrag .he-text { + font-size: 11.5px; color: var(--text-leise); margin-top: 3px; line-height: 1.45; +} +.hinweis-eintrag .he-zeit { font-size: 10px; color: var(--text-leise); flex: none; } + +/* ===================================================================== + Fenster (Dialoge) +===================================================================== */ +.fenster-ebene { position: absolute; inset: 0; z-index: 18; pointer-events: none; } +.fenster-ebene > * { pointer-events: auto; } +.fenster-hg { + position: absolute; inset: 0; background: rgba(3,8,10,0.6); backdrop-filter: blur(3px); + display: grid; place-items: center; padding: 30px; +} +.fenster { + width: min(720px, 92%); max-height: 86vh; display: grid; grid-template-rows: auto 1fr auto; + background: var(--flaeche); border: 1px solid var(--rand-stark); + border-radius: 15px; box-shadow: 0 40px 90px -30px rgba(0,0,0,0.8); + animation: fensterRein .16s ease; +} +@keyframes fensterRein { from { opacity: 0; transform: translateY(10px) scale(.99); } } +.fenster-kopf { + display: flex; align-items: center; justify-content: space-between; + padding: 15px 18px; border-bottom: 1px solid var(--rand); +} +.fenster-kopf h3 { margin: 0; font-size: 15.5px; font-weight: 800; } +.fenster-koerper { padding: 18px; overflow-y: auto; } +.fenster-fuss { + display: flex; gap: 9px; justify-content: flex-end; + padding: 13px 18px; border-top: 1px solid var(--rand); +} + +/* Formularelemente */ +.feld { margin-bottom: 14px; } +.feld label { + display: block; font-size: 11px; font-weight: 800; color: var(--text-leise); + text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; +} +.feld input, .feld select, .feld textarea { + width: 100%; padding: 10px 12px; border-radius: 9px; + background: var(--flaeche-2); border: 1px solid var(--rand); + font-size: 13px; outline: none; +} +.feld input:focus, .feld select:focus, .feld textarea:focus { border-color: var(--akzent); } +.feld textarea { min-height: 90px; resize: vertical; line-height: 1.5; } +.feld-hinweis { font-size: 11px; color: var(--text-leise); margin-top: 5px; line-height: 1.45; } + +/* Formularspalten */ +.feld-reihe { + display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 0 14px; +} + +/* ===================================================================== + Kurzmeldungen +===================================================================== */ +.meldungen { + position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%); + z-index: 25; display: flex; flex-direction: column; gap: 8px; align-items: center; + pointer-events: none; +} +.meldung { + padding: 11px 20px; border-radius: 10px; font-size: 13px; font-weight: 600; + background: var(--flaeche-3); border: 1px solid var(--rand-stark); + box-shadow: 0 16px 34px -14px rgba(0,0,0,0.6); + animation: meldungRein .2s ease; +} +@keyframes meldungRein { from { opacity: 0; transform: translateY(10px); } } +.meldung.gut { border-color: var(--gut); } +.meldung.fehler { border-color: var(--kritisch); } + +/* Ladeanzeige */ +.laden { + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 12px; padding: 60px; color: var(--text-leise); font-size: 13px; +} +.spinner { + width: 26px; height: 26px; border-radius: 50%; + border: 2.5px solid var(--rand-stark); border-top-color: var(--akzent); + animation: dreh .7s linear infinite; +} +@keyframes dreh { to { transform: rotate(360deg); } } + +/* Grundsatz-Hinweis (erscheint an Entscheidungspunkten) */ +.grundsatz { + display: flex; gap: 10px; padding: 11px 13px; border-radius: 9px; + background: var(--akzent-weich); border: 1px solid rgba(20,184,166,0.25); + font-size: 11.5px; line-height: 1.5; color: var(--text-leise); margin-top: 12px; +} +.grundsatz b { color: var(--text); } + +/* Reiter innerhalb eines Moduls */ +.reiter { display: flex; gap: 5px; margin-bottom: 14px; flex-wrap: wrap; } +.reiter button { + padding: 8px 14px; border-radius: 9px; cursor: pointer; + background: transparent; border: 1px solid transparent; + color: var(--text-leise); font-size: 12.5px; font-weight: 700; +} +.reiter button:hover { color: var(--text); background: var(--flaeche-2); } +.reiter button.aktiv { + background: var(--akzent-weich); border-color: rgba(20,184,166,0.3); color: var(--akzent); +} + +/* Filterleiste */ +.filterleiste { + display: flex; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; align-items: center; +} +.filterleiste input, .filterleiste select { + padding: 9px 12px; border-radius: 9px; font-size: 12.5px; outline: none; + background: var(--flaeche-2); border: 1px solid var(--rand); +} +.filterleiste input { flex: 1; min-width: 200px; } +.filterleiste input:focus, .filterleiste select:focus { border-color: var(--akzent); } + +/* ===================================================================== + Dashboard +===================================================================== */ +.db-warnungen { margin-bottom: 16px; } + +.db-spalten { + display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 14px; +} + +.db-dienstpunkt { + width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--flaeche-3); +} +.db-nummer { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11.5px; font-weight: 700; color: var(--text-leise); + min-width: 84px; flex: none; +} +.db-zeit { + font-size: 11px; color: var(--text-leise); white-space: nowrap; flex: none; + font-variant-numeric: tabular-nums; +} + +/* Belegungsbalken */ +.db-belegung { display: flex; flex-direction: column; gap: 9px; margin-top: 4px; } +.db-bel-zeile { + display: grid; grid-template-columns: 130px 1fr 74px; gap: 10px; align-items: center; +} +.db-bel-name { font-size: 12.5px; color: var(--text-leise); } +.db-bel-balken { + height: 8px; border-radius: 4px; background: var(--flaeche-3); overflow: hidden; +} +.db-bel-balken > i { display: block; height: 100%; border-radius: 4px; transition: width .3s; } +.db-bel-zahl { + font-size: 12px; font-weight: 800; text-align: right; + font-variant-numeric: tabular-nums; white-space: nowrap; +} + +.db-grundsatz { + margin-top: 16px; padding: 13px 15px; border-radius: 11px; + background: var(--flaeche); border: 1px solid var(--rand); + font-size: 11.5px; line-height: 1.6; color: var(--text-leise); +} +.db-grundsatz b { color: var(--text); } + +/* ===================================================================== + Medizinisches MDT +===================================================================== */ +.mdt-kopf { + display: grid; grid-template-columns: 132px 1fr auto; + gap: 18px; align-items: start; + background: var(--flaeche); border: 1px solid var(--rand); + border-radius: 14px; padding: 16px; margin-bottom: 16px; +} +@media (max-width: 1000px) { + .mdt-kopf { grid-template-columns: 96px 1fr; } + .mdt-kopf-aktionen { grid-column: 1 / -1; } +} + +.mdt-bild { + width: 132px; height: 132px; border-radius: 12px; overflow: hidden; + background: var(--flaeche-2); border: 1px solid var(--rand); + display: grid; place-items: center; + font-size: 11px; color: var(--text-leise); text-align: center; +} +.mdt-bild img { width: 100%; height: 100%; object-fit: cover; } +.mdt-bild.klein { + width: 34px; height: 34px; border-radius: 8px; flex: none; font-size: 13px; +} + +.mdt-kopf-werte { + display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 3px 20px; min-width: 0; +} +.mdt-wert { + display: flex; justify-content: space-between; align-items: baseline; + gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--rand); +} +.mdt-wert span { font-size: 11.5px; color: var(--text-leise); white-space: nowrap; } +.mdt-wert b { + font-size: 12.5px; font-weight: 700; text-align: right; + overflow: hidden; text-overflow: ellipsis; +} +/* Nicht erhoben ist etwas anderes als unauffällig — deshalb blass, nicht grün */ +.mdt-wert.fehlt b { color: var(--text-leise); font-weight: 500; font-style: italic; } + +.mdt-kopf-aktionen { display: flex; flex-direction: column; gap: 7px; } + +.mdt-zahl { + display: inline-block; margin-left: 6px; padding: 1px 6px; border-radius: 999px; + background: var(--flaeche-3); color: var(--text-leise); + font-size: 10px; font-weight: 800; +} + +/* Zeitleiste */ +.mdt-verlauf { display: flex; flex-direction: column; } +.mdt-vl-schritt { + display: grid; grid-template-columns: 34px 1fr; gap: 12px; + padding-bottom: 14px; position: relative; +} +.mdt-vl-schritt:not(:last-child)::before { + content: ""; position: absolute; left: 16px; top: 32px; bottom: 0; + width: 1px; background: var(--rand); +} +.mdt-vl-punkt { + width: 32px; height: 32px; border-radius: 50%; z-index: 1; + display: grid; place-items: center; font-size: 14px; + background: var(--flaeche-2); border: 1px solid var(--rand); +} +.mdt-vl-punkt.gut { border-color: rgba(52,211,153,0.4); } +.mdt-vl-punkt.warn { border-color: rgba(245,158,11,0.4); } +.mdt-vl-punkt.kritisch { border-color: rgba(239,68,68,0.4); } +.mdt-vl-punkt.info { border-color: rgba(56,189,248,0.4); } + +.mdt-vl-inhalt { min-width: 0; padding-top: 4px; } +.mdt-vl-kopf { + display: flex; justify-content: space-between; align-items: baseline; + gap: 12px; font-size: 13px; +} +.mdt-vl-text { font-size: 12.5px; margin-top: 3px; line-height: 1.5; } +.mdt-vl-meta { font-size: 11px; color: var(--text-leise); margin-top: 3px; } + +/* Akten */ +.mdt-akte-aktionen { display: flex; gap: 6px; flex-wrap: wrap; } +.mdt-akte-titel { margin: 4px 0 8px; font-size: 17px; font-weight: 800; } +.mdt-akte-meta { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; +} +.mdt-akte-meta .feld-hinweis { margin: 0; } +.mdt-akte-inhalt { + font-size: 13px; line-height: 1.65; white-space: normal; word-break: break-word; + padding: 14px; border-radius: 10px; + background: var(--flaeche-2); border: 1px solid var(--rand); +} + +/* ===================================================================== + Wertepaare und Kartenfuss (von mehreren Modulen genutzt) +===================================================================== */ +.werte-liste { + display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 4px 18px; margin-top: 4px; +} +.werte-liste > div { + display: flex; justify-content: space-between; align-items: baseline; + gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--rand); +} +.werte-liste span { font-size: 12px; color: var(--text-leise); white-space: nowrap; } +.werte-liste b { font-size: 12.5px; text-align: right; overflow-wrap: anywhere; } + +.karte-fuss { + margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--rand); + font-size: 11.5px; line-height: 1.55; color: var(--text-leise); +} +.karte-fuss b { color: var(--text); } + +/* Auswahl in Trefferlisten */ +.liste-zeile.gewaehlt { + border-color: var(--akzent); background: var(--flaeche-3); +} + +/* ===================================================================== + Patientenverwaltung +===================================================================== */ +.pat-geburt { + font-size: 11px; font-weight: 500; color: var(--text-leise); margin-left: 7px; +} + +.pat-trefferliste { max-height: 220px; overflow-y: auto; margin-bottom: 14px; } + +/* Stationen und Zimmerwahl */ +.pat-station { margin-bottom: 14px; } +.pat-station:last-child { margin-bottom: 0; } +.pat-station-kopf { + display: flex; align-items: center; justify-content: space-between; + gap: 10px; margin-bottom: 7px; +} +.pat-station-kopf b { font-size: 12.5px; } + +.pat-zimmer { max-height: 320px; overflow-y: auto; padding-right: 4px; } +.pat-zimmerreihe { display: flex; flex-wrap: wrap; gap: 6px; } + +.pat-zimmer-knopf { + display: flex; flex-direction: column; gap: 2px; align-items: flex-start; + min-width: 104px; padding: 9px 11px; border-radius: 9px; cursor: pointer; + background: var(--flaeche-2); border: 1px solid var(--rand); + text-align: left; transition: .12s; +} +.pat-zimmer-knopf:hover:not(:disabled) { border-color: var(--akzent); } +.pat-zimmer-knopf.gewaehlt { + background: var(--akzent-weich); border-color: var(--akzent); +} +/* Belegte und gesperrte Zimmer bleiben sichtbar, aber nicht waehlbar */ +.pat-zimmer-knopf.belegt, .pat-zimmer-knopf:disabled { + opacity: .42; cursor: not-allowed; +} +.pz-name { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12px; font-weight: 800; +} +.pz-betten { font-size: 10.5px; color: var(--text-leise); } +.pz-status { + font-size: 9.5px; font-weight: 800; text-transform: uppercase; + letter-spacing: 0.05em; color: var(--warn); +} + +.pat-status { + padding: 6px 9px; border-radius: 8px; font-size: 11.5px; outline: none; flex: none; + background: var(--flaeche-2); border: 1px solid var(--rand); +} + +/* ===================================================================== + Leitstelle und Einsatzverwaltung +===================================================================== */ +.ls-kopf-aktionen { display: flex; gap: 8px; flex-wrap: wrap; } + +.ls-raster { + display: grid; grid-template-columns: 1.55fr 1fr; gap: 14px; align-items: start; +} +@media (max-width: 1250px) { .ls-raster { grid-template-columns: 1fr; } } + +.ls-liste { display: flex; flex-direction: column; gap: 9px; } + +/* Einsatzkarte */ +.ls-einsatz { + padding: 12px 14px 11px; border-radius: 11px; + background: var(--flaeche-2); border: 1px solid var(--rand); + border-left: 3px solid var(--text-leise); transition: .12s; +} +.ls-einsatz.prio-1 { border-left-color: var(--kritisch); } +.ls-einsatz.prio-2 { border-left-color: var(--warn); } +.ls-einsatz.prio-3 { border-left-color: var(--akzent); } +.ls-einsatz.prio-4 { border-left-color: var(--text-leise); } +/* Einsätze, an denen man selbst hängt, heben sich ab */ +.ls-einsatz.meiner { background: var(--akzent-weich); } +/* Ziel beim Ziehen einer Kraft */ +.ls-einsatz.uebergabe { + border-color: var(--akzent); box-shadow: 0 0 0 2px var(--akzent-weich); +} + +.ls-e-kopf { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 5px; +} +.ls-e-nummer { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11.5px; font-weight: 800; color: var(--text-leise); +} +.ls-e-kopf .db-zeit { margin-left: auto; } + +.ls-e-titel { font-size: 14px; font-weight: 700; } +.ls-e-unter { font-size: 11.5px; color: var(--text-leise); margin-top: 2px; } +.ls-e-kategorie { font-weight: 800; } + +.ls-e-fuss { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; flex-wrap: wrap; margin-top: 10px; + padding-top: 9px; border-top: 1px solid var(--rand); +} +.ls-e-fuss .feld-hinweis { margin: 0; } +.ls-e-aktionen { display: flex; gap: 6px; flex-wrap: wrap; } + +/* Ziehbare Kräfte */ +.ls-kraft[draggable="true"] { cursor: grab; } +.ls-kraft[draggable="true"]:active { cursor: grabbing; } +.ls-kraft.zieht { opacity: .45; } + +/* ===================================================================== + Behandlungsverwaltung +===================================================================== */ +.beh-kopf { + display: grid; grid-template-columns: 1fr auto; gap: 18px; align-items: start; + background: var(--flaeche); border: 1px solid var(--rand); + border-radius: 14px; padding: 16px; margin-bottom: 16px; +} +@media (max-width: 900px) { .beh-kopf { grid-template-columns: 1fr; } } +.beh-kopf-aktionen { display: flex; flex-direction: column; gap: 7px; } +/* Fehlende Stammdaten heissen „nicht erhoben", nicht „unauffaellig" */ +.werte-liste > div.leer b { color: var(--text-leise); font-style: italic; font-weight: 500; } + +/* Dokumentationsbogen */ +.beh-bogen { + display: grid; grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); gap: 14px; +} +.beh-feld { + background: var(--flaeche); border: 1px solid var(--rand); + border-radius: 12px; padding: 14px; +} +.beh-feld > label { + display: block; font-size: 11px; font-weight: 800; color: var(--text-leise); + text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 7px; +} +.beh-feld textarea { + width: 100%; padding: 10px 12px; border-radius: 9px; resize: vertical; + background: var(--flaeche-2); border: 1px solid var(--rand); + font-size: 13px; line-height: 1.55; outline: none; min-height: 92px; +} +.beh-feld textarea:focus { border-color: var(--akzent); } +.beh-feld .feld-hinweis { margin-top: 6px; } +.beh-feld.gesperrt { background: var(--flaeche-2); } + +.beh-text { + font-size: 13px; line-height: 1.6; padding: 10px 12px; border-radius: 9px; + background: var(--flaeche); border: 1px solid var(--rand); + overflow-wrap: anywhere; +} +.beh-text.leer { color: var(--text-leise); font-style: italic; } + +/* Medikamentenauswahl */ +.beh-medikamente { + max-height: 300px; overflow-y: auto; margin-top: 9px; + padding: 4px; border-radius: 10px; + background: var(--flaeche-2); border: 1px solid var(--rand); +} +.beh-med-gruppe { margin-bottom: 10px; } +.beh-med-gruppe:last-child { margin-bottom: 0; } +.beh-med-titel { + font-size: 10px; font-weight: 800; text-transform: uppercase; + letter-spacing: 0.06em; color: var(--text-leise); padding: 4px 8px; +} +.beh-med { + width: 100%; display: grid; grid-template-columns: 1fr auto; + gap: 2px 10px; align-items: center; text-align: left; + padding: 8px 10px; margin-bottom: 3px; border-radius: 8px; cursor: pointer; + background: transparent; border: 1px solid transparent; transition: .12s; +} +.beh-med:hover:not(:disabled) { background: var(--flaeche-3); } +.beh-med.gewaehlt { + background: var(--akzent-weich); border-color: var(--akzent); +} +/* Betaeubungsmittel ohne Recht bleiben sichtbar, aber unwaehlbar */ +.beh-med.gesperrt, .beh-med:disabled { opacity: .4; cursor: not-allowed; } + +.beh-med-name { + font-size: 12.5px; font-weight: 700; display: flex; align-items: center; gap: 7px; +} +.beh-med-unter { + grid-column: 1; font-size: 10.5px; color: var(--text-leise); +} +.beh-med-bestand { + grid-row: 1 / 3; font-size: 11px; font-weight: 800; white-space: nowrap; + color: var(--text-leise); font-variant-numeric: tabular-nums; +} +.beh-med-bestand.knapp { color: var(--warn); } + +.beh-bestandswarnung { margin-top: 10px; } + +/* Ankreuzfeld in Formularen */ +.haken { width: auto !important; margin-right: 7px; vertical-align: -1px; } +.feld label:has(.haken) { + text-transform: none; letter-spacing: 0; font-size: 12.5px; + color: var(--text); cursor: pointer; +} + +/* ===================================================================== + Labor +===================================================================== */ +.lab-spalten { + display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 14px; +} +/* Der Befundblock hebt sich ab, solange die Bewertung fehlt */ +.lab-block.offen { border-color: rgba(245,158,11,0.45); } +.lab-block .beh-text { min-height: 120px; } + +/* ===================================================================== + Operationsverwaltung +===================================================================== */ +.op-saele { + display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + gap: 10px; margin-top: 4px; +} +.op-saal { + padding: 12px 14px; border-radius: 11px; + background: var(--flaeche-2); border: 1px solid var(--rand); + border-left: 3px solid var(--gut); +} +.op-saal.belegt { border-left-color: var(--warn); } +.op-saal.gesperrt { border-left-color: var(--kritisch); opacity: .7; } +.op-saal-kopf { + display: flex; align-items: center; justify-content: space-between; + gap: 8px; margin-bottom: 6px; +} +.op-saal-name { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 13px; font-weight: 800; +} +.op-saal-inhalt { font-size: 12px; line-height: 1.45; } +.op-saal-inhalt .feld-hinweis { margin-top: 3px; } + +/* ===================================================================== + Medikamentenverwaltung +===================================================================== */ +.med-aus { opacity: .5; } +.med-aus .lz-titel { text-decoration: line-through; } + +/* Ausgewaehlte Kachel dient als Schnellfilter */ +.kennzahl.gewaehlt { + border-color: var(--akzent); box-shadow: 0 0 0 1px var(--akzent-weich) inset; +} + +.med-bestand { + display: flex; flex-direction: column; gap: 4px; align-items: flex-end; + min-width: 110px; flex: none; +} +.med-balken { + width: 100px; height: 6px; border-radius: 4px; + background: var(--flaeche-3); overflow: hidden; +} +.med-balken > i { display: block; height: 100%; border-radius: 4px; transition: width .3s; } +.med-zahl { + font-size: 12px; font-weight: 800; white-space: nowrap; + font-variant-numeric: tabular-nums; color: var(--text-leise); +} +.med-zahl.knapp { color: var(--warn); } +.med-zahl.leer { color: var(--kritisch); } + +/* ===================================================================== + Stationen und Belegung +===================================================================== */ +.st-stationen { display: flex; flex-direction: column; gap: 14px; } +.st-station .karte-titel { display: flex; align-items: center; gap: 8px; } + +/* Kennung in der Verwaltungsliste */ +.st-kennung { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11px; font-weight: 800; padding: 2px 6px; border-radius: 6px; + background: var(--flaeche-3); color: var(--text-leise); +} +.st-verwaltung-knoepfe { display: flex; gap: 6px; flex: none; } + +/* Zimmerkacheln einer Station */ +.st-zimmerraster { + display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; +} +.st-zimmer { + padding: 11px 12px; border-radius: 11px; cursor: pointer; transition: .12s; + background: var(--flaeche-2); border: 1px solid var(--rand); + border-left: 3px solid var(--gut); +} +.st-zimmer:hover { border-color: var(--akzent); } +/* Der linke Streifen zeigt nur den gesetzten Status — nie eine Bewertung */ +.st-zimmer.status-reinigung { border-left-color: var(--warn); } +.st-zimmer.status-gesperrt { border-left-color: var(--kritisch); opacity: .72; } + +.st-zimmer-kopf { + display: flex; align-items: center; justify-content: space-between; + gap: 8px; margin-bottom: 8px; +} +.st-zimmer-name { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12.5px; font-weight: 800; +} +.st-zimmer-fuss { + margin-top: 8px; font-size: 10.5px; line-height: 1.4; color: var(--text-leise); +} + +/* Einzelne Betten */ +.st-betten { display: flex; flex-direction: column; gap: 4px; } +.st-bett { + display: flex; align-items: center; gap: 8px; + padding: 5px 8px; border-radius: 7px; background: var(--flaeche); + border: 1px dashed var(--rand); +} +.st-bett.belegt { border-style: solid; border-color: var(--rand-stark); } + +.st-bett-nr { + font-size: 10px; font-weight: 800; color: var(--text-leise); + min-width: 12px; font-variant-numeric: tabular-nums; flex: none; +} +.st-bett-name { + font-size: 11.5px; flex: 1; min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.st-bett.frei .st-bett-name { color: var(--text-leise); font-style: italic; } +.st-bett.belegt .st-bett-name { font-weight: 700; } + +.st-bett-marker { display: flex; gap: 3px; flex: none; } +.st-bett-marker .marker { font-size: 8.5px; padding: 2px 5px; } + +/* ===================================================================== + Intensivstation +===================================================================== */ +/* Der Punkt traegt die Farbe des eingetragenen Zustands — nicht mehr */ +.its-punkt { + width: 10px; height: 10px; border-radius: 50%; flex: none; + box-shadow: 0 0 0 3px var(--flaeche-2); +} + +/* Angeschlossene Geraete als Textbausteine */ +.its-geraete { display: flex; flex-wrap: wrap; gap: 6px; } +.its-geraet { + font-size: 11px; font-weight: 700; padding: 5px 10px; border-radius: 8px; + background: var(--flaeche-2); border: 1px solid var(--rand); + font-variant-numeric: tabular-nums; +} + +.its-geraetewahl { display: flex; flex-wrap: wrap; gap: 6px; } +.its-geraet-knopf { + font-size: 11.5px; font-weight: 700; padding: 7px 11px; border-radius: 8px; + cursor: pointer; transition: .12s; + background: var(--flaeche-2); border: 1px solid var(--rand); color: var(--text); +} +.its-geraet-knopf:hover { border-color: var(--akzent); } +.its-geraet-knopf.gewaehlt { + background: var(--akzent-weich); border-color: var(--akzent); +} + +/* ===================================================================== + Blutbank +===================================================================== */ +.blut-gruppe { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 12.5px; font-weight: 800; flex: none; + min-width: 42px; text-align: center; + padding: 4px 8px; border-radius: 8px; + background: rgba(239,68,68,0.12); color: var(--kritisch); +} + +/* Bestandskacheln fallen etwas kompakter aus als die Kennzahlen darueber */ +.blut-bestand .kennzahl { padding: 11px 13px; } +.blut-bestand .kz-wert { font-size: 22px; } + +/* Verträglichkeitstabelle — reine Auskunft */ +.blut-reihe { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; } +.blut-zelle { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 10.5px; font-weight: 800; padding: 3px 7px; border-radius: 6px; + background: var(--flaeche-2); border: 1px solid var(--rand); + color: var(--text-leise); opacity: .5; +} +.blut-zelle.ja { + background: rgba(52,211,153,0.14); border-color: rgba(52,211,153,0.4); + color: var(--gut); opacity: 1; +} + +/* ===================================================================== + Fuhrpark +===================================================================== */ +.fz-raster { + display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; +} +.fz-karte { + padding: 14px 15px; border-radius: 13px; cursor: pointer; transition: .12s; + background: var(--flaeche); border: 1px solid var(--rand); + border-left: 3px solid var(--gut); +} +.fz-karte:hover { border-color: var(--akzent); } +/* Der Streifen zeigt den eingetragenen Status — nie einen gemeldeten Wert */ +.fz-karte.status-im_einsatz { border-left-color: var(--warn); } +.fz-karte.status-werkstatt { border-left-color: var(--info); } +.fz-karte.status-ausser_dienst { border-left-color: var(--text-leise); opacity: .72; } + +.fz-kopf { + display: flex; align-items: flex-start; justify-content: space-between; + gap: 10px; margin-bottom: 9px; +} +.fz-name { font-size: 14px; font-weight: 800; } +.fz-kennzeichen { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11px; color: var(--text-leise); margin-top: 1px; letter-spacing: 0.03em; +} +.fz-zeile { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 11px; } + +/* Gemeldete Werte */ +.fz-werte { display: flex; flex-direction: column; gap: 8px; } +.fz-wert-kopf { + display: flex; justify-content: space-between; align-items: baseline; + font-size: 10.5px; color: var(--text-leise); margin-bottom: 3px; +} +.fz-wert-kopf b { font-size: 11.5px; color: var(--text); font-variant-numeric: tabular-nums; } +.fz-balken { + height: 6px; border-radius: 4px; background: var(--flaeche-3); overflow: hidden; +} +.fz-balken > i { display: block; height: 100%; border-radius: 4px; transition: width .3s; } +/* Eine alte Meldung sagt nichts ueber jetzt — das darf man sehen */ +.fz-wert.alt, .fz-werte.alt { opacity: .45; } + +.fz-fuss { + margin-top: 10px; font-size: 10.5px; line-height: 1.4; color: var(--text-leise); +} + +/* Ausstattung */ +.fz-ausstattung { display: flex; flex-wrap: wrap; gap: 6px; } +.fz-teil { + font-size: 11px; font-weight: 700; padding: 5px 10px; border-radius: 8px; + background: var(--flaeche-2); border: 1px solid var(--rand); +} + +.fz-ausstattungswahl { display: flex; flex-wrap: wrap; gap: 6px; } +.fz-teil-knopf { + font-size: 11.5px; font-weight: 700; padding: 7px 11px; border-radius: 8px; + cursor: pointer; transition: .12s; + background: var(--flaeche-2); border: 1px solid var(--rand); color: var(--text); +} +.fz-teil-knopf:hover { border-color: var(--akzent); } +.fz-teil-knopf.gewaehlt { background: var(--akzent-weich); border-color: var(--akzent); } + +/* ===================================================================== + Nachrichten und Aushänge +===================================================================== */ +/* Ungelesene Post faellt auf, ohne zu blinken */ +.nk-punkt { + width: 7px; height: 7px; border-radius: 50%; flex: none; + background: transparent; +} +.nk-punkt.offen { background: var(--akzent); } +.liste-zeile.nk-ungelesen .lz-titel { font-weight: 800; } +.liste-zeile.nk-ungelesen .lz-unter { color: var(--text); } + +/* Empfaengerart beim Schreiben */ +.nk-typwahl { margin-bottom: 0; } + +/* Schwarzes Brett */ +.ah-raster { + display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 12px; +} +.ah-karte { + display: flex; flex-direction: column; + padding: 14px 15px; border-radius: 13px; cursor: pointer; transition: .12s; + background: var(--flaeche); border: 1px solid var(--rand); + border-left: 3px solid var(--text-leise); +} +.ah-karte:hover { border-color: var(--akzent); } +.ah-karte.angeheftet { background: var(--flaeche-2); } +.ah-karte.archiviert { opacity: .6; } + +.ah-kopf { + display: flex; align-items: flex-start; justify-content: space-between; + gap: 10px; margin-bottom: 8px; +} +.ah-titel { font-size: 13.5px; font-weight: 800; line-height: 1.35; } +.ah-nadel { margin-right: 4px; } + +.ah-text { + font-size: 12px; line-height: 1.5; color: var(--text-leise); + flex: 1; overflow-wrap: anywhere; +} +.ah-fuss { + display: flex; flex-direction: column; gap: 7px; + margin-top: 11px; font-size: 10.5px; color: var(--text-leise); +} +.ah-marker { display: flex; flex-wrap: wrap; gap: 5px; } +.ah-archiv { + margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--rand); + font-size: 10.5px; color: var(--text-leise); +} + +/* ===================================================================== + Bausteine +===================================================================== */ +.bs-liste { max-height: 240px; overflow-y: auto; } + +/* Vorschau eines Bausteins: Feldname, darunter der Wortlaut */ +.bs-feld { margin-bottom: 11px; } +.bs-feld:last-child { margin-bottom: 0; } +.bs-feld > label { + display: block; font-size: 11px; font-weight: 800; color: var(--text-leise); + text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; +} +.bs-feld .beh-text { background: var(--flaeche-2); font-size: 12.5px; } diff --git a/resources/[aether]/aether-medical/html/index.html b/resources/[aether]/aether-medical/html/index.html new file mode 100644 index 0000000..af732ac --- /dev/null +++ b/resources/[aether]/aether-medical/html/index.html @@ -0,0 +1,145 @@ + + + + + + +LSMC — Klinikinformationssystem + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/[aether]/aether-medical/html/js/app.js b/resources/[aether]/aether-medical/html/js/app.js new file mode 100644 index 0000000..3e6a86b --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/app.js @@ -0,0 +1,628 @@ +/* ===================================================================== + aether-medical — Oberflächenkern + + Der Kern kennt keine Medizin. Er bringt die Hülle mit (Seitenleiste, + Kopfleiste, Dialoge, Mitteilungen) und ruft Module auf, die sich + selbst über `App.registriereAnsicht(...)` anmelden. + + Läuft die Datei im Browser statt in FiveM, schaltet sie in eine + Vorschau um — so bleibt die Oberfläche eigenständig ansehbar. +===================================================================== */ +"use strict"; + +const IST_FIVEM = (typeof GetParentResourceName === "function"); +const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-medical"; + +const App = (() => { + + /* ---- Zustand ------------------------------------------------------ */ + const zustand = { + imDienst: false, + mitarbeiter: null, + module: [], + aktivesModul: null, + modulZiel: null, // Sprungziel für die geöffnete Ansicht + hinweise: [], + dienstliste: [], + status: [], + funktionen: [], + stationen: [], + }; + + // Registrierte Modulansichten: [modulId] = { rendern, aktualisieren? } + const ansichten = {}; + // Anbieter für die globale Suche + const suchAnbieter = []; + // Offene Aufrufe an den Server: [ruf] = { aufloesen, ablehnen } + const offeneRufe = {}; + let rufZaehler = 0; + + const $ = (id) => document.getElementById(id); + + /* ---- Kleine Helfer ------------------------------------------------ */ + + /** Maskiert Text, damit er gefahrlos in HTML landen kann */ + function sicher(text) { + if (text === null || text === undefined) return ""; + return String(text).replace(/[&<>"']/g, + (z) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[z])); + } + + /** Formatiert einen Zeitpunkt aus der Datenbank */ + function zeit(wert) { + if (!wert) return "—"; + const d = new Date(String(wert).replace(" ", "T")); + if (isNaN(d)) return sicher(wert); + return d.toLocaleString("de-DE", + { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); + } + + /** Formatiert eine Dauer in Sekunden */ + function dauer(sekunden) { + const s = Math.max(0, Math.floor(sekunden || 0)); + const std = Math.floor(s / 3600); + const min = Math.floor((s % 3600) / 60); + return (std > 0 ? std + " Std " : "") + min + " Min"; + } + + /** Formatiert Minuten als lesbare Wartezeit */ + function minuten(wert) { + const m = Math.max(0, Math.floor(wert || 0)); + if (m < 60) return m + " Min"; + return Math.floor(m / 60) + " Std " + (m % 60) + " Min"; + } + + /** Formatiert einen Betrag als Euro */ + function euro(betrag) { + return (Math.round(betrag || 0)).toLocaleString("de-DE", + { style: "currency", currency: "EUR", maximumFractionDigits: 0 }); + } + + /** Schickt etwas an den FiveM-Client */ + function anClient(pfad, daten) { + if (!IST_FIVEM) return Promise.resolve({}); + return fetch(`https://${RESSOURCE}/${pfad}`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=UTF-8" }, + body: JSON.stringify(daten || {}), + }).then((a) => a.json()).catch(() => ({})); + } + + /* ---- Aufruf an ein Servermodul ------------------------------------ */ + + /** + * Ruft eine Modulaktion auf und wartet auf die Antwort. + * @param {string} modul Modulkennung + * @param {string} aktion Name der Aktion + * @param {object} daten Nutzdaten + * @returns {Promise} + */ + function aufruf(modul, aktion, daten) { + return new Promise((aufloesen, ablehnen) => { + if (!IST_FIVEM) { + // Browser-Vorschau: es gibt keinen Server zum Fragen + ablehnen(new Error("Vorschaumodus — keine Verbindung zum Server.")); + return; + } + + const ruf = ++rufZaehler; + offeneRufe[ruf] = { aufloesen, ablehnen }; + + anClient("aufruf", { modul, aktion, daten: daten || {}, ruf }); + + // Nicht ewig warten, falls eine Antwort ausbleibt + setTimeout(() => { + if (offeneRufe[ruf]) { + delete offeneRufe[ruf]; + ablehnen(new Error("Zeitüberschreitung — der Server hat nicht geantwortet.")); + } + }, 15000); + }); + } + + /* ---- Kurzmeldungen ------------------------------------------------ */ + + /** + * Zeigt eine kurze Meldung. + * @param {string} text Inhalt + * @param {string} art "gut" | "fehler" | "" + */ + function meldung(text, art) { + const el = document.createElement("div"); + el.className = "meldung " + (art || ""); + el.textContent = text; + $("meldungen").appendChild(el); + setTimeout(() => el.remove(), 3200); + } + + /* ---- Fenster (Dialoge) -------------------------------------------- */ + + /** + * Öffnet ein Dialogfenster. + * @param {object} o { titel, inhalt, knoepfe: [{text, art?, klick}], nachOeffnen? } + */ + function fenster(o) { + const ebene = $("fenster-ebene"); + const hg = document.createElement("div"); + hg.className = "fenster-hg"; + + const knoepfe = (o.knoepfe || []).map((k, i) => + `` + ).join(""); + + hg.innerHTML = + `
+
+

${sicher(o.titel || "")}

+ +
+
${o.inhalt || ""}
+ ${knoepfe ? `
${knoepfe}
` : ""} +
`; + + ebene.appendChild(hg); + + const schliessen = () => hg.remove(); + hg.querySelector("[data-zu]").addEventListener("click", schliessen); + hg.addEventListener("click", (e) => { if (e.target === hg) schliessen(); }); + + hg.querySelectorAll("[data-k]").forEach((b) => { + b.addEventListener("click", () => { + const eintrag = o.knoepfe[+b.dataset.k]; + // Der Rückruf entscheidet selbst, ob das Fenster zugeht + if (eintrag.klick) eintrag.klick(hg, schliessen); + else schliessen(); + }); + }); + + if (o.nachOeffnen) o.nachOeffnen(hg); + return { element: hg, schliessen }; + } + + /** + * Fenster mit Bestätigungspflicht. + * Für jede Maßnahme, die ein Mediziner selbst verantworten muss. + */ + function bestaetigen(o) { + return fenster({ + titel: o.titel, + inhalt: `${o.inhalt || ""} +
+ ℹ +
Diese Entscheidung triffst du. Das System hat den Vorgang nur + vorbereitet — dokumentiert wird er erst mit deiner Bestätigung.
+
`, + knoepfe: [ + { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() }, + { text: o.bestaetigenText || "Bestätigen", art: o.gefahr ? "gefahr" : "", + klick: (el, zu) => { if (o.bestaetigt) o.bestaetigt(el, zu); } }, + ], + nachOeffnen: o.nachOeffnen, + }); + } + + /* ---- Navigation ---------------------------------------------------- */ + + function zeichneNavigation() { + const nav = $("navigation"); + + nav.innerHTML = zustand.module.map((m) => + ``).join(""); + + nav.querySelectorAll("[data-modul]").forEach((el) => { + el.addEventListener("click", () => oeffneModul(el.dataset.modul)); + }); + } + + /** + * Öffnet ein Modul. + * @param {string} modulId Modul + * @param {object|null} ziel Sprungziel, z. B. { patient: 12 }. + * Die Ansicht liest ihn über App.zustand.modulZiel und räumt ihn auf. + */ + async function oeffneModul(modulId, ziel) { + const ansicht = ansichten[modulId]; + if (!ansicht) { + meldung("Dieses Modul ist nicht geladen.", "fehler"); + return; + } + + zustand.aktivesModul = modulId; + zustand.modulZiel = ziel || null; + zeichneNavigation(); + + const behaelter = $("inhalt"); + behaelter.innerHTML = `
Lade …
`; + + try { + await ansicht.rendern(behaelter); + } catch (fehler) { + behaelter.innerHTML = + `

Fehler

+

${sicher(fehler.message)}

`; + } + } + + /* ---- Dienststatus und Funktion -------------------------------------- */ + + function zeichneStatus() { + const aktuell = zustand.status.find((s) => s.id === (zustand.mitarbeiter || {}).status) + || zustand.status[0]; + + if (aktuell) { + $("status-text").textContent = aktuell.name; + $("status-punkt").style.background = aktuell.farbe; + } + + $("status-liste").innerHTML = zustand.status.map((s) => + ``).join(""); + + $("status-liste").querySelectorAll("[data-status]").forEach((el) => { + el.addEventListener("click", () => { + const gewaehlt = el.dataset.status; + zustand.mitarbeiter.status = gewaehlt; + anClient("status", { status: gewaehlt }); + $("status-liste").hidden = true; + zeichneStatus(); + }); + }); + } + + function zeichneFunktion() { + const aktuell = zustand.funktionen.find( + (f) => f.id === (zustand.mitarbeiter || {}).funktion); + + $("funktion-text").textContent = aktuell ? aktuell.name : "Stationsdienst"; + + $("funktion-liste").innerHTML = zustand.funktionen.map((f) => + ``).join(""); + + $("funktion-liste").querySelectorAll("[data-funktion]").forEach((el) => { + el.addEventListener("click", () => { + const gewaehlt = el.dataset.funktion; + zustand.mitarbeiter.funktion = gewaehlt; + anClient("funktion", { funktion: gewaehlt }); + $("funktion-liste").hidden = true; + zeichneFunktion(); + }); + }); + } + + /* ---- Mitteilungen --------------------------------------------------- */ + + function zeichneHinweise() { + const ungelesen = zustand.hinweise.filter((h) => !h.gelesen).length; + const zaehler = $("hinweise-zaehler"); + zaehler.hidden = ungelesen === 0; + zaehler.textContent = ungelesen > 99 ? "99+" : ungelesen; + + const symbole = { + einsatz: "🚑", patient: "🧍", labor: "🧪", medikament: "💊", + blut: "🩸", op: "🔪", doku: "📄", system: "ℹ", + }; + + $("hinweise-inhalt").innerHTML = zustand.hinweise.length + ? zustand.hinweise.map((h) => + `
+
+ ${symbole[h.kategorie] || "•"} ${sicher(h.titel)} + ${zeit(h.erstellt_am)} +
+ ${h.text ? `
${sicher(h.text)}
` : ""} +
` + ).join("") + : `
Keine Mitteilungen.
`; + + $("hinweise-inhalt").querySelectorAll("[data-hinweis]").forEach((el) => { + el.addEventListener("click", () => { + const id = +el.dataset.hinweis; + if (!id) return; + const treffer = zustand.hinweise.find((h) => h.id === id); + if (treffer) treffer.gelesen = 1; + aufruf("dashboard", "hinweisGelesen", { id }).catch(() => {}); + zeichneHinweise(); + if (treffer) springeZuHinweis(treffer); + }); + }); + } + + /** + * Öffnet das Modul, zu dem ein Hinweis gehört. + * Nur Ziele, die eine Ansicht auch auswertet, werden mitgegeben — + * alles andere öffnet schlicht das passende Modul. + */ + function springeZuHinweis(hinweis) { + if (!hinweis.zielTyp) return; + + // zielTyp → [Modul, Schlüssel des Sprungziels oder null] + const wege = { + patient: ["mdt", "patient"], + akte: ["mdt", "akte"], + aufnahme: ["patienten", null], + behandlung: ["behandlung", "behandlung"], + bericht: ["bericht", "bericht"], + operation: ["operation", null], + labor: ["labor", null], + blut: ["blutbank", "blut"], + medikament: ["medikament", null], + zimmer: ["station", "zimmer"], + fahrzeug: ["fahrzeug", "fahrzeug"], + einsatz: ["einsatz", null], + intensiv: ["intensiv", "intensiv"], + nachricht: ["kommunikation", "nachricht"], + aushang: ["kommunikation", "aushang"], + }; + + const weg = wege[hinweis.zielTyp]; + if (!weg) return; + + // Nur öffnen, wenn das Modul für diesen Mitarbeiter sichtbar ist + if (!zustand.module.some((m) => m.id === weg[0])) return; + + const ziel = weg[1] && hinweis.zielId ? { [weg[1]]: +hinweis.zielId } : null; + oeffneModul(weg[0], ziel); + $("hinweise-schublade").hidden = true; + } + + /** Nimmt einen neu eingetroffenen Hinweis auf */ + function neuerHinweis(hinweis) { + zustand.hinweise.unshift(Object.assign({ erstellt_am: new Date().toISOString() }, hinweis)); + if (zustand.hinweise.length > 50) zustand.hinweise.pop(); + zeichneHinweise(); + meldung(hinweis.titel, hinweis.prioritaet === "kritisch" ? "fehler" : ""); + + // Offene Ansicht auffrischen, falls sie das betrifft + const ansicht = ansichten[zustand.aktivesModul]; + if (ansicht && ansicht.aktualisieren) ansicht.aktualisieren(); + } + + /* ---- Aufbau beim Öffnen ---------------------------------------------- */ + + function zeigeTablet(daten) { + zustand.imDienst = true; + zustand.mitarbeiter = daten.mitarbeiter || {}; + zustand.module = daten.module || []; + zustand.status = daten.status || []; + zustand.funktionen = daten.funktionen || []; + zustand.stationen = daten.stationen || []; + + $("anmeldung").hidden = true; + $("tablet").hidden = false; + + $("tb-kuerzel").textContent = daten.kuerzel || "LSMC"; + $("tb-klinik").textContent = daten.klinik || "Klinikinformationssystem"; + $("tb-name").textContent = zustand.mitarbeiter.name || "—"; + $("tb-rolle").textContent = zustand.mitarbeiter.rolleName || "—"; + $("tb-nummer").textContent = zustand.mitarbeiter.personalnummer || "—"; + + zeichneNavigation(); + zeichneStatus(); + zeichneFunktion(); + zeichneHinweise(); + + if (zustand.module.length) oeffneModul(zustand.module[0].id); + } + + function zeigeAnmeldung(daten) { + zustand.imDienst = false; + $("tablet").hidden = true; + $("anmeldung").hidden = false; + + $("an-klinik").textContent = daten.klinik || "Los Santos Medical Center"; + $("an-name").textContent = daten.name || "—"; + $("an-nummer").textContent = daten.personalnummer || "—"; + $("an-rolle").textContent = daten.rolleName || "—"; + $("an-station").textContent = daten.station || "—"; + + // Dienstfunktion auswählen — die Vorauswahl ist nur ein Vorschlag + const funktionen = daten.funktionen || [{ id: "station", name: "Stationsdienst" }]; + let gewaehlt = funktionen[0].id; + + $("an-funktionen").innerHTML = funktionen.map((f, i) => + ``).join(""); + + $("an-funktionen").querySelectorAll("[data-funktion]").forEach((el) => { + el.addEventListener("click", () => { + gewaehlt = el.dataset.funktion; + $("an-funktionen").querySelectorAll("[data-funktion]") + .forEach((k) => k.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + }); + }); + + $("an-antreten").onclick = () => anClient("dienstAntreten", { funktion: gewaehlt }); + } + + /* ---- Verdrahtung der Hülle -------------------------------------------- */ + + function verdrahteShell() { + $("an-abbrechen").addEventListener("click", () => anClient("schliessen", {})); + $("tablet-schliessen").addEventListener("click", () => anClient("schliessen", {})); + + $("dienst-beenden").addEventListener("click", () => { + bestaetigen({ + titel: "Dienst beenden", + inhalt: `

Du meldest dich vom Dienst ab. Laufende Behandlungen und + Dokumentationen bleiben offen — sie werden nicht abgeschlossen.

`, + bestaetigenText: "Dienst beenden", + bestaetigt: (_, zu) => { zu(); anClient("dienstBeenden", {}); }, + }); + }); + + // Statuswähler + $("status-knopf").addEventListener("click", () => { + const liste = $("status-liste"); + liste.hidden = !liste.hidden; + $("funktion-liste").hidden = true; + }); + + // Funktionswähler + $("funktion-knopf").addEventListener("click", () => { + const liste = $("funktion-liste"); + liste.hidden = !liste.hidden; + $("status-liste").hidden = true; + }); + + // Mitteilungen + $("hinweise-knopf").addEventListener("click", () => { + const schublade = $("hinweise-schublade"); + schublade.hidden = !schublade.hidden; + if (!schublade.hidden) zeichneHinweise(); + }); + $("hinweise-zu").addEventListener("click", () => { $("hinweise-schublade").hidden = true; }); + + // Design umschalten + $("thema-wechsel").addEventListener("click", () => { + document.body.classList.toggle("thema-dunkel"); + document.body.classList.toggle("thema-hell"); + }); + + // Globale Suche + $("global-suche").addEventListener("keydown", (e) => { + if (e.key === "Enter") { + const begriff = e.target.value.trim(); + if (begriff.length >= 2) globalSuchen(begriff); + } + }); + + // Klick ins Leere schließt die Auswahllisten + document.addEventListener("click", (e) => { + if (!e.target.closest("#status-waehler")) $("status-liste").hidden = true; + if (!e.target.closest("#funktion-waehler")) $("funktion-liste").hidden = true; + }); + + document.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + if (document.querySelector(".fenster-hg")) { + document.querySelector(".fenster-hg").remove(); + return; + } + if (!$("hinweise-schublade").hidden) { $("hinweise-schublade").hidden = true; return; } + anClient("schliessen", {}); + }); + + // Nachrichten vom Client + window.addEventListener("message", (e) => { + const daten = e.data || {}; + + switch (daten.aktion) { + case "oeffnen": + zeigeTablet(daten); + document.body.style.display = ""; + break; + + case "anmeldung": + zeigeAnmeldung(daten); + document.body.style.display = ""; + break; + + case "schliessen": + document.body.style.display = "none"; + $("hinweise-schublade").hidden = true; + $("fenster-ebene").innerHTML = ""; + break; + + case "antwort": { + const offen = offeneRufe[daten.ruf]; + if (!offen) break; + delete offeneRufe[daten.ruf]; + if (daten.fehler) offen.ablehnen(new Error(daten.fehler)); + else offen.aufloesen(daten.ergebnis || {}); + break; + } + + case "hinweis": + neuerHinweis(daten.hinweis || {}); + break; + + case "dienstliste": { + zustand.dienstliste = daten.mitarbeiter || []; + const ansicht = ansichten[zustand.aktivesModul]; + if (ansicht && ansicht.aktualisieren) ansicht.aktualisieren(); + break; + } + + case "einsatzAenderung": { + const ansicht = ansichten[zustand.aktivesModul]; + if (ansicht && ansicht.aktualisieren) ansicht.aktualisieren(); + break; + } + + case "einsatzZuweisung": + meldung(`Zuweisung: ${daten.daten.nummer} — ${daten.daten.titel}`); + break; + + case "fehler": + meldung(daten.text, "fehler"); + break; + } + }); + } + + /* ---- Globale Suche ----------------------------------------------------- */ + + /** Registriert einen Anbieter für die globale Suche */ + function registriereSuche(funktion) { suchAnbieter.push(funktion); } + + async function globalSuchen(begriff) { + const treffer = []; + + for (const anbieter of suchAnbieter) { + try { + const ergebnis = await anbieter(begriff); + if (Array.isArray(ergebnis)) treffer.push(...ergebnis); + } catch (e) { /* einzelne Anbieter dürfen scheitern */ } + } + + fenster({ + titel: `Suche: „${begriff}"`, + inhalt: treffer.length + ? `
${treffer.map((t) => + `
+ ${sicher(t.bereich)} +
+
${sicher(t.titel)}
+
${sicher(t.unter || "")}
+
+
`).join("")}
` + : `
Keine Treffer.
`, + }); + } + + /* ---- Öffentliche Schnittstelle ------------------------------------------ */ + return { + /** Registriert eine Modulansicht */ + registriereAnsicht(modulId, ansicht) { ansichten[modulId] = ansicht; }, + /** Registriert einen Anbieter für die globale Suche */ + registriereSuche, + zustand, aufruf, meldung, fenster, bestaetigen, + sicher, zeit, dauer, minuten, euro, $, + anClient, oeffneModul, + start() { + verdrahteShell(); + if (IST_FIVEM) document.body.style.display = "none"; + else { + // Eigenständige Browser-Ansicht: Anmeldebildschirm zeigen + zeigeAnmeldung({ + klinik: "Los Santos Medical Center", name: "Vorschau", + personalnummer: "LSMC-0000", rolleName: "Assistenzarzt", station: "notaufnahme", + funktionen: [ + { id: "station", name: "Stationsdienst" }, + { id: "rtw", name: "Rettungswagen" }, + ], + }); + } + }, + }; +})(); + +App.start(); diff --git a/resources/[aether]/aether-medical/html/js/bausteine.js b/resources/[aether]/aether-medical/html/js/bausteine.js new file mode 100644 index 0000000..5ab258a --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/bausteine.js @@ -0,0 +1,156 @@ +/* ===================================================================== + aether-medical — Bausteine für die Bögen + + Setzt vorformulierten Text in ein Formular ein. Mehr tut diese Datei + nicht, und mehr soll sie nicht tun: + + * Sie schreibt nichts zum Server. Der Text landet im Textfeld, + gespeichert wird danach von Hand. + * Sie überschreibt nichts. Steht schon etwas im Feld, wird der + Baustein darunter gehängt. + * Sie wählt nichts aus. Welcher Baustein passt, entscheidet der, + der ihn einsetzt. +===================================================================== */ +"use strict"; + +const Bausteine = (() => { + const { sicher, aufruf, meldung, bestaetigen } = App; + + /** + * Hängt Text an ein Textfeld an, ohne Vorhandenes zu verlieren. + * @param {HTMLTextAreaElement} feld Zielfeld + * @param {string} text Einzusetzender Text + */ + function anhaengen(feld, text) { + const vorhanden = feld.value.trim(); + feld.value = vorhanden ? vorhanden + "\n\n" + text : text; + } + + /** + * Öffnet die Bausteinauswahl für einen Bogen. + * @param {object} o { + * modul Modulkennung, unter der die Aktion liegt + * behaelter Element, in dem die Textfelder stehen + * nachEinsetzen Rückruf nach dem Einsetzen (freiwillig) + * } + */ + async function einsetzen(o) { + let d; + try { + d = await aufruf(o.modul, "bausteine", {}); + } catch (fehler) { return meldung(fehler.message, "fehler"); } + + if (!d.bausteine.length) { + return meldung("Für diesen Bogen ist kein Baustein hinterlegt.", "fehler"); + } + + let gewaehlt = null; // aktuell gewählter Baustein + const abgewaehlt = {}; // Felder, die der Nutzer ausgenommen hat + + bestaetigen({ + titel: "Baustein einsetzen", + inhalt: ` +
+ 🧩 +
Der Baustein füllt nur das Formular. Gespeichert wird nichts — + das machst du danach selbst. Text, der schon im Feld steht, bleibt + erhalten; der Baustein kommt darunter.
+
+ +
+
+ ${d.bausteine.map((b) => ` +
+ ${sicher(b.kennung)} +
+
${sicher(b.name)}
+
${d.felder + .filter((f) => b.inhalt[f.id]) + .map((f) => sicher(f.name)).join(" · ") || "leer"}
+
+
`).join("")} +
+ +
+
Wähle einen Baustein, um seinen Text zu sehen.
+
`, + bestaetigenText: "In den Bogen einsetzen", + bestaetigt: (el, zu) => { + if (!gewaehlt) return meldung("Bitte einen Baustein wählen.", "fehler"); + + const gefuellt = []; + const uebersprungen = []; + + d.felder.forEach((feld) => { + const text = gewaehlt.inhalt[feld.id]; + if (!text || abgewaehlt[feld.id]) return; + + // Gesperrte Felder gibt es im Bogen gar nicht — sie bleiben aussen vor + const ziel = o.behaelter.querySelector(`textarea[name=${feld.id}]`); + if (!ziel) { + uebersprungen.push(feld.name); + return; + } + + anhaengen(ziel, text); + gefuellt.push(feld.name); + }); + + zu(); + + if (!gefuellt.length) { + return meldung("Kein Feld des Bogens ließ sich füllen.", "fehler"); + } + + meldung(uebersprungen.length + ? `Eingesetzt in ${gefuellt.length} Feld(er). Übersprungen: ${uebersprungen.join(", ")}.` + : `Eingesetzt in ${gefuellt.length} Feld(er). Noch nicht gespeichert.`, "gut"); + + if (o.nachEinsetzen) o.nachEinsetzen(gefuellt); + }, + nachOeffnen: (hg) => { + const vorschau = hg.querySelector("[data-vorschau]"); + + /** Zeigt, was der Baustein in welches Feld schreiben würde */ + function zeigeVorschau() { + vorschau.innerHTML = ` +
+ Wird eingesetzt + Häkchen entfernen, was nicht mit soll +
+ ${d.felder.filter((f) => gewaehlt.inhalt[f.id]).map((f) => ` +
+ +
${sicher(gewaehlt.inhalt[f.id]) + .replace(/\n/g, "
")}
+
`).join("")}`; + + vorschau.querySelectorAll("[data-feld]").forEach((el) => { + el.addEventListener("change", () => { + abgewaehlt[el.dataset.feld] = !el.checked; + }); + }); + } + + hg.querySelectorAll("[data-baustein]").forEach((el) => { + el.addEventListener("click", () => { + gewaehlt = d.bausteine.find((b) => b.id === +el.dataset.baustein); + // Bei einem Wechsel gelten wieder alle Felder + Object.keys(abgewaehlt).forEach((k) => delete abgewaehlt[k]); + + hg.querySelectorAll("[data-baustein]") + .forEach((zeile) => zeile.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + + zeigeVorschau(); + }); + }); + }, + }); + } + + return { einsetzen }; +})(); diff --git a/resources/[aether]/aether-medical/html/js/module/behandlung.js b/resources/[aether]/aether-medical/html/js/module/behandlung.js new file mode 100644 index 0000000..a8414fb --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/module/behandlung.js @@ -0,0 +1,730 @@ +/* ===================================================================== + aether-medical / Ansicht: Behandlungsverwaltung + + Ein Dokumentationsbogen, mehr nicht. Kein Feld wird vorausgefüllt, + keine Diagnose vorgeschlagen, kein Symptom ausgewertet. Was hier + steht, hat ein Mediziner geschrieben. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen } = App; + + const z = { + ordner: "laufend", // laufend | meine | abgeschlossen + suche: "", + daten: null, + offen: null, + offeneDaten: null, + }; + + /* ---- Übersicht --------------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Behandlungen

+

Das System speichert ausschließlich die Dokumentation. Es behandelt + nicht, es diagnostiziert nicht und es schlägt nichts vor.

+
+ ${d.rechte.dokumentieren + ? `` : ""} +
+ +
+
+
Laufend
+
${d.zaehler.laufend}
+
noch nicht abgeschlossen
+
+
+
Davon von dir
+
${d.zaehler.meine}
+
deine offene Dokumentation
+
+
+
Heute abgeschlossen
+
${d.zaehler.heute}
+
in der ganzen Klinik
+
+
+ +
+ + + +
+ +
+ +
+ +
+
+ Dokumentationen + ${d.liste.length} Einträge +
+
+ ${d.liste.length ? d.liste.map(zeile).join("") + : `
Keine Dokumentation gefunden.
`} +
+
+ `; + + verdrahteUebersicht(behaelter); + } + + function zeile(b) { + const laufend = b.status === "laufend"; + + return `
+ ${sicher(b.kennung)} +
+
${sicher(b.patientName)} + geb. ${sicher(b.geburt)}
+
${sicher(b.diagnose || b.massnahmen + || "Noch nichts eingetragen")}
+
+ + ${b.hinweis ? `⚠` : ""} + ${b.allergien ? `A` : ""} + ${b.gaben > 0 ? `💊 ${b.gaben}` : ""} + ${b.einsatzNummer ? `${sicher(b.einsatzNummer)}` : ""} + ${b.eigene ? `von dir` : ""} + + ${sicher(b.status)} + ${laufend + ? "offen seit " + minuten(b.offenSeit) : zeit(b.abgeschlossen_am)} +
`; + } + + function verdrahteUebersicht(behaelter) { + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.ordner = el.dataset.reiter; + rendern(behaelter); + }); + }); + + const suchfeld = behaelter.querySelector("[data-suche]"); + let warten = null; + suchfeld.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(() => { + z.suche = suchfeld.value.trim(); + rendern(behaelter); + }, 280); + }); + + behaelter.querySelectorAll("[data-oeffne]").forEach((el) => { + el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne)); + }); + + const neu = behaelter.querySelector("[data-neu]"); + if (neu) neu.addEventListener("click", () => anlegenFenster(behaelter)); + } + + /* ---- Dokumentationsbogen ------------------------------------------------- */ + + function einzelAnsicht(behaelter, d) { + const b = d.behandlung, s = d.stamm; + const laufend = b.status === "laufend"; + // Die Diagnose darf nur mit eigenem Recht geschrieben werden + const diagnoseSperre = !d.rechte.diagnose; + + behaelter.innerHTML = ` +
+
+

${sicher(b.kennung)} · ${sicher(b.patientName)}

+

geb. ${sicher(b.geburt)} + ${b.einsatzNummer ? " · Einsatz " + sicher(b.einsatzNummer) : ""} + ${b.aufnahmeGrund ? " · Aufnahme: " + sicher(b.aufnahmeGrund) : ""}

+
+ +
+ + ${s.hinweis ? ` +
+ ⚠ +
Warnhinweis — ${sicher(s.hinweis)}
+
` : ""} + + ${s.allergien ? ` +
+ 💊 +
Allergien — ${sicher(s.allergien)} +
Vor jeder Gabe selbst prüfen. Das System + gleicht Medikamente nicht gegen Allergien ab.
+
` : ""} + +
+
+
Status${sicher(b.status)}
+
Angelegt${zeit(b.erstelltAm)}
+
Von${sicher(b.erstelltVon)}
+
Blutgruppe + ${sicher(s.blutgruppe || "nicht bestimmt")}
+
Vorerkrankungen + ${sicher(s.vorerkrankungen || "keine erfasst")}
+
Dauermedikation + ${sicher(s.dauermedikation || "keine erfasst")}
+ ${b.abgeschlossenAm ? ` +
Abgeschlossen${zeit(b.abgeschlossenAm)}
+
Durch${sicher(b.abgeschlossenVon)}
` : ""} +
+
+ + ${laufend && d.rechte.dokumentieren + ? ` + ` : ""} + ${laufend && d.rechte.abschliessen + ? `` : ""} + ${!laufend && d.rechte.abschliessen + ? `` : ""} +
+
+ +
+ ${feld("symptome", "Symptome / Anamnese", b.symptome, laufend && d.rechte.dokumentieren, + "Was schildert der Patient?")} + ${feld("untersuchung", "Untersuchung", b.untersuchung, laufend && d.rechte.dokumentieren, + "Was hast du erhoben? Vitalwerte, Befunde am Patienten…")} + ${feld("diagnose", "Diagnose", b.diagnose, + laufend && d.rechte.dokumentieren && !diagnoseSperre, + diagnoseSperre ? "Für die Diagnose fehlt dir die Berechtigung." + : "Deine Einschätzung. Das System stellt keine Diagnose.")} + ${feld("massnahmen", "Maßnahmen / Behandlung", b.massnahmen, + laufend && d.rechte.dokumentieren, + "Verbände, Lagerung, Eingriffe, Reanimation…")} + ${feld("empfehlung", "Empfehlung", b.empfehlung, laufend && d.rechte.dokumentieren, + "Was empfiehlst du dem Patienten?")} + ${feld("nachsorge", "Nachsorge", b.nachsorge, laufend && d.rechte.dokumentieren, + "Kontrolltermin, Wundversorgung, Medikamentenplan…")} +
+ +
+
+ Verabreichte Medikamente + ${laufend && d.rechte.medikament + ? `` : ""} +
+
+ ${d.gaben.length ? d.gaben.map((g) => ` +
+ + ${g.btm ? "BTM" : "💊"} +
+
${sicher(g.name)} + ${g.menge} ${sicher(g.einheit)}
+
+ ${g.dosierung ? sicher(g.dosierung) + " · " : ""} + ${sicher(g.autor)} · ${zeit(g.gegeben_am)} + ${g.hinweis ? " · " + sicher(g.hinweis) : ""} +
+
+ ${laufend && (g.eigene || d.rechte.bestand) + ? `` : ""} +
`).join("") + : `
Keine Gabe dokumentiert.
`} +
+
+ +
+
+
Laborergebnisse des Patienten
+
+ ${d.labor.length ? d.labor.map((l) => ` +
+ ${sicher(l.kennung)} +
+
${sicher(l.art)}
+
${sicher(l.befund || l.ergebnis + || "Noch kein Ergebnis")}
+
+ + ${sicher(l.status)} +
`).join("") + : `
Keine Laboraufträge.
`} +
+
+ +
+
Operationen des Patienten
+
+ ${d.operationen.length ? d.operationen.map((o) => ` +
+ ${sicher(o.kennung)} +
+
${sicher(o.art)}
+
${o.beginn ? zeit(o.beginn) : "noch nicht begonnen"}
+
+ ${sicher(o.status)} +
`).join("") + : `
Keine Operationen.
`} +
+
+
+ `; + + verdrahteEinzel(behaelter, d); + } + + /** + * Ein Feld des Dokumentationsbogens. + * @param {string} name Feldname + * @param {string} titel Beschriftung + * @param {string} wert Bisheriger Inhalt + * @param {boolean} schreibbar Darf bearbeitet werden? + * @param {string} hinweis Text unter dem Feld + */ + function feld(name, titel, wert, schreibbar, hinweis) { + if (!schreibbar) { + return `
+ +
${wert + ? sicher(wert).replace(/\n/g, "
") + : "Nicht dokumentiert."}
+ ${hinweis ? `
${sicher(hinweis)}
` : ""} +
`; + } + + return `
+ + + ${hinweis ? `
${sicher(hinweis)}
` : ""} +
`; + } + + function verdrahteEinzel(behaelter, d) { + const b = d.behandlung; + + behaelter.querySelector("[data-zurueck]").addEventListener("click", () => { + z.offen = null; + rendern(behaelter); + }); + + behaelter.querySelector("[data-akte]").addEventListener("click", () => { + if (!App.zustand.module.some((m) => m.id === "mdt")) { + return meldung("Das MDT steht dir nicht zur Verfügung.", "fehler"); + } + App.oeffneModul("mdt", { patient: b.patient }); + }); + + // Baustein einsetzen: füllt nur den Bogen, gespeichert wird danach von Hand + const baustein = behaelter.querySelector("[data-baustein]"); + if (baustein) baustein.addEventListener("click", () => { + Bausteine.einsetzen({ modul: "behandlung", behaelter }); + }); + + const speichern = behaelter.querySelector("[data-speichern]"); + if (speichern) speichern.addEventListener("click", async () => { + const lies = (name) => { + const el = behaelter.querySelector(`[name=${name}]`); + return el ? el.value.trim() : undefined; + }; + try { + await aufruf("behandlung", "speichern", { + id: b.id, + symptome: lies("symptome"), + untersuchung: lies("untersuchung"), + diagnose: lies("diagnose"), + massnahmen: lies("massnahmen"), + empfehlung: lies("empfehlung"), + nachsorge: lies("nachsorge"), + }); + meldung("Dokumentation gespeichert.", "gut"); + oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }); + + const abschliessen = behaelter.querySelector("[data-abschliessen]"); + if (abschliessen) abschliessen.addEventListener("click", () => { + bestaetigen({ + titel: "Behandlung abschließen", + inhalt: ` +

${sicher(b.kennung)} — ${sicher(b.patientName)}

+

Die Dokumentation wird damit schreibgeschützt. Sie lässt sich später + mit Begründung wieder öffnen.

+ ${!b.diagnose ? ` +
+ 📄 +
Es ist keine Diagnose eingetragen. Abschließen ist trotzdem + möglich — das System trägt keine für dich ein.
+
` : ""} +

Nicht gespeicherte Änderungen im Bogen gehen dabei + verloren. Erst speichern, dann abschließen.

+
+
`, + bestaetigenText: "Abschließen", + bestaetigt: async (el, zu) => { + try { + await aufruf("behandlung", "abschliessen", { + id: b.id, vermerk: el.querySelector("[name=vermerk]").value.trim(), + }); + zu(); + meldung("Behandlung abgeschlossen.", "gut"); + oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + + const wiederOeffnen = behaelter.querySelector("[data-oeffnen]"); + if (wiederOeffnen) wiederOeffnen.addEventListener("click", () => { + bestaetigen({ + titel: "Dokumentation wieder öffnen", + inhalt: `

Die Behandlung wird wieder bearbeitbar. Der Vorgang steht + dauerhaft im Protokoll.

+
+
`, + bestaetigenText: "Wieder öffnen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler"); + try { + await aufruf("behandlung", "wiedereroeffnen", { id: b.id, grund }); + zu(); + meldung("Dokumentation wieder geöffnet.", "gut"); + oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + + const gabe = behaelter.querySelector("[data-gabe]"); + if (gabe) gabe.addEventListener("click", () => gabeFenster(behaelter, d)); + + behaelter.querySelectorAll("[data-zuruecknehmen]").forEach((el) => { + el.addEventListener("click", () => { + bestaetigen({ + titel: "Eintrag zurücknehmen", + gefahr: true, + inhalt: `

Der Eintrag wird aus der Dokumentation entfernt. Wurde damals + Bestand gebucht, wird er als Korrektur zurückgebucht.

+
+
`, + bestaetigenText: "Zurücknehmen", + bestaetigt: async (dialog, zu) => { + const grund = dialog.querySelector("[name=grund]").value.trim(); + if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler"); + try { + await aufruf("behandlung", "medikamentZuruecknehmen", { + gabe: +el.dataset.zuruecknehmen, grund, + }); + zu(); + meldung("Eintrag zurückgenommen.", "gut"); + oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + }); + } + + /* ---- Medikamentengabe ---------------------------------------------------- */ + + async function gabeFenster(behaelter, d) { + const b = d.behandlung; + + let katalog; + try { + katalog = await aufruf("behandlung", "medikamente", {}); + } catch (f) { return meldung(f.message, "fehler"); } + + let gewaehlt = null; + // Wird gesetzt, wenn der Bestand nicht reicht und trotzdem dokumentiert wird + let ohneBestand = false; + + // Medikamente nach Kategorie gruppieren + const gruppen = {}; + katalog.medikamente.forEach((m) => { + (gruppen[m.kategorie] = gruppen[m.kategorie] || []).push(m); + }); + const kategorieName = {}; + katalog.kategorien.forEach((k) => { kategorieName[k.id] = k.name; }); + + const dialog = bestaetigen({ + titel: "Medikamentengabe dokumentieren", + inhalt: ` +

${sicher(b.patientName)} · ${sicher(b.kennung)}

+ ${d.stamm.allergien ? ` +
+ ⚠ +
Bekannte Allergien: ${sicher(d.stamm.allergien)}
+ Das System prüft nicht gegen diese Liste. + Der Abgleich ist deine Aufgabe.
+
` : ""} + +
+ +
+ ${Object.keys(gruppen).map((kategorie) => ` +
+
${sicher(kategorieName[kategorie] || kategorie)}
+ ${gruppen[kategorie].map((m) => ` + `).join("")} +
`).join("")} +
+
Die Standarddosierung ist ein Vorschlag aus dem + Katalog. Was du gibst, entscheidest du.
+
+ +
+
+
+
+
+
+
+
+ `, + bestaetigenText: "Gabe dokumentieren", + bestaetigt: async (el, zu) => { + if (!gewaehlt) return meldung("Bitte ein Medikament wählen.", "fehler"); + try { + await aufruf("behandlung", "medikamentGeben", { + id: b.id, + medikament: gewaehlt, + menge: +el.querySelector("[name=menge]").value, + dosierung: el.querySelector("[name=dosierung]").value.trim(), + hinweis: el.querySelector("[name=hinweis]").value.trim(), + ohneBestand, + }); + zu(); + meldung("Gabe dokumentiert.", "gut"); + oeffne(behaelter, b.id); + } catch (f) { + meldung(f.message, "fehler"); + // Bestand reicht nicht: Dokumentation trotzdem anbieten + if (f.message.indexOf("Bestand") !== -1) zeigeBestandsWeg(el); + } + }, + nachOeffnen: (hg) => { + const suchfeld = hg.querySelector("[name=suche]"); + const dosierung = hg.querySelector("[name=dosierung]"); + + hg.querySelectorAll("[data-med]").forEach((el) => { + el.addEventListener("click", () => { + gewaehlt = +el.dataset.med; + ohneBestand = false; + hg.querySelector("[data-warnung]").style.display = "none"; + hg.querySelectorAll("[data-med]").forEach((k) => k.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + // Standarddosierung als Vorschlag übernehmen, überschreibbar + if (!dosierung.value) dosierung.value = el.dataset.dosierung; + }); + }); + + suchfeld.addEventListener("input", () => { + const begriff = suchfeld.value.trim().toLowerCase(); + hg.querySelectorAll("[data-med]").forEach((el) => { + el.style.display = !begriff || el.dataset.name.indexOf(begriff) !== -1 ? "" : "none"; + }); + // Leere Gruppen ausblenden + hg.querySelectorAll("[data-gruppe]").forEach((gruppe) => { + const sichtbar = [...gruppe.querySelectorAll("[data-med]")] + .some((el) => el.style.display !== "none"); + gruppe.style.display = sichtbar ? "" : "none"; + }); + }); + }, + }); + + /** Bietet an, die Gabe ohne Bestandsbuchung zu dokumentieren */ + function zeigeBestandsWeg(el) { + const warnung = el.querySelector("[data-warnung]"); + warnung.style.display = ""; + warnung.innerHTML = ` +
+ 📦 +
Der Lagerbestand reicht nicht. Eine Dokumentation darf daran + nicht scheitern — etwa wenn das Mittel aus dem RTW stammt. +
+
`; + + warnung.querySelector("[data-ohne]").addEventListener("click", () => { + ohneBestand = true; + meldung("Bestandsbuchung deaktiviert — jetzt bestätigen.", "gut"); + warnung.querySelector("[data-ohne]").disabled = true; + }); + } + + return dialog; + } + + /* ---- Anlegen ------------------------------------------------------------- */ + + function anlegenFenster(behaelter) { + let gewaehlt = null; + let einsaetze = []; + + bestaetigen({ + titel: "Behandlung dokumentieren", + inhalt: ` +
+
+
+
Mindestens zwei Zeichen eingeben.
+
+
+ +
+
+
+ +
Diagnose, Maßnahmen und Nachsorge trägst du + anschließend im Bogen nach.
+
`, + bestaetigenText: "Anlegen", + bestaetigt: async (el, zu) => { + if (!gewaehlt) return meldung("Bitte zuerst einen Patienten wählen.", "fehler"); + const einsatzFeld = el.querySelector("[name=einsatz]"); + try { + const antwort = await aufruf("behandlung", "anlegen", { + patient: gewaehlt.id, + aufnahme: gewaehlt.aufnahme || null, + einsatz: einsatzFeld && einsatzFeld.value ? +einsatzFeld.value : null, + symptome: el.querySelector("[name=symptome]").value.trim(), + untersuchung: el.querySelector("[name=untersuchung]").value.trim(), + }); + zu(); + meldung("Behandlung " + antwort.kennung + " angelegt.", "gut"); + oeffne(behaelter, antwort.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + const suchfeld = hg.querySelector("[name=suche]"); + const liste = hg.querySelector("[data-treffer]"); + const formular = hg.querySelector("[data-formular]"); + const einsatzfeld = hg.querySelector("[data-einsatzfeld]"); + let warten = null; + + suchfeld.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(async () => { + const begriff = suchfeld.value.trim(); + if (begriff.length < 2) { + liste.innerHTML = `
Mindestens zwei Zeichen eingeben.
`; + return; + } + try { + const antwort = await aufruf("behandlung", "patientSuche", { begriff }); + const treffer = antwort.treffer || []; + einsaetze = antwort.einsaetze || []; + + liste.innerHTML = treffer.length ? treffer.map((t) => ` +
+
+
${sicher(t.firstname)} ${sicher(t.lastname)}
+
geb. ${sicher(t.dob)} + ${t.blutgruppe ? " · " + sicher(t.blutgruppe) : ""}
+
+ ${t.hinweis ? `⚠` : ""} + ${t.allergien ? `A` : ""} + ${t.aufnahme ? `aufgenommen` : ""} +
`).join("") + : `
Keine Person gefunden.
`; + + liste.querySelectorAll("[data-person]").forEach((el) => { + el.addEventListener("click", () => { + gewaehlt = treffer.find((t) => t.id === +el.dataset.person); + liste.querySelectorAll("[data-person]") + .forEach((zeile) => zeile.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + formular.style.display = ""; + + // Laufende Einsätze anbieten, an denen man beteiligt ist + if (einsaetze.length) { + einsatzfeld.style.display = ""; + hg.querySelector("[name=einsatz]").innerHTML = + `` + + einsaetze.map((e) => + `` + ).join(""); + } + }); + }); + } catch (f) { meldung(f.message, "fehler"); } + }, 260); + }); + }, + }); + } + + /* ---- Laden ---------------------------------------------------------------- */ + + async function oeffne(behaelter, behandlungId) { + try { + const d = await aufruf("behandlung", "oeffnen", { id: behandlungId }); + z.offen = behandlungId; + z.offeneDaten = d; + einzelAnsicht(behaelter, d); + } catch (f) { meldung(f.message, "fehler"); } + } + + async function rendern(behaelter) { + try { + z.daten = await aufruf("behandlung", "uebersicht", { + ordner: z.ordner, begriff: z.suche, + }); + + // Sprungziel aus einem Hinweis, z. B. „Dokumentation fehlt" + const ziel = App.zustand.modulZiel; + if (ziel && ziel.behandlung) { + App.zustand.modulZiel = null; + return oeffne(behaelter, ziel.behandlung); + } + + if (z.offen) await oeffne(behaelter, z.offen); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Behandlungen

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("behandlung", { rendern }); + + App.registriereSuche(async (begriff) => { + try { + const d = await aufruf("behandlung", "uebersicht", { + ordner: "laufend", begriff, + }); + return (d.liste || []).slice(0, 5).map((b) => ({ + bereich: "Behandlung", + titel: b.kennung + " · " + b.patientName, + unter: b.diagnose || b.massnahmen || "Noch nichts eingetragen", + })); + } catch (e) { return []; } + }); +})(); diff --git a/resources/[aether]/aether-medical/html/js/module/bericht.js b/resources/[aether]/aether-medical/html/js/module/bericht.js new file mode 100644 index 0000000..0059386 --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/module/bericht.js @@ -0,0 +1,848 @@ +/* ===================================================================== + aether-medical / Ansicht: Berichte + + Ein Bericht ist ein Dokument mit einer Unterschrift darunter. Kein + Satz darin entsteht von selbst: Text aus einer Behandlung wird nur + übernommen, wenn du es auslöst, und die Freigabe ist eine Unterschrift + von Hand. Ein freigegebener Bericht bleibt, wie er ist. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, aufruf, meldung, fenster, bestaetigen } = App; + + const z = { + ordner: "meine", // meine | freigabe | freigegeben | alle + suche: "", + offen: null, // geöffneter Bericht + daten: null, + }; + + // Marker-Art je Status + const statusArt = { entwurf: "neutral", eingereicht: "warn", freigegeben: "gut" }; + + // Beschriftung der Unterschriftsarten + const unterschriftArt = { + verfasser: "Verfasser", freigabe: "Freigabe", gegenzeichnung: "Gegengezeichnet", + }; + + /* ---- Kleine Helfer ---------------------------------------------------- */ + + /** Anzeigename eines Status */ + function statusName(kennung) { + const treffer = (z.daten.status || []).find((s) => s.id === kennung); + return treffer ? treffer.name : kennung; + } + + /* ---- Gerüst ------------------------------------------------------------ */ + + function reiterleiste(d) { + const eintraege = [ + { id: "meine", text: "Meine Entwürfe", zahl: d.zaehler.meine }, + { id: "freigabe", text: "Zur Freigabe", zahl: d.zaehler.freigabe }, + { id: "freigegeben", text: "Freigegeben", zahl: d.zaehler.freigegeben }, + { id: "alle", text: "Alle", zahl: 0 }, + ]; + + return `
+ ${eintraege.map((e) => ` + `).join("")} +
`; + } + + function kennzahlen(d) { + const zk = d.zaehler; + + return `
+
+
Eigene Entwürfe
+
${zk.meine}
+
nur du siehst sie
+
+
+
Warten auf Freigabe
+
${zk.freigabe}
+
liegen zur Unterschrift
+
+
+
Davon von dir
+
${zk.meineOffen}
+
eingereicht, noch nicht gezeichnet
+
+
+
Heute freigegeben
+
${zk.freigegeben}
+
endgültig, unveränderlich
+
+
`; + } + + /* ---- Übersicht ---------------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Berichte

+

Das System schreibt keinen Satz. Der Weg vom Entwurf zur Freigabe wird + Schritt für Schritt gegangen — freigegeben wird durch Unterschrift, + nie durch Zeitablauf.

+
+ ${d.rechte.erstellen + ? `` : ""} +
+ + ${kennzahlen(d)} + ${reiterleiste(d)} + +
+ + Fremde Entwürfe sind nicht einsehbar — + erst mit dem Einreichen wird ein Bericht sichtbar. +
+ +
+
+ ${ + z.ordner === "meine" ? "Deine offenen Entwürfe" + : z.ordner === "freigabe" ? "Eingereicht, wartet auf Unterschrift" + : z.ordner === "freigegeben" ? "Freigegebene Berichte" : "Alle Berichte"} + ${d.liste.length} Einträge +
+
+ ${d.liste.length ? d.liste.map(zeile).join("") + : `
Keine Berichte in diesem Ordner.
`} +
+
+ `; + + verdrahteUebersicht(behaelter); + } + + function zeile(b) { + return `
+ ${sicher(b.nummer)} + +
+
${sicher(b.titel)}
+
+ ${b.patientName ? sicher(b.patientName) : "ohne Patientenbezug"} + · ${sicher(b.autor)} + ${b.behandlungKennung ? " · " + sicher(b.behandlungKennung) : ""} + ${b.einsatzNummer ? " · " + sicher(b.einsatzNummer) : ""} +
+
+ + ${b.vonMir ? `von dir` : ""} + ${b.hatDiagnose ? "" : `ohne Diagnose`} + ${b.unterschriften > 0 + ? `${b.unterschriften} Unterschrift(en)` : ""} + + + ${sicher(statusName(b.status))} + ${zeit(b.erstellt_am)} +
`; + } + + function verdrahteUebersicht(behaelter) { + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.ordner = el.dataset.reiter; + rendern(behaelter); + }); + }); + + const suche = behaelter.querySelector("[data-suche]"); + let warten = null; + suche.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(() => { + z.suche = suche.value.trim(); + rendern(behaelter); + }, 300); + }); + + behaelter.querySelectorAll("[data-oeffne]").forEach((el) => { + el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne)); + }); + + const neu = behaelter.querySelector("[data-neu]"); + if (neu) neu.addEventListener("click", () => anlegenFenster(behaelter)); + } + + /* ---- Einzelansicht -------------------------------------------------------- */ + + /** Ein Feld des Berichtsbogens */ + function bogenFeld(feld, wert, schreibbar) { + if (!schreibbar) { + return `
+ +
${wert + ? sicher(wert).replace(/\n/g, "
") : "Nicht ausgefüllt."}
+
`; + } + return `
+ + +
`; + } + + function einzelAnsicht(behaelter, d) { + const b = d.bericht; + const schreibbar = d.rechte.aendern; + + behaelter.innerHTML = ` +
+
+

${sicher(b.titel)}

+

${sicher(b.nummer)} · ${sicher(statusName(b.status))} + ${b.patientName ? " · " + sicher(b.patientName) + + (b.patientGeburt ? ", geb. " + sicher(b.patientGeburt) : "") : ""}

+
+ +
+ + ${b.status === "freigegeben" ? ` +
+ 🔒 +
Freigegeben. Der Wortlaut steht fest und wird nicht mehr geändert. + Eine weitere Unterschrift ist möglich, eine Änderung nicht.
+
` : ""} + + ${b.status === "eingereicht" ? ` +
+ ✍ +
Liegt zur Freigabe. Bis zur Unterschrift ist der Text gesperrt. + ${b.vonMir ? "Zum Weiterschreiben musst du ihn zurückziehen." : ""}
+
` : ""} + + ${b.selbstFreigabe ? ` +
+ ℹ +
Verfasser und Freigabe sind dieselbe Person. Das ist zulässig und + steht so im Dokument.
+
` : ""} + +
+
+
Nummer${sicher(b.nummer)}
+
Verfasser${sicher(b.autor)}
+
Angelegt${zeit(b.erstelltAm)}
+
Patient + ${sicher(b.patientName || "kein Bezug")}
+
Behandlung + ${sicher(b.behandlungKennung || "keine")}
+
Einsatz + ${sicher(b.einsatzNummer || "keiner")}
+
+ +
+ ${b.patient + ? `` : ""} + ${schreibbar ? ` + + + ${b.behandlung + ? `` : ""} + + ` : ""} + ${b.status === "eingereicht" && b.vonMir && d.rechte.erstellen + ? `` : ""} + ${b.status === "eingereicht" && d.rechte.freigeben ? ` + + ` : ""} + ${b.status !== "entwurf" && d.rechte.freigeben + ? `` : ""} +
+
+ + ${schreibbar ? ` +
+ + +
` : ""} + +
+ ${d.felder.map((feld) => bogenFeld(feld, b[feld.id], schreibbar)).join("")} +
+ +
+
+
+ Unterschriften + ${d.unterschriften.length} · nicht zurücknehmbar +
+
+ ${d.unterschriften.length ? d.unterschriften.map((u) => ` +
+ + ${sicher(unterschriftArt[u.art] || u.art)} +
+
${sicher(u.name)}
+
${sicher(u.rolle || "")} + ${u.personalnummer ? " · " + sicher(u.personalnummer) : ""}
+
+ ${zeit(u.am)} +
`).join("") + : `
Noch nicht unterschrieben.
`} +
+ +
+ Anlagen + ${d.anlagen.length} +
+
+ ${d.anlagen.length ? d.anlagen.map((a, i) => ` +
+
+
${sicher(a.titel)}
+
${sicher(a.von)} · ${zeit(a.am)}
+
+ ${schreibbar + ? `` + : ""} +
`).join("") + : `
Keine Anlagen.
`} +
+
+ +
+
+ Weg des Berichts + aus dem Protokoll, unveränderlich +
+
+ ${d.spur.length ? d.spur.map((s) => ` +
+
+
${sicher(s.aktion)}
+
${sicher(s.autor)} + ${s.grund ? " · " + sicher(s.grund) : ""}
+
+ ${zeit(s.am)} +
`).join("") + : `
Noch kein Eintrag.
`} +
+
+
+ `; + + verdrahteEinzel(behaelter, d); + } + + function verdrahteEinzel(behaelter, d) { + const b = d.bericht; + + /** Liest die Felder des Bogens aus */ + const bogen = () => { + const werte = { id: b.id }; + const titel = behaelter.querySelector("[name=titel]"); + if (titel) werte.titel = titel.value.trim(); + + d.felder.forEach((feld) => { + const el = behaelter.querySelector(`[name=${feld.id}]`); + if (el) werte[feld.id] = el.value.trim(); + }); + return werte; + }; + + behaelter.querySelector("[data-zurueck]").addEventListener("click", () => { + z.offen = null; + rendern(behaelter); + }); + + const akte = behaelter.querySelector("[data-akte]"); + if (akte) { + akte.addEventListener("click", () => { + if (!App.zustand.module.some((m) => m.id === "mdt")) { + return meldung("Das MDT steht dir nicht zur Verfügung.", "fehler"); + } + App.oeffneModul("mdt", { patient: b.patient }); + }); + } + + // Baustein einsetzen: füllt nur den Bogen, gespeichert wird danach von Hand + const baustein = behaelter.querySelector("[data-baustein]"); + if (baustein) { + baustein.addEventListener("click", () => { + Bausteine.einsetzen({ modul: "bericht", behaelter }); + }); + } + + const speichern = behaelter.querySelector("[data-speichern]"); + if (speichern) { + speichern.addEventListener("click", async () => { + try { + await aufruf("bericht", "speichern", bogen()); + meldung("Entwurf gespeichert.", "gut"); + oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }); + } + + const uebernehmen = behaelter.querySelector("[data-uebernehmen]"); + if (uebernehmen) { + uebernehmen.addEventListener("click", () => uebernehmenFenster(behaelter, b, bogen)); + } + + const anlage = behaelter.querySelector("[data-anlage]"); + if (anlage) anlage.addEventListener("click", () => anlageFenster(behaelter, b)); + + behaelter.querySelectorAll("[data-anlageweg]").forEach((el) => { + el.addEventListener("click", async () => { + try { + await aufruf("bericht", "anlage", { id: b.id, entfernen: +el.dataset.anlageweg }); + meldung("Anlage entfernt.", "gut"); + oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }); + }); + + const einreichen = behaelter.querySelector("[data-einreichen]"); + if (einreichen) { + einreichen.addEventListener("click", () => einreichenFenster(behaelter, b, d, bogen)); + } + + const zurueckziehen = behaelter.querySelector("[data-zurueckziehen]"); + if (zurueckziehen) { + zurueckziehen.addEventListener("click", () => zurueckziehenFenster(behaelter, b)); + } + + const freigeben = behaelter.querySelector("[data-freigeben]"); + if (freigeben) freigeben.addEventListener("click", () => freigebenFenster(behaelter, b, d)); + + const zurueckweisen = behaelter.querySelector("[data-zurueckweisen]"); + if (zurueckweisen) { + zurueckweisen.addEventListener("click", () => zurueckweisenFenster(behaelter, b)); + } + + const gegenzeichnen = behaelter.querySelector("[data-gegenzeichnen]"); + if (gegenzeichnen) { + gegenzeichnen.addEventListener("click", () => gegenzeichnenFenster(behaelter, b)); + } + } + + /* ---- Bericht anlegen ------------------------------------------------------- */ + + function anlegenFenster(behaelter) { + let patient = null; // gewählter Patient + let behandlung = null; // gewählte Behandlung + let einsatz = null; // gewählter Einsatz + + bestaetigen({ + titel: "Bericht anlegen", + inhalt: ` +
+
+ +
+
+
+
Nicht jeder Bericht gehört zu einem Patienten.
+
+ +
+
+
+
+
+
+ +
Eine Verknüpfung erlaubt es später, den Wortlaut + der Behandlung ausdrücklich zu übernehmen. Von selbst wird nichts kopiert.
`, + bestaetigenText: "Anlegen", + bestaetigt: async (el, zu) => { + const titel = el.querySelector("[name=titel]").value.trim(); + if (!titel) return meldung("Bitte einen Titel angeben.", "fehler"); + + try { + const antwort = await aufruf("bericht", "anlegen", { + titel, patient, behandlung, einsatz, + }); + zu(); + meldung(`Bericht ${antwort.nummer} angelegt.`, "gut"); + z.ordner = "meine"; + oeffne(behaelter, antwort.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + const suchfeld = hg.querySelector("[name=suche]"); + const liste = hg.querySelector("[data-treffer]"); + const vorgaenge = hg.querySelector("[data-vorgaenge]"); + let warten = null; + + /** Lädt Behandlungen und Einsätze zum gewählten Patienten */ + async function ladeVorgaenge() { + let antwort; + try { + antwort = await aufruf("bericht", "quellen", { patient }); + } catch (f) { return meldung(f.message, "fehler"); } + + vorgaenge.style.display = ""; + + const behListe = hg.querySelector("[data-behandlungen]"); + behListe.innerHTML = antwort.behandlungen.length + ? antwort.behandlungen.map((b) => ` +
+ ${sicher(b.kennung)} +
+
${sicher(b.diagnose || "ohne Diagnose")}
+
${zeit(b.erstellt_am)}
+
+ + ${sicher(b.status)} +
`).join("") + : `
Keine Behandlung erfasst.
`; + + const einListe = hg.querySelector("[data-einsaetze]"); + einListe.innerHTML = antwort.einsaetze.length + ? antwort.einsaetze.map((e) => ` +
+ ${sicher(e.nummer)} +
+
${sicher(e.titel)}
+
${zeit(e.erstellt_am)}
+
+
`).join("") + : `
Kein Einsatz verknüpft.
`; + + // Ein zweiter Klick nimmt die Verknüpfung wieder zurück + behListe.querySelectorAll("[data-behandlung]").forEach((el) => { + el.addEventListener("click", () => { + const schon = behandlung === +el.dataset.behandlung; + behListe.querySelectorAll("[data-behandlung]") + .forEach((k) => k.classList.remove("gewaehlt")); + behandlung = schon ? null : +el.dataset.behandlung; + if (!schon) el.classList.add("gewaehlt"); + }); + }); + + einListe.querySelectorAll("[data-einsatz]").forEach((el) => { + el.addEventListener("click", () => { + const schon = einsatz === +el.dataset.einsatz; + einListe.querySelectorAll("[data-einsatz]") + .forEach((k) => k.classList.remove("gewaehlt")); + einsatz = schon ? null : +el.dataset.einsatz; + if (!schon) el.classList.add("gewaehlt"); + }); + }); + } + + suchfeld.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(async () => { + const begriff = suchfeld.value.trim(); + if (begriff.length < 2) return; + + try { + const antwort = await aufruf("bericht", "quellen", { begriff }); + const treffer = antwort.treffer || []; + + liste.innerHTML = treffer.length ? treffer.map((t) => ` +
+
+
${sicher(t.name)}
+
geb. ${sicher(t.dob)}
+
+ ${t.blutgruppe + ? `${sicher(t.blutgruppe)}` : ""} +
`).join("") + : `
Keine Person gefunden.
`; + + liste.querySelectorAll("[data-person]").forEach((el) => { + el.addEventListener("click", () => { + patient = +el.dataset.person; + behandlung = null; + einsatz = null; + liste.querySelectorAll("[data-person]") + .forEach((k) => k.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + ladeVorgaenge(); + }); + }); + } catch (f) { meldung(f.message, "fehler"); } + }, 280); + }); + }, + }); + } + + /* ---- Wortlaut übernehmen ----------------------------------------------------- */ + + function uebernehmenFenster(behaelter, b, bogen) { + bestaetigen({ + titel: "Wortlaut aus der Behandlung übernehmen", + inhalt: ` +

Aus ${sicher(b.behandlungKennung)} wird der dort dokumentierte Text + in diesen Bericht kopiert.

+ +
+ ℹ +
Das System formuliert nichts um und fasst nichts zusammen — + es kopiert den Wortlaut. Bereits ausgefüllte Felder bleiben unberührt; + nur leere werden gefüllt. Was danach im Bericht steht, verantwortest du.
+
+ +
Ungespeicherte Änderungen im Bogen gehen dabei + verloren — der Entwurf wird zuerst gespeichert.
`, + bestaetigenText: "Übernehmen", + bestaetigt: async (_, zu) => { + try { + // Erst sichern, was der Verfasser bereits geschrieben hat + await aufruf("bericht", "speichern", bogen()); + const antwort = await aufruf("bericht", "uebernehmen", { + id: b.id, behandlung: b.behandlung, + }); + zu(); + meldung(`Übernommen: ${antwort.uebernommen.join(", ")}.`, "gut"); + oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Anlage ------------------------------------------------------------------- */ + + function anlageFenster(behaelter, b) { + bestaetigen({ + titel: "Anlage hinzufügen", + inhalt: ` +
+
+
+
+
Die Anlage wird verlinkt, nicht hochgeladen. + Entfernen lässt sie sich nur, solange der Bericht ein Entwurf ist.
`, + bestaetigenText: "Hinzufügen", + bestaetigt: async (el, zu) => { + try { + await aufruf("bericht", "anlage", { + id: b.id, + titel: el.querySelector("[name=titel]").value.trim(), + adresse: el.querySelector("[name=adresse]").value.trim(), + }); + zu(); + meldung("Anlage hinzugefügt.", "gut"); + oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Einreichen und zurückziehen ------------------------------------------------ */ + + function einreichenFenster(behaelter, b, d, bogen) { + // Welche Felder sind im Bogen gerade leer? + const werte = bogen(); + const leer = d.felder.filter((f) => !werte[f.id]).map((f) => f.name); + + bestaetigen({ + titel: "Bericht einreichen", + inhalt: ` +

${sicher(b.nummer)} geht zur Freigabe. Du unterschreibst ihn damit + als Verfasser.

+ + ${leer.length ? ` +
+ ℹ +
Noch nicht ausgefüllt: ${sicher(leer.join(", "))}. + Das hält die Einreichung nicht auf — es steht nur so im Protokoll.
+
` : ""} + +
+ ✍ +
Nach dem Einreichen ist der Text gesperrt. Zum Weiterschreiben musst du + den Bericht zurückziehen — das wird festgehalten.
+
`, + bestaetigenText: "Unterschreiben und einreichen", + bestaetigt: async (_, zu) => { + try { + // Der zuletzt getippte Stand ist der, der eingereicht wird + await aufruf("bericht", "speichern", werte); + await aufruf("bericht", "einreichen", { id: b.id }); + zu(); + meldung("Bericht eingereicht.", "gut"); + oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + function zurueckziehenFenster(behaelter, b) { + bestaetigen({ + titel: "Bericht zurückziehen", + inhalt: ` +

${sicher(b.nummer)} geht zurück in den Entwurf und lässt sich + wieder bearbeiten.

+
+ ℹ +
Deine Unterschrift als Verfasser bleibt stehen — sie wurde geleistet. + Dass du danach weitergeschrieben hast, steht im Protokoll.
+
+
+
`, + bestaetigenText: "Zurückziehen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (!grund) return meldung("Bitte einen Grund angeben.", "fehler"); + try { + await aufruf("bericht", "zurueckziehen", { id: b.id, grund }); + zu(); + meldung("Bericht zurückgezogen.", "gut"); + oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Freigabe ------------------------------------------------------------------- */ + + function freigebenFenster(behaelter, b, d) { + const leer = d.felder.filter((f) => !b[f.id]).map((f) => f.name); + + bestaetigen({ + titel: "Bericht freigeben", + inhalt: ` +

Du unterschreibst ${sicher(b.nummer)} — „${sicher(b.titel)}", + verfasst von ${sicher(b.autor)}.

+ +
+ 🔒 +
Die Freigabe ist endgültig. Danach wird am Wortlaut nichts mehr + geändert — von niemandem.
+
+ + ${b.vonMir ? ` +
+ ℹ +
Du hast diesen Bericht selbst geschrieben. Verfasser und Freigabe in + einer Person ist zulässig und wird so im Dokument vermerkt.
+
` : ""} + + ${leer.length ? ` +
+ 📄 +
Nicht ausgefüllt: ${sicher(leer.join(", "))}.
+
` : ""} + +
+
`, + bestaetigenText: "Freigeben", + bestaetigt: async (el, zu) => { + try { + await aufruf("bericht", "freigeben", { + id: b.id, vermerk: el.querySelector("[name=vermerk]").value.trim(), + }); + zu(); + meldung("Bericht freigegeben.", "gut"); + oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + function zurueckweisenFenster(behaelter, b) { + bestaetigen({ + titel: "Bericht zurückweisen", + gefahr: true, + inhalt: ` +

${sicher(b.nummer)} geht als Entwurf an ${sicher(b.autor)} zurück.

+
+ +
Der Verfasser bekommt diese Begründung angezeigt.
`, + bestaetigenText: "Zurückweisen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (grund.length < 5) return meldung("Bitte eine Begründung angeben.", "fehler"); + try { + await aufruf("bericht", "zurueckweisen", { id: b.id, grund }); + zu(); + meldung("Bericht zurückgewiesen.", "gut"); + z.offen = null; + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + function gegenzeichnenFenster(behaelter, b) { + bestaetigen({ + titel: "Bericht gegenzeichnen", + inhalt: ` +

Du setzt deine Unterschrift unter ${sicher(b.nummer)}.

+
+ ✍ +
Eine Gegenzeichnung ändert nichts am Wortlaut und lässt sich nicht + zurücknehmen. Sie sagt nur: du hast diesen Bericht gelesen und trägst ihn mit.
+
+
+
`, + bestaetigenText: "Gegenzeichnen", + bestaetigt: async (el, zu) => { + try { + await aufruf("bericht", "gegenzeichnen", { + id: b.id, vermerk: el.querySelector("[name=vermerk]").value.trim(), + }); + zu(); + meldung("Gegengezeichnet.", "gut"); + oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Laden -------------------------------------------------------------------- */ + + async function oeffne(behaelter, berichtId) { + try { + const d = await aufruf("bericht", "oeffnen", { id: berichtId }); + z.offen = berichtId; + einzelAnsicht(behaelter, d); + } catch (f) { + meldung(f.message, "fehler"); + z.offen = null; + } + } + + async function rendern(behaelter) { + try { + z.daten = await aufruf("bericht", "uebersicht", { + ordner: z.ordner, suche: z.suche, + }); + + // Sprungziel aus einem Hinweis, z. B. „Bericht zurückgewiesen" + const ziel = App.zustand.modulZiel; + if (ziel && ziel.bericht) { + App.zustand.modulZiel = null; + return oeffne(behaelter, ziel.bericht); + } + + if (z.offen) await oeffne(behaelter, z.offen); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Berichte

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("bericht", { + rendern, + aktualisieren() { + // Einen offenen Bogen nie überschreiben — dort steht ungespeicherter Text + if (App.zustand.aktivesModul === "bericht" && !z.offen + && !document.querySelector(".fenster-hg")) { + rendern(App.$("inhalt")); + } + }, + }); +})(); diff --git a/resources/[aether]/aether-medical/html/js/module/blutbank.js b/resources/[aether]/aether-medical/html/js/module/blutbank.js new file mode 100644 index 0000000..2c3b6fa --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/module/blutbank.js @@ -0,0 +1,755 @@ +/* ===================================================================== + aether-medical / Ansicht: Blutbank + + Ein Lagerbuch. Die Verträglichkeitstabelle ist eine Nachschlagehilfe + und keine Schranke: Weicht eine Kombination ab, wird das deutlich + angezeigt — ausgegeben werden kann sie trotzdem. Auch abgelaufene + Konserven verschwinden nicht von selbst. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, aufruf, meldung, fenster, bestaetigen } = App; + + const z = { + ordner: "vorraetig", // vorraetig | reserviert | archiv + gruppe: "", // Filter auf eine Blutgruppe + daten: null, + }; + + // Marker-Art je Status + const statusArt = { + vorraetig: "gut", reserviert: "warn", + ausgegeben: "info", verworfen: "neutral", + }; + + /* ---- Kleine Helfer ---------------------------------------------------- */ + + /** Anzeigename eines Status */ + function statusName(status) { + const treffer = (z.daten.status || []).find((s) => s.id === status); + return treffer ? treffer.name : status; + } + + /** + * Schlägt in der Verträglichkeitstabelle nach. + * @returns {boolean|null} null, wenn sich nichts sagen lässt + */ + function vertraeglich(empfaenger, konserve) { + if (!empfaenger) return null; + const erlaubt = (z.daten.vertraeglichkeit || {})[empfaenger]; + if (!erlaubt) return null; + return erlaubt.includes(konserve); + } + + /** Kurzer Hinweistext zum Verfallsdatum */ + function verfallText(k) { + if (k.tage === null || k.tage === undefined) return "ohne Verfallsdatum"; + if (k.tage <= 0) return `seit ${Math.abs(k.tage)} Tagen über dem Datum`; + return `noch ${k.tage} Tage`; + } + + /* ---- Gerüst ------------------------------------------------------------ */ + + function reiterleiste(d) { + const eintraege = [ + { id: "vorraetig", text: "Vorrätig", zahl: d.zaehler.vorraetig }, + { id: "reserviert", text: "Reserviert", zahl: d.zaehler.reserviert }, + { id: "archiv", text: "Archiv", zahl: d.zaehler.ausgegebenHeute }, + ]; + + return `
+ ${eintraege.map((e) => ` + `).join("")} +
`; + } + + function kennzahlen(d) { + const zk = d.zaehler; + + return `
+
+
Vorrätig
+
${zk.vorraetig}
+
frei verfügbare Konserven
+
+
+
Reserviert
+
${zk.reserviert}
+
für Patienten zurückgelegt
+
+
+
Läuft bald ab
+
${zk.baldAb}
+
innerhalb von ${d.vorlaufTage} Tagen
+
+
+
Über dem Datum
+
${zk.abgelaufen}
+
liegt weiter im Lager
+
+
`; + } + + /** Bestandskacheln je Blutgruppe — dienen zugleich als Filter */ + function bestandsKacheln(d) { + return `
+ ${d.bestand.map((b) => ` +
+
Blutgruppe ${sicher(b.gruppe)}
+
${b.vorraetig}
+
+ vorrätig · ${b.reserviert} reserviert + ${b.baldAb > 0 ? ` · ${b.baldAb} läuft ab` : ""} +
+
`).join("")} +
`; + } + + /* ---- Übersicht ----------------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Blutbank

+

Das System verwirft keine Konserve und wählt keine aus. Ein abgelaufenes + Datum ist eine Erinnerung — ob eine Konserve noch brauchbar ist, beurteilt + ein Mensch.

+
+
+ + ${d.rechte.verwalten + ? `` : ""} +
+
+ + ${kennzahlen(d)} + ${bestandsKacheln(d)} + ${reiterleiste(d)} + +
+ + ${z.ordner === "vorraetig" + ? "Die älteste Konserve steht oben — welche genommen wird, entscheidest du." + : "Klick auf eine Zeile öffnet die Konserve."} +
+ +
+
+ ${z.ordner === "vorraetig" ? "Bestand" + : z.ordner === "reserviert" ? "Zurückgelegte Konserven" + : "Ausgegeben und verworfen"} + ${d.liste.length} Einträge +
+
+ ${d.liste.length ? d.liste.map(zeile).join("") + : `
Keine Konserve in diesem Ordner.
`} +
+
+ `; + + verdrahteUebersicht(behaelter); + } + + function zeile(k) { + return `
+ ${sicher(k.konserve)} + ${sicher(k.blutgruppe)} + +
+
${k.menge_ml} ml + ${k.lagerort ? `${sicher(k.lagerort)}` : ""}
+
+ ${k.empfaenger ? "für " + sicher(k.empfaenger) + : "eingelagert " + zeit(k.eingelagert_am)} + · ${sicher(verfallText(k))} +
+
+ + ${k.vertraeglich === false + ? `Tabelle ✕` : ""} + ${k.abgelaufen ? `abgelaufen` + : k.laeuftBaldAb ? `läuft ab` : ""} + + + ${sicher(statusName(k.status))} + + ${k.status === "ausgegeben" ? zeit(k.ausgegeben_am) + : k.verfaellt_am ? sicher(String(k.verfaellt_am).slice(0, 10)) : "—"} +
`; + } + + function verdrahteUebersicht(behaelter) { + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.ordner = el.dataset.reiter; + rendern(behaelter); + }); + }); + + behaelter.querySelectorAll("[data-gruppe]").forEach((el) => { + el.addEventListener("click", () => { + // Ein zweiter Klick hebt den Filter wieder auf + z.gruppe = z.gruppe === el.dataset.gruppe ? "" : el.dataset.gruppe; + rendern(behaelter); + }); + }); + + behaelter.querySelector("[data-gruppenfilter]").addEventListener("change", (e) => { + z.gruppe = e.target.value; + rendern(behaelter); + }); + + behaelter.querySelectorAll("[data-oeffne]").forEach((el) => { + el.addEventListener("click", () => konserveFenster(behaelter, +el.dataset.oeffne)); + }); + + behaelter.querySelector("[data-tabelle]") + .addEventListener("click", () => tabellenFenster()); + + const einlagern = behaelter.querySelector("[data-einlagern]"); + if (einlagern) einlagern.addEventListener("click", () => einlagernFenster(behaelter)); + } + + /* ---- Einzelne Konserve ------------------------------------------------------ */ + + async function konserveFenster(behaelter, konserveId) { + let d; + try { + d = await aufruf("blutbank", "konserve", { id: konserveId }); + } catch (f) { return meldung(f.message, "fehler"); } + + const k = d.konserve; + const imLager = k.status === "vorraetig" || k.status === "reserviert"; + + const dialog = fenster({ + titel: `Konserve ${k.kennung} · ${k.blutgruppe}`, + inhalt: ` + ${k.vertraeglich === false ? ` +
+ ⚠ +
Weicht von der Verträglichkeitstabelle ab. + Konserve ${sicher(k.blutgruppe)} für einen Empfänger mit + ${sicher(k.empfaengerGruppe)}. Das System sperrt nichts — + die Entscheidung liegt bei dir.
+
` : ""} + + ${k.abgelaufen ? ` +
+ 📅 +
Über dem Verfallsdatum — ${sicher(verfallText(k))}. + Die Konserve bleibt im Bestand, bis jemand sie verwirft.
+
` : ""} + +
+
Status${sicher(statusName(k.status))}
+
Blutgruppe${sicher(k.blutgruppe)}
+
Menge${k.menge} ml
+
Lagerort${sicher(k.lagerort || "—")}
+
Eingelagert${zeit(k.eingelagertAm)}
+
Eingelagert von${sicher(k.eingelagertVon)}
+
Verfällt am + ${k.verfaelltAm ? sicher(String(k.verfaelltAm).slice(0, 10)) + : "kein Datum"} · ${sicher(verfallText(k))}
+ ${k.empfaengerName ? ` +
Zurückgelegt für + ${sicher(k.empfaengerName)} + ${k.empfaengerGruppe ? ` (${sicher(k.empfaengerGruppe)})` + : " (Blutgruppe nicht bestimmt)"}
` : ""} + ${k.reserviertVon + ? `
Reserviert von${sicher(k.reserviertVon)}
` : ""} + ${k.ausgegebenAm ? ` +
Ausgegeben${zeit(k.ausgegebenAm)}
+
Ausgegeben von${sicher(k.ausgegebenVon)}
` : ""} +
+ + ${d.rechte.verwalten && imLager ? ` +
+ ${k.status === "vorraetig" + ? `` + : ``} + + + +
` : ""} + +
+ Weg der Konserve + aus dem Protokoll, unveränderlich +
+
+ ${d.spur.length ? d.spur.map((s) => ` +
+
+
${sicher(s.aktion)}
+
${sicher(s.autor)} + ${s.grund ? " · " + sicher(s.grund) : ""}
+
+ ${zeit(s.am)} +
`).join("") + : `
Noch kein Eintrag.
`} +
+ `, + nachOeffnen: (hg) => { + const knopf = (name, funktion) => { + const el = hg.querySelector(`[data-${name}]`); + if (el) el.addEventListener("click", () => { dialog.schliessen(); funktion(); }); + }; + + knopf("reservieren", () => reservierenFenster(behaelter, k)); + knopf("loesen", () => loesenFenster(behaelter, k)); + knopf("ausgeben", () => ausgebenFenster(behaelter, k)); + knopf("umlagern", () => umlagernFenster(behaelter, k)); + knopf("verwerfen", () => verwerfenFenster(behaelter, k)); + }, + }); + } + + /* ---- Einlagern --------------------------------------------------------------- */ + + function einlagernFenster(behaelter) { + const d = z.daten; + + bestaetigen({ + titel: "Konserven einlagern", + inhalt: ` +
+
+
+
+
+
+
+
+ +
+
+ +
+ +
Ohne Datum erinnert später nichts an einen Ablauf. + Mit Datum erinnert das System — verwerfen muss trotzdem jemand von Hand.
+ +
+
`, + bestaetigenText: "Einlagern", + bestaetigt: async (el, zu) => { + try { + const antwort = await aufruf("blutbank", "einlagern", { + blutgruppe: el.querySelector("[name=blutgruppe]").value, + anzahl: +el.querySelector("[name=anzahl]").value, + menge: +el.querySelector("[name=menge]").value, + lagerort: el.querySelector("[name=lagerort]").value, + verfaellt: el.querySelector("[name=verfaellt]").value, + vermerk: el.querySelector("[name=vermerk]").value.trim(), + }); + zu(); + meldung(`${antwort.anzahl} Konserve(n) eingelagert.`, "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Reservieren ---------------------------------------------------------------- */ + + function reservierenFenster(behaelter, k) { + let gewaehlt = null; + + bestaetigen({ + titel: `${k.kennung} zurücklegen`, + inhalt: ` +

Konserve ${sicher(k.blutgruppe)}, ${k.menge} ml.

+ +
+
+
+
Mindestens zwei Zeichen eingeben.
+
+ +
+ +
+
`, + bestaetigenText: "Zurücklegen", + bestaetigt: async (el, zu) => { + if (!gewaehlt) return meldung("Bitte zuerst einen Patienten wählen.", "fehler"); + try { + const antwort = await aufruf("blutbank", "reservieren", { + id: k.id, + patient: gewaehlt.id, + vermerk: el.querySelector("[name=vermerk]").value.trim(), + }); + zu(); + meldung(antwort.vertraeglich === false + ? "Zurückgelegt — die Abweichung wurde gemeldet." + : "Konserve zurückgelegt.", antwort.vertraeglich === false ? "" : "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + const suchfeld = hg.querySelector("[name=suche]"); + const liste = hg.querySelector("[data-treffer]"); + const pruefung = hg.querySelector("[data-pruefung]"); + let warten = null; + + /** Zeigt, was die Nachschlagetabelle zu dieser Kombination sagt */ + function zeigePruefung(person) { + const passt = vertraeglich(person.blutgruppe, k.blutgruppe); + + pruefung.innerHTML = passt === null + ? `
? +
Blutgruppe nicht bestimmt. Zur Verträglichkeit lässt sich + nichts nachschlagen — das ist kein Hindernis, aber es steht so da.
` + : passt + ? `
✔ +
Laut Tabelle verträglich: Konserve ${sicher(k.blutgruppe)} für + Empfänger ${sicher(person.blutgruppe)}.
` + : `
⚠ +
Laut Tabelle nicht verträglich: Konserve + ${sicher(k.blutgruppe)} für Empfänger ${sicher(person.blutgruppe)}. + Das System hält dich nicht auf — es meldet die Abweichung.
`; + } + + suchfeld.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(async () => { + const begriff = suchfeld.value.trim(); + if (begriff.length < 2) { + liste.innerHTML = `
Mindestens zwei Zeichen eingeben.
`; + return; + } + try { + const antwort = await aufruf("blutbank", "patientSuche", { begriff }); + const treffer = antwort.treffer || []; + + liste.innerHTML = treffer.length ? treffer.map((t) => ` +
+
+
${sicher(t.firstname)} ${sicher(t.lastname)}
+
geb. ${sicher(t.dob)}
+
+ ${t.hinweis ? `⚠` : ""} + + ${sicher(t.blutgruppe || "nicht bestimmt")} + ${t.laufend ? `${sicher(t.laufend)}` : ""} +
`).join("") + : `
Keine Person gefunden.
`; + + liste.querySelectorAll("[data-person]").forEach((el) => { + el.addEventListener("click", () => { + gewaehlt = treffer.find((t) => t.id === +el.dataset.person); + liste.querySelectorAll("[data-person]") + .forEach((zeile) => zeile.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + zeigePruefung(gewaehlt); + }); + }); + } catch (f) { meldung(f.message, "fehler"); } + }, 260); + }); + }, + }); + } + + function loesenFenster(behaelter, k) { + bestaetigen({ + titel: "Reservierung aufheben", + inhalt: ` +

Konserve ${sicher(k.kennung)} war für + ${sicher(k.empfaengerName || "einen Patienten")} zurückgelegt und geht + zurück in den freien Bestand.

+
+
`, + bestaetigenText: "Reservierung aufheben", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (!grund) return meldung("Bitte einen Grund angeben.", "fehler"); + try { + await aufruf("blutbank", "reservierungLoesen", { id: k.id, grund }); + zu(); + meldung("Reservierung aufgehoben.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Ausgeben --------------------------------------------------------------------- */ + + function ausgebenFenster(behaelter, k) { + // Bei einer reservierten Konserve steht der Empfänger schon fest + let gewaehlt = k.empfaenger ? { id: k.empfaenger, blutgruppe: k.empfaengerGruppe } : null; + + const passtSchon = k.empfaenger ? vertraeglich(k.empfaengerGruppe, k.blutgruppe) : null; + + bestaetigen({ + titel: `${k.kennung} ausgeben`, + gefahr: true, + inhalt: ` +

Konserve ${sicher(k.blutgruppe)}, ${k.menge} ml verlässt das Lager.

+ +
+ ℹ +
Das System transfundiert nicht. Es hält fest, dass die Konserve + herausgegeben wurde. Alles Weitere verantwortet der Arzt am Patienten.
+
+ + ${k.abgelaufen ? ` +
+ 📅 +
Diese Konserve ist über dem Verfallsdatum — + ${sicher(verfallText(k))}. Die Ausgabe ist möglich und wird so vermerkt.
+
` : ""} + + ${k.empfaenger ? ` +
+
${sicher(k.empfaengerName)} · + ${sicher(k.empfaengerGruppe || "Blutgruppe nicht bestimmt")}
+ ${passtSchon === false ? ` +
+ ⚠ +
Laut Tabelle nicht verträglich. Die Ausgabe wird trotzdem + dokumentiert und gemeldet.
+
` : ""}` + : ` +
+
+
+
Mindestens zwei Zeichen eingeben.
+
+
`} + +
+
`, + bestaetigenText: "Ausgabe dokumentieren", + bestaetigt: async (el, zu) => { + if (!gewaehlt) return meldung("Bitte einen Empfänger wählen.", "fehler"); + + const vermerk = el.querySelector("[name=vermerk]").value.trim(); + if (!vermerk) return meldung("Bitte einen Vermerk hinterlassen.", "fehler"); + + try { + const antwort = await aufruf("blutbank", "ausgeben", { + id: k.id, patient: gewaehlt.id, vermerk, + }); + zu(); + meldung(antwort.vertraeglich === false + ? "Ausgegeben — die Abweichung wurde gemeldet." + : "Ausgabe dokumentiert.", antwort.vertraeglich === false ? "" : "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + const suchfeld = hg.querySelector("[name=suche]"); + if (!suchfeld) return; + + const liste = hg.querySelector("[data-treffer]"); + const pruefung = hg.querySelector("[data-pruefung]"); + let warten = null; + + suchfeld.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(async () => { + const begriff = suchfeld.value.trim(); + if (begriff.length < 2) return; + try { + const antwort = await aufruf("blutbank", "patientSuche", { begriff }); + const treffer = antwort.treffer || []; + + liste.innerHTML = treffer.length ? treffer.map((t) => ` +
+
+
${sicher(t.firstname)} ${sicher(t.lastname)}
+
geb. ${sicher(t.dob)}
+
+ + ${sicher(t.blutgruppe || "nicht bestimmt")} +
`).join("") + : `
Keine Person gefunden.
`; + + liste.querySelectorAll("[data-person]").forEach((el) => { + el.addEventListener("click", () => { + gewaehlt = treffer.find((t) => t.id === +el.dataset.person); + liste.querySelectorAll("[data-person]") + .forEach((zeile) => zeile.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + + const passt = vertraeglich(gewaehlt.blutgruppe, k.blutgruppe); + pruefung.innerHTML = passt === false + ? `
⚠ +
Laut Tabelle nicht verträglich. Die Ausgabe bleibt + möglich und wird gemeldet.
` + : passt === null + ? `
? +
Blutgruppe nicht bestimmt — nichts nachzuschlagen.
` + : ""; + }); + }); + } catch (f) { meldung(f.message, "fehler"); } + }, 260); + }); + }, + }); + } + + /* ---- Verwerfen ---------------------------------------------------------------------- */ + + function verwerfenFenster(behaelter, k) { + bestaetigen({ + titel: `${k.kennung} verwerfen`, + gefahr: true, + inhalt: ` +

Konserve ${sicher(k.blutgruppe)}, ${k.menge} ml wird als verworfen + festgehalten und verschwindet aus dem Bestand.

+ + ${k.empfaengerName ? ` +
+ 🧍 +
Diese Konserve war für ${sicher(k.empfaengerName)} zurückgelegt. + Das wird allen im Dienst gemeldet.
+
` : ""} + +
+
+
Das Verfallsdatum allein verwirft nichts. Ob eine + Konserve noch brauchbar ist, beurteilst du.
`, + bestaetigenText: "Verwerfen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler"); + try { + await aufruf("blutbank", "verwerfen", { id: k.id, grund }); + zu(); + meldung("Konserve verworfen.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Umlagern ------------------------------------------------------------------------ */ + + function umlagernFenster(behaelter, k) { + bestaetigen({ + titel: `${k.kennung} umlagern`, + inhalt: ` +

Aktueller Lagerort: ${sicher(k.lagerort || "—")}

+
+
+
+
`, + bestaetigenText: "Umlagern", + bestaetigt: async (el, zu) => { + try { + await aufruf("blutbank", "umlagern", { + id: k.id, + lagerort: el.querySelector("[name=lagerort]").value, + vermerk: el.querySelector("[name=vermerk]").value.trim(), + }); + zu(); + meldung("Lagerort geändert.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Verträglichkeitstabelle ------------------------------------------------------------ */ + + function tabellenFenster() { + const d = z.daten; + + fenster({ + titel: "Verträglichkeit nachschlagen", + inhalt: ` +
+ ℹ +
Eine Nachschlagehilfe, keine Schranke. Das System sperrt keine + Konserve und wählt keine aus. Weicht eine Kombination ab, wird das + angezeigt und protokolliert — entschieden wird am Patienten.
+
+ +
+ ${d.gruppen.map((empfaenger) => { + const erlaubt = (d.vertraeglichkeit || {})[empfaenger] || []; + return `
+ ${sicher(empfaenger)} +
+
verträgt laut Tabelle
+
+ ${d.gruppen.map((spender) => ` + + ${sicher(spender)}`).join("")} +
+
+ ${erlaubt.length} von ${d.gruppen.length} +
`; + }).join("")} +
`, + }); + } + + /* ---- Laden -------------------------------------------------------------------------- */ + + async function rendern(behaelter) { + try { + z.daten = await aufruf("blutbank", "uebersicht", { + ordner: z.ordner, gruppe: z.gruppe, + }); + + uebersicht(behaelter); + + // Sprungziel aus einem Hinweis, z. B. „Blutkonserve läuft ab" + const ziel = App.zustand.modulZiel; + if (ziel && ziel.blut) { + App.zustand.modulZiel = null; + return konserveFenster(behaelter, ziel.blut); + } + } catch (f) { + behaelter.innerHTML = `

Blutbank

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("blutbank", { + rendern, + aktualisieren() { + // Kein offenes Fenster überschreiben + if (App.zustand.aktivesModul === "blutbank" && !document.querySelector(".fenster-hg")) { + rendern(App.$("inhalt")); + } + }, + }); +})(); diff --git a/resources/[aether]/aether-medical/html/js/module/dashboard.js b/resources/[aether]/aether-medical/html/js/module/dashboard.js new file mode 100644 index 0000000..296741b --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/module/dashboard.js @@ -0,0 +1,259 @@ +/* ===================================================================== + aether-medical / Ansicht: Übersicht + + Der Lagebericht der Klinik. Jede Zahl kommt frisch vom Server; keine + ist fest verdrahtet. Die Warnungen betreffen ausschliesslich Bestände, + Fristen und Besetzung — nie den Zustand eines Patienten, den das + System selbst bewertet hätte. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, minuten, aufruf, meldung } = App; + + const z = { daten: null, behaelter: null }; + + /* ---- Bausteine ------------------------------------------------------ */ + + /** + * Kennzahlkachel + * @param {string|number} wert Angezeigter Wert + * @param {string} titel Beschriftung + * @param {string} zusatz Zusatzzeile + * @param {string} art "", "gut", "warn", "hoch" + * @param {string} ziel Modul, in das ein Klick springt + */ + function kachel(wert, titel, zusatz, art, ziel) { + return `
+
${sicher(titel)}
+
${sicher(wert)}
+ ${zusatz ? `
${sicher(zusatz)}
` : ""} +
`; + } + + /** Waagerechter Belegungsbalken */ + function belegungsBalken(name, belegt, gesamt) { + const anteil = gesamt > 0 ? Math.round((belegt / gesamt) * 100) : 0; + // Je voller, desto dringlicher die Farbe + const farbe = anteil >= 90 ? "var(--kritisch)" + : anteil >= 70 ? "var(--warn)" : "var(--gut)"; + + return `
+ ${sicher(name)} +
+ ${belegt}/${gesamt} +
`; + } + + /** Name einer Station anhand ihrer Kennung */ + function stationsName(id) { + const treffer = (App.zustand.stationen || []).find((s) => s.id === id); + return treffer ? treffer.name : (id || "—"); + } + + /* ---- Zeichnen -------------------------------------------------------- */ + + function zeichne(behaelter) { + const d = z.daten; + const dienst = d.dienst, ein = d.einsaetze, pat = d.patienten; + const bel = d.belegung, offen = d.offen; + + behaelter.innerHTML = ` +
+
+

Übersicht

+

Stand der Klinik. Alle Zahlen sind Momentaufnahmen — + sie lösen nichts aus und ersetzen keine Einschätzung am Patienten.

+
+
${zeit(new Date().toISOString())}
+
+ + ${d.warnungen.length ? `
+ ${d.warnungen.map((w) => ` +
+ ${w.art === "kritisch" ? "⚠" : "❗"} +
${sicher(w.titel)} — ${sicher(w.text)}
+
`).join("")} +
` : ""} + +
+ ${kachel(dienst.imDienst, "Im Dienst", + dienst.leitstelle ? "Leitstelle: " + dienst.leitstelle : "Leitstelle unbesetzt", + dienst.imDienst === 0 ? "hoch" : "gut")} + ${kachel(dienst.notaerzte, "Notärzte", + dienst.aufRtw + " auf RTW · " + dienst.imOp + " im OP")} + ${kachel(ein.offen, "Offene Notrufe", + ein.laufend + " laufend · " + ein.dringend + " dringend", + ein.offen > 0 ? "warn" : "", "leitstelle")} + ${kachel(pat.aufgenommen, "Patienten im Haus", + pat.wartend + " warten auf Aufnahme", "", "patienten")} +
+ +
+ ${kachel(pat.intensiv, "Intensivpatienten", + pat.kritisch + " als kritisch eingetragen", + pat.kritisch > 0 ? "hoch" : "", "intensiv")} + ${kachel(bel.opLaufend + "/" + bel.opSaeleGesamt, "OP-Säle belegt", + bel.opGeplant + " geplant", "", "operation")} + ${kachel(bel.bettenFrei, "Freie Betten", + "von " + bel.bettenGesamt + " insgesamt", + bel.bettenFrei === 0 ? "hoch" : bel.bettenFrei <= 2 ? "warn" : "gut", "klinik")} + ${kachel(bel.behandlungFrei, "Freie Behandlungsräume", + "von " + bel.behandlungGesamt + " insgesamt", + bel.behandlungFrei === 0 ? "warn" : "", "klinik")} +
+ +
+ ${kachel(offen.dokumentationen, "Offene Dokumentationen", + offen.eigeneDoku + " davon von dir", + offen.eigeneDoku > 0 ? "warn" : "", "behandlung")} + ${kachel(offen.laborOffen, "Laboraufträge offen", + offen.laborUnbefundet + " warten auf Bewertung", "", "labor")} + ${kachel(offen.berichteEntwurf, "Eigene Berichtsentwürfe", + "noch nicht eingereicht", + offen.berichteEntwurf > 0 ? "warn" : "", "bericht")} + ${kachel(pat.laengsteWartezeit > 0 ? minuten(pat.laengsteWartezeit) : "—", + "Längste Wartezeit", + pat.wartend + " Patienten warten", + pat.laengsteWartezeit >= 30 ? "hoch" + : pat.laengsteWartezeit >= 10 ? "warn" : "", "patienten")} +
+ +
+
+
+ Wartende Patienten + Aufnahme entscheidet ein Mediziner +
+
+ ${pat.warteliste.length ? pat.warteliste.map((p) => ` +
+ +
+
${sicher(p.firstname)} ${sicher(p.lastname)}
+
${sicher(String(p.grund).slice(0, 70))}
+
+ wartet ${minuten(p.wartet)} +
`).join("") + : `
Niemand wartet auf Aufnahme.
`} +
+
+ +
+
+ Offene und laufende Einsätze + Zuweisung durch die Leitstelle +
+
+ ${ein.liste.length ? ein.liste.map((e) => ` +
+ + ${sicher(e.nummer)} +
+
${sicher(e.titel)}
+
${sicher(e.ort || "Ort offen")}
+
+ + ${e.status === "offen" ? "offen" : "laufend"} +
`).join("") + : `
Keine offenen Einsätze.
`} +
+
+
+ +
+
+
Belegung
+
+ ${belegungsBalken("Betten", bel.bettenBelegt, bel.bettenGesamt)} + ${belegungsBalken("Behandlungsräume", + bel.behandlungGesamt - bel.behandlungFrei, bel.behandlungGesamt)} + ${belegungsBalken("OP-Säle", bel.opLaufend, bel.opSaeleGesamt)} +
+
+ +
+
+ Kollegen im Dienst + ${dienst.imDienst} anwesend +
+
+ ${dienst.liste.length ? dienst.liste.slice(0, 8).map((m) => ` +
+ + ${sicher(m.personalnummer)} +
+
${sicher(m.name)}
+
${sicher(m.rolleName)} + · ${sicher(stationsName(m.station))}
+
+ ${m.leitstelle ? `Leitstelle` : ""} + ${sicher(funktionsName(m.funktion))} +
`).join("") + : `
Niemand im Dienst.
`} +
+
+
+ +
+ Das System unterstützt medizinische Entscheidungen — es trifft niemals selbst + welche. Es nimmt niemanden auf, entlässt niemanden, stellt keine Diagnose, + gibt kein Medikament aus und stellt keinen Tod fest. Wartezeiten und Fristen + laufen ab, ohne dass etwas passiert, bis ein Mediziner entscheidet. +
+ `; + + verdrahte(behaelter); + } + + /** Farbe zum Dienststatus aus der Konfiguration */ + function farbeZuStatus(status) { + const treffer = (App.zustand.status || []).find((s) => s.id === status); + return treffer ? treffer.farbe : "var(--flaeche-3)"; + } + + /** Klartextname einer Dienstfunktion */ + function funktionsName(funktion) { + const treffer = (App.zustand.funktionen || []).find((f) => f.id === funktion); + return treffer ? treffer.name : (funktion || "—"); + } + + function verdrahte(behaelter) { + // Kacheln und Warnungen springen in das zuständige Modul + behaelter.querySelectorAll("[data-springe]").forEach((el) => { + el.addEventListener("click", () => { + const ziel = el.dataset.springe; + if (!App.zustand.module.some((m) => m.id === ziel)) { + meldung("Dieses Modul steht dir nicht zur Verfügung.", "fehler"); + return; + } + App.oeffneModul(ziel); + }); + }); + } + + /* ---- Laden ------------------------------------------------------------ */ + + async function rendern(behaelter) { + z.behaelter = behaelter; + try { + z.daten = await aufruf("dashboard", "laden", {}); + // Hinweise aus dem ersten Laden in den Kern übernehmen + App.zustand.hinweise = z.daten.hinweise || []; + zeichne(behaelter); + } catch (f) { + behaelter.innerHTML = `

Übersicht

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("dashboard", { + rendern, + // Wird gerufen, wenn sich Dienstliste oder Einsätze ändern + aktualisieren() { + if (z.behaelter && App.zustand.aktivesModul === "dashboard") rendern(z.behaelter); + }, + }); +})(); diff --git a/resources/[aether]/aether-medical/html/js/module/einsatz.js b/resources/[aether]/aether-medical/html/js/module/einsatz.js new file mode 100644 index 0000000..744d766 --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/module/einsatz.js @@ -0,0 +1,641 @@ +/* ===================================================================== + aether-medical / Ansicht: Leitstelle und Einsatzverwaltung + + Links die Einsätze, rechts die Kräfte. Eine Kraft lässt sich auf + einen Einsatz ziehen — das schickt eine ANFRAGE, keine Zuweisung. + Ob sie den Einsatz übernimmt, entscheidet sie selbst. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen, anClient } = App; + + const z = { + ordner: "aktuell", // aktuell | archiv + daten: null, + offen: null, + }; + + const prioText = { 1: "Priorität 1", 2: "Priorität 2", 3: "Priorität 3", 4: "Priorität 4" }; + + /* ---- Leitstelle --------------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Leitstelle

+

Notrufe erzeugen offene Einsätze. Wer hinfährt, entscheidet die + Leitstelle — und die angefragte Kraft selbst.

+
+
+ ${d.rechte.leitstelle && !d.istLeitstelle + ? `` : ""} + ${d.rechte.erstellen + ? `` : ""} +
+
+ +
+
+
Offen
+
${d.zaehler.offen}
+
noch niemand gebunden
+
+
+
Laufend
+
${d.zaehler.laufend}
+
Kräfte im Einsatz
+
+
+
Kräfte im Dienst
+
${d.kraefte.length}
+
${d.kraefte.filter((k) => !k.gebundenAn).length} ungebunden
+
+
+
Leitstelle
+
+ ${sicher(d.leitstelleName || "unbesetzt")}
+
${d.zaehler.heute} Einsätze heute beendet
+
+
+ +
+ + +
+ +
+
+
+ ${z.ordner === "archiv" + ? "Abgeschlossene Einsätze" : "Einsätze"} + ${d.rechte.zuweisen && z.ordner === "aktuell" + ? "Kraft von rechts auf einen Einsatz ziehen" + : d.einsaetze.length + " Einträge"} +
+
+ ${d.einsaetze.length ? d.einsaetze.map(einsatzKarte).join("") + : `
${z.ordner === "archiv" + ? "Das Archiv ist leer." : "Keine offenen Einsätze."}
`} +
+
+ +
+
+ Kräfte im Dienst + ${d.rechte.zuweisen ? "ziehbar" : "Übersicht"} +
+
+ ${d.kraefte.length ? d.kraefte.map((k) => kraftZeile(k, d)).join("") + : `
Niemand im Dienst.
`} +
+
+
+ `; + + verdrahteUebersicht(behaelter); + } + + function einsatzKarte(e) { + const abgeschlossen = e.status === "abgeschlossen"; + + return `
+
+ ${sicher(e.nummer)} + ${sicher(e.status)} + ${e.ichDabei ? `du bist dabei` : ""} + ${abgeschlossen + ? zeit(e.geschlossen_am) : "offen seit " + minuten(e.offenSeit)} +
+ +
${sicher(e.titel)}
+
+ + ${sicher(e.kategorieName)} + · ${sicher(prioText[e.prioritaet] || "")} + ${e.ort ? " · " + sicher(e.ort) : ""} + ${e.quelle === "notruf" ? ` · Notruf` : ""} +
+ +
+ ${e.kraefte} Kraft/Kräfte gebunden + · angelegt von ${sicher(e.erstelltVon)} +
+ ${e.pos_x ? `` : ""} + ${!abgeschlossen ? (e.ichDabei + ? `` + : ``) : ""} + +
+
+
`; + } + + function kraftZeile(k, d) { + const farbe = (d.status.find((s) => s.id === k.status) || {}).farbe || "var(--flaeche-3)"; + const funktion = (d.funktionen.find((f) => f.id === k.funktion) || {}).name || k.funktion; + + return `
+ + ${sicher(k.personalnummer)} +
+
${sicher(k.name)}
+
${sicher(k.rolleName)} · ${sicher(funktion)}
+
+ ${k.leitstelle ? `Leitstelle` : ""} + ${k.gebundenAn + ? ` + ${sicher(k.gebundenAn)}` + : `frei`} +
`; + } + + function verdrahteUebersicht(behaelter) { + const d = z.daten; + + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.ordner = el.dataset.reiter; + rendern(behaelter); + }); + }); + + const neu = behaelter.querySelector("[data-neu]"); + if (neu) neu.addEventListener("click", () => erstellenFenster(behaelter)); + + const uebernehmen = behaelter.querySelector("[data-uebernehmen]"); + if (uebernehmen) uebernehmen.addEventListener("click", () => { + bestaetigen({ + titel: "Leitstelle übernehmen", + inhalt: `

Du übernimmst die Disposition. Damit fragst du Kräfte für + Einsätze an — verpflichten kannst du niemanden.

`, + bestaetigenText: "Übernehmen", + bestaetigt: (_, zu) => { + zu(); + anClient("leitstelle", {}); + setTimeout(() => rendern(behaelter), 400); + }, + }); + }); + + behaelter.querySelectorAll("[data-oeffnen]").forEach((el) => { + el.addEventListener("click", (e) => { + e.stopPropagation(); + oeffne(behaelter, +el.dataset.oeffnen); + }); + }); + + behaelter.querySelectorAll("[data-beitreten]").forEach((el) => { + el.addEventListener("click", async (e) => { + e.stopPropagation(); + try { + await aufruf("einsatz", "beitreten", { id: +el.dataset.beitreten }); + meldung("Einsatz übernommen.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }); + }); + + behaelter.querySelectorAll("[data-verlassen]").forEach((el) => { + el.addEventListener("click", async (e) => { + e.stopPropagation(); + try { + await aufruf("einsatz", "verlassen", { id: +el.dataset.verlassen }); + meldung("Vom Einsatz abgerückt.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }); + }); + + behaelter.querySelectorAll("[data-wegpunkt]").forEach((el) => { + el.addEventListener("click", (e) => { + e.stopPropagation(); + const einsatz = d.einsaetze.find((x) => x.id === +el.dataset.wegpunkt); + if (einsatz && einsatz.pos_x) { + anClient("wegpunkt", { x: einsatz.pos_x, y: einsatz.pos_y }); + meldung("Wegpunkt gesetzt."); + } + }); + }); + + if (d.rechte.zuweisen && z.ordner === "aktuell") verdrahteDisposition(behaelter); + } + + /* ---- Disposition per Ziehen ---------------------------------------------- */ + + function verdrahteDisposition(behaelter) { + let gezogeneQuelle = null; + + behaelter.querySelectorAll("[data-kraft]").forEach((el) => { + el.addEventListener("dragstart", (e) => { + gezogeneQuelle = +el.dataset.kraft; + el.classList.add("zieht"); + // Ohne Nutzdaten lässt Firefox das Ziehen nicht zu + e.dataTransfer.setData("text/plain", String(gezogeneQuelle)); + e.dataTransfer.effectAllowed = "link"; + }); + + el.addEventListener("dragend", () => { + gezogeneQuelle = null; + el.classList.remove("zieht"); + behaelter.querySelectorAll("[data-ziel]") + .forEach((ziel) => ziel.classList.remove("uebergabe")); + }); + }); + + behaelter.querySelectorAll("[data-ziel]").forEach((el) => { + el.addEventListener("dragover", (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "link"; + el.classList.add("uebergabe"); + }); + + el.addEventListener("dragleave", () => el.classList.remove("uebergabe")); + + el.addEventListener("drop", (e) => { + e.preventDefault(); + el.classList.remove("uebergabe"); + const quelle = gezogeneQuelle || +e.dataTransfer.getData("text/plain"); + if (quelle) anfrageFenster(behaelter, +el.dataset.einsatz, quelle); + }); + }); + } + + function anfrageFenster(behaelter, einsatzId, zielQuelle) { + const einsatz = z.daten.einsaetze.find((e) => e.id === einsatzId); + const kraft = z.daten.kraefte.find((k) => k.quelle === zielQuelle); + if (!einsatz || !kraft) return; + + bestaetigen({ + titel: "Kraft anfragen", + inhalt: ` +

${sicher(kraft.name)} (${sicher(kraft.personalnummer)}) + soll ${sicher(einsatz.nummer)} übernehmen:

+

${sicher(einsatz.titel)}${einsatz.ort ? " — " + sicher(einsatz.ort) : ""}

+ ${kraft.gebundenAn ? ` +
+ ⚠ +
Diese Kraft ist bereits an ${sicher(kraft.gebundenAn)} gebunden.
+
` : ""} +
+ ℹ +
Das ist eine Anfrage, keine Zuweisung. Die Kraft bekommt den + Einsatz mit Wegpunkt angezeigt und entscheidet selbst, ob sie übernimmt.
+
`, + bestaetigenText: "Anfragen", + bestaetigt: async (_, zu) => { + try { + const antwort = await aufruf("einsatz", "anfragen", { + id: einsatzId, quelle: zielQuelle, + }); + zu(); + meldung("Anfrage an " + antwort.name + " gesendet.", "gut"); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Einzelansicht --------------------------------------------------------- */ + + function einzelAnsicht(behaelter, d) { + const e = d.einsatz; + const aktiveKraefte = d.kraefte.filter((k) => k.aktiv); + + behaelter.innerHTML = ` +
+
+

${sicher(e.nummer)} · ${sicher(e.titel)}

+

${sicher(e.kategorieName)} · ${sicher(prioText[e.prioritaet] || "")} + ${e.ort ? " · " + sicher(e.ort) : ""}

+
+ +
+ +
+
+
+ Einsatz + ${sicher(e.status)} +
+
+
Angelegt${zeit(e.erstelltAm)}
+
Von${sicher(e.erstelltVon)}
+
Quelle${sicher(e.quelle)}
+ ${e.geschlossenAm ? ` +
Abgeschlossen${zeit(e.geschlossenAm)}
+
Durch${sicher(e.geschlossenVon)}
` : ""} +
+ ${e.beschreibung + ? `
${sicher(e.beschreibung).replace(/\n/g, "
")}
` : ""} + +
+ ${e.position ? `` : ""} + ${e.status !== "abgeschlossen" ? (d.ichDabei + ? `` + : ``) : ""} + ${e.status !== "abgeschlossen" && d.rechte.zuweisen + ? `` : ""} + ${e.status !== "abgeschlossen" && d.rechte.schliessen + ? `` : ""} +
+
+ +
+
+ Beteiligte Kräfte + ${aktiveKraefte.length} gebunden +
+
+ ${d.kraefte.length ? d.kraefte.map((k) => ` +
+ ${sicher(k.personalnummer || "—")} +
+
${sicher(k.name)}
+
${sicher(k.rolleName || "")} · seit ${zeit(k.seit)}
+
+ + ${k.aktiv ? "im Einsatz" : "abgerückt"} +
`).join("") + : `
Noch niemand übernommen.
`} +
+
+
+ +
+
+ Einsatzverlauf + +
+
+ ${d.notizen.length ? d.notizen.map((n) => ` +
+
+
${sicher(n.text)}
+
${sicher(n.autor)} · ${zeit(n.am)}
+
+
`).join("") + : `
Noch keine Einträge im Verlauf.
`} +
+
+ +
+
+ Dokumentation zu diesem Einsatz + Das System legt keine an +
+
+ ${d.behandlungen.length ? d.behandlungen.map((b) => ` +
+ ${sicher(b.kennung)} +
+
${sicher(b.patientName)}
+
${sicher(b.diagnose || "Noch keine Diagnose")}
+
+ + ${sicher(b.status)} +
`).join("") + : `
Noch keine Behandlung dokumentiert.
`} +
+
+ `; + + verdrahteEinzel(behaelter, d); + } + + function verdrahteEinzel(behaelter, d) { + const e = d.einsatz; + + behaelter.querySelector("[data-zurueck]").addEventListener("click", () => { + z.offen = null; + rendern(behaelter); + }); + + const wegpunkt = behaelter.querySelector("[data-wegpunkt]"); + if (wegpunkt) wegpunkt.addEventListener("click", () => { + anClient("wegpunkt", { x: e.position.x, y: e.position.y }); + meldung("Wegpunkt gesetzt."); + }); + + const beitreten = behaelter.querySelector("[data-beitreten]"); + if (beitreten) beitreten.addEventListener("click", async () => { + try { + await aufruf("einsatz", "beitreten", { id: e.id }); + meldung("Einsatz übernommen.", "gut"); + oeffne(behaelter, e.id); + } catch (f) { meldung(f.message, "fehler"); } + }); + + const verlassen = behaelter.querySelector("[data-verlassen]"); + if (verlassen) verlassen.addEventListener("click", async () => { + try { + await aufruf("einsatz", "verlassen", { id: e.id }); + meldung("Abgerückt.", "gut"); + oeffne(behaelter, e.id); + } catch (f) { meldung(f.message, "fehler"); } + }); + + behaelter.querySelector("[data-notiz]").addEventListener("click", () => { + fenster({ + titel: "Notiz zum Einsatzverlauf", + inhalt: `
+
`, + knoepfe: [ + { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() }, + { text: "Speichern", klick: async (el, zu) => { + try { + await aufruf("einsatz", "notiz", { + id: e.id, text: el.querySelector("[name=text]").value.trim(), + }); + zu(); + oeffne(behaelter, e.id); + } catch (f) { meldung(f.message, "fehler"); } + } }, + ], + }); + }); + + const prio = behaelter.querySelector("[data-prio]"); + if (prio) prio.addEventListener("click", () => { + bestaetigen({ + titel: "Priorität ändern", + inhalt: ` +

Aktuell: ${sicher(prioText[e.prioritaet])}

+
+
+
+
`, + bestaetigenText: "Setzen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler"); + try { + await aufruf("einsatz", "prioritaet", { + id: e.id, + prioritaet: +el.querySelector("[name=prioritaet]").value, + grund, + }); + zu(); + meldung("Priorität geändert.", "gut"); + oeffne(behaelter, e.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + + const schliessen = behaelter.querySelector("[data-schliessen]"); + if (schliessen) schliessen.addEventListener("click", () => { + const dokumentiert = d.behandlungen.length; + + bestaetigen({ + titel: "Einsatz abschließen", + gefahr: true, + inhalt: ` +

${sicher(e.nummer)} — ${sicher(e.titel)}

+

Alle noch gebundenen Kräfte werden abgemeldet.

+ ${dokumentiert === 0 ? ` +
+ 📄 +
Zu diesem Einsatz ist noch keine Behandlung dokumentiert. + Das System legt keine an — du bekommst nur eine Erinnerung. + Abschließen ist trotzdem möglich.
+
` : ""} +
+
`, + bestaetigenText: "Abschließen", + bestaetigt: async (el, zu) => { + const vermerk = el.querySelector("[name=vermerk]").value.trim(); + if (vermerk.length < 5) return meldung("Bitte einen Vermerk angeben.", "fehler"); + try { + await aufruf("einsatz", "schliessen", { id: e.id, vermerk }); + zu(); + meldung("Einsatz abgeschlossen.", "gut"); + z.offen = null; + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + } + + /* ---- Einsatz anlegen --------------------------------------------------------- */ + + function erstellenFenster(behaelter) { + const d = z.daten; + + fenster({ + titel: "Einsatz anlegen", + inhalt: ` +
+
+
+
+
+
+
+
+
+
+
+ +
Der Einsatz erscheint für alle als offen. + Alarmiert wird niemand gezielt.
`, + knoepfe: [ + { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() }, + { text: "Anlegen", klick: async (el, zu) => { + try { + await aufruf("einsatz", "erstellen", { + titel: el.querySelector("[name=titel]").value.trim(), + kategorie: el.querySelector("[name=kategorie]").value, + prioritaet: +el.querySelector("[name=prioritaet]").value, + ort: el.querySelector("[name=ort]").value.trim(), + beschreibung: el.querySelector("[name=beschreibung]").value.trim(), + }); + zu(); + meldung("Einsatz angelegt.", "gut"); + z.ordner = "aktuell"; + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + } }, + ], + }); + } + + /* ---- Laden --------------------------------------------------------------------- */ + + async function oeffne(behaelter, einsatzId) { + try { + const d = await aufruf("einsatz", "oeffnen", { id: einsatzId }); + z.offen = einsatzId; + einzelAnsicht(behaelter, d); + } catch (f) { meldung(f.message, "fehler"); } + } + + async function rendern(behaelter) { + try { + z.daten = await aufruf("einsatz", "uebersicht", { ordner: z.ordner }); + + // Sprungziel aus einem Hinweis, z. B. „Neuer Einsatz" + const ziel = App.zustand.modulZiel; + if (ziel && ziel.einsatz) { + App.zustand.modulZiel = null; + return oeffne(behaelter, ziel.einsatz); + } + + if (z.offen) await oeffne(behaelter, z.offen); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Leitstelle

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("einsatz", { + rendern, + aktualisieren() { + if (App.zustand.aktivesModul === "einsatz") rendern(App.$("inhalt")); + }, + }); + + App.registriereSuche(async (begriff) => { + try { + const d = await aufruf("einsatz", "uebersicht", { ordner: "aktuell" }); + const klein = begriff.toLowerCase(); + return (d.einsaetze || []) + .filter((e) => (e.nummer + " " + e.titel + " " + (e.ort || "")) + .toLowerCase().includes(klein)) + .slice(0, 5) + .map((e) => ({ + bereich: "Einsatz", titel: e.nummer + " · " + e.titel, + unter: e.kategorieName + (e.ort ? " · " + e.ort : ""), + })); + } catch (e) { return []; } + }); +})(); diff --git a/resources/[aether]/aether-medical/html/js/module/fahrzeug.js b/resources/[aether]/aether-medical/html/js/module/fahrzeug.js new file mode 100644 index 0000000..2396848 --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/module/fahrzeug.js @@ -0,0 +1,621 @@ +/* ===================================================================== + aether-medical / Ansicht: Fuhrpark + + Zwei Sorten von Angaben, streng getrennt dargestellt: + GEMELDET (Tank, Zustand, Position) kommt aus dem Spiel und trägt + immer sein Alter mit sich. EINGETRAGEN (Status, Wartung, Zuweisung) + stammt von Menschen. Kein gemeldeter Wert ändert einen eingetragenen. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen, anClient } = App; + + const z = { + ordner: "dienst", // dienst | werkstatt | ausser + art: "", // Filter auf eine Fahrzeugart + daten: null, + }; + + // Marker-Art je Einsatzstatus + const statusArt = { + verfuegbar: "gut", im_einsatz: "warn", + werkstatt: "info", ausser_dienst: "neutral", + }; + + /* ---- Kleine Helfer ---------------------------------------------------- */ + + /** Anzeigename aus einer Konfigurationsliste */ + function nameAus(liste, kennung) { + const treffer = (liste || []).find((e) => e.id === kennung); + return treffer ? treffer.name : kennung; + } + + /** Farbe eines Balkens nach seinem Wert */ + function balkenFarbe(wert, grenze) { + if (wert === null || wert === undefined) return "var(--text-leise)"; + if (wert <= grenze / 2) return "var(--kritisch)"; + if (wert <= grenze) return "var(--warn)"; + return "var(--gut)"; + } + + /** + * Ein Balken für einen gemeldeten Wert. + * Ist die Meldung alt, wird der Balken blass — der Wert sagt dann + * nichts über den jetzigen Zustand. + */ + function balken(titel, wert, grenze, veraltet) { + const hat = wert !== null && wert !== undefined; + + return `
+
+ ${sicher(titel)} + ${hat ? wert + " %" : "nie gemeldet"} +
+
+ +
+
`; + } + + /* ---- Gerüst ------------------------------------------------------------ */ + + function reiterleiste(d) { + const eintraege = [ + { id: "dienst", text: "Im Dienst", zahl: d.zaehler.verfuegbar + d.zaehler.imEinsatz }, + { id: "werkstatt", text: "Wartung", zahl: d.zaehler.werkstatt }, + { id: "ausser", text: "Außer Dienst", zahl: d.zaehler.ausserDienst }, + ]; + + return `
+ ${eintraege.map((e) => ` + `).join("")} +
`; + } + + function kennzahlen(d) { + const zk = d.zaehler; + + return `
+
+
Verfügbar
+
${zk.verfuegbar}
+
so eingetragen
+
+
+
Im Einsatz
+
${zk.imEinsatz}
+
${zk.zugewiesen} Fahrzeuge zugewiesen
+
+
+
Wartung offen
+
${zk.werkstatt}
+
Werkstatt oder fällig
+
+
+
Außer Dienst
+
${zk.ausserDienst}
+
fahren nicht
+
+
`; + } + + /* ---- Übersicht ---------------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Fuhrpark

+

Tank, Zustand und Position werden gemeldet, während jemand fährt. + Status, Wartung und Zuweisung trägt ein Mensch ein — das eine ändert + das andere nie.

+
+ ${d.rechte.pflegen + ? `` : ""} +
+ + ${kennzahlen(d)} + ${reiterleiste(d)} + +
+ + Eine Meldung älter als ${d.grenzen.meldung} Minuten + wird blass dargestellt. +
+ + ${d.liste.length + ? `
${d.liste.map((f) => karte(f, d)).join("")}
` + : `
Kein Fahrzeug in diesem Ordner.
`} + `; + + verdrahteUebersicht(behaelter); + } + + function karte(f, d) { + return `
+
+
+
${sicher(f.bezeichnung)}
+
${sicher(f.kennzeichen)}
+
+ + ${sicher(f.statusName)} +
+ +
+ ${sicher(f.artName)} + ${f.wartung !== "ok" + ? `${sicher(f.wartungName)}` : ""} + ${f.zugewiesenAn + ? `${sicher(f.zugewiesenAn)}` : ""} +
+ +
+ ${balken("Tank", f.tank, d.grenzen.tank, f.meldungVeraltet)} + ${balken("Zustand", f.zustand, d.grenzen.zustand, f.meldungVeraltet)} +
+ +
+ ${f.gemeldet_am + ? `gemeldet vor ${sicher(minuten(f.meldungAlter))}${ + f.letzterFahrer ? " · " + sicher(f.letzterFahrer) : ""}` + : "noch nie gemeldet"} + ${f.notiz ? ` · ${sicher(f.notiz)}` : ""} +
+
`; + } + + function verdrahteUebersicht(behaelter) { + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.ordner = el.dataset.reiter; + rendern(behaelter); + }); + }); + + behaelter.querySelector("[data-artfilter]").addEventListener("change", (e) => { + z.art = e.target.value; + rendern(behaelter); + }); + + behaelter.querySelectorAll("[data-oeffne]").forEach((el) => { + el.addEventListener("click", () => fahrzeugFenster(behaelter, +el.dataset.oeffne)); + }); + + const neu = behaelter.querySelector("[data-neu]"); + if (neu) neu.addEventListener("click", () => bearbeitenFenster(behaelter, null)); + } + + /* ---- Einzelnes Fahrzeug ---------------------------------------------------- */ + + async function fahrzeugFenster(behaelter, fahrzeugId) { + let d; + try { + d = await aufruf("fahrzeug", "oeffnen", { id: fahrzeugId }); + } catch (f) { return meldung(f.message, "fehler"); } + + const f = d.fahrzeug; + const ausstattung = String(f.ausstattung || "") + .split(",").map((t) => t.trim()).filter(Boolean); + + const dialog = fenster({ + titel: `${f.bezeichnung} · ${f.kennzeichen}`, + inhalt: ` + ${f.wartung !== "ok" ? ` +
+ 🔧 +
${sicher(f.wartungName)}${f.notiz ? " — " + sicher(f.notiz) : ""}. + Eingetragen von Hand; ob das Fahrzeug fährt, steht im Einsatzstatus.
+
` : ""} + + ${f.meldungVeraltet ? ` +
+ 🕓 +
Die letzte Meldung ist ${sicher(minuten(f.meldungAlter))} alt. + Tank und Zustand sagen nichts über den jetzigen Stand.
+
` : ""} + +
+
Einsatzstatus${sicher(f.statusName)}
+
Wartung${sicher(f.wartungName)}
+
Art${sicher(f.artName)}
+
Modell${sicher(f.modell)}
+
Station${sicher(f.station || "—")}
+
Zugewiesen an + ${sicher(f.zugewiesenAn || "niemand")}
+
+ +
+ Gemeldet + ${f.gemeldetAm + ? "vor " + sicher(minuten(f.meldungAlter)) + " von " + + sicher(f.letzterFahrer || "unbekannt") + : "noch nie"} +
+
+ ${balken("Tank", f.tank, 20, f.meldungVeraltet)} + ${balken("Zustand", f.zustand, 60, f.meldungVeraltet)} +
+ ${f.position + ? `` + : `
Keine Position gemeldet.
`} + +
+ Ausstattung + notiert, nicht geprüft +
+ ${ausstattung.length + ? `
${ausstattung.map((a) => + `${sicher(a)}`).join("")}
` + : `
Nichts eingetragen.
`} + +
+ ${d.rechte.zuweisen + ? ` + ` : ""} + ${d.rechte.wartung + ? `` : ""} + ${d.rechte.pflegen + ? `` : ""} + ${d.rechte.pflegen && f.status === "ausser_dienst" + ? `` : ""} +
+ +
+ Eingetragene Vorgänge + aus dem Protokoll, unveränderlich +
+
+ ${d.spur.length ? d.spur.map((s) => ` +
+
+
${sicher(s.aktion)}
+
${sicher(s.autor)} + ${s.grund ? " · " + sicher(s.grund) : ""}
+
+ ${zeit(s.am)} +
`).join("") + : `
Noch kein Eintrag.
`} +
+ `, + nachOeffnen: (hg) => { + const wegpunkt = hg.querySelector("[data-wegpunkt]"); + if (wegpunkt) { + wegpunkt.addEventListener("click", () => { + anClient("wegpunkt", { x: f.position.x, y: f.position.y }); + meldung("Wegpunkt zur letzten gemeldeten Position gesetzt."); + }); + } + + const knopf = (name, funktion) => { + const el = hg.querySelector(`[data-${name}]`); + if (el) el.addEventListener("click", () => { dialog.schliessen(); funktion(); }); + }; + + knopf("status", () => statusFenster(behaelter, f, d)); + knopf("zuweisen", () => zuweisenFenster(behaelter, f)); + knopf("wartung", () => wartungFenster(behaelter, f, d)); + knopf("bearbeiten", () => bearbeitenFenster(behaelter, f)); + knopf("entfernen", () => entfernenFenster(behaelter, f)); + }, + }); + } + + /* ---- Einsatzstatus ----------------------------------------------------------- */ + + function statusFenster(behaelter, f, d) { + bestaetigen({ + titel: "Einsatzstatus setzen", + inhalt: ` +

Aktuell eingetragen: ${sicher(f.statusName)}

+ +
+ +
Werkstatt und Außer Dienst darf nur eintragen, + wer die Wartung führt — und beides braucht einen Grund.
+ +
+
+ + ${f.zugewiesenAn ? ` +
+ 🧍 +
Das Fahrzeug ist ${sicher(f.zugewiesenAn)} zugewiesen. + Bei Werkstatt oder Außer Dienst wird das gemeldet — die Zuweisung + selbst bleibt bestehen.
+
` : ""}`, + bestaetigenText: "Status setzen", + bestaetigt: async (el, zu) => { + try { + await aufruf("fahrzeug", "status", { + id: f.id, + status: el.querySelector("[name=status]").value, + grund: el.querySelector("[name=grund]").value.trim(), + }); + zu(); + meldung("Status gesetzt.", "gut"); + rendern(behaelter); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }, + }); + } + + /* ---- Zuweisung ------------------------------------------------------------------ */ + + async function zuweisenFenster(behaelter, f) { + let k; + try { + k = await aufruf("fahrzeug", "kollegen", {}); + } catch (fehler) { return meldung(fehler.message, "fehler"); } + + let gewaehlt = f.zugewiesen || null; + let aufheben = false; + + bestaetigen({ + titel: `${f.bezeichnung} zuweisen`, + inhalt: ` +

Zugewiesen ist zurzeit: ${sicher(f.zugewiesenAn || "niemand")}

+ +
+ ℹ +
Eine Zuweisung ist ein Eintrag im Fahrzeugbuch. Sie spawnt kein + Fahrzeug, schließt keines auf und gibt keinen Schlüssel aus.
+
+ +
+
+ ${k.liste.length ? k.liste.map((p) => ` +
+
+
${sicher(p.name)} + ${p.selbst ? `du` : ""}
+
${sicher(p.rolleName)} · + ${sicher(p.personalnummer || "")}
+
+ ${sicher(p.funktion || "")} +
`).join("") + : `
Niemand im Dienst.
`} +
+ +
+ +
+
`, + bestaetigenText: "Eintragen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + + if (aufheben && !grund) { + return meldung("Bitte angeben, warum die Zuweisung entfällt.", "fehler"); + } + if (!aufheben && !gewaehlt) { + return meldung("Bitte eine Person wählen.", "fehler"); + } + + try { + await aufruf("fahrzeug", "zuweisen", { + id: f.id, + charakter: aufheben ? null : gewaehlt, + grund, + }); + zu(); + meldung(aufheben ? "Zuweisung aufgehoben." : "Fahrzeug zugewiesen.", "gut"); + rendern(behaelter); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }, + nachOeffnen: (hg) => { + hg.querySelectorAll("[data-person]").forEach((el) => { + el.addEventListener("click", () => { + gewaehlt = +el.dataset.person; + hg.querySelectorAll("[data-person]") + .forEach((zeile) => zeile.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + }); + }); + + const haken = hg.querySelector("[name=aufheben]"); + haken.addEventListener("change", () => { + aufheben = haken.checked; + hg.querySelector("[data-kollegen]").style.opacity = aufheben ? ".45" : ""; + }); + }, + }); + } + + /* ---- Wartung ---------------------------------------------------------------------- */ + + function wartungFenster(behaelter, f, d) { + bestaetigen({ + titel: "Wartungsstatus", + inhalt: ` +

Aktuell eingetragen: ${sicher(f.wartungName)}

+ +
+ ℹ +
Ein gemeldeter Schaden setzt diesen Status nicht. Was das Spiel + meldet, ist eine Beobachtung — ob gewartet wird, beurteilst du.
+
+ +
+
+ +
+
+
Ein leeres Feld löscht die Notiz.
`, + bestaetigenText: "Eintragen", + bestaetigt: async (el, zu) => { + try { + await aufruf("fahrzeug", "wartung", { + id: f.id, + wartung: el.querySelector("[name=wartung]").value, + notiz: el.querySelector("[name=notiz]").value.trim(), + }); + zu(); + meldung("Wartungsstatus eingetragen.", "gut"); + rendern(behaelter); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }, + }); + } + + /* ---- Fahrzeug anlegen und bearbeiten ------------------------------------------------ */ + + function bearbeitenFenster(behaelter, f) { + const d = z.daten; + const neu = !f; + const vorhanden = String((f && f.ausstattung) || "") + .split(",").map((t) => t.trim()).filter(Boolean); + const eigene = vorhanden.filter((a) => !d.ausstattung.includes(a)); + + bestaetigen({ + titel: neu ? "Fahrzeug aufnehmen" : `${f.bezeichnung} bearbeiten`, + inhalt: ` +
+
+
+
+
+
+
Über das Kennzeichen findet die Telemetrie das + Fahrzeug wieder — es muss genau dem im Spiel entsprechen.
+ +
+
+
+
+
+
+ +
+
+ +
+
+ ${d.ausstattung.map((a) => ` + `).join("")} +
+ +
+
`, + bestaetigenText: neu ? "Aufnehmen" : "Speichern", + bestaetigt: async (el, zu) => { + const gewaehlt = Array.from(el.querySelectorAll(".fz-teil-knopf.gewaehlt")) + .map((k) => k.dataset.teil); + const weitere = el.querySelector("[name=eigene]").value + .split(",").map((t) => t.trim()).filter(Boolean); + + try { + await aufruf("fahrzeug", "speichern", { + id: neu ? null : f.id, + kennzeichen: el.querySelector("[name=kennzeichen]").value.trim(), + bezeichnung: el.querySelector("[name=bezeichnung]").value.trim(), + modell: el.querySelector("[name=modell]").value.trim(), + art: el.querySelector("[name=art]").value, + station: el.querySelector("[name=station]").value, + ausstattung: gewaehlt.concat(weitere).join(", "), + }); + zu(); + meldung(neu ? "Fahrzeug aufgenommen." : "Fahrzeug gespeichert.", "gut"); + rendern(behaelter); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }, + nachOeffnen: (hg) => { + hg.querySelectorAll("[data-teil]").forEach((el) => { + el.addEventListener("click", () => el.classList.toggle("gewaehlt")); + }); + }, + }); + } + + function entfernenFenster(behaelter, f) { + bestaetigen({ + titel: `${f.bezeichnung} aus dem Fuhrpark nehmen`, + gefahr: true, + inhalt: ` +

Das Fahrzeug verschwindet aus dem Fuhrpark. Die Protokolleinträge + dazu bleiben erhalten.

+
+
`, + bestaetigenText: "Entfernen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (!grund) return meldung("Bitte einen Grund angeben.", "fehler"); + try { + await aufruf("fahrzeug", "entfernen", { id: f.id, grund }); + zu(); + meldung("Fahrzeug entfernt.", "gut"); + rendern(behaelter); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }, + }); + } + + /* ---- Laden ---------------------------------------------------------------------- */ + + async function rendern(behaelter) { + try { + z.daten = await aufruf("fahrzeug", "uebersicht", { ordner: z.ordner, art: z.art }); + + uebersicht(behaelter); + + // Sprungziel aus einem Hinweis, z. B. „Tankstand gemeldet" + const ziel = App.zustand.modulZiel; + if (ziel && ziel.fahrzeug) { + App.zustand.modulZiel = null; + return fahrzeugFenster(behaelter, ziel.fahrzeug); + } + } catch (f) { + behaelter.innerHTML = `

Fuhrpark

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("fahrzeug", { + rendern, + aktualisieren() { + // Kein offenes Fenster überschreiben + if (App.zustand.aktivesModul === "fahrzeug" && !document.querySelector(".fenster-hg")) { + rendern(App.$("inhalt")); + } + }, + }); +})(); diff --git a/resources/[aether]/aether-medical/html/js/module/intensiv.js b/resources/[aether]/aether-medical/html/js/module/intensiv.js new file mode 100644 index 0000000..d43d40f --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/module/intensiv.js @@ -0,0 +1,747 @@ +/* ===================================================================== + aether-medical / Ansicht: Intensivstation + + Diese Ansicht ist ein Monitor für Dokumentation, nicht für Patienten. + Kein Wert hier wird gemessen: der Zustand steht da, weil ein Arzt ihn + eingetragen hat, und die Geräteliste ist eine Liste von Namen. + Ein Aufenthalt endet erst, wenn jemand ihn beendet. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen } = App; + + const z = { + ordner: "laufend", // laufend | beendet + offen: null, // geöffneter Aufenthalt + daten: null, + }; + + // Marker-Art je Zustand — nur Farbe, keine Wertung + const zustandArt = { kritisch: "kritisch", stabil: "info", besserung: "gut" }; + + /* ---- Kleine Helfer ---------------------------------------------------- */ + + /** Farbe eines Zustands aus der Konfiguration */ + function zustandFarbe(kennung) { + const liste = (z.daten && z.daten.zustaende) || []; + const treffer = liste.find((s) => s.id === kennung); + return treffer ? treffer.farbe : "#8697a4"; + } + + /** Zerlegt die Geräteliste in einzelne Namen */ + function geraeteTeile(text) { + return String(text || "").split(",").map((t) => t.trim()).filter(Boolean); + } + + /* ---- Gerüst ------------------------------------------------------------ */ + + function reiterleiste(d) { + const eintraege = [ + { id: "laufend", text: "In Betreuung", zahl: d.zaehler.laufend }, + { id: "beendet", text: "Beendet", zahl: d.zaehler.beendetHeute }, + ]; + + return `
+ ${eintraege.map((e) => ` + `).join("")} +
`; + } + + function kennzahlen(d) { + const zk = d.zaehler; + + return `
+
+
In Betreuung
+
${zk.laufend}
+
laufende Aufenthalte
+
+
+
Als kritisch eingetragen
+
${zk.kritisch}
+
von Hand eingestuft
+
+
+
Ohne zuständigen Arzt
+
${zk.ohneArzt}
+
niemand eingetragen
+
+
+
Ohne frischen Eintrag
+
${zk.langeStill}
+
seit über ${d.stilleNach} Minuten
+
+
`; + } + + /* ---- Übersicht ---------------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Intensivstation

+

Das System misst nichts und schlägt keinen Alarm. Was hier steht, hat + jemand eingetragen — der Zustand ändert sich nie von selbst.

+
+ ${d.rechte.betreuen + ? `` : ""} +
+ + ${kennzahlen(d)} + ${reiterleiste(d)} + +
+
+ ${z.ordner === "laufend" + ? "Patienten in Intensivbetreuung" : "Beendete Aufenthalte"} + ${z.ordner === "laufend" + ? "Oben steht, was als am schwersten eingetragen wurde" + : d.liste.length + " Einträge"} +
+
+ ${d.liste.length ? d.liste.map(zeile).join("") + : `
${z.ordner === "laufend" + ? "Zurzeit wird niemand intensiv betreut." + : "Noch keine beendeten Aufenthalte."}
`} +
+
+ `; + + verdrahteUebersicht(behaelter); + } + + function zeile(a) { + const laufend = !a.beendet_am; + + return `
+ + +
+
${sicher(a.name)} + geb. ${sicher(a.geburt)}
+
+ ${a.zimmerKennung ? "Zimmer " + sicher(a.zimmerKennung) : "ohne festen Platz"} + ${a.geraete ? " · " + sicher(String(a.geraete).slice(0, 60)) : ""} + ${laufend ? " · zuletzt dokumentiert vor " + sicher(minuten(a.stillSeit)) : ""} +
+
+ + ${a.warnhinweis ? `⚠` : ""} + ${a.allergien ? `A` : ""} + ${a.blutgruppe ? `${sicher(a.blutgruppe)}` : ""} + + ${a.langeStill ? `Verlauf offen` : ""} + ${laufend && !a.verantwortlichName + ? `kein Arzt` + : a.verantwortlichName + ? `${sicher(a.verantwortlichName)}` : ""} + + + ${sicher(a.zustandName)} + + ${laufend + ? "seit " + sicher(minuten(a.liegtSeit)) + : zeit(a.beendet_am)} +
`; + } + + function verdrahteUebersicht(behaelter) { + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.ordner = el.dataset.reiter; + rendern(behaelter); + }); + }); + + behaelter.querySelectorAll("[data-oeffne]").forEach((el) => { + el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne)); + }); + + const aufnehmen = behaelter.querySelector("[data-aufnehmen]"); + if (aufnehmen) aufnehmen.addEventListener("click", () => aufnahmeFenster(behaelter)); + } + + /* ---- Einzelansicht -------------------------------------------------------- */ + + function einzelAnsicht(behaelter, d) { + const a = d.aufenthalt, s = d.stamm; + const geraete = geraeteTeile(a.geraete); + + behaelter.innerHTML = ` +
+
+

${sicher(a.name)}

+

geb. ${sicher(a.geburt)} · ${sicher(a.zustandName)} + ${a.zimmerKennung ? " · Zimmer " + sicher(a.zimmerKennung) : ""} + ${a.stationName ? " · " + sicher(a.stationName) : ""}

+
+ +
+ + ${a.laufend ? "" : ` +
+ ✔ +
Intensivbetreuung beendet — ${zeit(a.beendetAm)} von + ${sicher(a.beendetVon || "unbekannt")}. Der Aufenthalt ist nur noch + zum Nachlesen da.
+
`} + + ${s.hinweis ? ` +
+ ⚠ +
Warnhinweis — ${sicher(s.hinweis)}
+
` : ""} + + ${s.allergien ? ` +
+ 💊 +
Allergien — ${sicher(s.allergien)}
+
` : ""} + + ${a.laufend && !a.verantwortlichName ? ` +
+ 🧑‍⚕️ +
Kein zuständiger Arzt eingetragen. Das System wählt keinen aus — + jemand muss die Verantwortung übernehmen.
+
` : ""} + +
+
${s.lichtbild + ? `Lichtbild` + : `Kein Lichtbild`}
+ +
+
Zustand + + ${sicher(a.zustandName)}
+
In Betreuung seit + ${sicher(minuten(a.liegtSeit))}
+
Zuständig + ${sicher(a.verantwortlichName || "niemand eingetragen")}
+
Platz + ${sicher(a.zimmerKennung || "kein fester Platz")}
+
Blutgruppe + ${sicher(s.blutgruppe || "nicht bestimmt")}
+
Dauermedikation + ${sicher(s.dauermedikation || "keine erfasst")}
+
+ +
+ + ${a.aufnahme + ? `` : ""} + ${a.laufend && d.rechte.betreuen ? ` + + + + ` : ""} +
+
+ +
+
+
Aufenthalt
+
+
Aufgenommen${zeit(a.aufgenommenAm)}
+
Aufgenommen von${sicher(a.aufgenommenVon)}
+ ${a.aufnahmeGrund + ? `
Grund der Aufnahme${sicher(a.aufnahmeGrund)}
` : ""} + ${a.beendetAm ? ` +
Beendet${zeit(a.beendetAm)}
+
Beendet von${sicher(a.beendetVon)}
` : ""} +
+ +
+ Angeschlossene Geräte + nur notiert, nicht gesteuert +
+ ${geraete.length + ? `
${geraete.map((g) => + `${sicher(g)}`).join("")}
` + : `
Nichts eingetragen.
`} + +
+ Grund der Intensivbetreuung +
+
+ ${sicher(a.notiz || "Nichts eingetragen.")}
+
+ +
+
+ Verlauf + ${d.verlauf.length} Einträge +
+ + ${a.laufend && d.rechte.betreuen ? ` +
+ +
+ + Der Wortlaut bleibt genau so stehen. +
+
` : ""} + + ${d.verlauf.length ? ` +
+ ${d.verlauf.map((v) => ` +
+
✎
+
+
+ ${sicher(v.autor)} + ${zeit(v.am)} +
+
${sicher(v.text)}
+
+
`).join("")} +
` + : `
Noch nichts dokumentiert.
`} +
+
+ `; + + verdrahteEinzel(behaelter, d); + } + + function verdrahteEinzel(behaelter, d) { + const a = d.aufenthalt; + + behaelter.querySelector("[data-zurueck]").addEventListener("click", () => { + z.offen = null; + rendern(behaelter); + }); + + behaelter.querySelector("[data-akte]").addEventListener("click", () => { + if (!App.zustand.module.some((m) => m.id === "mdt")) { + return meldung("Das MDT steht dir nicht zur Verfügung.", "fehler"); + } + App.oeffneModul("mdt", { patient: a.patient }); + }); + + const vorgang = behaelter.querySelector("[data-vorgang]"); + if (vorgang) { + vorgang.addEventListener("click", () => { + if (!App.zustand.module.some((m) => m.id === "patienten")) { + return meldung("Die Patientenverwaltung steht dir nicht zur Verfügung.", "fehler"); + } + App.oeffneModul("patienten", { aufnahme: a.aufnahme }); + }); + } + + const zustand = behaelter.querySelector("[data-zustand]"); + if (zustand) zustand.addEventListener("click", () => zustandFenster(behaelter, d)); + + const geraete = behaelter.querySelector("[data-geraete]"); + if (geraete) geraete.addEventListener("click", () => geraeteFenster(behaelter, d)); + + const zustaendig = behaelter.querySelector("[data-zustaendig]"); + if (zustaendig) zustaendig.addEventListener("click", () => zustaendigFenster(behaelter, d)); + + const beenden = behaelter.querySelector("[data-beenden]"); + if (beenden) beenden.addEventListener("click", () => beendenFenster(behaelter, d)); + + const speichern = behaelter.querySelector("[data-verlaufspeichern]"); + if (speichern) { + speichern.addEventListener("click", async () => { + const feld = behaelter.querySelector("[data-neuerverlauf]"); + const text = feld.value.trim(); + if (text.length < 3) return meldung("Bitte einen Text eingeben.", "fehler"); + try { + await aufruf("intensiv", "verlauf", { id: a.id, text }); + meldung("Eintrag geschrieben.", "gut"); + oeffne(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }); + } + } + + /* ---- Intensivaufnahme ------------------------------------------------------ */ + + async function aufnahmeFenster(behaelter) { + let k; + try { + k = await aufruf("intensiv", "kandidaten", {}); + } catch (f) { return meldung(f.message, "fehler"); } + + let gewaehlt = null; // Patient/Aufnahme + let gewaehltesZimmer = null; // Zimmer der Betreuung + + bestaetigen({ + titel: "Patient auf die Intensivstation aufnehmen", + inhalt: ` +
+
+ ${k.patienten.length ? k.patienten.map((p) => ` +
+ +
+
${sicher(p.name)} + geb. ${sicher(p.geburt)}
+
${sicher(String(p.grund || "").slice(0, 70))}
+
+ ${p.warnhinweis ? `⚠` : ""} + ${p.allergien ? `A` : ""} + ${p.zimmerKennung ? `${sicher(p.zimmerKennung)}` : ""} +
`).join("") + : `
Kein aufgenommener Patient verfügbar.
`} +
+
Auf die Intensivstation kommt nur, wer bereits + aufgenommen ist. Die Reihenfolge ist die eingetragene Dringlichkeit.
+
+ +
+ +
Deine Einschätzung. Das System leitet sie aus nichts ab.
+
+ +
+
+ ${k.zimmer.length ? k.zimmer.map((zi) => ` + `).join("") + : `
Kein Intensivplatz angelegt.
`} +
+
Hier wird nur festgehalten, wo betreut wird. + Das Bett selbst belegt die Stationsverwaltung.
+
+ +
+
+ +
+
+ +
+
Ohne Häkchen bleibt der Aufenthalt ohne zuständigen + Arzt — das wird allen im Dienst gemeldet.
`, + bestaetigenText: "Aufnehmen", + bestaetigt: async (el, zu) => { + if (!gewaehlt) return meldung("Bitte zuerst einen Patienten wählen.", "fehler"); + + const notiz = el.querySelector("[name=notiz]").value.trim(); + if (notiz.length < 5) return meldung("Bitte den Grund angeben.", "fehler"); + + try { + const antwort = await aufruf("intensiv", "aufnehmen", { + aufnahme: gewaehlt, + zimmer: gewaehltesZimmer, + zustand: el.querySelector("[name=zustand]").value, + geraete: el.querySelector("[name=geraete]").value.trim(), + notiz, + selbst: el.querySelector("[name=selbst]").checked, + }); + zu(); + meldung("Intensivaufenthalt eröffnet.", "gut"); + z.ordner = "laufend"; + oeffne(behaelter, antwort.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + hg.querySelectorAll("[data-patient]").forEach((el) => { + el.addEventListener("click", () => { + gewaehlt = +el.dataset.patient; + hg.querySelectorAll("[data-patient]") + .forEach((zeile) => zeile.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + }); + }); + + hg.querySelectorAll("[data-zimmer]").forEach((el) => { + el.addEventListener("click", () => { + // Ein zweiter Klick nimmt die Wahl wieder zurück + const schonGewaehlt = gewaehltesZimmer === +el.dataset.zimmer; + hg.querySelectorAll("[data-zimmer]").forEach((k) => k.classList.remove("gewaehlt")); + gewaehltesZimmer = schonGewaehlt ? null : +el.dataset.zimmer; + if (!schonGewaehlt) el.classList.add("gewaehlt"); + }); + }); + }, + }); + } + + /* ---- Zustand eintragen ------------------------------------------------------- */ + + function zustandFenster(behaelter, d) { + const a = d.aufenthalt; + + bestaetigen({ + titel: "Zustand eintragen", + inhalt: ` +

Aktuell eingetragen: ${sicher(a.zustandName)} + — von ${sicher(a.verantwortlichName || "niemandem")} verantwortet.

+ +
+
+ +
+ +
Dieser Text wandert unverändert in den Verlauf.
`, + bestaetigenText: "Eintragen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler"); + try { + await aufruf("intensiv", "zustand", { + id: a.id, + zustand: el.querySelector("[name=zustand]").value, + grund, + }); + zu(); + meldung("Zustand eingetragen.", "gut"); + oeffne(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Geräte ------------------------------------------------------------------- */ + + function geraeteFenster(behaelter, d) { + const a = d.aufenthalt; + const vorschlaege = d.geraeteliste || []; + const vorhanden = geraeteTeile(a.geraete); + + // Alles, was nicht in der Vorschlagsliste steht, bleibt als Freitext erhalten + const eigene = vorhanden.filter((g) => !vorschlaege.includes(g)); + + bestaetigen({ + titel: "Angeschlossene Geräte", + inhalt: ` +
+ ℹ +
Das System steuert kein Gerät, liest keinen Wert aus und schlägt keinen + Alarm. Es hält fest, was jemand angeschlossen hat.
+
+ +
+
+ ${vorschlaege.map((g) => ` + `).join("")} +
+ +
+
`, + bestaetigenText: "Speichern", + bestaetigt: async (el, zu) => { + const gewaehlt = Array.from(el.querySelectorAll(".its-geraet-knopf.gewaehlt")) + .map((k) => k.dataset.geraet); + const eigeneTexte = geraeteTeile(el.querySelector("[name=eigene]").value); + + try { + await aufruf("intensiv", "geraete", { + id: a.id, + geraete: gewaehlt.concat(eigeneTexte).join(", "), + }); + zu(); + meldung("Geräte eingetragen.", "gut"); + oeffne(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + hg.querySelectorAll("[data-geraet]").forEach((el) => { + el.addEventListener("click", () => el.classList.toggle("gewaehlt")); + }); + }, + }); + } + + /* ---- Zuständigkeit -------------------------------------------------------------- */ + + async function zustaendigFenster(behaelter, d) { + const a = d.aufenthalt; + + let b; + try { + b = await aufruf("intensiv", "betreuer", {}); + } catch (f) { return meldung(f.message, "fehler"); } + + let gewaehlt = a.verantwortlich || null; + let niederlegen = false; + + bestaetigen({ + titel: "Zuständigkeit", + inhalt: ` +

Zuständig ist zurzeit: + ${sicher(a.verantwortlichName || "niemand")}

+ +
+
+ ${b.liste.length ? b.liste.map((p) => ` +
+
+
${sicher(p.name)} + ${p.selbst ? `du` : ""}
+
${sicher(p.rolleName)} · + ${sicher(p.personalnummer || "")}
+
+ ${sicher(p.funktion || "")} +
`).join("") + : `
Niemand mit diesem Recht ist im Dienst.
`} +
+
Das System verteilt keine Zuständigkeit — + sie wird übernommen oder übertragen.
+ +
+ +
+
`, + bestaetigenText: "Übernehmen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + + if (niederlegen && !grund) { + return meldung("Bitte angeben, warum niemand mehr zuständig ist.", "fehler"); + } + if (!niederlegen && !gewaehlt) { + return meldung("Bitte eine Person wählen.", "fehler"); + } + + try { + await aufruf("intensiv", "verantwortlich", { + id: a.id, + charakter: niederlegen ? null : gewaehlt, + grund, + }); + zu(); + meldung(niederlegen ? "Zuständigkeit niedergelegt." : "Zuständigkeit eingetragen.", "gut"); + oeffne(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + hg.querySelectorAll("[data-person]").forEach((el) => { + el.addEventListener("click", () => { + gewaehlt = +el.dataset.person; + hg.querySelectorAll("[data-person]") + .forEach((zeile) => zeile.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + }); + }); + + const haken = hg.querySelector("[name=niederlegen]"); + haken.addEventListener("change", () => { + niederlegen = haken.checked; + // Die Personenliste wird unbrauchbar, sobald niedergelegt wird + hg.querySelector("[data-betreuer]").style.opacity = niederlegen ? ".45" : ""; + }); + }, + }); + } + + /* ---- Betreuung beenden ------------------------------------------------------------ */ + + function beendenFenster(behaelter, d) { + const a = d.aufenthalt; + + bestaetigen({ + titel: "Intensivbetreuung beenden", + gefahr: true, + inhalt: ` +

${sicher(a.name)} wird ab sofort nicht mehr intensiv betreut.

+ +
+ ℹ +
Das ist keine Entlassung. Der Patient bleibt aufgenommen, sein Bett + bleibt belegt und keine Behandlung wird dadurch abgeschlossen.
+
+ + ${a.zustand === "kritisch" ? ` +
+ ⚠ +
Der Zustand steht weiterhin auf kritisch. Das Beenden ist + trotzdem möglich — es wird so festgehalten.
+
` : ""} + +
+ +
Dieser Text schließt den Verlauf ab.
`, + bestaetigenText: "Betreuung beenden", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (grund.length < 5) return meldung("Bitte eine Begründung angeben.", "fehler"); + try { + await aufruf("intensiv", "beenden", { id: a.id, grund }); + zu(); + meldung("Intensivbetreuung beendet.", "gut"); + z.offen = null; + z.ordner = "beendet"; + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Laden ----------------------------------------------------------------------- */ + + async function oeffne(behaelter, intensivId) { + try { + const d = await aufruf("intensiv", "oeffnen", { id: intensivId }); + z.offen = intensivId; + einzelAnsicht(behaelter, d); + } catch (f) { meldung(f.message, "fehler"); } + } + + async function rendern(behaelter) { + try { + z.daten = await aufruf("intensiv", "uebersicht", { ordner: z.ordner }); + + // Sprungziel aus einem Hinweis, z. B. „als kritisch eingetragen" + const ziel = App.zustand.modulZiel; + if (ziel && ziel.intensiv) { + App.zustand.modulZiel = null; + return oeffne(behaelter, ziel.intensiv); + } + + if (z.offen) await oeffne(behaelter, z.offen); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Intensivstation

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("intensiv", { + rendern, + aktualisieren() { + // Eine offene Einzelansicht oder ein offenes Fenster nicht stören + if (App.zustand.aktivesModul === "intensiv" && !z.offen + && !document.querySelector(".fenster-hg")) { + rendern(App.$("inhalt")); + } + }, + }); +})(); diff --git a/resources/[aether]/aether-medical/html/js/module/kommunikation.js b/resources/[aether]/aether-medical/html/js/module/kommunikation.js new file mode 100644 index 0000000..beaa52d --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/module/kommunikation.js @@ -0,0 +1,741 @@ +/* ===================================================================== + aether-medical / Ansicht: Nachrichten und Aushänge + + Postfach und Schwarzes Brett. Abgeschickt ist abgeschickt — es gibt + hier bewusst kein Bearbeiten und kein Löschen von Nachrichten. + Ein Aushang wird archiviert, nie entfernt. Und eine Kenntnisnahme + setzt jeder nur für sich selbst. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen } = App; + + const z = { + ordner: "posteingang", // posteingang | gesendet | aushang | archiv + daten: null, + }; + + // Marker-Art je Empfängerart + const typArt = { rundmeldung: "kritisch", station: "warn", person: "info" }; + const typName = { rundmeldung: "Rundmeldung", station: "Station", person: "Persönlich" }; + + /* ---- Kleine Helfer ---------------------------------------------------- */ + + /** Anzeigename einer Aushangkategorie */ + function kategorieName(kennung) { + const treffer = (z.daten.kategorien || []).find((k) => k.id === kennung); + return treffer ? treffer.name : kennung; + } + + /** Farbe einer Aushangkategorie */ + function kategorieFarbe(kennung) { + const treffer = (z.daten.kategorien || []).find((k) => k.id === kennung); + return treffer ? treffer.farbe : "#8697a4"; + } + + /** Beschreibt eine Frist in Worten */ + function fristText(a) { + if (a.restMinuten === null || a.restMinuten === undefined) return null; + if (a.restMinuten <= 0) return `Frist seit ${minuten(-a.restMinuten)} abgelaufen`; + return `noch ${minuten(a.restMinuten)}`; + } + + /* ---- Gerüst ------------------------------------------------------------ */ + + function reiterleiste(d) { + const eintraege = [ + { id: "posteingang", text: "Posteingang", zahl: d.zaehler.ungelesen }, + { id: "gesendet", text: "Gesendet", zahl: d.zaehler.gesendet }, + { id: "aushang", text: "Schwarzes Brett", zahl: d.zaehler.aushaenge }, + { id: "archiv", text: "Archiv", zahl: 0 }, + ]; + + return `
+ ${eintraege.map((e) => ` + `).join("")} +
`; + } + + function kennzahlen(d) { + const zk = d.zaehler; + + return `
+
+
Ungelesen
+
${zk.ungelesen}
+
im Posteingang
+
+
+
Kenntnisnahme offen
+
${zk.offeneKenntnis}
+
von dir noch nicht bestätigt
+
+
+
Aushänge
+
${zk.aushaenge}
+
hängen gerade
+
+
+
Von dir gesendet
+
${zk.gesendet}
+
bleiben unverändert stehen
+
+
`; + } + + /* ---- Übersicht ---------------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + const amBrett = z.ordner === "aushang" || z.ordner === "archiv"; + + behaelter.innerHTML = ` +
+
+

Nachrichten und Aushänge

+

Was hier steht, hat jemand geschrieben und unterschrieben. Eine gesendete + Nachricht lässt sich nicht mehr ändern, ein Aushang wird archiviert statt + gelöscht.

+
+
+ ${d.rechte.senden + ? `` : ""} + ${d.rechte.schreiben + ? `` : ""} +
+
+ + ${kennzahlen(d)} + ${reiterleiste(d)} + + ${amBrett ? brettAnsicht(d) : postAnsicht(d)} + `; + + verdrahteUebersicht(behaelter); + } + + /* ---- Postfach ------------------------------------------------------------- */ + + function postAnsicht(d) { + const gesendet = z.ordner === "gesendet"; + + return `
+
+ ${gesendet ? "Von dir gesendet" : "Posteingang"} + ${gesendet + ? "Gelesen heißt: jemand hat sie geöffnet" + : "Als gelesen gilt, was du geöffnet hast"} +
+
+ ${d.liste.length ? d.liste.map(gesendet ? gesendetZeile : postZeile).join("") + : `
${gesendet + ? "Du hast noch nichts geschrieben." : "Keine Nachrichten."}
`} +
+
`; + } + + function postZeile(n) { + return `
+ + +
+
${sicher(n.betreff || "(ohne Betreff)")}
+
${sicher(n.absenderName)} · ${sicher(n.vorschau)}
+
+ + + ${sicher(typName[n.empf_typ] || n.empf_typ)} + ${zeit(n.gesendet)} +
`; + } + + function gesendetZeile(n) { + return `
+
+
${sicher(n.betreff || "(ohne Betreff)")}
+
an ${sicher(n.empfaengerName)} · ${sicher(n.vorschau)}
+
+ + + ${sicher(typName[n.empf_typ] || n.empf_typ)} + ${n.gelesenVon} gelesen + ${zeit(n.gesendet)} +
`; + } + + /* ---- Schwarzes Brett ------------------------------------------------------- */ + + function brettAnsicht(d) { + const archiv = z.ordner === "archiv"; + + if (!d.liste.length) { + return `
${archiv + ? "Nichts archiviert." : "Am Brett hängt nichts."}
`; + } + + return `
${d.liste.map((a) => ` +
+
+
+ ${a.angeheftet ? `📌` : ""} + ${sicher(a.titel)}
+ ${sicher(kategorieName(a.kategorie))} +
+ +
${sicher(a.vorschau)}
+ +
+ ${sicher(a.autor)} · ${zeit(a.erstellt_am)} + + ${a.station ? `${sicher(a.stationName)}` : ""} + ${a.kenntnispflicht + ? (a.bestaetigt + ? `gelesen` + : `Kenntnis offen`) + : ""} + ${a.kenntnispflicht + ? `${a.kenntnisse} bestätigt` : ""} + ${fristText(a) + ? ` + ${sicher(fristText(a))}` : ""} + +
+ + ${archiv && a.archiviertVon + ? `
Abgenommen von ${sicher(a.archiviertVon)} · + ${zeit(a.archiviert_am)}
` : ""} +
`).join("")}
`; + } + + /* ---- Verdrahtung ------------------------------------------------------------ */ + + function verdrahteUebersicht(behaelter) { + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.ordner = el.dataset.reiter; + rendern(behaelter); + }); + }); + + behaelter.querySelectorAll("[data-nachricht]").forEach((el) => { + el.addEventListener("click", () => nachrichtFenster(behaelter, +el.dataset.nachricht)); + }); + + behaelter.querySelectorAll("[data-aushang]").forEach((el) => { + el.addEventListener("click", () => aushangFenster(behaelter, +el.dataset.aushang)); + }); + + const schreiben = behaelter.querySelector("[data-schreiben]"); + if (schreiben) schreiben.addEventListener("click", () => schreibenFenster(behaelter, null)); + + const neuerAushang = behaelter.querySelector("[data-neueraushang]"); + if (neuerAushang) { + neuerAushang.addEventListener("click", () => aushangSchreibenFenster(behaelter, null)); + } + } + + /* ---- Nachricht lesen ---------------------------------------------------------- */ + + async function nachrichtFenster(behaelter, nachrichtId) { + let d; + try { + d = await aufruf("kommunikation", "nachricht", { id: nachrichtId }); + } catch (f) { return meldung(f.message, "fehler"); } + + const n = d.nachricht; + + const dialog = fenster({ + titel: n.betreff || "(ohne Betreff)", + inhalt: ` +
+
Von${sicher(n.absenderName)}
+
An${sicher(n.empfaengerName)}
+
Gesendet${zeit(n.gesendet)}
+
+ +
${sicher(n.text)}
+ + ${n.vonMir ? ` +
+ Gelesen von + ${d.leser.length} Person(en) +
+
+ ${d.leser.length ? d.leser.map((l) => ` +
+
${sicher(l.name)}
+ ${zeit(l.gelesen_am)} +
`).join("") + : `
Noch niemand hat sie geöffnet.
`} +
` : ""} + + ${!n.vonMir && d.rechte.senden ? ` +
+ +
` : ""} + `, + nachOeffnen: (hg) => { + const antworten = hg.querySelector("[data-antworten]"); + if (antworten) { + antworten.addEventListener("click", () => { + dialog.schliessen(); + schreibenFenster(behaelter, { + typ: "person", + ziel: n.absender, + zielName: n.absenderName, + betreff: n.betreff && !/^Re:/i.test(n.betreff) + ? "Re: " + n.betreff : (n.betreff || ""), + }); + }); + } + }, + }); + + // Der Posteingang zeigt sie danach als gelesen + rendernStill(behaelter); + } + + /* ---- Nachricht schreiben -------------------------------------------------------- */ + + async function schreibenFenster(behaelter, vorgabe) { + let e; + try { + e = await aufruf("kommunikation", "empfaenger", {}); + } catch (f) { return meldung(f.message, "fehler"); } + + let typ = (vorgabe && vorgabe.typ) || "person"; + let ziel = (vorgabe && vorgabe.ziel) || null; + + bestaetigen({ + titel: "Nachricht schreiben", + inhalt: ` +
+ ℹ +
Eine abgeschickte Nachricht lässt sich weder ändern noch löschen. + Sie bleibt mit deinem Namen stehen.
+
+ +
+
+ + + ${e.rechte.rundmeldung + ? `` : ""} +
+
+ +
+ +
+ ${e.personal.map((p) => ` +
+
+
${sicher(p.name)} + ${p.selbst ? `du` : ""}
+
${sicher(p.rolleName)} · + ${sicher(p.personalnummer || "")} · ${sicher(p.stationName)}
+
+ ${p.imDienst ? `im Dienst` + : `außer Dienst`} +
`).join("")} +
+
Wer nicht im Dienst ist, bekommt die Nachricht + beim nächsten Öffnen des Systems.
+
+ +
+ + +
+ +
+ 📣 +
Diese Nachricht geht an jeden im Haus und wird allen im Dienst + sofort gemeldet.
+
+ +
+
+ +
+
`, + bestaetigenText: "Abschicken", + bestaetigt: async (el, zu) => { + const text = el.querySelector("[name=text]").value.trim(); + if (text.length < 2) return meldung("Bitte einen Text schreiben.", "fehler"); + + if (typ === "person" && !ziel) { + return meldung("Bitte einen Empfänger wählen.", "fehler"); + } + + const zielWert = typ === "station" + ? el.querySelector("[name=station]").value + : (typ === "person" ? ziel : null); + + try { + const antwort = await aufruf("kommunikation", "senden", { + typ, ziel: zielWert, + betreff: el.querySelector("[name=betreff]").value.trim(), + text, + }); + zu(); + meldung(`Nachricht an ${antwort.empfaenger} abgeschickt.`, "gut"); + z.ordner = "gesendet"; + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + // Empfängerart umschalten + hg.querySelectorAll("[data-typ]").forEach((el) => { + el.addEventListener("click", () => { + typ = el.dataset.typ; + hg.querySelectorAll("[data-typ]").forEach((k) => k.classList.remove("aktiv")); + el.classList.add("aktiv"); + hg.querySelectorAll("[data-wahl]").forEach((block) => { + block.style.display = block.dataset.wahl === typ ? "" : "none"; + }); + }); + }); + + // Person wählen + hg.querySelectorAll("[data-person]").forEach((el) => { + el.addEventListener("click", () => { + ziel = +el.dataset.person; + hg.querySelectorAll("[data-person]") + .forEach((zeile) => zeile.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + }); + }); + + // Personenliste filtern + const suche = hg.querySelector("[name=suche]"); + suche.addEventListener("input", () => { + const begriff = suche.value.trim().toLowerCase(); + hg.querySelectorAll("[data-person]").forEach((el) => { + el.style.display = !begriff || el.dataset.suchtext.includes(begriff) ? "" : "none"; + }); + }); + }, + }); + } + + /* ---- Aushang lesen -------------------------------------------------------------- */ + + async function aushangFenster(behaelter, aushangId) { + let d; + try { + d = await aufruf("kommunikation", "aushang", { id: aushangId }); + } catch (f) { return meldung(f.message, "fehler"); } + + const a = d.aushang; + + const dialog = fenster({ + titel: a.titel, + inhalt: ` + ${a.aktiv ? "" : ` +
+ 📁 +
Archiviert — abgenommen von ${sicher(a.archiviertVon || "unbekannt")} + am ${zeit(a.archiviertAm)}. Der Wortlaut bleibt nachlesbar.
+
`} + + ${a.kenntnispflicht && !a.bestaetigt && a.aktiv ? ` +
+ ✋ +
Kenntnisnahme steht aus. Bestätigen kannst nur du selbst — + niemand kann das für dich tun.
+
` : ""} + + ${fristText(a) ? ` +
+ 🕓 +
${sicher(fristText(a))}. Eine abgelaufene Frist erinnert — + sie hat keine weitere Folge.
+
` : ""} + +
+
Kategorie${sicher(kategorieName(a.kategorie))}
+
Gilt für${sicher(a.stationName)}
+
Veröffentlicht${zeit(a.erstelltAm)}
+
Von${sicher(a.autor)}
+
+ +
${sicher(a.text)}
+ +
+ ${a.aktiv && a.kenntnispflicht && !a.bestaetigt + ? `` : ""} + ${a.aktiv && d.rechte.schreiben + ? ` + ` : ""} + ${a.aktiv && d.rechte.archivieren + ? `` : ""} +
+ + ${a.textGesperrt && a.aktiv ? ` +
+ ${d.kenntnis.length} Person(en) haben diesen Wortlaut gelesen — Titel und Text + lassen sich deshalb nicht mehr ändern. Für eine Änderung: abnehmen und neu + veröffentlichen.
` : ""} + + ${a.kenntnispflicht ? ` +
+
+
+ Zur Kenntnis genommen + ${d.kenntnis.length} +
+
+ ${d.kenntnis.length ? d.kenntnis.map((k) => ` +
+ ${sicher(k.personalnummer || "—")} +
${sicher(k.name)}
+ ${zeit(k.bestaetigt_am)} +
`).join("") + : `
Noch niemand.
`} +
+
+ +
+
+ Steht noch aus + ${d.offen.length} +
+
+ ${d.offen.length ? d.offen.map((o) => ` +
+ ${sicher(o.personalnummer || "—")} +
${sicher(o.name)}
+
`).join("") + : `
Alle haben bestätigt.
`} +
+
+
` : ""} + `, + nachOeffnen: (hg) => { + const kenntnis = hg.querySelector("[data-kenntnis]"); + if (kenntnis) { + kenntnis.addEventListener("click", async () => { + try { + await aufruf("kommunikation", "kenntnis", { id: a.id }); + dialog.schliessen(); + meldung("Kenntnisnahme bestätigt.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }); + } + + const anheften = hg.querySelector("[data-anheften]"); + if (anheften) { + anheften.addEventListener("click", async () => { + try { + await aufruf("kommunikation", "anheften", { + id: a.id, angeheftet: !a.angeheftet, + }); + dialog.schliessen(); + meldung(a.angeheftet ? "Aushang gelöst." : "Aushang angeheftet.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }); + } + + const bearbeiten = hg.querySelector("[data-bearbeiten]"); + if (bearbeiten) { + bearbeiten.addEventListener("click", () => { + dialog.schliessen(); + aushangSchreibenFenster(behaelter, a); + }); + } + + const archivieren = hg.querySelector("[data-archivieren]"); + if (archivieren) { + archivieren.addEventListener("click", () => { + dialog.schliessen(); + archivierenFenster(behaelter, a); + }); + } + }, + }); + } + + /* ---- Aushang veröffentlichen ------------------------------------------------------ */ + + function aushangSchreibenFenster(behaelter, a) { + const d = z.daten; + const neu = !a; + + bestaetigen({ + titel: neu ? "Aushang veröffentlichen" : `„${a.titel}" bearbeiten`, + inhalt: ` + ${!neu && a.textGesperrt ? ` +
+ ✋ +
Titel und Text sind gesperrt. Es haben bereits Personen diesen + Wortlaut zur Kenntnis genommen. Für eine inhaltliche Änderung den Aushang + abnehmen und neu veröffentlichen.
+
` : ""} + +
+
+ +
+
+
+
+
+
+ +
+
+ +
+ +
+
Das System erinnert die Betroffenen. Bestätigen kann + jeder nur für sich — erzwungen wird nichts.
+ +
+ +
Läuft die Frist ab, geschieht nichts weiter — + es wird nur deutlicher erinnert.
`, + bestaetigenText: neu ? "Veröffentlichen" : "Speichern", + bestaetigt: async (el, zu) => { + const gesperrt = !neu && a.textGesperrt; + + const titel = gesperrt ? a.titel : el.querySelector("[name=titel]").value.trim(); + const text = gesperrt ? a.text : el.querySelector("[name=text]").value.trim(); + + if (!titel) return meldung("Bitte einen Titel angeben.", "fehler"); + if (text.length < 5) return meldung("Bitte einen Text schreiben.", "fehler"); + + try { + await aufruf("kommunikation", "aushangSchreiben", { + id: neu ? null : a.id, + titel, text, + kategorie: el.querySelector("[name=kategorie]").value, + station: el.querySelector("[name=station]").value, + angeheftet: el.querySelector("[name=angeheftet]").checked, + kenntnispflicht: el.querySelector("[name=kenntnispflicht]").checked, + frist: el.querySelector("[name=frist]").value, + }); + zu(); + meldung(neu ? "Aushang hängt." : "Aushang gespeichert.", "gut"); + z.ordner = "aushang"; + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + function archivierenFenster(behaelter, a) { + bestaetigen({ + titel: "Aushang abnehmen", + gefahr: true, + inhalt: ` +

„${sicher(a.titel)}" wird vom Brett genommen und wandert ins Archiv. + Gelöscht wird nichts — der Wortlaut und alle Kenntnisnahmen bleiben.

+ ${a.kenntnispflicht ? ` +
+ ℹ +
Offene Kenntnisnahmen halten das Abnehmen nicht auf. Wie viele noch + ausstanden, wird im Protokoll festgehalten.
+
` : ""} +
+
`, + bestaetigenText: "Abnehmen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (!grund) return meldung("Bitte einen Grund angeben.", "fehler"); + try { + const antwort = await aufruf("kommunikation", "aushangArchivieren", + { id: a.id, grund }); + zu(); + meldung(antwort.offen > 0 + ? `Abgenommen — ${antwort.offen} Kenntnisnahme(n) standen noch aus.` + : "Aushang abgenommen.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Laden ------------------------------------------------------------------------ */ + + /** Frischt die Liste im Hintergrund auf, ohne ein offenes Fenster zu stören */ + async function rendernStill(behaelter) { + try { + z.daten = await aufruf("kommunikation", "uebersicht", { ordner: z.ordner }); + if (!document.querySelector(".fenster-hg")) uebersicht(behaelter); + } catch (f) { /* eine stille Auffrischung darf folgenlos scheitern */ } + } + + async function rendern(behaelter) { + try { + z.daten = await aufruf("kommunikation", "uebersicht", { ordner: z.ordner }); + + uebersicht(behaelter); + + // Sprungziel aus einem Hinweis + const ziel = App.zustand.modulZiel; + if (ziel && ziel.nachricht) { + App.zustand.modulZiel = null; + return nachrichtFenster(behaelter, ziel.nachricht); + } + if (ziel && ziel.aushang) { + App.zustand.modulZiel = null; + return aushangFenster(behaelter, ziel.aushang); + } + } catch (f) { + behaelter.innerHTML = `

Nachrichten und Aushänge

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("kommunikation", { + rendern, + aktualisieren() { + if (App.zustand.aktivesModul === "kommunikation" + && !document.querySelector(".fenster-hg")) { + rendern(App.$("inhalt")); + } + }, + }); +})(); diff --git a/resources/[aether]/aether-medical/html/js/module/labor.js b/resources/[aether]/aether-medical/html/js/module/labor.js new file mode 100644 index 0000000..badb9ad --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/module/labor.js @@ -0,0 +1,603 @@ +/* ===================================================================== + aether-medical / Ansicht: Labor + + Zwei Blöcke, streng getrennt: das ERGEBNIS trägt das Labor ein, den + BEFUND ein Mediziner. Dazwischen liegt die Bewertung — und die nimmt + das System niemandem ab. Kein Wert wird markiert, verglichen oder + als auffällig dargestellt. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen } = App; + + const z = { + ordner: "offen", // offen | befund | meine | archiv + art: "", + suche: "", + daten: null, + offen: null, + }; + + const statusArt = { + offen: "warn", in_arbeit: "info", fertig: "gut", verworfen: "neutral", + }; + const statusName = { + offen: "offen", in_arbeit: "in Arbeit", fertig: "Ergebnis liegt vor", + verworfen: "verworfen", + }; + + /* ---- Übersicht ---------------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Labor

+

Das Labor speichert Ergebnisse. Ob ein Wert etwas bedeutet, + entscheidet ein Mediziner — nicht das System.

+
+ ${d.rechte.beauftragen + ? `` : ""} +
+ +
+
+
Offen
+
${d.zaehler.offen}
+
wartet auf das Labor
+
+
+
Davon dringend
+
${d.zaehler.dringend}
+
vorrangig bearbeiten
+
+
+
Ohne Bewertung
+
${d.zaehler.befund}
+
Ergebnis da, Befund fehlt
+
+
+
Eigene Aufträge
+
${d.zaehler.meine}
+
von dir gestellt
+
+
+ +
+ + + + +
+ +
+ + +
+ +
+
+ Aufträge + ${d.liste.length} Einträge +
+
+ ${d.liste.length ? d.liste.map(zeile).join("") + : `
Kein Auftrag gefunden.
`} +
+
+ `; + + verdrahteUebersicht(behaelter); + } + + function zeile(l) { + return `
+ ${l.dringend ? `` + : ``} + ${sicher(l.kennung)} +
+
${sicher(l.patientName)} + ${sicher(l.artName)}
+
${sicher(String(l.fragestellung || "").slice(0, 90))}
+
+ + ${l.dringend ? `dringend` : ""} + ${l.hatErgebnis && !l.hatBefund + ? `Befund fehlt` : ""} + ${l.hatBefund ? `befundet` : ""} + ${l.eigene ? `dein Auftrag` : ""} + + + ${sicher(statusName[l.status] || l.status)} + ${l.status === "offen" || l.status === "in_arbeit" + ? "seit " + minuten(l.offenSeit) : zeit(l.auftrag_am)} +
`; + } + + function verdrahteUebersicht(behaelter) { + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.ordner = el.dataset.reiter; + rendern(behaelter); + }); + }); + + behaelter.querySelector("[data-art]").addEventListener("change", (e) => { + z.art = e.target.value; + rendern(behaelter); + }); + + const suchfeld = behaelter.querySelector("[data-suche]"); + let warten = null; + suchfeld.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(() => { + z.suche = suchfeld.value.trim(); + rendern(behaelter); + }, 280); + }); + + behaelter.querySelectorAll("[data-oeffne]").forEach((el) => { + el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne)); + }); + + const neu = behaelter.querySelector("[data-neu]"); + if (neu) neu.addEventListener("click", () => auftragFenster(behaelter)); + } + + /* ---- Einzelansicht -------------------------------------------------------- */ + + function einzelAnsicht(behaelter, d) { + const a = d.auftrag, s = d.stamm; + const offen = a.status === "offen"; + const inArbeit = a.status === "in_arbeit"; + const verworfen = a.status === "verworfen"; + + behaelter.innerHTML = ` +
+
+

${sicher(a.kennung)} · ${sicher(a.artName)}

+

${sicher(a.patientName)} · geb. ${sicher(a.geburt)} + ${a.dringend ? " · dringend" : ""}

+
+ +
+ + ${s.hinweis ? ` +
+ ⚠ +
Warnhinweis — ${sicher(s.hinweis)}
+
` : ""} + +
+
+
Status${sicher(statusName[a.status] || a.status)}
+
Beauftragt${zeit(a.auftragAm)}
+
Von${sicher(a.auftragVon)}
+ ${a.bearbeitetVon ? ` +
Im Labor${sicher(a.bearbeitetVon)}
+
Angenommen${zeit(a.bearbeitetAm)}
` : ""} +
Blutgruppe (Stammdaten) + ${sicher(s.blutgruppe || "nicht bestimmt")}
+
+
+ + ${offen && d.rechte.bearbeiten + ? `` : ""} + ${(offen || inArbeit) && d.rechte.bearbeiten + ? `` : ""} +
+
+ +
+ +
${sicher(a.fragestellung || "—")}
+
+ +
+ +
+
+ Ergebnis — gemessene Werte + ${d.rechte.bearbeiten && !verworfen + ? `` : ""} +
+
${a.ergebnis + ? sicher(a.ergebnis).replace(/\n/g, "
") + : "Das Labor hat noch nichts eingetragen."}
+
+ Reine Messwerte. Das System vergleicht sie mit nichts und markiert + nichts als auffällig. +
+
+ + +
+
+ Befund — ärztliche Bewertung + ${d.rechte.befunden && a.ergebnis && !verworfen + ? `` : ""} +
+
${a.befund + ? sicher(a.befund).replace(/\n/g, "
") + : a.ergebnis + ? "Das Ergebnis ist noch nicht bewertet." + : "Erst wenn ein Ergebnis vorliegt, lässt sich befunden."}
+
+ ${a.befundVon + ? `Bewertet von ${sicher(a.befundVon)} am ${zeit(a.befundAm)}.` + : "Die Bewertung stammt immer von einem Mediziner."} +
+
+
+ + ${a.ergebnis && !verworfen && d.rechte.stammdaten ? ` +
+
+ Blutgruppe in die Stammdaten übernehmen +
+

+ Falls in diesem Auftrag eine Blutgruppe bestimmt wurde: sie wandert + nicht von selbst in die Patientenakte. Du trägst sie ausdrücklich + ein und stehst dafür gerade. +

+ +
` : ""} + +
+
+ Frühere Aufträge dieses Patienten + zum Vergleich — nicht verrechnet +
+
+ ${d.frueher.length ? d.frueher.map((f) => ` +
+ ${sicher(f.kennung)} +
+
${sicher(f.art)}
+
${zeit(f.auftrag_am)}
+
+ + ${sicher(statusName[f.status] || f.status)} +
`).join("") + : `
Keine früheren Aufträge.
`} +
+
+ `; + + verdrahteEinzel(behaelter, d); + } + + function verdrahteEinzel(behaelter, d) { + const a = d.auftrag; + + behaelter.querySelector("[data-zurueck]").addEventListener("click", () => { + z.offen = null; + rendern(behaelter); + }); + + behaelter.querySelector("[data-akte]").addEventListener("click", () => { + if (!App.zustand.module.some((m) => m.id === "mdt")) { + return meldung("Das MDT steht dir nicht zur Verfügung.", "fehler"); + } + App.oeffneModul("mdt", { patient: a.patient }); + }); + + behaelter.querySelectorAll("[data-frueher]").forEach((el) => { + el.addEventListener("click", () => oeffne(behaelter, +el.dataset.frueher)); + }); + + const annehmen = behaelter.querySelector("[data-annehmen]"); + if (annehmen) annehmen.addEventListener("click", async () => { + try { + await aufruf("labor", "annehmen", { id: a.id }); + meldung("Auftrag in Arbeit genommen.", "gut"); + oeffne(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }); + + const ergebnis = behaelter.querySelector("[data-ergebnis]"); + if (ergebnis) ergebnis.addEventListener("click", () => ergebnisFenster(behaelter, d)); + + const befund = behaelter.querySelector("[data-befund]"); + if (befund) befund.addEventListener("click", () => befundFenster(behaelter, d)); + + const verwerfen = behaelter.querySelector("[data-verwerfen]"); + if (verwerfen) verwerfen.addEventListener("click", () => { + bestaetigen({ + titel: "Probe verwerfen", + gefahr: true, + inhalt: `

${sicher(a.kennung)} wird als verworfen abgelegt. + Der Auftraggeber wird informiert; ein neuer Auftrag muss von Hand + gestellt werden.

+
+
`, + bestaetigenText: "Verwerfen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler"); + try { + await aufruf("labor", "verwerfen", { id: a.id, grund }); + zu(); + meldung("Auftrag verworfen.", "gut"); + oeffne(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + + const blutgruppe = behaelter.querySelector("[data-blutgruppe]"); + if (blutgruppe) blutgruppe.addEventListener("click", () => blutgruppeFenster(behaelter, d)); + } + + /* ---- Ergebnis und Befund --------------------------------------------------- */ + + function ergebnisFenster(behaelter, d) { + const a = d.auftrag; + + fenster({ + titel: "Ergebnis eintragen · " + a.kennung, + inhalt: ` +

+ ${sicher(a.patientName)} · ${sicher(a.artName)}
+ Fragestellung: ${sicher(a.fragestellung || "—")} +

+
+ +
Nur Messwerte, keine Bewertung. Die Einordnung + nimmt anschließend ein Mediziner im Befund vor.
+ ${d.vorlage && !a.ergebnis + ? `` : ""}`, + knoepfe: [ + { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() }, + { text: "Ergebnis speichern", klick: async (el, zu) => { + try { + await aufruf("labor", "ergebnis", { + id: a.id, ergebnis: el.querySelector("[name=ergebnis]").value.trim(), + }); + zu(); + meldung("Ergebnis gespeichert. Der Auftraggeber wurde informiert.", "gut"); + oeffne(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + } }, + ], + nachOeffnen: (hg) => { + const vorlage = hg.querySelector("[data-vorlage]"); + if (vorlage) vorlage.addEventListener("click", () => { + const feld = hg.querySelector("[name=ergebnis]"); + feld.value = d.vorlage; + feld.focus(); + }); + }, + }); + } + + function befundFenster(behaelter, d) { + const a = d.auftrag; + + bestaetigen({ + titel: "Befund eintragen · " + a.kennung, + inhalt: ` +
+
${sicher(a.ergebnis).replace(/\n/g, "
")}
+
+ +
Der Befund erscheint in der Patientenakte und im + Behandlungsbogen. Er trägt deinen Namen.
`, + bestaetigenText: "Befund speichern", + bestaetigt: async (el, zu) => { + try { + await aufruf("labor", "befund", { + id: a.id, befund: el.querySelector("[name=befund]").value.trim(), + }); + zu(); + meldung("Befund gespeichert.", "gut"); + oeffne(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Blutgruppe übernehmen -------------------------------------------------- */ + + function blutgruppeFenster(behaelter, d) { + const a = d.auftrag; + + bestaetigen({ + titel: "Blutgruppe in die Stammdaten übernehmen", + inhalt: ` +
+
${sicher(a.ergebnis).replace(/\n/g, "
")}
+
+ +
Das System liest die Blutgruppe nicht aus dem + Ergebnistext aus. Du wählst sie, und damit steht dein Name darunter.
+ ${d.stamm.blutgruppe ? ` +
+ ⚠ +
In den Stammdaten steht bereits ${sicher(d.stamm.blutgruppe)}. + Ein Überschreiben wird protokolliert.
+
` : ""}`, + bestaetigenText: "Übernehmen", + bestaetigt: async (el, zu) => { + const gewaehlt = el.querySelector("[name=blutgruppe]").value; + if (!gewaehlt) return meldung("Bitte eine Blutgruppe wählen.", "fehler"); + try { + const antwort = await aufruf("labor", "blutgruppeUebernehmen", { + id: a.id, blutgruppe: gewaehlt, + }); + zu(); + meldung("Blutgruppe " + antwort.blutgruppe + " eingetragen.", "gut"); + oeffne(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Auftrag stellen --------------------------------------------------------- */ + + function auftragFenster(behaelter) { + const d = z.daten; + let gewaehlt = null; + + bestaetigen({ + titel: "Laborauftrag stellen", + inhalt: ` +
+
+
+
Mindestens zwei Zeichen eingeben.
+
+
+
+
+
+ +
Je genauer die Frage, desto brauchbarer das + Ergebnis. Das Labor misst — bewerten musst du selbst.
+
+ +
Dringende Aufträge stehen im Labor oben und + erzeugen einen Hinweis mit hoher Priorität. Bearbeitet werden sie + trotzdem von einem Menschen.
+
+
`, + bestaetigenText: "Auftrag stellen", + bestaetigt: async (el, zu) => { + if (!gewaehlt) return meldung("Bitte zuerst einen Patienten wählen.", "fehler"); + try { + const antwort = await aufruf("labor", "beauftragen", { + patient: gewaehlt.id, + art: el.querySelector("[name=art]").value, + fragestellung: el.querySelector("[name=fragestellung]").value.trim(), + dringend: el.querySelector("[name=dringend]").checked, + }); + zu(); + meldung("Auftrag " + antwort.kennung + " gestellt.", "gut"); + z.ordner = "meine"; + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + const suchfeld = hg.querySelector("[name=suche]"); + const liste = hg.querySelector("[data-treffer]"); + const formular = hg.querySelector("[data-formular]"); + let warten = null; + + suchfeld.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(async () => { + const begriff = suchfeld.value.trim(); + if (begriff.length < 2) { + liste.innerHTML = `
Mindestens zwei Zeichen eingeben.
`; + return; + } + try { + const antwort = await aufruf("labor", "patientSuche", { begriff }); + const treffer = antwort.treffer || []; + + liste.innerHTML = treffer.length ? treffer.map((t) => ` +
+
+
${sicher(t.firstname)} ${sicher(t.lastname)}
+
geb. ${sicher(t.dob)} + ${t.blutgruppe ? " · " + sicher(t.blutgruppe) + : " · Blutgruppe nicht bestimmt"}
+
+ ${t.hinweis ? `⚠` : ""} +
`).join("") + : `
Keine Person gefunden.
`; + + liste.querySelectorAll("[data-person]").forEach((el) => { + el.addEventListener("click", () => { + gewaehlt = treffer.find((t) => t.id === +el.dataset.person); + liste.querySelectorAll("[data-person]") + .forEach((zeile) => zeile.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + formular.style.display = ""; + }); + }); + } catch (f) { meldung(f.message, "fehler"); } + }, 260); + }); + }, + }); + } + + /* ---- Laden -------------------------------------------------------------------- */ + + async function oeffne(behaelter, laborId) { + try { + const d = await aufruf("labor", "oeffnen", { id: laborId }); + z.offen = laborId; + einzelAnsicht(behaelter, d); + } catch (f) { meldung(f.message, "fehler"); } + } + + async function rendern(behaelter) { + try { + z.daten = await aufruf("labor", "uebersicht", { + ordner: z.ordner, art: z.art, begriff: z.suche, + }); + + // Sprungziel aus einem Hinweis, z. B. „Laborergebnis verfügbar" + const ziel = App.zustand.modulZiel; + if (ziel && ziel.labor) { + App.zustand.modulZiel = null; + return oeffne(behaelter, ziel.labor); + } + + if (z.offen) await oeffne(behaelter, z.offen); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Labor

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("labor", { + rendern, + aktualisieren() { + if (!z.offen && App.zustand.aktivesModul === "labor") rendern(App.$("inhalt")); + }, + }); + + App.registriereSuche(async (begriff) => { + try { + const d = await aufruf("labor", "uebersicht", { ordner: "archiv", begriff }); + return (d.liste || []).slice(0, 5).map((l) => ({ + bereich: "Labor", + titel: l.kennung + " · " + l.patientName, + unter: l.artName + " · " + (statusName[l.status] || l.status), + })); + } catch (e) { return []; } + }); +})(); diff --git a/resources/[aether]/aether-medical/html/js/module/mdt.js b/resources/[aether]/aether-medical/html/js/module/mdt.js new file mode 100644 index 0000000..5274e47 --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/module/mdt.js @@ -0,0 +1,766 @@ +/* ===================================================================== + aether-medical / Ansicht: Medizinisches MDT + + Patientensuche und Patientenakte. Alles, was hier rot oder auffällig + dargestellt wird, hat ein Mediziner eingetragen — das System leitet + aus keinem Feld eine Warnung, eine Diagnose oder eine Empfehlung ab. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, aufruf, meldung, fenster, bestaetigen } = App; + + const z = { + suche: "", + treffer: [], + patient: null, // geöffnete Patientenakte (Serverantwort) + reiter: "stamm", // stamm | verlauf | akten | notizen + unterdaten: null, // Daten des aktiven Reiters + offeneAkte: null, + }; + + // Symbol und Farbe je Verlaufsart + const verlaufsArt = { + aufnahme: { symbol: "🏥", art: "info" }, + entlassung: { symbol: "🚪", art: "neutral" }, + behandlung: { symbol: "🩺", art: "gut" }, + operation: { symbol: "🔪", art: "warn" }, + labor: { symbol: "🧪", art: "info" }, + medikament: { symbol: "💊", art: "neutral" }, + notiz: { symbol: "📝", art: "neutral" }, + }; + + const statusArt = { + entwurf: "neutral", offen: "warn", freigegeben: "gut", geschlossen: "info", + }; + + /* ---- Suche ------------------------------------------------------------ */ + + function sucheAnsicht(behaelter) { + behaelter.innerHTML = ` +
+
+

Medizinisches MDT

+

Patientensuche über Name, Geburtsdatum oder Telefonnummer. + Was in einer Akte steht, hat ein Mediziner eingetragen.

+
+
+ +
+ +
+ +
+
+ Treffer + ${z.treffer.length} Personen +
+
+ ${z.treffer.length ? z.treffer.map(trefferZeile).join("") + : `
${z.suche.length >= 2 + ? "Keine Person gefunden." + : "Mindestens zwei Zeichen eingeben."}
`} +
+
+ `; + + const feld = behaelter.querySelector("[data-suche]"); + let warten = null; + feld.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(async () => { + z.suche = feld.value.trim(); + if (z.suche.length < 2) { z.treffer = []; return sucheAnsicht(behaelter); } + try { + const d = await aufruf("mdt", "suche", { begriff: z.suche }); + z.treffer = d.treffer || []; + sucheAnsicht(behaelter); + // Den Fokus zurück ins Suchfeld, damit man weitertippen kann + const neu = behaelter.querySelector("[data-suche]"); + neu.focus(); + neu.setSelectionRange(neu.value.length, neu.value.length); + } catch (f) { meldung(f.message, "fehler"); } + }, 280); + }); + + behaelter.querySelectorAll("[data-patient]").forEach((el) => { + el.addEventListener("click", () => oeffnePatient(behaelter, +el.dataset.patient)); + }); + } + + function trefferZeile(p) { + return `
+
${p.lichtbild + ? `` + : `—`}
+
+
${sicher(p.firstname)} ${sicher(p.lastname)}
+
+ geb. ${sicher(p.dob)} + ${p.telefon ? " · ☎ " + sicher(p.telefon) : ""} + ${p.blutgruppe ? " · " + sicher(p.blutgruppe) : " · Blutgruppe unbekannt"} +
+
+ ${p.hinweis ? `Warnhinweis` : ""} + ${p.allergien ? `Allergien` : ""} + ${p.imHaus ? `im Haus` : ""} + ${p.wartet ? `wartet` : ""} +
`; + } + + /* ---- Patientenakte ------------------------------------------------------ */ + + function akteAnsicht(behaelter) { + const d = z.patient; + const p = d.patient, s = d.stamm; + + behaelter.innerHTML = ` +
+
+

${sicher(p.name)}

+

geb. ${sicher(p.geburt)} · ${sicher(p.geschlecht === "w" ? "weiblich" : "männlich")} + ${p.telefon ? " · ☎ " + sicher(p.telefon) : ""}

+
+ +
+ + ${s.hinweis ? ` +
+ ⚠ +
Warnhinweis — ${sicher(s.hinweis)} + ${s.gepflegtVon ? `
Eingetragen von + ${sicher(s.gepflegtVon)}${s.gepflegtAm ? " am " + zeit(s.gepflegtAm) : ""}. + Das System hat diesen Hinweis nicht erzeugt.` : ""} +
+
` : ""} + + ${d.aufnahme ? ` +
+ 🏥 +
${d.aufnahme.status === "wartend" + ? "Wartet auf Aufnahme" : "Aufgenommen"} + — ${sicher(d.aufnahme.grund)} + ${d.aufnahme.zimmer ? " · Zimmer " + sicher(d.aufnahme.zimmer) : ""} +
Seit ${zeit(d.aufnahme.aufgenommen_am)} + durch ${sicher(d.aufnahme.aufgenommenVon)}. +
+
` : ""} + + ${d.intensiv ? ` +
+ 🫀 +
Auf Intensivstation — Zustand als + ${sicher(d.intensiv.zustand)} eingetragen + ${d.intensiv.verantwortlichName + ? " · zuständig: " + sicher(d.intensiv.verantwortlichName) : ""} +
+
` : ""} + +
+
${s.lichtbild + ? `Lichtbild` + : `Kein Lichtbild`}
+ +
+ ${wertZeile("Blutgruppe", s.blutgruppe, !s.blutgruppe && "nicht bestimmt")} + ${wertZeile("Allergien", s.allergien, "keine erfasst")} + ${wertZeile("Vorerkrankungen", s.vorerkrankungen, "keine erfasst")} + ${wertZeile("Dauermedikation", s.dauermedikation, "keine erfasst")} + ${wertZeile("Implantate", s.implantate, "keine erfasst")} + ${wertZeile("Organspende", s.organspenderName)} + ${wertZeile("Notfallkontakt", s.notfallkontakt, "keiner hinterlegt")} +
+ +
+ ${d.rechte.stammdaten + ? `` : ""} + ${d.rechte.hinweis + ? `` : ""} +
+
+ +
+ + + + +
+ +
+
Lade …
+
+ `; + + verdrahteAkte(behaelter); + zeichneReiter(behaelter); + } + + /** Eine Zeile im Stammdatenblock */ + function wertZeile(name, wert, ersatz) { + const fehlt = !wert; + return `
+ ${sicher(name)} + ${fehlt ? sicher(ersatz || "nicht erfasst") : sicher(wert)} +
`; + } + + function verdrahteAkte(behaelter) { + behaelter.querySelector("[data-zurueck]").addEventListener("click", () => { + z.patient = null; + z.offeneAkte = null; + sucheAnsicht(behaelter); + }); + + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.reiter = el.dataset.reiter; + z.offeneAkte = null; + akteAnsicht(behaelter); + }); + }); + + const stamm = behaelter.querySelector("[data-stamm]"); + if (stamm) stamm.addEventListener("click", () => stammdatenFenster(behaelter)); + + const hinweis = behaelter.querySelector("[data-hinweis]"); + if (hinweis) hinweis.addEventListener("click", () => hinweisFenster(behaelter)); + } + + /* ---- Reiterinhalte ------------------------------------------------------- */ + + async function zeichneReiter(behaelter) { + const ziel = behaelter.querySelector("#mdt-unterinhalt"); + const patientId = z.patient.patient.id; + + try { + if (z.reiter === "verlauf") { + z.unterdaten = await aufruf("mdt", "verlauf", { patient: patientId }); + zeichneVerlauf(ziel); + } else if (z.reiter === "akten") { + z.unterdaten = await aufruf("mdt", "akten", { patient: patientId }); + zeichneAkten(ziel, behaelter); + } else if (z.reiter === "notizen") { + z.unterdaten = await aufruf("mdt", "notizen", { patient: patientId }); + zeichneNotizen(ziel, behaelter); + } else { + zeichneUebersicht(ziel); + } + } catch (f) { + ziel.innerHTML = `
${sicher(f.message)}
`; + } + } + + function zeichneUebersicht(ziel) { + const d = z.patient; + const zaehler = d.zaehler; + + ziel.innerHTML = ` +
+
+
Behandlungen
+
${zaehler.behandlungen}
+
dokumentiert
+
+
+
Operationen
+
${zaehler.operationen}
+
dokumentiert
+
+
+
Laboraufträge
+
${zaehler.labor}
+
gestellt
+
+
+
Aufnahmen
+
${zaehler.aufnahmen}
+
insgesamt
+
+
+
Akten
+
${zaehler.akten}
+
angelegt
+
+
+
Vermerke
+
${zaehler.notizen}
+
geschrieben
+
+
+ +
+ Fehlende Angaben bedeuten „nicht erhoben", nicht „unauffällig". + Eine leere Allergieliste heißt, dass niemand danach gefragt oder es + eingetragen hat. Das System füllt keines dieser Felder von selbst und + schließt aus keinem etwas. +
+ `; + } + + function zeichneVerlauf(ziel) { + const eintraege = z.unterdaten.eintraege || []; + + ziel.innerHTML = eintraege.length ? ` +
+
+ Zeitleiste + ${z.unterdaten.gesamt} Einträge insgesamt +
+
+ ${eintraege.map((e) => { + const stil = verlaufsArt[e.art] || { symbol: "•", art: "neutral" }; + return `
+
${stil.symbol}
+
+
+ ${sicher(e.titel)} + ${zeit(e.am)} +
+ ${e.text ? `
${sicher(e.text)}
` : ""} + ${e.marker ? `
${sicher(e.marker)}
` : ""} +
+
`; + }).join("")} +
+
` + : `
Für diesen Patienten ist noch nichts dokumentiert.
`; + } + + function zeichneAkten(ziel, behaelter) { + const d = z.unterdaten; + + // Eine einzelne Akte ist geöffnet + if (z.offeneAkte) return zeichneEinzelakte(ziel, behaelter); + + ziel.innerHTML = ` +
+
+ Akten dieses Patienten + ${z.patient.rechte.akteErstellen + ? `` : ""} +
+
+ ${d.akten.length ? d.akten.map((a) => ` +
+ ${sicher(a.aktenzeichen)} +
+
${sicher(a.titel)}
+
${sicher(a.typName)} · ${sicher(a.autor)} + · ${zeit(a.erstellt_am)}
+
+ v${a.version} + ${sicher(a.status)} +
`).join("") + : `
Noch keine Akte angelegt.
`} +
+
`; + + ziel.querySelectorAll("[data-akte]").forEach((el) => { + el.addEventListener("click", async () => { + try { + z.offeneAkte = await aufruf("mdt", "akteOeffnen", { id: +el.dataset.akte }); + zeichneEinzelakte(ziel, behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }); + }); + + const neu = ziel.querySelector("[data-neu]"); + if (neu) neu.addEventListener("click", () => akteAnlegenFenster(behaelter)); + } + + function zeichneEinzelakte(ziel, behaelter) { + const d = z.offeneAkte; + const a = d.akte; + + ziel.innerHTML = ` +
+
+ ${sicher(a.aktenzeichen)} · Version ${a.version} +
+ ${d.rechte.bearbeiten && a.status !== "geschlossen" + ? `` : ""} + ${d.rechte.freigeben && a.status !== "freigegeben" && a.status !== "geschlossen" + ? `` : ""} + ${d.rechte.schliessen && a.status !== "geschlossen" + ? `` : ""} + +
+
+ +

${sicher(a.titel)}

+
+ ${sicher(a.status)} + Angelegt von ${sicher(a.autor)} am ${zeit(a.erstelltAm)} + ${a.freigabeVon ? ` · freigegeben von ${sicher(a.freigabeVon)} + am ${zeit(a.freigabeAm)}` : ""} +
+ +
${a.inhalt + ? sicher(a.inhalt).replace(/\n/g, "
") + : `Diese Akte hat noch keinen Inhalt.`}
+
+ +
+
+ Versionsverlauf + Nichts wird überschrieben +
+
+ ${d.versionen.map((v) => ` +
+ + v${v.version} +
+
${sicher(v.kommentar || "ohne Kommentar")}
+
${sicher(v.autor)} · ${zeit(v.geaendert_am)}
+
+ ${v.version === a.version + ? `aktuell` + : `ansehen`} +
`).join("")} +
+
+ `; + + ziel.querySelector("[data-zurueckliste]").addEventListener("click", () => { + z.offeneAkte = null; + zeichneReiter(behaelter); + }); + + const bearbeiten = ziel.querySelector("[data-bearbeiten]"); + if (bearbeiten) bearbeiten.addEventListener("click", () => akteBearbeitenFenster(behaelter)); + + ziel.querySelectorAll("[data-status]").forEach((el) => { + el.addEventListener("click", () => akteStatusFenster(behaelter, el.dataset.status)); + }); + + ziel.querySelectorAll("[data-version]").forEach((el) => { + el.addEventListener("click", () => versionAnsehen(+el.dataset.version)); + }); + } + + function zeichneNotizen(ziel, behaelter) { + const notizen = z.unterdaten.notizen || []; + + ziel.innerHTML = ` +
+
+ Vermerke + +
+
+ ${notizen.length ? notizen.map((n) => ` +
+
+
${sicher(n.text)}
+
${sicher(n.autor)} + ${n.personalnummer ? " (" + sicher(n.personalnummer) + ")" : ""} + · ${zeit(n.am)}
+
+ ${n.eigene ? `von dir` : ""} +
`).join("") + : `
Keine Vermerke.
`} +
+
`; + + ziel.querySelector("[data-neu-notiz]").addEventListener("click", () => { + fenster({ + titel: "Vermerk anlegen", + inhalt: `
+ +
Ein Vermerk ist keine Diagnose und ersetzt + keine Behandlungsdokumentation.
`, + knoepfe: [ + { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() }, + { text: "Speichern", klick: async (el, zu) => { + try { + await aufruf("mdt", "notizAnlegen", { + patient: z.patient.patient.id, + text: el.querySelector("[name=text]").value.trim(), + }); + zu(); + meldung("Vermerk gespeichert.", "gut"); + oeffnePatient(behaelter, z.patient.patient.id, "notizen"); + } catch (f) { meldung(f.message, "fehler"); } + } }, + ], + }); + }); + } + + /* ---- Dialoge -------------------------------------------------------------- */ + + function stammdatenFenster(behaelter) { + const d = z.patient; + const s = d.stamm; + + fenster({ + titel: "Stammdaten pflegen", + inhalt: ` +
+
+ +
Wird nicht aus dem Labor übernommen — die + Feststellung verantwortet ein Mediziner.
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
`, + knoepfe: [ + { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() }, + { text: "Speichern", klick: async (el, zu) => { + const wert = (name) => el.querySelector(`[name=${name}]`).value.trim(); + try { + await aufruf("mdt", "stammdaten", { + patient: d.patient.id, + blutgruppe: wert("blutgruppe"), + organspender: wert("organspender"), + allergien: wert("allergien"), + vorerkrankungen: wert("vorerkrankungen"), + dauermedikation: wert("dauermedikation"), + implantate: wert("implantate"), + notfallkontakt: wert("notfallkontakt"), + lichtbild: wert("lichtbild"), + }); + zu(); + meldung("Stammdaten gespeichert.", "gut"); + oeffnePatient(behaelter, d.patient.id, z.reiter); + } catch (f) { meldung(f.message, "fehler"); } + } }, + ], + }); + } + + function hinweisFenster(behaelter) { + const d = z.patient; + + bestaetigen({ + titel: d.stamm.hinweis ? "Warnhinweis ändern" : "Warnhinweis setzen", + inhalt: ` +

Ein Warnhinweis erscheint bei jeder Suche und ganz oben in der Akte. + Er ist für Dinge gedacht, die vor jeder Maßnahme bekannt sein müssen.

+
+
+
+
`, + bestaetigenText: "Übernehmen", + bestaetigt: async (el, zu) => { + try { + await aufruf("mdt", "hinweisSetzen", { + patient: d.patient.id, + hinweis: el.querySelector("[name=hinweis]").value.trim(), + grund: el.querySelector("[name=grund]").value.trim(), + }); + zu(); + meldung("Warnhinweis übernommen.", "gut"); + oeffnePatient(behaelter, d.patient.id, z.reiter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + function akteAnlegenFenster(behaelter) { + const arten = z.unterdaten.aktenarten || []; + + fenster({ + titel: "Akte anlegen", + inhalt: ` +
+
+
+
+
+ +
Die Akte startet als Entwurf. Jede spätere Änderung + erzeugt eine neue Version — überschrieben wird nichts.
`, + knoepfe: [ + { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() }, + { text: "Anlegen", klick: async (el, zu) => { + try { + const antwort = await aufruf("mdt", "akteAnlegen", { + patient: z.patient.patient.id, + titel: el.querySelector("[name=titel]").value.trim(), + typ: el.querySelector("[name=typ]").value, + inhalt: el.querySelector("[name=inhalt]").value, + }); + zu(); + meldung("Akte " + antwort.aktenzeichen + " angelegt.", "gut"); + oeffnePatient(behaelter, z.patient.patient.id, "akten"); + } catch (f) { meldung(f.message, "fehler"); } + } }, + ], + }); + } + + function akteBearbeitenFenster(behaelter) { + const a = z.offeneAkte.akte; + + fenster({ + titel: "Akte bearbeiten · " + a.aktenzeichen, + inhalt: ` +
+
+
+
+
+ +
Der Kommentar steht dauerhaft im Versionsverlauf. + Version ${a.version} bleibt unverändert erhalten.
`, + knoepfe: [ + { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() }, + { text: "Neue Version speichern", klick: async (el, zu) => { + const kommentar = el.querySelector("[name=kommentar]").value.trim(); + if (kommentar.length < 3) { + return meldung("Bitte angeben, was geändert wurde.", "fehler"); + } + try { + await aufruf("mdt", "akteSpeichern", { + id: a.id, + titel: el.querySelector("[name=titel]").value.trim(), + inhalt: el.querySelector("[name=inhalt]").value, + kommentar, + }); + zu(); + meldung("Neue Version gespeichert.", "gut"); + z.offeneAkte = await aufruf("mdt", "akteOeffnen", { id: a.id }); + zeichneEinzelakte(behaelter.querySelector("#mdt-unterinhalt"), behaelter); + } catch (f) { meldung(f.message, "fehler"); } + } }, + ], + }); + } + + function akteStatusFenster(behaelter, status) { + const a = z.offeneAkte.akte; + const beschreibung = { + freigegeben: "Die Akte gilt damit als geprüft und freigegeben. Sie lässt sich weiterhin bearbeiten.", + geschlossen: "Eine geschlossene Akte lässt sich nicht mehr ändern. Der Inhalt bleibt lesbar.", + }; + + bestaetigen({ + titel: status === "freigegeben" ? "Akte freigeben" : "Akte schließen", + gefahr: status === "geschlossen", + inhalt: `

${sicher(a.aktenzeichen)} — ${sicher(a.titel)}

+

${beschreibung[status] || ""}

+
+
`, + bestaetigenText: status === "freigegeben" ? "Freigeben" : "Schließen", + bestaetigt: async (el, zu) => { + try { + await aufruf("mdt", "akteStatus", { + id: a.id, status, + grund: el.querySelector("[name=grund]").value.trim(), + }); + zu(); + meldung("Status geändert.", "gut"); + z.offeneAkte = await aufruf("mdt", "akteOeffnen", { id: a.id }); + zeichneEinzelakte(behaelter.querySelector("#mdt-unterinhalt"), behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + async function versionAnsehen(version) { + try { + const v = await aufruf("mdt", "akteVersion", { id: z.offeneAkte.akte.id, version }); + + fenster({ + titel: `Version ${v.version} · ${z.offeneAkte.akte.aktenzeichen}`, + inhalt: ` +

${sicher(v.autor)} · ${zeit(v.geaendert_am)} + ${v.kommentar ? " · " + sicher(v.kommentar) : ""}

+
${v.inhalt + ? sicher(v.inhalt).replace(/\n/g, "
") + : `Diese Version hatte keinen Inhalt.`}
`, + }); + } catch (f) { meldung(f.message, "fehler"); } + } + + /* ---- Laden ----------------------------------------------------------------- */ + + async function oeffnePatient(behaelter, patientId, reiter) { + try { + z.patient = await aufruf("mdt", "oeffnen", { patient: patientId }); + z.reiter = reiter || "stamm"; + z.offeneAkte = null; + akteAnsicht(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + } + + async function rendern(behaelter) { + // Sprungziel aus einem anderen Modul oder aus einem Hinweis + const ziel = App.zustand.modulZiel; + if (ziel && ziel.patient) { + App.zustand.modulZiel = null; + return oeffnePatient(behaelter, ziel.patient); + } + if (ziel && ziel.akte) { + App.zustand.modulZiel = null; + try { + const akte = await aufruf("mdt", "akteOeffnen", { id: ziel.akte }); + if (akte.akte.patient) { + await oeffnePatient(behaelter, akte.akte.patient, "akten"); + z.offeneAkte = akte; + zeichneEinzelakte(behaelter.querySelector("#mdt-unterinhalt"), behaelter); + return; + } + } catch (f) { meldung(f.message, "fehler"); } + } + + if (z.patient) akteAnsicht(behaelter); + else sucheAnsicht(behaelter); + } + + App.registriereAnsicht("mdt", { rendern }); + + App.registriereSuche(async (begriff) => { + try { + const d = await aufruf("mdt", "suche", { begriff }); + return (d.treffer || []).slice(0, 5).map((p) => ({ + bereich: "Patient", + titel: p.firstname + " " + p.lastname, + unter: "geb. " + p.dob + (p.blutgruppe ? " · " + p.blutgruppe : ""), + })); + } catch (e) { return []; } + }); +})(); diff --git a/resources/[aether]/aether-medical/html/js/module/medikament.js b/resources/[aether]/aether-medical/html/js/module/medikament.js new file mode 100644 index 0000000..0eff4da --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/module/medikament.js @@ -0,0 +1,679 @@ +/* ===================================================================== + aether-medical / Ansicht: Medikamentenverwaltung + + Bestand, Buchungen und BTM-Buch. Das System zählt und erinnert — + nachbestellt, entnommen und entsorgt wird von Hand. Abgelaufene + Präparate werden auffällig markiert, aber nicht gesperrt. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, aufruf, meldung, fenster, bestaetigen } = App; + + const z = { + reiter: "bestand", // bestand | btm + filter: "", + kategorie: "", + suche: "", + stillgelegt: false, + daten: null, + offen: null, + btmMedikament: null, + // Aus der jeweils letzten Antwort uebernommen, damit Dialoge sie sicher haben + rechte: { bestand: false, btm: false, katalog: false }, + kategorien: [], + }; + + /* ---- Übersicht ----------------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + // Nach Kategorie gruppieren + const gruppen = {}; + d.liste.forEach((m) => { (gruppen[m.kategorie] = gruppen[m.kategorie] || []).push(m); }); + + behaelter.innerHTML = ` +
+
+

Medikamente

+

Bestandsführung. Das System gibt nichts aus, verordnet nichts und + bestellt nichts nach — es zählt, was gebucht wurde.

+
+ ${d.rechte.katalog + ? `` : ""} +
+ +
+ ${kachel("gesamt", "Im Katalog", d.zaehler.gesamt, "aktive Präparate", "")} + ${kachel("knapp", "Unter Mindestbestand", d.zaehler.knapp, + "nachbestellen", d.zaehler.knapp > 0 ? "warn" : "")} + ${kachel("leer", "Nicht vorrätig", d.zaehler.leer, + "Bestand null", d.zaehler.leer > 0 ? "hoch" : "")} + ${kachel("verfall", "Läuft ab", d.zaehler.verfall, + "Verfallsdatum nahe", d.zaehler.verfall > 0 ? "warn" : "")} + ${kachel("btm", "Betäubungsmittel", d.zaehler.btm, "gesondert geführt", "")} +
+ +
+ + ${d.rechte.btm + ? `` : ""} +
+ +
+ + + ${z.filter + ? `` + : ""} + ${d.rechte.katalog + ? `` : ""} +
+ + ${Object.keys(gruppen).length + ? Object.keys(gruppen).map((kategorie) => ` +
+
+ ${sicher(gruppen[kategorie][0].kategorieName)} + ${gruppen[kategorie].length} Präparate +
+
${gruppen[kategorie].map(zeile).join("")}
+
`).join("") + : `
Kein Medikament gefunden.
`} + `; + + verdrahteUebersicht(behaelter); + } + + function kachel(filter, titel, wert, zusatz, art) { + const aktiv = z.filter === filter; + return `
+
${sicher(titel)}
+
${wert}
+
${sicher(zusatz)}
+
`; + } + + function zeile(m) { + // Der Bestandsbalken zeigt das Verhältnis zum Mindestbestand + const anteil = m.mindestbestand > 0 + ? Math.min(100, Math.round((m.bestand / (m.mindestbestand * 3)) * 100)) + : (m.bestand > 0 ? 100 : 0); + const farbe = m.leer ? "var(--kritisch)" : m.knapp ? "var(--warn)" : "var(--gut)"; + + return `
+ ${sicher(m.kennung)} +
+
${sicher(m.name)} + ${m.btm ? `BTM` : ""}
+
+ ${sicher(m.wirkstoff || "—")} + ${m.dosierung_standard ? " · " + sicher(m.dosierung_standard) : ""} + ${m.lagerort ? " · " + sicher(m.lagerort) : ""} +
+
+ + ${m.abgelaufen + ? `abgelaufen` + : m.laeuftAb + ? ` + in ${m.tageBisVerfall} Tagen` : ""} + ${!m.aktiv ? `stillgelegt` : ""} + +
+
+ + ${m.bestand} ${sicher(m.einheit)} +
+
`; + } + + function verdrahteUebersicht(behaelter) { + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.reiter = el.dataset.reiter; + rendern(behaelter); + }); + }); + + behaelter.querySelectorAll("[data-filter]").forEach((el) => { + el.addEventListener("click", () => { + const gewaehlt = el.dataset.filter; + // „gesamt" ist kein Filter, sondern hebt ihn auf + z.filter = (gewaehlt === "gesamt" || z.filter === gewaehlt) ? "" : gewaehlt; + rendern(behaelter); + }); + }); + + const filterweg = behaelter.querySelector("[data-filterweg]"); + if (filterweg) filterweg.addEventListener("click", () => { + z.filter = ""; + rendern(behaelter); + }); + + behaelter.querySelector("[data-kategorie]").addEventListener("change", (e) => { + z.kategorie = e.target.value; + rendern(behaelter); + }); + + const stillgelegt = behaelter.querySelector("[data-stillgelegt]"); + if (stillgelegt) stillgelegt.addEventListener("click", () => { + z.stillgelegt = !z.stillgelegt; + rendern(behaelter); + }); + + const suchfeld = behaelter.querySelector("[data-suche]"); + let warten = null; + suchfeld.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(() => { + z.suche = suchfeld.value.trim(); + rendern(behaelter); + }, 280); + }); + + behaelter.querySelectorAll("[data-oeffne]").forEach((el) => { + el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne)); + }); + + const neu = behaelter.querySelector("[data-neu]"); + if (neu) neu.addEventListener("click", () => katalogFenster(behaelter, null)); + } + + /* ---- Einzelansicht --------------------------------------------------------- */ + + function einzelAnsicht(behaelter, d) { + const m = d.medikament; + + behaelter.innerHTML = ` +
+
+

${sicher(m.kennung)} · ${sicher(m.name)}

+

${sicher(m.wirkstoff || "ohne Wirkstoffangabe")} + ${m.btm ? " · Betäubungsmittel" : ""} + ${m.aktiv ? "" : " · stillgelegt"}

+
+ +
+ + ${m.abgelaufen ? ` +
+ ⏳ +
Verfallsdatum überschritten — am ${zeit(m.verfaelltAm)}. +
Das Präparat bleibt buchbar und + dokumentierbar. Ob es noch verwendbar ist, beurteilt die Apotheke — + das System sperrt nichts anhand eines Datums.
+
` : ""} + + ${m.bestand <= m.mindestbestand ? ` +
+ 📦 +
${m.bestand <= 0 ? "Nicht vorrätig" : "Unter Mindestbestand"} + — ${m.bestand} von mindestens ${m.mindestbestand} ${sicher(m.einheit)}. + Nachbestellen muss jemand von Hand.
+
` : ""} + +
+
+
Bestand${m.bestand} ${sicher(m.einheit)}
+
Mindestbestand${m.mindestbestand} ${sicher(m.einheit)}
+
Standarddosierung + ${sicher(m.dosierung || "keine hinterlegt")}
+
Lagerort + ${sicher(m.lagerort || "nicht angegeben")}
+
Verfällt am + ${m.verfaelltAm ? String(m.verfaelltAm).slice(0, 10) : "kein Datum"}
+
Hinweis + ${sicher(m.hinweis || "keiner")}
+
+
+ ${d.rechte.bestand && m.aktiv + ? `` : ""} + ${d.rechte.katalog + ? `` : ""} + ${d.rechte.katalog + ? `` : ""} +
+
+ +
+
+
+ Buchungen + Bestand nach jeder Buchung +
+
+ ${d.buchungen.length ? d.buchungen.map((b) => ` +
+ + ${sicher(b.artName)} +
+
${b.art === "korrektur" ? "auf" : b.art === "ausgabe" + || b.art === "verfall" ? "−" : "+"} ${b.menge} ${sicher(m.einheit)} + Stand danach: ${b.bestand_danach}
+
${sicher(b.grund || "")} + ${b.patientName ? " · " + sicher(b.patientName) : ""} + · ${sicher(b.autor)} · ${zeit(b.gebucht_am)}
+
+
`).join("") + : `
Noch keine Buchung.
`} +
+
+ +
+
+ Dokumentierte Gaben + aus den Behandlungsbögen +
+
+ ${d.gaben.length ? d.gaben.map((g) => ` +
+ ${sicher(g.behandlung)} +
+
${sicher(g.patientName)} + ${g.menge} ${sicher(m.einheit)}
+
${g.dosierung ? sicher(g.dosierung) + " · " : ""} + ${sicher(g.autor)} · ${zeit(g.gegeben_am)}
+
+ ${g.ohneBuchung + ? ` + ohne Buchung` : ""} +
`).join("") + : `
Noch keine Gabe dokumentiert.
`} +
+
+
+ `; + + verdrahteEinzel(behaelter, d); + } + + function verdrahteEinzel(behaelter, d) { + const m = d.medikament; + + behaelter.querySelector("[data-zurueck]").addEventListener("click", () => { + z.offen = null; + rendern(behaelter); + }); + + const buchen = behaelter.querySelector("[data-buchen]"); + if (buchen) buchen.addEventListener("click", () => buchungsFenster(behaelter, d)); + + const bearbeiten = behaelter.querySelector("[data-bearbeiten]"); + if (bearbeiten) bearbeiten.addEventListener("click", () => katalogFenster(behaelter, m)); + + const schalten = behaelter.querySelector("[data-schalten]"); + if (schalten) schalten.addEventListener("click", () => { + const freigeben = !m.aktiv; + + bestaetigen({ + titel: freigeben ? "Medikament freigeben" : "Medikament stilllegen", + gefahr: !freigeben, + inhalt: `

${sicher(m.kennung)} — ${sicher(m.name)}

+

${freigeben + ? "Das Präparat steht wieder zur Auswahl." + : "Das Präparat lässt sich für neue Gaben nicht mehr auswählen."}

+ ${!freigeben && m.bestand > 0 ? ` +
+ 📦 +
Es liegen noch ${m.bestand} ${sicher(m.einheit)} im Schrank. + Die Entnahme muss gebucht werden — das System bucht sie nicht weg.
+
` : ""} +

Gelöscht wird nichts. Bestehende Gaben und Buchungen + bleiben vollständig nachvollziehbar.

+
+
`, + bestaetigenText: freigeben ? "Freigeben" : "Stilllegen", + bestaetigt: async (el, zu) => { + try { + await aufruf("medikament", "schalten", { + id: m.id, aktiv: freigeben, + grund: el.querySelector("[name=grund]").value.trim(), + }); + zu(); + meldung(freigeben ? "Freigegeben." : "Stillgelegt.", "gut"); + oeffne(behaelter, m.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + } + + /* ---- Buchen ----------------------------------------------------------------- */ + + function buchungsFenster(behaelter, d) { + const m = d.medikament; + // Die Ausgabe entsteht nur aus einer Gabe und fehlt hier bewusst + const arten = ["eingang", "ruecknahme", "verfall", "korrektur"]; + + bestaetigen({ + titel: "Bestand buchen · " + m.name, + inhalt: ` +

Aktueller Bestand: ${m.bestand} ${sicher(m.einheit)}

+
+ +
+ Wareneingang und Rücknahme erhöhen den Bestand, + Verfall entnommen verringert ihn. + Korrektur setzt ihn auf den gezählten Wert. + Eine Ausgabe entsteht ausschließlich aus einer dokumentierten Gabe. +
+
+
+
+
`, + bestaetigenText: "Buchen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (grund.length < 3) return meldung("Bitte einen Grund angeben.", "fehler"); + try { + const antwort = await aufruf("medikament", "buchen", { + id: m.id, + art: el.querySelector("[name=art]").value, + menge: +el.querySelector("[name=menge]").value, + grund, + }); + zu(); + meldung("Gebucht. Neuer Bestand: " + antwort.bestand + " " + m.einheit + ".", "gut"); + oeffne(behaelter, m.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + // Bei einer Korrektur ist die Menge der neue Stand, nicht die Differenz + const art = hg.querySelector("[name=art]"); + const label = hg.querySelector("[data-mengenlabel]"); + art.addEventListener("change", () => { + label.textContent = art.value === "korrektur" + ? "Gezählter Bestand" : "Menge"; + }); + }, + }); + } + + /* ---- Katalog ---------------------------------------------------------------- */ + + function katalogFenster(behaelter, m) { + const neu = !m; + m = m || { + kennung: "", name: "", wirkstoff: "", kategorie: "sonstiges", + dosierung: "", einheit: "Stück", mindestbestand: 0, lagerort: "", + verfaelltAm: null, hinweis: "", btm: false, sortierung: 0, + }; + + const darfBtm = z.rechte.btm; + + fenster({ + titel: neu ? "Medikament anlegen" : "Stammdaten · " + m.name, + inhalt: ` +
+
+
+
+
+
+
+
+
+
+ +
+
+ +
Ein Vorschlag im Gabe-Dialog, keine Verordnung.
+
+
+
+ +
+
+ +
Ab hier erinnert das System.
+
+
+
+ +
+
+
+
+
+
+ +
+
+ +
+ +
${darfBtm + ? "Betäubungsmittel erscheinen zusätzlich im BTM-Buch und erfordern für jede Gabe und Buchung das BTM-Recht." + : "Zum Kennzeichnen als Betäubungsmittel fehlt dir das BTM-Recht."}
+
+ + ${neu ? `
+ ℹ +
Ein neues Präparat startet mit Bestand 0. Den Zugang buchst du + anschließend als Wareneingang — damit steht jede Menge im Schrank + nachvollziehbar in der Historie.
+
` : ""}`, + knoepfe: [ + { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() }, + { text: neu ? "Anlegen" : "Speichern", klick: async (el, zu) => { + const wert = (name) => el.querySelector(`[name=${name}]`).value.trim(); + try { + const antwort = await aufruf("medikament", "speichern", { + id: neu ? null : m.id, + kennung: wert("kennung"), + name: wert("name"), + wirkstoff: wert("wirkstoff"), + kategorie: el.querySelector("[name=kategorie]").value, + dosierung: wert("dosierung"), + einheit: wert("einheit"), + mindestbestand: +wert("mindestbestand"), + verfaelltAm: wert("verfaellt"), + lagerort: wert("lagerort"), + sortierung: +wert("sortierung"), + hinweis: wert("hinweis"), + btm: el.querySelector("[name=btm]").checked, + }); + zu(); + meldung(neu ? "Angelegt: " + antwort.kennung : "Gespeichert.", "gut"); + if (neu) rendern(behaelter); + else oeffne(behaelter, m.id); + } catch (f) { meldung(f.message, "fehler"); } + } }, + ], + }); + } + + /* ---- BTM-Buch ---------------------------------------------------------------- */ + + function btmAnsicht(behaelter, d) { + behaelter.innerHTML = ` +
+
+

BTM-Buch

+

Lückenlose Bewegungsliste aller Betäubungsmittel. Reine Auskunft — + hier lässt sich nichts ändern.

+
+ +
+ +
+
Aktueller Stand
+
+ ${d.bestaende.length ? d.bestaende.map((b) => ` +
+ ${sicher(b.kennung)} +
+
${sicher(b.name)}
+
${sicher(b.lagerort || "kein Lagerort")} + ${b.verfaellt_am ? " · verfällt " + + String(b.verfaellt_am).slice(0, 10) : ""}
+
+ ${b.abgelaufen ? `abgelaufen` : ""} + + ${b.bestand} ${sicher(b.einheit)} +
`).join("") + : `
Keine Betäubungsmittel im Katalog.
`} +
+
+ + ${d.ohneBuchung.length ? ` +
+
+ Gaben ohne Bestandsbuchung + ${d.ohneBuchung.length} Einträge +
+

+ Diese Gaben wurden bewusst ohne Bestandsbuchung dokumentiert — etwa weil + das Präparat aus einem Fahrzeug stammte. Sie stehen hier, damit die + Zahlen im Buch erklärbar bleiben, nicht als Beanstandung. +

+
+ ${d.ohneBuchung.map((g) => ` +
+ ${sicher(g.behandlung)} +
+
${sicher(g.name)} + ${g.menge} ${sicher(g.einheit)}
+
${sicher(g.patientName)} + · ${sicher(g.autor)} · ${zeit(g.gegeben_am)}
+
+ ohne Buchung +
`).join("")} +
+
` : ""} + +
+
+ Bewegungen + ${d.bewegungen.length} Einträge + ${d.gewaehlt ? "· gefiltert" : ""} +
+ ${d.gewaehlt + ? `` : ""} +
+ ${d.bewegungen.length ? d.bewegungen.map((b) => ` +
+ + ${sicher(b.artName)} + ${sicher(b.kennung)} +
+
${sicher(b.name)} + ${b.art === "korrektur" ? "auf" + : b.art === "ausgabe" || b.art === "verfall" ? "−" : "+"} + ${b.menge} ${sicher(b.einheit)} · Stand danach: ${b.bestand_danach}
+
${sicher(b.grund || "")} + ${b.patientName ? " · " + sicher(b.patientName) : ""} + · ${sicher(b.autor)}
+
+ ${zeit(b.gebucht_am)} +
`).join("") + : `
Keine Bewegungen erfasst.
`} +
+
+ `; + + behaelter.querySelector("[data-zurueck]").addEventListener("click", () => { + z.reiter = "bestand"; + z.btmMedikament = null; + rendern(behaelter); + }); + + behaelter.querySelectorAll("[data-med]").forEach((el) => { + el.addEventListener("click", () => { + z.btmMedikament = +el.dataset.med; + rendern(behaelter); + }); + }); + + const alle = behaelter.querySelector("[data-alle]"); + if (alle) alle.addEventListener("click", () => { + z.btmMedikament = null; + rendern(behaelter); + }); + } + + /* ---- Laden -------------------------------------------------------------------- */ + + async function oeffne(behaelter, medikamentId) { + try { + const d = await aufruf("medikament", "oeffnen", { id: medikamentId }); + z.offen = medikamentId; + z.rechte = d.rechte; + z.kategorien = d.kategorien; + einzelAnsicht(behaelter, d); + } catch (f) { meldung(f.message, "fehler"); } + } + + async function rendern(behaelter) { + try { + if (z.reiter === "btm") { + const d = await aufruf("medikament", "btmBuch", { medikament: z.btmMedikament }); + return btmAnsicht(behaelter, d); + } + + z.daten = await aufruf("medikament", "uebersicht", { + filter: z.filter, kategorie: z.kategorie, + begriff: z.suche, stillgelegt: z.stillgelegt, + }); + z.rechte = z.daten.rechte; + z.kategorien = z.daten.kategorien; + + // Sprungziel aus einem Hinweis, z. B. „Medikament fast leer" + const ziel = App.zustand.modulZiel; + if (ziel && ziel.medikament) { + App.zustand.modulZiel = null; + return oeffne(behaelter, ziel.medikament); + } + + if (z.offen) await oeffne(behaelter, z.offen); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Medikamente

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("medikament", { rendern }); + + App.registriereSuche(async (begriff) => { + try { + const d = await aufruf("medikament", "uebersicht", { begriff }); + return (d.liste || []).slice(0, 5).map((m) => ({ + bereich: "Medikament", + titel: m.kennung + " · " + m.name, + unter: m.bestand + " " + m.einheit + (m.knapp ? " · unter Mindestbestand" : ""), + })); + } catch (e) { return []; } + }); +})(); diff --git a/resources/[aether]/aether-medical/html/js/module/operation.js b/resources/[aether]/aether-medical/html/js/module/operation.js new file mode 100644 index 0000000..a0bac07 --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/module/operation.js @@ -0,0 +1,735 @@ +/* ===================================================================== + aether-medical / Ansicht: Operationsverwaltung + + OP-Plan, OP-Säle und Operationsbericht. Der geplante Zeitpunkt ist + eine Erinnerung — beginnen und beenden trägt das Team von Hand ein. + Überschneidungen im Saal werden angezeigt, aber nicht verhindert. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen } = App; + + const z = { + ordner: "plan", // plan | laufend | archiv + daten: null, + offen: null, + }; + + const statusArt = { + geplant: "info", laufend: "warn", abgeschlossen: "gut", abgesagt: "neutral", + }; + + /* ---- Übersicht ----------------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Operationen

+

Der OP-Plan erinnert an Termine. Begonnen und beendet wird eine + Operation ausschließlich durch Eintrag des OP-Teams.

+
+ ${d.rechte.planen + ? `` : ""} +
+ +
+
+
Geplant
+
${d.zaehler.geplant}
+
im OP-Plan
+
+
+
Termin erreicht
+
${d.zaehler.faellig}
+
wartet auf Beginn
+
+
+
Laufend
+
${d.zaehler.laufend}
+
Eingriffe im Gange
+
+
+
Heute beendet
+
${d.zaehler.heute}
+
abgeschlossen
+
+
+ +
+
+ OP-Säle + Belegung ergibt sich aus laufenden Eingriffen +
+
+ ${d.saele.length ? d.saele.map(saalKachel).join("") + : `
Keine OP-Säle angelegt.
`} +
+
+ +
+ + + +
+ +
+
+ ${z.ordner === "archiv" + ? "Abgeschlossene und abgesagte Operationen" : "Eingriffe"} + ${d.liste.length} Einträge +
+
+ ${d.liste.length ? d.liste.map(zeile).join("") + : `
Nichts geplant.
`} +
+
+ `; + + verdrahteUebersicht(behaelter); + } + + function saalKachel(s) { + const belegt = !!s.laufend; + const gesperrt = s.status === "gesperrt"; + + return `
+
+ ${sicher(s.kennung)} + + ${belegt ? "belegt" : gesperrt ? "gesperrt" : "frei"} +
+ ${belegt ? ` +
+ ${sicher(s.laufend.kennung)} · ${sicher(s.laufend.art)} +
${sicher(s.laufend.patientName)} + · läuft seit ${minuten(s.laufend.dauer)}
+
` + : `
+
${s.geplant} geplante Eingriffe + ${s.hinweis ? " · " + sicher(s.hinweis) : ""}
+
`} +
`; + } + + function zeile(o) { + const laufend = o.status === "laufend"; + const faellig = o.status === "geplant" && o.restMinuten !== null && o.restMinuten <= 0; + + return `
+ ${sicher(o.kennung)} +
+
${sicher(o.art)} + ${sicher(o.patientName)}
+
+ ${o.saalName ? "Saal " + sicher(o.saalName) : "kein Saal"} + · ${o.teamGroesse} im Team + · geplant von ${sicher(o.erstelltVon)} +
+
+ + ${o.hinweis ? `⚠` : ""} + ${o.allergien ? `A` : ""} + ${o.blutgruppe ? `${sicher(o.blutgruppe)}` : ""} + ${o.hatKomplikationen ? `Komplikationen` : ""} + ${o.ichDabei ? `du bist im Team` : ""} + ${faellig ? `Termin erreicht` : ""} + + ${sicher(o.status)} + ${laufend + ? "läuft seit " + minuten(o.dauerMinuten) + : o.status === "geplant" + ? (o.geplant_fuer + ? (o.restMinuten > 0 ? "in " + minuten(o.restMinuten) : "seit " + minuten(-o.restMinuten) + " fällig") + : "ohne Termin") + : zeit(o.ende)} +
`; + } + + function verdrahteUebersicht(behaelter) { + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.ordner = el.dataset.reiter; + rendern(behaelter); + }); + }); + + behaelter.querySelectorAll("[data-oeffne]").forEach((el) => { + el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne)); + }); + + const neu = behaelter.querySelector("[data-neu]"); + if (neu) neu.addEventListener("click", () => planungsFenster(behaelter)); + } + + /* ---- Einzelansicht -------------------------------------------------------- */ + + function einzelAnsicht(behaelter, d) { + const o = d.operation, s = d.stamm; + const geplant = o.status === "geplant"; + const laufend = o.status === "laufend"; + const beendet = o.status === "abgeschlossen" || o.status === "abgesagt"; + const schreibbar = !beendet && d.rechte.dokumentieren; + + behaelter.innerHTML = ` +
+
+

${sicher(o.kennung)} · ${sicher(o.art)}

+

${sicher(o.patientName)} · geb. ${sicher(o.geburt)} + ${o.saalName ? " · Saal " + sicher(o.saalName) : ""}

+
+ +
+ + ${s.hinweis ? ` +
+ ⚠ +
Warnhinweis — ${sicher(s.hinweis)}
+
` : ""} + + ${s.allergien ? ` +
+ 💊 +
Allergien — ${sicher(s.allergien)}
+
` : ""} + + ${s.implantate ? ` +
+ 🔩 +
Implantate — ${sicher(s.implantate)}
+
` : ""} + + ${d.ueberschneidungen.length ? ` +
+ 🕐 +
Zeitgleich im selben Saal: + ${d.ueberschneidungen.map((u) => + `${sicher(u.kennung)} (${sicher(u.patientName)})`).join(", ")} +
Das System verhindert nichts — vielleicht + ist der andere Eingriff früher fertig. Die Entscheidung liegt bei dir. +
+
` : ""} + +
+
+
Status${sicher(o.status)}
+
Geplant für${o.geplantFuer + ? zeit(o.geplantFuer) : "ohne Termin"}
+
Beginn${o.beginn ? zeit(o.beginn) : "noch nicht begonnen"}
+
Ende${o.ende ? zeit(o.ende) : "—"}
+
Geplant von${sicher(o.erstelltVon)}
+
Blutgruppe + ${sicher(s.blutgruppe || "nicht bestimmt")}
+
+
+ + ${geplant && d.rechte.dokumentieren + ? `` : ""} + ${laufend && d.rechte.dokumentieren + ? `` : ""} + ${geplant && d.rechte.planen + ? `` : ""} + ${!beendet && d.rechte.dokumentieren + ? `` : ""} + ${!beendet && d.rechte.planen + ? `` : ""} +
+
+ +
+
+
+ OP-Team + ${!beendet && d.rechte.planen + ? `` : ""} +
+
+ ${d.team.length ? d.team.map((t) => ` +
+ + ${sicher(t.funktionName)} +
+
${sicher(t.name)}
+
${sicher(t.personalnummer || "—")} + · ${sicher(t.rolleName || "")}
+
+ ${!beendet && d.rechte.planen + ? `` : ""} +
`).join("") + : `
Noch niemand eingeteilt.
`} +
+
+ +
+
Relevante Vorgeschichte
+
+
Vorerkrankungen + ${sicher(s.vorerkrankungen || "keine erfasst")}
+
Dauermedikation + ${sicher(s.dauermedikation || "keine erfasst")}
+
Implantate + ${sicher(s.implantate || "keine erfasst")}
+ ${o.aufnahmeGrund + ? `
Aufnahmegrund${sicher(o.aufnahmeGrund)}
` : ""} +
+
+ Fehlende Angaben heißen „nicht erhoben". Das System schließt daraus nichts. +
+
+
+ +
+ ${bogenFeld("verlauf", "Operationsverlauf", o.verlauf, schreibbar, + "Zugang, Vorgehen, Befund, Versorgung…")} + ${bogenFeld("komplikationen", "Komplikationen", o.komplikationen, schreibbar, + "Nur eintragen, wenn welche auftraten.")} + ${bogenFeld("nachsorge", "Nachsorge", o.nachsorge, schreibbar, + "Drainagen, Verbandwechsel, Kontrolle, Belastung…")} +
+ + ${schreibbar ? ` +
+ +
` : ""} + `; + + verdrahteEinzel(behaelter, d); + } + + /** Ein Feld des Operationsberichts */ + function bogenFeld(name, titel, wert, schreibbar, hinweis) { + if (!schreibbar) { + return `
+ +
${wert + ? sicher(wert).replace(/\n/g, "
") : "Nicht dokumentiert."}
+
`; + } + return `
+ + +
${sicher(hinweis || "")}
+
`; + } + + function verdrahteEinzel(behaelter, d) { + const o = d.operation; + + behaelter.querySelector("[data-zurueck]").addEventListener("click", () => { + z.offen = null; + rendern(behaelter); + }); + + behaelter.querySelector("[data-akte]").addEventListener("click", () => { + if (!App.zustand.module.some((m) => m.id === "mdt")) { + return meldung("Das MDT steht dir nicht zur Verfügung.", "fehler"); + } + App.oeffneModul("mdt", { patient: o.patient }); + }); + + /** Liest die Felder des Berichts aus */ + const bericht = () => ({ + verlauf: (behaelter.querySelector("[name=verlauf]") || {}).value, + komplikationen: (behaelter.querySelector("[name=komplikationen]") || {}).value, + nachsorge: (behaelter.querySelector("[name=nachsorge]") || {}).value, + }); + + const speichern = behaelter.querySelector("[data-speichern]"); + if (speichern) speichern.addEventListener("click", async () => { + try { + await aufruf("operation", "dokumentieren", + Object.assign({ id: o.id }, bericht())); + meldung("OP-Bericht gespeichert.", "gut"); + oeffne(behaelter, o.id); + } catch (f) { meldung(f.message, "fehler"); } + }); + + const beginnen = behaelter.querySelector("[data-beginnen]"); + if (beginnen) beginnen.addEventListener("click", () => { + bestaetigen({ + titel: "Operation beginnen", + inhalt: `

${sicher(o.kennung)} — ${sicher(o.art)} + bei ${sicher(o.patientName)}.

+

Der Beginn wird mit der aktuellen Uhrzeit vermerkt und der Saal gilt + als belegt. ${o.geplantFuer + ? "Der geplante Termin war " + zeit(o.geplantFuer) + "." : ""}

+ ${d.team.length === 0 ? ` +
+ 👥 +
Es ist noch niemand im OP-Team. Du wirst mit dem Beginn + als Beteiligter eingetragen.
+
` : ""}`, + bestaetigenText: "Beginn eintragen", + bestaetigt: async (_, zu) => { + try { + await aufruf("operation", "beginnen", { id: o.id }); + zu(); + meldung("Beginn eingetragen.", "gut"); + oeffne(behaelter, o.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + + const beenden = behaelter.querySelector("[data-beenden]"); + if (beenden) beenden.addEventListener("click", () => { + const aktuell = bericht(); + + bestaetigen({ + titel: "Operation beenden", + inhalt: `

${sicher(o.kennung)} wird abgeschlossen. Der Bericht + ist danach schreibgeschützt.

+ ${!aktuell.verlauf && !o.verlauf ? ` +
+ 📄 +
Der Operationsverlauf ist leer. Ohne Verlauf lässt sich + nicht abschließen — bitte zuerst dokumentieren.
+
` : ""} +

Was gerade im Bogen steht, wird mit übernommen.

`, + bestaetigenText: "Beenden", + gefahr: true, + bestaetigt: async (_, zu) => { + try { + await aufruf("operation", "beenden", + Object.assign({ id: o.id }, aktuell)); + zu(); + meldung("Operation abgeschlossen.", "gut"); + oeffne(behaelter, o.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + + const umplanen = behaelter.querySelector("[data-umplanen]"); + if (umplanen) umplanen.addEventListener("click", () => umplanungsFenster(behaelter, d)); + + const absagen = behaelter.querySelector("[data-absagen]"); + if (absagen) absagen.addEventListener("click", () => { + bestaetigen({ + titel: "Operation absagen", + gefahr: true, + inhalt: `

${sicher(o.kennung)} — ${sicher(o.art)}

+

Die Operation wird als abgesagt abgelegt. Die Begründung wandert + in den Verlauf und bleibt dauerhaft lesbar.

+
+
`, + bestaetigenText: "Absagen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (grund.length < 5) return meldung("Bitte eine Begründung angeben.", "fehler"); + try { + await aufruf("operation", "absagen", { id: o.id, grund }); + zu(); + meldung("Operation abgesagt.", "gut"); + z.offen = null; + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + + const team = behaelter.querySelector("[data-team]"); + if (team) team.addEventListener("click", async () => { + try { + const antwort = await aufruf("operation", "teamWechsel", { + id: o.id, beitreten: !d.ichDabei, funktion: "assistenz", + }); + meldung(antwort.dabei ? "Du bist im OP-Team." : "Team verlassen.", "gut"); + oeffne(behaelter, o.id); + } catch (f) { meldung(f.message, "fehler"); } + }); + + const teamZu = behaelter.querySelector("[data-teamzu]"); + if (teamZu) teamZu.addEventListener("click", () => teamFenster(behaelter, d)); + + behaelter.querySelectorAll("[data-teamweg]").forEach((el) => { + el.addEventListener("click", async () => { + try { + await aufruf("operation", "teamEntfernen", { + id: o.id, charakterId: +el.dataset.teamweg, + }); + meldung("Aus dem Team genommen.", "gut"); + oeffne(behaelter, o.id); + } catch (f) { meldung(f.message, "fehler"); } + }); + }); + } + + /* ---- Team ergänzen --------------------------------------------------------- */ + + async function teamFenster(behaelter, d) { + const o = d.operation; + + let auswahl; + try { + auswahl = await aufruf("operation", "kollegen", {}); + } catch (f) { return meldung(f.message, "fehler"); } + + const imTeam = new Set(d.team.map((t) => t.character)); + const frei = (auswahl.kollegen || []).filter((k) => !imTeam.has(k.charakterId)); + + let gewaehlt = null; + + fenster({ + titel: "OP-Team ergänzen", + inhalt: ` +
+
+ ${frei.length ? frei.map((k) => ` +
+ ${sicher(k.personalnummer)} +
+
${sicher(k.name)}
+
${sicher(k.rolleName)}
+
+
`).join("") + : `
Niemand weiter im Dienst.
`} +
+
+ +
Die Einteilung ist eine Zuordnung im Bericht — + teilnehmen muss der Kollege trotzdem selbst.
`, + knoepfe: [ + { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() }, + { text: "Einteilen", klick: async (el, zu) => { + if (!gewaehlt) return meldung("Bitte einen Kollegen wählen.", "fehler"); + try { + await aufruf("operation", "teamSetzen", { + id: o.id, charakterId: gewaehlt, + funktion: el.querySelector("[name=funktion]").value, + }); + zu(); + meldung("Ins Team eingeteilt.", "gut"); + oeffne(behaelter, o.id); + } catch (f) { meldung(f.message, "fehler"); } + } }, + ], + nachOeffnen: (hg) => { + hg.querySelectorAll("[data-kollege]").forEach((el) => { + el.addEventListener("click", () => { + gewaehlt = +el.dataset.kollege; + hg.querySelectorAll("[data-kollege]") + .forEach((k) => k.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + }); + }); + }, + }); + } + + /* ---- Planen und Umplanen ---------------------------------------------------- */ + + function planungsFenster(behaelter) { + let gewaehlt = null; + let saele = []; + + bestaetigen({ + titel: "Operation planen", + inhalt: ` +
+
+
+
Mindestens zwei Zeichen eingeben.
+
+
+
+
+
+
+
+
+ +
0 = ohne Termin
+
+
+ +
+
+ ℹ +
Der Termin ist eine Erinnerung. Zum geplanten Zeitpunkt passiert + nichts von selbst — das System meldet sich, beginnen musst du.
+
+
`, + bestaetigenText: "Planen", + bestaetigt: async (el, zu) => { + if (!gewaehlt) return meldung("Bitte zuerst einen Patienten wählen.", "fehler"); + const saalFeld = el.querySelector("[name=saal]"); + try { + const antwort = await aufruf("operation", "planen", { + patient: gewaehlt.id, + aufnahme: gewaehlt.aufnahme || null, + art: el.querySelector("[name=art]").value.trim(), + saal: saalFeld.value ? +saalFeld.value : null, + vorlaufMinuten: +el.querySelector("[name=vorlauf]").value, + selbstOperateur: el.querySelector("[name=operateur]").checked, + }); + zu(); + meldung("Operation " + antwort.kennung + " geplant.", "gut"); + oeffne(behaelter, antwort.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + const suchfeld = hg.querySelector("[name=suche]"); + const liste = hg.querySelector("[data-treffer]"); + const formular = hg.querySelector("[data-formular]"); + let warten = null; + + suchfeld.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(async () => { + const begriff = suchfeld.value.trim(); + if (begriff.length < 2) { + liste.innerHTML = `
Mindestens zwei Zeichen eingeben.
`; + return; + } + try { + const antwort = await aufruf("operation", "patientSuche", { begriff }); + const treffer = antwort.treffer || []; + saele = antwort.saele || []; + + liste.innerHTML = treffer.length ? treffer.map((t) => ` +
+
+
${sicher(t.firstname)} ${sicher(t.lastname)}
+
geb. ${sicher(t.dob)} + ${t.blutgruppe ? " · " + sicher(t.blutgruppe) : ""}
+
+ ${t.hinweis ? `⚠` : ""} + ${t.allergien ? `A` : ""} + ${t.aufnahme ? `im Haus` : ""} +
`).join("") + : `
Keine Person gefunden.
`; + + liste.querySelectorAll("[data-person]").forEach((el) => { + el.addEventListener("click", () => { + gewaehlt = treffer.find((t) => t.id === +el.dataset.person); + liste.querySelectorAll("[data-person]") + .forEach((zeile) => zeile.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + formular.style.display = ""; + + hg.querySelector("[name=saal]").innerHTML = + `` + + saele.map((s) => + ``).join(""); + }); + }); + } catch (f) { meldung(f.message, "fehler"); } + }, 260); + }); + }, + }); + } + + function umplanungsFenster(behaelter, d) { + const o = d.operation; + + bestaetigen({ + titel: "Operation umplanen", + inhalt: ` +

${sicher(o.kennung)} — ${sicher(o.art)}

+
+
+
+
+ +
0 = Termin entfernen
+
+
+
`, + bestaetigenText: "Umplanen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler"); + const saalFeld = el.querySelector("[name=saal]"); + try { + await aufruf("operation", "umplanen", { + id: o.id, + saal: saalFeld.value ? +saalFeld.value : null, + vorlaufMinuten: +el.querySelector("[name=vorlauf]").value, + grund, + }); + zu(); + meldung("Umgeplant.", "gut"); + oeffne(behaelter, o.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Laden ------------------------------------------------------------------- */ + + async function oeffne(behaelter, operationId) { + try { + const d = await aufruf("operation", "oeffnen", { id: operationId }); + z.offen = operationId; + einzelAnsicht(behaelter, d); + } catch (f) { meldung(f.message, "fehler"); } + } + + async function rendern(behaelter) { + try { + z.daten = await aufruf("operation", "uebersicht", { ordner: z.ordner }); + + // Sprungziel aus einem Hinweis, z. B. „Operation beginnt in 10 Minuten" + const ziel = App.zustand.modulZiel; + if (ziel && ziel.operation) { + App.zustand.modulZiel = null; + return oeffne(behaelter, ziel.operation); + } + + if (z.offen) await oeffne(behaelter, z.offen); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Operationen

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("operation", { + rendern, + aktualisieren() { + if (!z.offen && App.zustand.aktivesModul === "operation") rendern(App.$("inhalt")); + }, + }); + + App.registriereSuche(async (begriff) => { + try { + const d = await aufruf("operation", "uebersicht", { ordner: "plan" }); + const klein = begriff.toLowerCase(); + return (d.liste || []) + .filter((o) => (o.kennung + " " + o.art + " " + o.patientName) + .toLowerCase().includes(klein)) + .slice(0, 5) + .map((o) => ({ + bereich: "Operation", titel: o.kennung + " · " + o.art, + unter: o.patientName + " · " + o.status, + })); + } catch (e) { return []; } + }); +})(); diff --git a/resources/[aether]/aether-medical/html/js/module/patienten.js b/resources/[aether]/aether-medical/html/js/module/patienten.js new file mode 100644 index 0000000..0bac9ab --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/module/patienten.js @@ -0,0 +1,667 @@ +/* ===================================================================== + aether-medical / Ansicht: Patientenverwaltung + + Warteliste, Belegung und Entlassung. Die Liste ist nach der vom + Mediziner gesetzten Dringlichkeit sortiert — nicht nach Wartezeit. + Wer als Nächstes drankommt, entscheidet niemand ausser dir. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen } = App; + + const z = { + ordner: "warteliste", // warteliste | station | entlassen + station: "", + daten: null, + offen: null, // geöffnete Aufnahme + }; + + // Farbe der Dringlichkeitsstufen + const prioArt = { 1: "kritisch", 2: "warn", 3: "info", 4: "neutral" }; + + /* ---- Gerüst ---------------------------------------------------------- */ + + function reiterleiste(d) { + const eintraege = [ + { id: "warteliste", text: "Warteliste", zahl: d.zaehler.wartend }, + { id: "station", text: "Im Haus", zahl: d.zaehler.aufgenommen }, + { id: "entlassen", text: "Entlassen", zahl: d.zaehler.entlassenHeute }, + ]; + + return `
+ ${eintraege.map((e) => ` + `).join("")} +
`; + } + + /* ---- Übersicht -------------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + // Belegung der Stationen als Kachelreihe + const stationsKacheln = d.stationen.map((s) => ` +
+
${sicher(s.name)}
+
${s.frei}
+
frei von ${s.betten} · ${s.zimmer} Zimmer
+
`).join(""); + + behaelter.innerHTML = ` +
+
+

Patientenverwaltung

+

Angemeldet wird von Hand, aufgenommen wird von Hand, entlassen wird von Hand. + Das System führt nur die Liste.

+
+ ${d.rechte.aufnehmen + ? `` : ""} +
+ +
${stationsKacheln}
+ + ${reiterleiste(d)} + +
+ + ${d.rechte.belegen + ? `` : ""} +
+ +
+
+ ${z.ordner === "warteliste" ? "Wartende Patienten" + : z.ordner === "station" ? "Patienten im Haus" : "Zuletzt entlassen"} + ${z.ordner === "warteliste" + ? "Sortiert nach eingetragener Dringlichkeit, nicht nach Wartezeit" + : d.liste.length + " Einträge"} +
+
+ ${d.liste.length ? d.liste.map(zeile).join("") + : `
${z.ordner === "warteliste" + ? "Niemand wartet." : "Keine Einträge."}
`} +
+
+ `; + + verdrahteUebersicht(behaelter); + } + + function zeile(a) { + const entlassen = a.status === "entlassen"; + + return `
+ +
+
${sicher(a.name)} + geb. ${sicher(a.geburt)}
+
${sicher(String(a.grund).slice(0, 90))}
+
+ + ${a.hinweis ? `⚠` : ""} + ${a.allergien ? `A` : ""} + ${a.blutgruppe ? `${sicher(a.blutgruppe)}` : ""} + ${a.aufIntensiv ? `Intensiv` : ""} + + ${a.zimmer ? `${sicher(a.zimmer)}` + : a.stationName ? `${sicher(a.stationName)}` : ""} + + + ${sicher(a.dringlichkeit)} + + ${entlassen + ? zeit(a.entlassen_am) + : (z.ordner === "warteliste" ? "wartet " : "seit ") + minuten(a.wartet)} +
`; + } + + function verdrahteUebersicht(behaelter) { + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.ordner = el.dataset.reiter; + rendern(behaelter); + }); + }); + + behaelter.querySelector("[data-station]").addEventListener("change", (e) => { + z.station = e.target.value; + rendern(behaelter); + }); + + behaelter.querySelectorAll("[data-oeffne]").forEach((el) => { + el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne)); + }); + + const anmelden = behaelter.querySelector("[data-anmelden]"); + if (anmelden) anmelden.addEventListener("click", () => anmeldeFenster(behaelter)); + + const zimmer = behaelter.querySelector("[data-zimmer]"); + if (zimmer) zimmer.addEventListener("click", () => zimmerUebersicht(behaelter)); + } + + /* ---- Einzelansicht ------------------------------------------------------ */ + + function einzelAnsicht(behaelter, d) { + const a = d.aufnahme, s = d.stamm; + + behaelter.innerHTML = ` +
+
+

${sicher(a.name)}

+

geb. ${sicher(a.geburt)} · ${sicher(a.dringlichkeit)} + ${a.stationName ? " · " + sicher(a.stationName) : ""} + ${a.zimmer ? " · Zimmer " + sicher(a.zimmer) + + (a.bett ? " / Bett " + a.bett : "") : ""}

+
+ +
+ + ${s.hinweis ? ` +
+ ⚠ +
Warnhinweis — ${sicher(s.hinweis)}
+
` : ""} + + ${s.allergien ? ` +
+ 💊 +
Allergien — ${sicher(s.allergien)}
+
` : ""} + +
+
${s.lichtbild + ? `Lichtbild` + : `Kein Lichtbild`}
+ +
+
Status + ${sicher(statusName(a.status))}
+
Dringlichkeit + ${sicher(a.dringlichkeit)}
+
Blutgruppe + ${sicher(s.blutgruppe || "nicht bestimmt")}
+
Vorerkrankungen + ${sicher(s.vorerkrankungen || "keine erfasst")}
+
Dauermedikation + ${sicher(s.dauermedikation || "keine erfasst")}
+
Notfallkontakt + ${sicher(s.notfallkontakt || "keiner hinterlegt")}
+
+ +
+ + ${a.status === "wartend" && d.rechte.aufnehmen + ? `` : ""} + ${a.status === "aufgenommen" && d.rechte.verlegen + ? `` : ""} + ${a.status !== "entlassen" && d.rechte.aufnehmen + ? `` : ""} + ${a.status !== "entlassen" && d.rechte.entlassen + ? `` : ""} +
+
+ +
+
+
Vorgang
+
+
Grund der Vorstellung${sicher(a.grund)}
+
Angemeldet${zeit(a.aufgenommenAm)}
+
Angemeldet von${sicher(a.aufgenommenVon)}
+ ${a.entlassenAm ? ` +
Entlassen${zeit(a.entlassenAm)}
+
Entlassen von${sicher(a.entlassenVon)}
` : ""} +
+ ${a.entlassGrund ? ` +
Entlassgrund: ${sicher(a.entlassGrund)}
` : ""} +
+ +
+
+ Behandlungen zu dieser Aufnahme +
+
+ ${d.behandlungen.length ? d.behandlungen.map((b) => ` +
+ ${sicher(b.kennung)} +
+
${sicher(b.diagnose || "Noch keine Diagnose eingetragen")}
+
${sicher(b.autor)} · ${zeit(b.erstellt_am)}
+
+ + ${sicher(b.status)} +
`).join("") + : `
Noch nichts dokumentiert.
`} +
+
+
+ `; + + verdrahteEinzel(behaelter, d); + } + + function statusName(status) { + return { wartend: "Wartet auf Aufnahme", aufgenommen: "Aufgenommen", + entlassen: "Entlassen" }[status] || status; + } + + function verdrahteEinzel(behaelter, d) { + const a = d.aufnahme; + + behaelter.querySelector("[data-zurueck]").addEventListener("click", () => { + z.offen = null; + rendern(behaelter); + }); + + behaelter.querySelector("[data-akte]").addEventListener("click", () => { + if (!App.zustand.module.some((m) => m.id === "mdt")) { + return meldung("Das MDT steht dir nicht zur Verfügung.", "fehler"); + } + App.oeffneModul("mdt", { patient: a.patient }); + }); + + const aufnehmen = behaelter.querySelector("[data-aufnehmen]"); + if (aufnehmen) aufnehmen.addEventListener("click", () => zuweisungsFenster(behaelter, d, false)); + + const verlegen = behaelter.querySelector("[data-verlegen]"); + if (verlegen) verlegen.addEventListener("click", () => zuweisungsFenster(behaelter, d, true)); + + const prio = behaelter.querySelector("[data-prio]"); + if (prio) prio.addEventListener("click", () => prioFenster(behaelter, d)); + + const entlassen = behaelter.querySelector("[data-entlassen]"); + if (entlassen) entlassen.addEventListener("click", () => entlassungsFenster(behaelter, d)); + } + + /* ---- Anmeldung ----------------------------------------------------------- */ + + function anmeldeFenster(behaelter) { + let gewaehlt = null; + + bestaetigen({ + titel: "Patient anmelden", + inhalt: ` +
+
+
+
Mindestens zwei Zeichen eingeben.
+
+
+
+
+
+ +
Diese Einstufung nimmst du vor. Das System + ändert sie nie — auch nicht, wenn jemand lange wartet.
+
`, + bestaetigenText: "Anmelden", + bestaetigt: async (el, zu) => { + if (!gewaehlt) return meldung("Bitte zuerst eine Person wählen.", "fehler"); + try { + await aufruf("patienten", "anmelden", { + patient: gewaehlt.id, + grund: el.querySelector("[name=grund]").value.trim(), + prioritaet: +el.querySelector("[name=prioritaet]").value, + }); + zu(); + meldung("Patient auf der Warteliste.", "gut"); + z.ordner = "warteliste"; + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + const suchfeld = hg.querySelector("[name=suche]"); + const liste = hg.querySelector("[data-treffer]"); + const formular = hg.querySelector("[data-formular]"); + let warten = null; + + suchfeld.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(async () => { + const begriff = suchfeld.value.trim(); + if (begriff.length < 2) { + liste.innerHTML = `
Mindestens zwei Zeichen eingeben.
`; + return; + } + try { + const antwort = await aufruf("patienten", "patientSuche", { begriff }); + const treffer = antwort.treffer || []; + + liste.innerHTML = treffer.length ? treffer.map((t) => ` +
+
+
${sicher(t.firstname)} ${sicher(t.lastname)}
+
geb. ${sicher(t.dob)} + ${t.blutgruppe ? " · " + sicher(t.blutgruppe) : ""}
+
+ ${t.hinweis ? `⚠` : ""} + ${t.laufend ? `${sicher(t.laufend)}` : ""} +
`).join("") + : `
Keine Person gefunden.
`; + + liste.querySelectorAll("[data-person]").forEach((el) => { + el.addEventListener("click", () => { + const person = treffer.find((t) => t.id === +el.dataset.person); + if (person && person.laufend) { + return meldung("Für diesen Patienten läuft bereits eine Aufnahme.", "fehler"); + } + gewaehlt = person; + liste.querySelectorAll("[data-person]") + .forEach((zeile) => zeile.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + formular.style.display = ""; + }); + }); + } catch (f) { meldung(f.message, "fehler"); } + }, 260); + }); + }, + }); + } + + /* ---- Aufnahme und Verlegung ----------------------------------------------- */ + + async function zuweisungsFenster(behaelter, d, istVerlegung) { + const a = d.aufnahme; + + let zimmerDaten; + try { + zimmerDaten = await aufruf("patienten", "freieZimmer", {}); + } catch (f) { return meldung(f.message, "fehler"); } + + // Zimmer nach Station gruppieren + const nachStation = {}; + zimmerDaten.zimmer.forEach((zi) => { + (nachStation[zi.station] = nachStation[zi.station] || { + name: zi.stationName, zimmer: [], + }).zimmer.push(zi); + }); + + let gewaehltesZimmer = null; + let gewaehlteStation = null; + + bestaetigen({ + titel: istVerlegung ? "Patient verlegen" : "Patient aufnehmen", + inhalt: ` +

${sicher(a.name)} — ${sicher(a.grund)}

+ ${istVerlegung + ? `
+
` + : `
+
`} + +
+
+ ${Object.keys(nachStation).map((kennung) => { + const st = nachStation[kennung]; + return `
+
+ ${sicher(st.name)} + +
+
+ ${st.zimmer.map((zi) => ` + `).join("")} +
+
`; + }).join("")} +
+
Das System zeigt nur, was frei ist. Welches Zimmer + zu diesem Patienten passt, entscheidest du.
+
`, + bestaetigenText: istVerlegung ? "Verlegen" : "Aufnehmen", + bestaetigt: async (el, zu) => { + if (!gewaehlteStation) { + return meldung("Bitte eine Station wählen.", "fehler"); + } + if (istVerlegung) { + const grund = el.querySelector("[name=grund]").value.trim(); + if (grund.length < 3) return meldung("Bitte den Grund angeben.", "fehler"); + } + try { + const antwort = await aufruf("patienten", istVerlegung ? "verlegen" : "aufnehmen", { + id: a.id, + station: gewaehlteStation, + zimmer: gewaehltesZimmer, + grund: istVerlegung ? el.querySelector("[name=grund]").value.trim() : null, + vermerk: istVerlegung ? null : el.querySelector("[name=vermerk]").value.trim(), + }); + zu(); + meldung(istVerlegung ? "Patient verlegt." + : "Patient aufgenommen." + (antwort.bett ? " Bett " + antwort.bett + "." : ""), "gut"); + oeffne(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + const alleKnoepfe = () => hg.querySelectorAll("[data-zimmer], [data-nurstation]"); + + hg.querySelectorAll("[data-zimmer]").forEach((el) => { + el.addEventListener("click", () => { + gewaehltesZimmer = +el.dataset.zimmer; + gewaehlteStation = el.dataset.station; + alleKnoepfe().forEach((k) => k.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + }); + }); + + hg.querySelectorAll("[data-nurstation]").forEach((el) => { + el.addEventListener("click", () => { + gewaehltesZimmer = null; + gewaehlteStation = el.dataset.nurstation; + alleKnoepfe().forEach((k) => k.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + }); + }); + }, + }); + } + + /* ---- Dringlichkeit --------------------------------------------------------- */ + + function prioFenster(behaelter, d) { + const a = d.aufnahme; + + bestaetigen({ + titel: "Dringlichkeit ändern", + inhalt: ` +

Aktuell: ${sicher(a.dringlichkeit)}

+
+
+
+
`, + bestaetigenText: "Einstufung setzen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (grund.length < 3) return meldung("Bitte eine Begründung angeben.", "fehler"); + try { + await aufruf("patienten", "prioritaet", { + id: a.id, + prioritaet: +el.querySelector("[name=prioritaet]").value, + grund, + }); + zu(); + meldung("Einstufung geändert.", "gut"); + oeffne(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Entlassung ------------------------------------------------------------ */ + + function entlassungsFenster(behaelter, d) { + const a = d.aufnahme; + const offeneDoku = d.behandlungen.filter((b) => b.status === "laufend").length; + + bestaetigen({ + titel: "Patient entlassen", + gefahr: true, + inhalt: ` +

${sicher(a.name)} wird aus der Klinik entlassen. + Ein belegtes Bett wird dabei frei.

+ ${offeneDoku > 0 ? ` +
+ 📄 +
${offeneDoku} Behandlung(en) noch nicht abgeschlossen. + Sie bleiben offen — das System schließt nichts für dich ab. + Die Entlassung ist trotzdem möglich.
+
` : ""} +
+
`, + bestaetigenText: "Entlassen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (grund.length < 5) return meldung("Bitte eine Begründung angeben.", "fehler"); + try { + await aufruf("patienten", "entlassen", { id: a.id, grund }); + zu(); + meldung("Patient entlassen.", "gut"); + z.offen = null; + z.ordner = "entlassen"; + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Zimmerübersicht -------------------------------------------------------- */ + + async function zimmerUebersicht(behaelter) { + let d; + try { + d = await aufruf("patienten", "freieZimmer", {}); + } catch (f) { return meldung(f.message, "fehler"); } + + const nachStation = {}; + d.zimmer.forEach((zi) => { + (nachStation[zi.station] = nachStation[zi.station] || { + name: zi.stationName, zimmer: [], + }).zimmer.push(zi); + }); + + const dialog = fenster({ + titel: "Zimmerübersicht", + inhalt: ` +

Ein leer gewordenes Zimmer meldet sich als frei, setzt + seinen Status aber nicht selbst zurück — Reinigung und Freigabe bleiben + eine Entscheidung.

+ ${Object.keys(nachStation).map((kennung) => { + const st = nachStation[kennung]; + return `
+
${sicher(st.name)}
+
+ ${st.zimmer.map((zi) => ` +
+ ${sicher(zi.kennung)} +
+
${zi.frei} von ${zi.betten} Betten frei
+
${sicher(zi.art)} + ${zi.hinweis ? " · " + sicher(zi.hinweis) : ""}
+
+ 0 ? "gut" : "warn") + : zi.status === "reinigung" ? "warn" : "kritisch"}"> + ${sicher(zi.status)} + +
`).join("")} +
+
`; + }).join("")}`, + nachOeffnen: (hg) => { + hg.querySelectorAll("[data-zimmer]").forEach((el) => { + el.addEventListener("change", async () => { + if (!el.value) return; + try { + await aufruf("patienten", "zimmerStatus", { + id: +el.dataset.zimmer, status: el.value, + }); + meldung("Zimmerstatus gesetzt.", "gut"); + dialog.schliessen(); + rendern(behaelter); + } catch (f) { + meldung(f.message, "fehler"); + el.value = ""; + } + }); + }); + }, + }); + } + + /* ---- Laden ------------------------------------------------------------------- */ + + async function oeffne(behaelter, aufnahmeId) { + try { + const d = await aufruf("patienten", "oeffnen", { id: aufnahmeId }); + z.offen = aufnahmeId; + einzelAnsicht(behaelter, d); + } catch (f) { meldung(f.message, "fehler"); } + } + + async function rendern(behaelter) { + try { + z.daten = await aufruf("patienten", "uebersicht", { + ordner: z.ordner, station: z.station, + }); + + // Sprungziel aus einem Hinweis, z. B. „Patient wartet" + const ziel = App.zustand.modulZiel; + if (ziel && ziel.aufnahme) { + App.zustand.modulZiel = null; + return oeffne(behaelter, ziel.aufnahme); + } + + if (z.offen) await oeffne(behaelter, z.offen); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Patientenverwaltung

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("patienten", { + rendern, + aktualisieren() { + // Nur die Übersicht auffrischen, nicht eine offene Einzelansicht stören + if (!z.offen && App.zustand.aktivesModul === "patienten") { + rendern(App.$("inhalt")); + } + }, + }); +})(); diff --git a/resources/[aether]/aether-medical/html/js/module/station.js b/resources/[aether]/aether-medical/html/js/module/station.js new file mode 100644 index 0000000..83a245f --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/module/station.js @@ -0,0 +1,849 @@ +/* ===================================================================== + aether-medical / Ansicht: Stationen und Zimmerbelegung + + Der Bettenspiegel ist eine Auskunft, keine Vorgabe. Er zeigt, was + frei ist und wer wo liegt — welches Bett zu welchem Patienten passt, + entscheidest du. Kein Zimmer wechselt hier seinen Status von selbst. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, minuten, aufruf, meldung, fenster, bestaetigen } = App; + + const z = { + reiter: "spiegel", // spiegel | ohnebett | verwaltung + station: "", // Filter auf eine Station (Kennung) + zeigeInaktive: false, // Abgeschaltete Stationen mitzeigen (nur Verwaltung) + daten: null, + }; + + // Farbe der Dringlichkeitsstufen — dieselbe Lesart wie in der Patientenverwaltung + const prioArt = { 1: "kritisch", 2: "warn", 3: "info", 4: "neutral" }; + + /* ---- Kleine Helfer --------------------------------------------------- */ + + /** Marker-Art zum Zimmerstatus */ + function statusArt(status, frei) { + if (status === "gesperrt") return "kritisch"; + if (status === "reinigung") return "warn"; + return frei > 0 ? "gut" : "neutral"; + } + + /** Anzeigename eines Zimmerstatus */ + function statusName(status) { + const treffer = (z.daten.zimmerstatus || []).find((s) => s.id === status); + return treffer ? treffer.name : status; + } + + /** Anzeigename einer Zimmerart */ + function artName(art) { + const treffer = (z.daten.zimmerarten || []).find((a) => a.id === art); + return treffer ? treffer.name : art; + } + + /** Alle Stationen, die der aktuelle Filter durchlässt */ + function sichtbareStationen() { + if (!z.station) return z.daten.stationen; + return z.daten.stationen.filter((s) => s.kennung === z.station); + } + + /* ---- Gerüst ----------------------------------------------------------- */ + + function reiterleiste(d) { + const eintraege = [ + { id: "spiegel", text: "Bettenspiegel", zahl: d.zaehler.frei }, + { id: "ohnebett", text: "Ohne Bett", zahl: d.ohneBett.length }, + ]; + + if (d.rechte.verwalten) { + eintraege.push({ id: "verwaltung", text: "Verwaltung", zahl: d.zaehler.zimmer }); + } + + return `
+ ${eintraege.map((e) => ` + `).join("")} +
`; + } + + function kennzahlen(d) { + const zk = d.zaehler; + const auslastung = zk.betten > 0 ? Math.round((zk.belegt / zk.betten) * 100) : 0; + + return `
+
+
Freie Betten
+
${zk.frei}
+
von ${zk.betten} Betten insgesamt
+
+
+
Belegt
+
${zk.belegt}
+
${auslastung} % ausgelastet
+
+
+
Nicht belegbar
+
${zk.gesperrt + zk.reinigung}
+
${zk.gesperrt} gesperrt · ${zk.reinigung} in Reinigung
+
+
+
Ohne Bett
+
${d.ohneBett.length}
+
aufgenommen, aber in keinem Zimmer
+
+
`; + } + + /* ---- Bettenspiegel ------------------------------------------------------ */ + + /** Ein einzelnes Bett als Zelle */ + function bettZelle(bett) { + const b = bett.belegung; + + if (!b) { + return `
+ ${bett.nummer} + frei +
`; + } + + return `
+ ${bett.nummer} + ${sicher(b.name)} + + ${b.warnhinweis ? `⚠` : ""} + ${b.allergien ? `A` : ""} + ${b.aufIntensiv ? `ITS` : ""} + ${b.prioritaet ? ` + ${b.prioritaet}` : ""} + +
`; + } + + /** Eine Zimmerkachel */ + function zimmerKachel(zi) { + return `
+
+ ${sicher(zi.kennung)} + + ${zi.status === "frei" ? zi.frei + " / " + zi.betten + " frei" + : sicher(statusName(zi.status))} +
+
${zi.bettenListe.map(bettZelle).join("")}
+ ${zi.hinweis + ? `
${sicher(zi.hinweis)}
` : ""} +
`; + } + + /** Eine Station mit allen ihren Zimmern */ + function stationsKarte(s) { + return `
+
+ + ${sicher(s.name)} + ${s.etage ? ` · ${sicher(s.etage)}` : ""} + ${s.aktiv ? "" : ` abgeschaltet`} + + + ${s.belegt} von ${s.betten} Betten belegt · ${s.frei} frei + +
+ ${s.zimmer.length + ? `
${s.zimmer.map(zimmerKachel).join("")}
` + : `
Für diese Station ist kein Zimmer angelegt.
`} +
`; + } + + function spiegelAnsicht(d) { + const stationen = sichtbareStationen(); + + return stationen.length + ? `
${stationen.map(stationsKarte).join("")}
` + : `
Keine Station gefunden.
`; + } + + /* ---- Patienten ohne Bett -------------------------------------------------- */ + + function ohneBettAnsicht(d) { + return `
+
+ Aufgenommen, aber ohne Bett + Das System legt niemanden von selbst hin +
+
+ ${d.ohneBett.length ? d.ohneBett.map((a) => ` +
+ +
+
${sicher(a.name)}
+
${sicher(String(a.grund || "").slice(0, 90))}
+
+ ${a.warnhinweis ? `⚠` : ""} + ${a.allergien ? `A` : ""} + ${a.blutgruppe ? `${sicher(a.blutgruppe)}` : ""} + ${a.stationName ? `${sicher(a.stationName)}` : ""} + seit ${sicher(minuten(a.wartet))} + ${d.rechte.belegen + ? `` : ""} +
`).join("") + : `
Jeder aufgenommene Patient liegt in einem Bett.
`} +
+
`; + } + + /* ---- Verwaltung ------------------------------------------------------------ */ + + function verwaltungsAnsicht(d) { + return ` +
+ ℹ +
Stationen und Zimmer werden abgeschaltet oder gesperrt, nicht gelöscht. + Nur ein Zimmer, in dem nie jemand gelegen hat, lässt sich noch entfernen — + sonst verlöre der Belegungsverlauf seinen Ort.
+
+ +
+ + +
+ + ${d.stationen.map((s) => ` +
+
+ + ${sicher(s.kennung)} + ${sicher(s.name)} + ${s.aktiv ? "" : ` abgeschaltet`} + + + + + + +
+ +
+ ${s.zimmer.length ? s.zimmer.map((zi) => ` +
+ ${sicher(zi.kennung)} +
+
${zi.betten} Bett(en) · ${sicher(artName(zi.art))}
+
${zi.belegt} belegt${ + zi.hinweis ? " · " + sicher(zi.hinweis) : ""}
+
+ + ${sicher(statusName(zi.status))} + + ${zi.belegt === 0 + ? `` + : ""} +
`).join("") + : `
Noch kein Zimmer angelegt.
`} +
+
`).join("")} + `; + } + + /* ---- Gesamtansicht ---------------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Stationen und Belegung

+

Belegt und frei gegeben wird von Hand. Ein leer gewordenes Zimmer meldet + sich, setzt seinen Status aber nicht selbst zurück — Reinigung und Freigabe + bleiben eine Entscheidung.

+
+
+ + ${kennzahlen(d)} + ${reiterleiste(d)} + + ${z.reiter === "spiegel" ? ` +
+ + Klick auf ein Zimmer öffnet die Belegung. +
` : ""} + + ${z.reiter === "spiegel" ? spiegelAnsicht(d) + : z.reiter === "ohnebett" ? ohneBettAnsicht(d) + : verwaltungsAnsicht(d)} + `; + + verdrahteUebersicht(behaelter); + } + + function verdrahteUebersicht(behaelter) { + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.reiter = el.dataset.reiter; + // Abgeschaltete Stationen gehören nur in die Verwaltung + if (z.reiter !== "verwaltung") z.zeigeInaktive = false; + rendern(behaelter); + }); + }); + + const filter = behaelter.querySelector("[data-stationsfilter]"); + if (filter) { + filter.addEventListener("change", (e) => { + z.station = e.target.value; + uebersicht(behaelter); + }); + } + + behaelter.querySelectorAll("[data-zimmer]").forEach((el) => { + el.addEventListener("click", () => zimmerFenster(behaelter, +el.dataset.zimmer)); + }); + + behaelter.querySelectorAll("[data-zuweisen]").forEach((el) => { + el.addEventListener("click", () => { + const aufnahme = z.daten.ohneBett.find((a) => a.id === +el.dataset.zuweisen); + if (aufnahme) zuweisungsFenster(behaelter, aufnahme); + }); + }); + + /* --- Verwaltung --- */ + + const inaktive = behaelter.querySelector("[data-inaktive]"); + if (inaktive) { + inaktive.addEventListener("change", () => { + z.zeigeInaktive = inaktive.checked; + rendern(behaelter); + }); + } + + const neueStation = behaelter.querySelector("[data-neuestation]"); + if (neueStation) { + neueStation.addEventListener("click", () => stationsFenster(behaelter, null)); + } + + behaelter.querySelectorAll("[data-station-bearbeiten]").forEach((el) => { + el.addEventListener("click", () => { + const station = z.daten.stationen.find((s) => s.id === +el.dataset.stationBearbeiten); + if (station) stationsFenster(behaelter, station); + }); + }); + + behaelter.querySelectorAll("[data-station-aktiv]").forEach((el) => { + el.addEventListener("click", () => { + const station = z.daten.stationen.find((s) => s.id === +el.dataset.stationAktiv); + if (station) aktivFenster(behaelter, station, el.dataset.ziel === "1"); + }); + }); + + behaelter.querySelectorAll("[data-neueszimmer]").forEach((el) => { + el.addEventListener("click", () => + zimmerBearbeitenFenster(behaelter, null, +el.dataset.neueszimmer)); + }); + + behaelter.querySelectorAll("[data-zimmer-bearbeiten]").forEach((el) => { + el.addEventListener("click", () => { + const zimmer = findeZimmer(+el.dataset.zimmerBearbeiten); + if (zimmer) zimmerBearbeitenFenster(behaelter, zimmer, zimmer.station); + }); + }); + + behaelter.querySelectorAll("[data-zimmer-entfernen]").forEach((el) => { + el.addEventListener("click", () => { + const zimmer = findeZimmer(+el.dataset.zimmerEntfernen); + if (zimmer) entfernenFenster(behaelter, zimmer); + }); + }); + } + + /** Sucht ein Zimmer über alle Stationen hinweg */ + function findeZimmer(zimmerId) { + for (const station of z.daten.stationen) { + const treffer = station.zimmer.find((zi) => zi.id === zimmerId); + if (treffer) return treffer; + } + return null; + } + + /* ---- Zimmerfenster ---------------------------------------------------------- */ + + async function zimmerFenster(behaelter, zimmerId) { + let d; + try { + d = await aufruf("station", "zimmer", { id: zimmerId }); + } catch (f) { return meldung(f.message, "fehler"); } + + const zi = d.zimmer; + + const dialog = fenster({ + titel: `Zimmer ${zi.kennung} · ${zi.stationName}`, + inhalt: ` +
+
Art${sicher(artName(zi.art))}
+
Status${sicher(statusName(zi.status))}
+
Betten${zi.betten}
+ ${zi.etage ? `
Etage${sicher(zi.etage)}
` : ""} +
+ ${zi.hinweis + ? `
ℹ +
${sicher(zi.hinweis)}
` : ""} + +
+ Belegung +
+
+ ${d.betten.map((bett) => { + const b = bett.belegung; + if (!b) { + return `
+ Bett ${bett.nummer} +
frei
+
`; + } + return `
+ Bett ${bett.nummer} +
+
${sicher(b.name)} + geb. ${sicher(b.geburt)}
+
liegt seit ${sicher(minuten(b.liegtSeit))} · + belegt von ${sicher(b.belegtVon)}
+
+ ${b.warnhinweis ? `⚠` : ""} + ${b.allergien ? `A` : ""} + ${b.blutgruppe ? `${sicher(b.blutgruppe)}` : ""} + ${b.aufnahme + ? `` : ""} + ${d.rechte.belegen + ? `` : ""} +
`; + }).join("")} +
+ + ${d.rechte.belegen ? ` +
+ +
+ + + +
+
Ein leeres Zimmer bleibt so lange in Reinigung oder + gesperrt, bis jemand es wieder freigibt. Das System tut das nie.
+
` : ""} + + ${d.verlauf.length ? ` +
+ Frühere Belegungen + bleibt dauerhaft erhalten +
+
+ ${d.verlauf.map((v) => ` +
+ Bett ${v.bett} +
+
${sicher(v.name)}
+
${zeit(v.belegt_am)} → ${zeit(v.frei_am)} · + freigegeben von ${sicher(v.freiVon)}
+
+
`).join("")} +
` : ""} + `, + nachOeffnen: (hg) => { + // Sprung in die Patientenverwaltung + hg.querySelectorAll("[data-akte]").forEach((el) => { + el.addEventListener("click", () => { + if (!App.zustand.module.some((m) => m.id === "patienten")) { + return meldung("Die Patientenverwaltung steht dir nicht zur Verfügung.", "fehler"); + } + dialog.schliessen(); + App.oeffneModul("patienten", { aufnahme: +el.dataset.akte }); + }); + }); + + hg.querySelectorAll("[data-freigeben]").forEach((el) => { + el.addEventListener("click", () => { + dialog.schliessen(); + freigabeFenster(behaelter, { + id: +el.dataset.freigeben, + name: el.dataset.name, + bett: el.dataset.bett, + zimmer: zi.kennung, + }); + }); + }); + + const setzen = hg.querySelector("[data-statussetzen]"); + if (setzen) { + setzen.addEventListener("click", async () => { + const status = hg.querySelector("[data-neuerstatus]").value; + const hinweis = hg.querySelector("[data-statushinweis]").value.trim(); + try { + await aufruf("station", "zimmerStatus", { id: zi.id, status, hinweis }); + dialog.schliessen(); + meldung("Zimmerstatus gesetzt.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }); + } + }, + }); + } + + /* ---- Bett freigeben ---------------------------------------------------------- */ + + function freigabeFenster(behaelter, belegung) { + bestaetigen({ + titel: "Bett freigeben", + inhalt: ` +

${sicher(belegung.name)} liegt in Zimmer + ${sicher(belegung.zimmer)}, Bett ${sicher(belegung.bett)}.

+
+ 🧍 +
Das ist keine Entlassung. Der Patient bleibt aufgenommen und + steht danach ohne Bett auf der Station. Entlassen wird ausschließlich + über die Patientenverwaltung.
+
+
+
`, + bestaetigenText: "Bett freigeben", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (grund.length < 3) return meldung("Bitte einen Grund angeben.", "fehler"); + try { + await aufruf("station", "bettFreigeben", { id: belegung.id, grund }); + zu(); + meldung("Bett freigegeben.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Bett zuweisen ------------------------------------------------------------ */ + + function zuweisungsFenster(behaelter, aufnahme) { + const d = z.daten; + + let gewaehltesZimmer = null; // Zimmerobjekt aus dem Bettenspiegel + let gewaehltesBett = null; // Bettnummer oder null für „erstes freies" + + // Nur Stationen mit belegbaren Zimmern anbieten + const stationen = d.stationen.filter( + (s) => s.aktiv && s.zimmer.some((zi) => zi.verfuegbar)); + + bestaetigen({ + titel: "Bett zuweisen", + inhalt: ` +

${sicher(aufnahme.name)} — ${sicher(aufnahme.grund || "")}

+ ${aufnahme.warnhinweis + ? `
⚠ +
Warnhinweis — ${sicher(aufnahme.warnhinweis)}
` : ""} + +
+
+ ${stationen.length ? stationen.map((s) => ` +
+
+ ${sicher(s.name)} + ${s.kennung !== aufnahme.station + ? `andere Station` : ""} +
+
+ ${s.zimmer.filter((zi) => zi.verfuegbar).map((zi) => ` + `).join("")} +
+
`).join("") + : `
Zurzeit ist kein Zimmer belegbar.
`} +
+
Angezeigt wird nur, was frei und nicht gesperrt ist. + Welches Zimmer zu diesem Patienten passt, entscheidest du.
+
+ + + +
+
`, + bestaetigenText: "Zuweisen", + bestaetigt: async (el, zu) => { + if (!gewaehltesZimmer) return meldung("Bitte ein Zimmer wählen.", "fehler"); + try { + const antwort = await aufruf("station", "bettZuweisen", { + aufnahme: aufnahme.id, + zimmer: gewaehltesZimmer.id, + bett: gewaehltesBett, + vermerk: el.querySelector("[name=vermerk]").value.trim(), + }); + zu(); + meldung(`Zimmer ${antwort.zimmer}, Bett ${antwort.bett} belegt.`, "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + const bettwahl = hg.querySelector("[data-bettwahl]"); + const bettreihe = hg.querySelector("[data-bettreihe]"); + + hg.querySelectorAll("[data-zimmer]").forEach((el) => { + el.addEventListener("click", () => { + gewaehltesZimmer = findeZimmer(+el.dataset.zimmer); + gewaehltesBett = null; + + hg.querySelectorAll("[data-zimmer]").forEach((k) => k.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + + // Freie Betten des gewählten Zimmers zur Auswahl stellen + const frei = gewaehltesZimmer.bettenListe.filter((b) => !b.belegung); + bettwahl.style.display = frei.length > 1 ? "" : "none"; + bettreihe.innerHTML = frei.map((b) => ` + `).join(""); + + bettreihe.querySelectorAll("[data-bett]").forEach((knopf) => { + knopf.addEventListener("click", () => { + gewaehltesBett = +knopf.dataset.bett; + bettreihe.querySelectorAll("[data-bett]") + .forEach((k) => k.classList.remove("gewaehlt")); + knopf.classList.add("gewaehlt"); + }); + }); + }); + }); + }, + }); + } + + /* ---- Station anlegen und bearbeiten -------------------------------------------- */ + + function stationsFenster(behaelter, station) { + const d = z.daten; + const neu = !station; + + bestaetigen({ + titel: neu ? "Station anlegen" : `Station ${station.kennung} bearbeiten`, + inhalt: ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${neu ? "" : ` +
Wird die Kennung geändert, ziehen alle Aufnahmen mit, + die auf diese Station verweisen.
`}`, + bestaetigenText: neu ? "Anlegen" : "Speichern", + bestaetigt: async (el, zu) => { + const kennung = el.querySelector("[name=kennung]").value.trim(); + const name = el.querySelector("[name=name]").value.trim(); + if (!kennung) return meldung("Bitte eine Kennung angeben.", "fehler"); + if (!name) return meldung("Bitte einen Namen angeben.", "fehler"); + + try { + await aufruf("station", "stationSpeichern", { + id: neu ? null : station.id, + kennung, + name, + art: el.querySelector("[name=art]").value, + etage: el.querySelector("[name=etage]").value.trim(), + sortierung: +el.querySelector("[name=sortierung]").value, + }); + zu(); + meldung(neu ? "Station angelegt." : "Station gespeichert.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + function aktivFenster(behaelter, station, einschalten) { + bestaetigen({ + titel: einschalten ? "Station einschalten" : "Station abschalten", + gefahr: !einschalten, + inhalt: einschalten + ? `

Station ${sicher(station.name)} steht danach wieder für + Aufnahmen und Belegungen zur Verfügung.

+
+
` + : `

Station ${sicher(station.name)} wird abgeschaltet. Zimmer und + Belegungsverlauf bleiben erhalten — es lässt sich dort nur niemand + mehr aufnehmen.

+
ℹ +
Solange dort Patienten liegen, ist das Abschalten nicht möglich.
+
+
`, + bestaetigenText: einschalten ? "Einschalten" : "Abschalten", + bestaetigt: async (el, zu) => { + try { + await aufruf("station", "stationAktiv", { + id: station.id, + aktiv: einschalten, + grund: el.querySelector("[name=grund]").value.trim(), + }); + zu(); + meldung(einschalten ? "Station eingeschaltet." : "Station abgeschaltet.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Zimmer anlegen und bearbeiten ---------------------------------------------- */ + + function zimmerBearbeitenFenster(behaelter, zimmer, stationId) { + const d = z.daten; + const neu = !zimmer; + const station = d.stationen.find((s) => s.id === stationId); + + bestaetigen({ + titel: neu ? `Zimmer anlegen · ${station ? station.name : ""}` + : `Zimmer ${zimmer.kennung} bearbeiten`, + inhalt: ` +
+
+
+
+
+
+
+
+
+
+
${neu + ? "Ein neues Zimmer startet gesperrt. Erst wenn es jemand freigibt, lässt es sich belegen." + : "Belegte Betten lassen sich nicht wegkürzen."}
`, + bestaetigenText: neu ? "Anlegen" : "Speichern", + bestaetigt: async (el, zu) => { + const kennung = el.querySelector("[name=kennung]").value.trim(); + if (!kennung) return meldung("Bitte eine Kennung angeben.", "fehler"); + + try { + await aufruf("station", "zimmerSpeichern", { + id: neu ? null : zimmer.id, + station: stationId, + kennung, + betten: +el.querySelector("[name=betten]").value, + art: el.querySelector("[name=art]").value, + hinweis: el.querySelector("[name=hinweis]").value.trim(), + }); + zu(); + meldung(neu ? "Zimmer angelegt." : "Zimmer gespeichert.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + function entfernenFenster(behaelter, zimmer) { + bestaetigen({ + titel: `Zimmer ${zimmer.kennung} entfernen`, + gefahr: true, + inhalt: ` +

Das Zimmer verschwindet aus dem Bettenspiegel.

+
+ ℹ +
Möglich ist das nur, solange nie jemand darin gelegen hat. Sobald es + eine Belegung gab, bleibt das Zimmer bestehen und lässt sich nur sperren.
+
+
+
`, + bestaetigenText: "Entfernen", + bestaetigt: async (el, zu) => { + try { + await aufruf("station", "zimmerEntfernen", { + id: zimmer.id, + grund: el.querySelector("[name=grund]").value.trim(), + }); + zu(); + meldung("Zimmer entfernt.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Laden ------------------------------------------------------------------- */ + + async function rendern(behaelter) { + try { + z.daten = await aufruf("station", "uebersicht", { alle: z.zeigeInaktive }); + + // Sprungziel aus einem Hinweis, z. B. „Zimmer wurde frei" + const ziel = App.zustand.modulZiel; + if (ziel && ziel.zimmer) { + App.zustand.modulZiel = null; + uebersicht(behaelter); + return zimmerFenster(behaelter, ziel.zimmer); + } + + uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Stationen und Belegung

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("station", { + rendern, + aktualisieren() { + // Nur auffrischen, wenn kein Fenster offen ist — sonst geht eine Eingabe verloren + if (App.zustand.aktivesModul === "station" && !document.querySelector(".fenster-hg")) { + rendern(App.$("inhalt")); + } + }, + }); +})(); diff --git a/resources/[aether]/aether-medical/html/js/module/vorlage.js b/resources/[aether]/aether-medical/html/js/module/vorlage.js new file mode 100644 index 0000000..d40f9f6 --- /dev/null +++ b/resources/[aether]/aether-medical/html/js/module/vorlage.js @@ -0,0 +1,272 @@ +/* ===================================================================== + aether-medical / Ansicht: Bausteine + + Pflege der Textvorlagen für Behandlungs- und Berichtsbogen. Ein + Baustein ist vorbereiteter Text — er wird nie von selbst eingesetzt + und schreibt nichts in eine Dokumentation. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, aufruf, meldung, fenster, bestaetigen } = App; + + const z = { + bereich: "behandlung", // gewählter Bogen + daten: null, + }; + + /* ---- Gerüst ------------------------------------------------------------ */ + + function reiterleiste(d) { + return `
+ ${d.bereiche.map((b) => ` + `).join("")} +
`; + } + + /* ---- Übersicht ---------------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Bausteine

+

Vorformulierter Text für die Bögen. Ein Baustein füllt beim Einsetzen nur + das Formular — gespeichert wird danach von Hand, und vorhandener Text + bleibt stehen.

+
+ +
+ + ${reiterleiste(d)} + +
+
+ Bausteine für diesen Bogen + Die Reihenfolge bestimmt nur die Anzeige +
+
+ ${d.liste.length ? d.liste.map(zeile).join("") + : `
Für diesen Bogen ist noch nichts hinterlegt.
`} +
+
+ +
+
+ Felder dieses Bogens +
+
+ ${d.felder.map((f) => `${sicher(f.name)}`).join("")} +
+
+ Ein Baustein kann nur füllen, was es im Bogen auch gibt. Felder, für die + jemandem das Recht fehlt, bleiben beim Einsetzen unberührt. +
+
+ `; + + verdrahteUebersicht(behaelter); + } + + function zeile(b) { + return `
+ ${sicher(b.kennung)} + +
+
${sicher(b.name)}
+
${b.felder.length + ? sicher(b.felder.join(" · ")) : "füllt kein Feld"}
+
+ + Reihenfolge ${b.sortierung} + + ${b.aktiv ? "einsetzbar" : "abgeschaltet"} + + + + + +
`; + } + + function verdrahteUebersicht(behaelter) { + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.bereich = el.dataset.reiter; + rendern(behaelter); + }); + }); + + const neu = behaelter.querySelector("[data-neu]"); + if (neu) neu.addEventListener("click", () => bearbeitenFenster(behaelter, null)); + + behaelter.querySelectorAll("[data-ansehen]").forEach((el) => { + el.addEventListener("click", () => { + const b = z.daten.liste.find((e) => e.id === +el.dataset.ansehen); + if (b) ansehenFenster(b); + }); + }); + + behaelter.querySelectorAll("[data-bearbeiten]").forEach((el) => { + el.addEventListener("click", () => { + const b = z.daten.liste.find((e) => e.id === +el.dataset.bearbeiten); + if (b) bearbeitenFenster(behaelter, b); + }); + }); + + behaelter.querySelectorAll("[data-aktiv]").forEach((el) => { + el.addEventListener("click", async () => { + try { + await aufruf("vorlage", "aktiv", { + id: +el.dataset.aktiv, aktiv: el.dataset.ziel === "1", + }); + meldung(el.dataset.ziel === "1" ? "Baustein einsetzbar." : "Baustein abgeschaltet.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }); + }); + + behaelter.querySelectorAll("[data-entfernen]").forEach((el) => { + el.addEventListener("click", () => { + const b = z.daten.liste.find((e) => e.id === +el.dataset.entfernen); + if (b) entfernenFenster(behaelter, b); + }); + }); + } + + /* ---- Ansehen ------------------------------------------------------------- */ + + function ansehenFenster(b) { + const d = z.daten; + + fenster({ + titel: `${b.kennung} · ${b.name}`, + inhalt: ` +

So sieht der Text aus, der beim Einsetzen in die + Felder wandert. Vorhandener Text im Bogen bleibt erhalten.

+ ${d.felder.filter((f) => b.inhalt[f.id]).map((f) => ` +
+ +
${sicher(b.inhalt[f.id]).replace(/\n/g, "
")}
+
`).join("") || `
Dieser Baustein ist leer.
`}`, + }); + } + + /* ---- Anlegen und bearbeiten ------------------------------------------------ */ + + function bearbeitenFenster(behaelter, b) { + const d = z.daten; + const neu = !b; + + bestaetigen({ + titel: neu ? `Baustein für den ${d.bereiche.find((e) => e.id === z.bereich).name}` + : `„${b.name}" bearbeiten`, + inhalt: ` +
+
+
+
+
+
+ +
Leer gelassene Felder bleiben beim Einsetzen + unberührt. Ein Baustein muss mindestens ein Feld füllen.
+ + ${d.felder.map((f) => ` +
+
`).join("")} + + ${neu ? "" : `
Der Bogen eines Bausteins lässt sich + nicht wechseln — die Felder des einen passen nicht auf den anderen.
`}`, + bestaetigenText: neu ? "Anlegen" : "Speichern", + bestaetigt: async (el, zu) => { + const name = el.querySelector("[name=name]").value.trim(); + if (!name) return meldung("Bitte einen Namen angeben.", "fehler"); + + // Nur gefüllte Felder wandern in den Baustein + const inhalt = {}; + d.felder.forEach((f) => { + const wert = el.querySelector(`[name=${f.id}]`).value.trim(); + if (wert) inhalt[f.id] = wert; + }); + + if (!Object.keys(inhalt).length) { + return meldung("Bitte mindestens ein Feld ausfüllen.", "fehler"); + } + + try { + await aufruf("vorlage", "speichern", { + id: neu ? null : b.id, + bereich: z.bereich, + name, + sortierung: +el.querySelector("[name=sortierung]").value, + inhalt, + }); + zu(); + meldung(neu ? "Baustein angelegt." : "Baustein gespeichert.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + function entfernenFenster(behaelter, b) { + bestaetigen({ + titel: `„${b.name}" entfernen`, + gefahr: true, + inhalt: ` +

Der Baustein verschwindet aus der Auswahl.

+
+ ℹ +
Was damit bereits geschrieben wurde, steht in den Bögen und bleibt + unberührt. Soll der Baustein nur vorübergehend verschwinden, ist + Abschalten der sanftere Weg.
+
+
+
`, + bestaetigenText: "Entfernen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (!grund) return meldung("Bitte einen Grund angeben.", "fehler"); + try { + await aufruf("vorlage", "entfernen", { id: b.id, grund }); + zu(); + meldung("Baustein entfernt.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Laden -------------------------------------------------------------------- */ + + async function rendern(behaelter) { + try { + z.daten = await aufruf("vorlage", "uebersicht", { bereich: z.bereich }); + uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Bausteine

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("vorlage", { + rendern, + aktualisieren() { + if (App.zustand.aktivesModul === "vorlage" && !document.querySelector(".fenster-hg")) { + rendern(App.$("inhalt")); + } + }, + }); +})(); diff --git a/resources/[aether]/aether-medical/server/dienst.lua b/resources/[aether]/aether-medical/server/dienst.lua new file mode 100644 index 0000000..ffb7f24 --- /dev/null +++ b/resources/[aether]/aether-medical/server/dienst.lua @@ -0,0 +1,296 @@ +-- ===================================================================== +-- aether-medical — Dienstsystem +-- +-- Speichert ausschliesslich Statusinformationen. Es gibt keine +-- automatische Dienstkleidung und keine automatische Fahrzeugausgabe. +-- Auch die Funktion (RTW, Notarzt, OP, Leitstelle) wählt jeder selbst. +-- ===================================================================== + +Dienst = {} + +-- Mitarbeiter im Dienst: [quelle] = mitarbeiter +local imDienst = {} +-- Wer hält die Leitstelle? (Server-ID oder nil) +local leitstelle = nil + +local Core = exports['aether-core'] + +-- --------------------------------------------------------------------- +-- Zugriff +-- --------------------------------------------------------------------- + +--- Liefert die Daten eines Mitarbeiters im Dienst +--- @param quelle number Server-ID +--- @return table|nil +function Dienst.Hole(quelle) + return imDienst[quelle] +end + +--- Liefert alle Mitarbeiter im Dienst +--- @return table [quelle] = mitarbeiter +function Dienst.AlleImDienst() + return imDienst +end + +--- Sucht die Server-ID zu einer Charakter-ID +--- @param charakterId number Charakter +--- @return number|nil +function Dienst.QuelleVonCharakter(charakterId) + for quelle, mitarbeiter in pairs(imDienst) do + if mitarbeiter.charakterId == charakterId then return quelle end + end + return nil +end + +--- Wer hält die Leitstelle? +--- @return number|nil Server-ID +function Dienst.Leitstelle() + return leitstelle +end + +-- --------------------------------------------------------------------- +-- Personalakte laden +-- --------------------------------------------------------------------- + +--- Lädt den Personaldatensatz und baut daraus den Mitarbeiter +--- @param quelle number Server-ID +--- @param charakterId number Charakter +--- @return table|nil Mitarbeiter, string|nil Fehler +function Dienst.LadeMitarbeiter(quelle, charakterId) + local zeilen = MySQL.query.await( + 'SELECT * FROM medical_personal WHERE `character` = ? AND aktiv = 1', { charakterId }) + + if not zeilen or not zeilen[1] then + return nil, 'Du gehörst nicht zum Personal der Klinik.' + end + + local personal = zeilen[1] + local person = Core:HoleSpielerInfo(quelle) + local rolle = Medizin.HoleRolle(personal.rolle) + + return { + quelle = quelle, + charakterId = charakterId, + name = ((person and person.vorname or '') .. ' ' .. + (person and person.nachname or '')), + personalnummer = personal.personalnummer, + rolle = personal.rolle, + rolleName = rolle and rolle.name or personal.rolle, + stufe = Medizin.Stufe(personal.rolle), + station = personal.station, + rechte = Rechte.Berechne(personal), + status = 'verfuegbar', + funktion = 'station', + seit = os.time(), + dienstId = nil, + } +end + +-- --------------------------------------------------------------------- +-- Dienst antreten und beenden +-- --------------------------------------------------------------------- + +--- Meldet einen Mitarbeiter zum Dienst an +--- @param quelle number Server-ID +--- @param funktion string|nil Gewählte Dienstfunktion +--- @return table|nil Mitarbeiter, string|nil Fehler +function Dienst.Antreten(quelle, funktion) + if imDienst[quelle] then return imDienst[quelle] end + + local charakterId = Core:HoleCharakterId(quelle) + if not charakterId then return nil, 'Kein Charakter geladen.' end + + local mitarbeiter, fehler = Dienst.LadeMitarbeiter(quelle, charakterId) + if not mitarbeiter then return nil, fehler end + + if not Rechte.Hat(mitarbeiter, 'dienst.antreten') then + return nil, 'Dir fehlt die Berechtigung, den Dienst anzutreten.' + end + + -- Funktion prüfen; manche erfordern ein eigenes Recht + local gewaehlt = Dienst.PruefeFunktion(mitarbeiter, funktion) + mitarbeiter.funktion = gewaehlt + + -- Dienstzeit protokollieren + mitarbeiter.dienstId = MySQL.insert.await( + 'INSERT INTO medical_dienst (`character`, station, funktion) VALUES (?, ?, ?)', + { charakterId, mitarbeiter.station, gewaehlt }) + + imDienst[quelle] = mitarbeiter + + Protokoll.Schreibe(mitarbeiter, 'dienst', 'Dienst angetreten', + nil, { funktion = gewaehlt, station = mitarbeiter.station }) + Kern.Loese('dienst.angetreten', quelle, mitarbeiter) + Dienst.Verteile() + + return mitarbeiter +end + +--- Prüft eine gewünschte Dienstfunktion und liefert eine zulässige zurück +--- @param mitarbeiter table Mitarbeiter +--- @param funktion string|nil Wunsch +--- @return string Zulässige Funktion +function Dienst.PruefeFunktion(mitarbeiter, funktion) + for _, eintrag in ipairs(Medizin.Config.Dienstfunktionen) do + if eintrag.id == funktion then + if not eintrag.recht or Rechte.Hat(mitarbeiter, eintrag.recht) then + return eintrag.id + end + break + end + end + -- Im Zweifel der einfache Stationsdienst + return 'station' +end + +--- Meldet einen Mitarbeiter vom Dienst ab +--- @param quelle number Server-ID +function Dienst.Beenden(quelle) + local mitarbeiter = imDienst[quelle] + if not mitarbeiter then return end + + if mitarbeiter.dienstId then + MySQL.update('UPDATE medical_dienst SET ende = NOW(), sekunden = ? WHERE id = ?', + { os.time() - mitarbeiter.seit, mitarbeiter.dienstId }) + end + + imDienst[quelle] = nil + + -- Leitstelle freigeben, wenn der Inhaber geht + if leitstelle == quelle then + leitstelle = nil + end + + Protokoll.Schreibe(mitarbeiter, 'dienst', 'Dienst beendet') + Kern.Loese('dienst.beendet', quelle, mitarbeiter) + Dienst.Verteile() +end + +--- Setzt den Status (verfügbar, am Patienten, Pause ...) +--- @param quelle number Server-ID +--- @param status string Statuskennung +--- @return boolean +function Dienst.SetzeStatus(quelle, status) + local mitarbeiter = imDienst[quelle] + if not mitarbeiter then return false end + + local gueltig = false + for _, eintrag in ipairs(Medizin.Config.Dienststatus) do + if eintrag.id == status then gueltig = true break end + end + if not gueltig then return false end + + mitarbeiter.status = status + Kern.Loese('dienst.status', quelle, mitarbeiter, status) + Dienst.Verteile() + return true +end + +--- Wechselt die Dienstfunktion im laufenden Dienst +--- @param quelle number Server-ID +--- @param funktion string Gewünschte Funktion +--- @return boolean, string|nil +function Dienst.SetzeFunktion(quelle, funktion) + local mitarbeiter = imDienst[quelle] + if not mitarbeiter then return false end + + local gewaehlt = Dienst.PruefeFunktion(mitarbeiter, funktion) + if gewaehlt ~= funktion then + return false, 'Dir fehlt die Berechtigung für diese Funktion.' + end + + mitarbeiter.funktion = gewaehlt + + if mitarbeiter.dienstId then + MySQL.update('UPDATE medical_dienst SET funktion = ? WHERE id = ?', + { gewaehlt, mitarbeiter.dienstId }) + end + + Protokoll.Schreibe(mitarbeiter, 'dienst', 'Dienstfunktion gewechselt', + nil, { funktion = gewaehlt }) + Kern.Loese('dienst.funktion', quelle, mitarbeiter, gewaehlt) + Dienst.Verteile() + return true +end + +--- Übernimmt oder überträgt die Leitstelle +--- @param quelle number Handelnder +--- @param zielQuelle number|nil Ziel (nil = selbst übernehmen) +--- @return boolean, string|nil +function Dienst.SetzeLeitstelle(quelle, zielQuelle) + local mitarbeiter = imDienst[quelle] + if not mitarbeiter then return false end + + if not Rechte.Hat(mitarbeiter, 'dienst.leitstelle') then + return false, 'Dir fehlt die Berechtigung für die Leitstelle.' + end + + if zielQuelle and zielQuelle ~= quelle then + -- Zuweisen an andere setzt voraus, dass man über ihnen steht + local ziel = imDienst[zielQuelle] + if not ziel then return false, 'Diese Person ist nicht im Dienst.' end + if not Rechte.StehtUeber(mitarbeiter, ziel.rolle) then + return false, 'Du kannst die Leitstelle nur an rangniedrigere Kollegen übertragen.' + end + leitstelle = zielQuelle + Protokoll.Schreibe(mitarbeiter, 'dienst', 'Leitstelle übertragen', + { typ = 'person', id = ziel.charakterId }) + else + leitstelle = quelle + Protokoll.Schreibe(mitarbeiter, 'dienst', 'Leitstelle übernommen') + end + + Kern.Loese('dienst.leitstelle', leitstelle) + Dienst.Verteile() + return true +end + +-- --------------------------------------------------------------------- +-- Verteilung an die Clients +-- --------------------------------------------------------------------- + +--- Baut die Liste der Mitarbeiter im Dienst +--- @return table +function Dienst.BaueListe() + local liste = {} + for quelle, mitarbeiter in pairs(imDienst) do + local position = GetEntityCoords(GetPlayerPed(quelle)) + liste[#liste + 1] = { + quelle = quelle, + charakterId = mitarbeiter.charakterId, + name = mitarbeiter.name, + personalnummer = mitarbeiter.personalnummer, + rolle = mitarbeiter.rolle, + rolleName = mitarbeiter.rolleName, + station = mitarbeiter.station, + funktion = mitarbeiter.funktion, + status = mitarbeiter.status, + leitstelle = leitstelle == quelle, + seit = mitarbeiter.seit, + position = { x = position.x, y = position.y, z = position.z }, + } + end + table.sort(liste, function(a, b) return a.personalnummer < b.personalnummer end) + return liste +end + +--- Schickt die Dienstliste an alle im Dienst +function Dienst.Verteile() + local liste = Dienst.BaueListe() + for quelle in pairs(imDienst) do + TriggerClientEvent('aether:medical:dienstliste', quelle, liste) + end +end + +-- Positionen regelmäßig auffrischen (für die Einsatzkarte) +CreateThread(function() + while true do + Wait(5000) + if next(imDienst) then Dienst.Verteile() end + end +end) + +-- Beim Verlassen sauber abmelden +AddEventHandler('playerDropped', function() + Dienst.Beenden(source) +end) diff --git a/resources/[aether]/aether-medical/server/hinweise.lua b/resources/[aether]/aether-medical/server/hinweise.lua new file mode 100644 index 0000000..fcdb6ca --- /dev/null +++ b/resources/[aether]/aether-medical/server/hinweise.lua @@ -0,0 +1,484 @@ +-- ===================================================================== +-- aether-medical — Hinweise und Fristenüberwachung +-- +-- Dies ist der einzige Teil des Systems, der von selbst tätig wird — +-- und er tut ausschliesslich eines: ERINNERN. +-- +-- Kein Hinweis nimmt einen Patienten auf, entlässt ihn, gibt ein +-- Medikament aus, wirft eine Blutkonserve weg oder schliesst eine +-- Dokumentation ab. Jede Frist läuft ab, ohne dass etwas passiert, +-- bis ein Mensch entscheidet. +-- ===================================================================== + +Hinweise = {} + +-- Bereits gemeldete Fristen, damit nicht im Minutentakt gewarnt wird: +-- [schluessel] = { [stufe] = true } +local gemeldet = {} + +--- Erstellt einen Hinweis +--- @param ziel table|nil { charakterId? } — ohne charakterId geht er an alle im Dienst +--- @param daten table { kategorie, prioritaet?, titel, text?, zielTyp?, zielId? } +function Hinweise.Erstelle(ziel, daten) + local id = MySQL.insert.await([[ + INSERT INTO medical_hinweis + (`character`, kategorie, prioritaet, titel, text, ziel_typ, ziel_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + ]], { + ziel and ziel.charakterId or nil, + daten.kategorie or 'system', + daten.prioritaet or 'normal', + daten.titel, + daten.text, + daten.zielTyp, + daten.zielId and tostring(daten.zielId) or nil, + }) + + -- Betroffene Clients sofort informieren. + -- Die Feldnamen entsprechen bewusst denen aus Hinweise.Lade, damit ein + -- frisch gemeldeter Hinweis genauso anklickbar ist wie ein geladener. + local paket = { + id = id, + kategorie = daten.kategorie or 'system', + prioritaet = daten.prioritaet or 'normal', + titel = daten.titel, + text = daten.text, + zielTyp = daten.zielTyp, + zielId = daten.zielId, + } + + if ziel and ziel.charakterId then + local quelle = Dienst.QuelleVonCharakter(ziel.charakterId) + if quelle then + TriggerClientEvent('aether:medical:hinweis', quelle, paket) + end + else + for quelle in pairs(Dienst.AlleImDienst()) do + TriggerClientEvent('aether:medical:hinweis', quelle, paket) + end + end + + return id +end + +--- Lädt die letzten Hinweise eines Mitarbeiters +--- @param charakterId number Charakter +--- @param limit number|nil Anzahl +--- @return table +function Hinweise.Lade(charakterId, limit) + local zeilen = MySQL.query.await([[ + SELECT * FROM medical_hinweis + WHERE (`character` = ? OR `character` IS NULL) + ORDER BY id DESC LIMIT ? + ]], { charakterId, limit or 30 }) or {} + + -- Sprungziel auf die Schreibweise der Oberfläche bringen + for _, eintrag in ipairs(zeilen) do + eintrag.zielTyp = eintrag.ziel_typ + eintrag.zielId = eintrag.ziel_id + eintrag.ziel_typ, eintrag.ziel_id = nil, nil + end + + return zeilen +end + +--- Markiert einen Hinweis als gelesen +function Hinweise.MarkiereGelesen(charakterId, hinweisId) + MySQL.update([[ + UPDATE medical_hinweis SET gelesen = 1 + WHERE id = ? AND (`character` = ? OR `character` IS NULL) + ]], { hinweisId, charakterId }) +end + +--- Meldet eine Frist genau einmal je Stufe +--- @param schluessel string Eindeutige Kennung des Vorgangs +--- @param stufe any Stufe der Erinnerung +--- @return boolean true, wenn jetzt gemeldet werden soll +local function ErstmalsMelden(schluessel, stufe) + gemeldet[schluessel] = gemeldet[schluessel] or {} + if gemeldet[schluessel][stufe] then return false end + gemeldet[schluessel][stufe] = true + return true +end + +--- Merkt eine Erinnerung als bereits erledigt vor. +--- Ein Modul, das selbst sofort benachrichtigt, verhindert damit, dass +--- die Fristenprüfung die gleiche Sache kurz darauf noch einmal meldet. +--- @param schluessel string Eindeutige Kennung des Vorgangs +--- @param stufe any Stufe der Erinnerung +function Hinweise.AlsGemeldet(schluessel, stufe) + gemeldet[schluessel] = gemeldet[schluessel] or {} + gemeldet[schluessel][stufe] = true +end + +-- --------------------------------------------------------------------- +-- Fristenüberwachung +-- +-- Läuft im Hintergrund. Jeder Block hier endet mit einem Hinweis — +-- niemals mit einem UPDATE auf einen medizinischen Vorgang. +-- --------------------------------------------------------------------- +CreateThread(function() + Wait(5000) + + while true do + Wait(Medizin.Config.Erinnerungen.pruefIntervall) + + -- --- Wartende Patienten --- + local wartend = MySQL.query.await([[ + SELECT a.id, a.patient, a.prioritaet, a.grund, + TIMESTAMPDIFF(MINUTE, a.aufgenommen_am, NOW()) AS wartet, + c.firstname, c.lastname + FROM medical_aufnahme a + JOIN aether_characters c ON c.id = a.patient + WHERE a.status = 'wartend' + ]]) or {} + + for _, eintrag in ipairs(wartend) do + local wartet = tonumber(eintrag.wartet) or 0 + local schluessel = 'warte_' .. eintrag.id + + -- Höchste erreichte Stufe melden, nicht jede einzeln + local stufe = nil + for _, grenze in ipairs(Medizin.Config.Erinnerungen.warteZeitStufen) do + if wartet >= grenze then stufe = grenze end + end + + if stufe and ErstmalsMelden(schluessel, stufe) then + Hinweise.Erstelle(nil, { + kategorie = 'patient', + prioritaet = tonumber(eintrag.prioritaet) <= 2 and 'hoch' or 'normal', + titel = ('Patient wartet seit %d Minuten'):format(stufe), + text = ('%s %s — %s'):format(eintrag.firstname, eintrag.lastname, + tostring(eintrag.grund):sub(1, 80)), + zielTyp = 'aufnahme', zielId = eintrag.id, + }) + end + end + + -- --- Geplante Operationen --- + local operationen = MySQL.query.await([[ + SELECT o.id, o.kennung, o.art, o.patient, + TIMESTAMPDIFF(MINUTE, NOW(), o.geplant_fuer) AS rest, + c.firstname, c.lastname + FROM medical_operation o + JOIN aether_characters c ON c.id = o.patient + WHERE o.status = 'geplant' AND o.geplant_fuer IS NOT NULL + AND o.geplant_fuer > DATE_SUB(NOW(), INTERVAL 2 HOUR) + ]]) or {} + + for _, operation in ipairs(operationen) do + local rest = tonumber(operation.rest) or 0 + local schluessel = 'op_' .. operation.id + + for _, vorlauf in ipairs(Medizin.Config.Erinnerungen.opVorlauf) do + if rest <= vorlauf and rest > 0 and ErstmalsMelden(schluessel, vorlauf) then + Hinweise.Erstelle(nil, { + kategorie = 'op', prioritaet = 'normal', + titel = ('Operation beginnt in %d Minuten'):format(vorlauf), + text = ('%s — %s (%s %s)'):format(operation.kennung, operation.art, + operation.firstname, operation.lastname), + zielTyp = 'operation', zielId = operation.id, + }) + break + end + end + + -- Der geplante Zeitpunkt ist erreicht — die OP startet trotzdem nicht + if rest <= 0 and ErstmalsMelden(schluessel, 'faellig') then + Hinweise.Erstelle(nil, { + kategorie = 'op', prioritaet = 'hoch', + titel = 'Geplante Operation ist fällig', + text = ('%s — %s. Beginn muss vom OP-Team eingetragen werden.') + :format(operation.kennung, operation.art), + zielTyp = 'operation', zielId = operation.id, + }) + end + end + + -- --- Offene Behandlungsdokumentationen --- + local offeneDoku = MySQL.query.await([[ + SELECT b.id, b.kennung, b.erstellt_von, c.firstname, c.lastname + FROM medical_behandlung b + JOIN aether_characters c ON c.id = b.patient + WHERE b.status = 'laufend' + AND b.erstellt_am < DATE_SUB(NOW(), INTERVAL ? MINUTE) + ]], { Medizin.Config.Erinnerungen.dokuOffenNach }) or {} + + for _, doku in ipairs(offeneDoku) do + if ErstmalsMelden('doku_' .. doku.id, 'offen') then + Hinweise.Erstelle({ charakterId = doku.erstellt_von }, { + kategorie = 'doku', prioritaet = 'normal', + titel = 'Dokumentation fehlt', + text = ('%s (%s %s) ist noch nicht abgeschlossen.') + :format(doku.kennung, doku.firstname, doku.lastname), + zielTyp = 'behandlung', zielId = doku.id, + }) + end + end + + -- --- Intensivpatienten ohne frischen Verlaufseintrag --- + -- + -- Erinnert daran, dass jemand nachschauen sollte. Das System + -- schaut nicht nach, misst nichts und ändert keinen Zustand. + local intensiv = MySQL.query.await([[ + SELECT i.id, i.patient, i.zustand, i.verantwortlich, + c.firstname, c.lastname, + TIMESTAMPDIFF(MINUTE, COALESCE( + (SELECT MAX(v.am) FROM medical_intensiv_verlauf v + WHERE v.intensiv = i.id), + i.aufgenommen_am), NOW()) AS stillSeit, + (SELECT COUNT(*) FROM medical_intensiv_verlauf v + WHERE v.intensiv = i.id) AS eintraege + FROM medical_intensiv i + JOIN aether_characters c ON c.id = i.patient + WHERE i.beendet_am IS NULL + ]]) or {} + + for _, aufenthalt in ipairs(intensiv) do + local still = tonumber(aufenthalt.stillSeit) or 0 + + -- Die Stufe enthält die Anzahl der Einträge: nach jedem neuen + -- Eintrag beginnt die Erinnerung von vorn. + local stufe = 'still_' .. tostring(aufenthalt.eintraege) + + if still >= Medizin.Config.Erinnerungen.intensivStilleNach + and ErstmalsMelden('intensiv_' .. aufenthalt.id, stufe) then + -- Wer zuständig ist, wird persönlich erinnert; sonst das ganze Haus + local ziel = aufenthalt.verantwortlich + and { charakterId = aufenthalt.verantwortlich } or nil + + Hinweise.Erstelle(ziel, { + kategorie = 'patient', + prioritaet = aufenthalt.zustand == 'kritisch' and 'hoch' or 'normal', + titel = ('Intensivverlauf seit %d Minuten ohne Eintrag'):format(still), + text = ('%s %s — zuletzt dokumentiert vor %d Minuten.') + :format(aufenthalt.firstname, aufenthalt.lastname, still), + zielTyp = 'intensiv', zielId = aufenthalt.id, + }) + end + end + + -- --- Laborergebnisse ohne Bewertung --- + local befunde = MySQL.query.await([[ + SELECT l.id, l.kennung, l.art, l.auftrag_von, c.firstname, c.lastname + FROM medical_labor l + JOIN aether_characters c ON c.id = l.patient + WHERE l.status = 'fertig' AND l.befund IS NULL + ]]) or {} + + for _, labor in ipairs(befunde) do + if ErstmalsMelden('labor_' .. labor.id, 'fertig') then + Hinweise.Erstelle({ charakterId = labor.auftrag_von }, { + kategorie = 'labor', prioritaet = 'normal', + titel = 'Laborergebnis verfügbar', + text = ('%s (%s) für %s %s — Bewertung steht aus.') + :format(labor.kennung, labor.art, labor.firstname, labor.lastname), + zielTyp = 'labor', zielId = labor.id, + }) + end + end + + -- --- Medikamente: Mindestbestand und Verfall --- + local medikamente = MySQL.query.await([[ + SELECT id, kennung, name, bestand, mindestbestand, einheit, + DATEDIFF(verfaellt_am, CURDATE()) AS tage + FROM medical_medikament + WHERE aktiv = 1 + AND (bestand <= mindestbestand + OR (verfaellt_am IS NOT NULL + AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY))) + ]], { Medizin.Config.Erinnerungen.medikamentVorlaufTage }) or {} + + for _, medikament in ipairs(medikamente) do + local schluessel = 'med_' .. medikament.id + + if medikament.bestand <= medikament.mindestbestand + and ErstmalsMelden(schluessel, 'bestand') then + Hinweise.Erstelle(nil, { + kategorie = 'medikament', + prioritaet = medikament.bestand <= 0 and 'hoch' or 'normal', + titel = medikament.bestand <= 0 and 'Medikament nicht vorrätig' + or 'Medikament fast leer', + text = ('%s — Bestand %d %s (Mindestbestand %d).') + :format(medikament.name, medikament.bestand, + medikament.einheit, medikament.mindestbestand), + zielTyp = 'medikament', zielId = medikament.id, + }) + end + + local tage = tonumber(medikament.tage) + if tage and tage <= Medizin.Config.Erinnerungen.medikamentVorlaufTage + and ErstmalsMelden(schluessel, 'verfall') then + Hinweise.Erstelle(nil, { + kategorie = 'medikament', prioritaet = 'normal', + titel = tage <= 0 and 'Medikament abgelaufen' or 'Medikament läuft ab', + text = ('%s — %s. Entscheidung über die Entnahme trifft die Apotheke.') + :format(medikament.name, + tage <= 0 and 'Verfallsdatum überschritten' + or ('noch ' .. tage .. ' Tage')), + zielTyp = 'medikament', zielId = medikament.id, + }) + end + end + + -- --- Blutkonserven kurz vor Ablauf --- + local konserven = MySQL.query.await([[ + SELECT id, konserve, blutgruppe, + DATEDIFF(verfaellt_am, CURDATE()) AS tage + FROM medical_blut + WHERE status IN ('vorraetig', 'reserviert') + AND verfaellt_am IS NOT NULL + AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY) + ]], { Medizin.Config.Erinnerungen.blutVorlaufTage }) or {} + + for _, konserve in ipairs(konserven) do + local tage = tonumber(konserve.tage) or 0 + if ErstmalsMelden('blut_' .. konserve.id, tage <= 0 and 'abgelaufen' or 'bald') then + Hinweise.Erstelle(nil, { + kategorie = 'blut', + prioritaet = tage <= 0 and 'hoch' or 'normal', + titel = tage <= 0 and 'Blutkonserve abgelaufen' or 'Blutkonserve läuft ab', + text = ('%s (%s) — %s. Verwerfen muss jemand von Hand.') + :format(konserve.konserve, konserve.blutgruppe, + tage <= 0 and 'Ablaufdatum überschritten' + or ('noch ' .. tage .. ' Tage')), + zielTyp = 'blut', zielId = konserve.id, + }) + end + end + + -- --- Blutgruppen mit knappem Bestand --- + -- + -- Erinnert, dass nachbestellt werden sollte. Das System + -- bestellt nichts und gibt nichts frei. + for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do + local vorraetig = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_blut + WHERE blutgruppe = ? AND status = 'vorraetig' + ]], { gruppe })) or 0 + + -- Die Stufe ist der Bestand selbst: jede neue Tiefe meldet sich + -- einmal, ein wieder aufgefüllter Bestand meldet sich nicht. + if vorraetig <= Medizin.Config.Erinnerungen.blutMindestbestand + and ErstmalsMelden('blutbestand_' .. gruppe, tostring(vorraetig)) then + Hinweise.Erstelle(nil, { + kategorie = 'blut', + prioritaet = vorraetig <= 0 and 'hoch' or 'normal', + titel = vorraetig <= 0 and ('Blutgruppe %s nicht vorrätig'):format(gruppe) + or ('Blutgruppe %s wird knapp'):format(gruppe), + text = ('Noch %d Konserve(n) im Bestand (Mindestbestand %d).') + :format(vorraetig, Medizin.Config.Erinnerungen.blutMindestbestand), + }) + end + end + + -- --- Fahrzeuge mit gemeldeten Auffälligkeiten --- + -- + -- Erinnert an das, was der Fahrer gemeldet hat. Kein Hinweis + -- setzt den Status, schickt ein Fahrzeug in die Werkstatt oder + -- nimmt es aus dem Dienst — das trägt ein Mensch ein. + local fahrzeuge = MySQL.query.await([[ + SELECT id, kennzeichen, bezeichnung, tank, zustand + FROM medical_fahrzeug + WHERE status IN ('verfuegbar', 'im_einsatz') + AND gemeldet_am IS NOT NULL + AND gemeldet_am > DATE_SUB(NOW(), INTERVAL 1 HOUR) + ]]) or {} + + for _, fahrzeug in ipairs(fahrzeuge) do + local tank = tonumber(fahrzeug.tank) + local zustand = tonumber(fahrzeug.zustand) + + -- Die Stufe ist ein Zehnerband: so meldet sich ein sinkender + -- Wert höchstens zweimal und nicht bei jedem Prozentpunkt. + if tank and tank <= Medizin.Config.Erinnerungen.fahrzeugTankUnter + and ErstmalsMelden('fahrzeug_' .. fahrzeug.id, + 'tank_' .. tostring(math.floor(tank / 10))) then + Hinweise.Erstelle(nil, { + kategorie = 'system', + prioritaet = tank <= 5 and 'hoch' or 'normal', + titel = ('Tankstand gemeldet · %s'):format(fahrzeug.bezeichnung), + text = ('%s (%s) — zuletzt %d %% gemeldet.') + :format(fahrzeug.bezeichnung, fahrzeug.kennzeichen, math.floor(tank)), + zielTyp = 'fahrzeug', zielId = fahrzeug.id, + }) + end + + if zustand and zustand <= Medizin.Config.Erinnerungen.fahrzeugZustandUnter + and ErstmalsMelden('fahrzeug_' .. fahrzeug.id, + 'zustand_' .. tostring(math.floor(zustand / 10))) then + Hinweise.Erstelle(nil, { + kategorie = 'system', + prioritaet = zustand <= 25 and 'hoch' or 'normal', + titel = ('Fahrzeugschaden gemeldet · %s'):format(fahrzeug.bezeichnung), + text = ('%s (%s) — Zustand zuletzt %d %%. Ob es in die Werkstatt muss, entscheidet die Wartung.') + :format(fahrzeug.bezeichnung, fahrzeug.kennzeichen, math.floor(zustand)), + zielTyp = 'fahrzeug', zielId = fahrzeug.id, + }) + end + end + + -- --- Aushänge mit Kenntnisnahmefrist --- + local fristen = MySQL.query.await([[ + SELECT a.id, a.titel, a.station, + TIMESTAMPDIFF(MINUTE, NOW(), a.frist) AS rest + FROM medical_aushang a + WHERE a.aktiv = 1 AND a.kenntnispflicht = 1 AND a.frist IS NOT NULL + AND a.frist > DATE_SUB(NOW(), INTERVAL 1 DAY) + ]]) or {} + + for _, aushang in ipairs(fristen) do + local rest = tonumber(aushang.rest) or 0 + local stufe = rest <= 0 and 'abgelaufen' + or (rest <= Medizin.Config.Erinnerungen.aushangVorwarnung and 'bald') + or nil + + if stufe and ErstmalsMelden('aushang_' .. aushang.id, stufe) then + -- Nur ansprechen, wer noch nicht bestätigt hat + for _, mitarbeiter in pairs(Dienst.AlleImDienst()) do + local passt = aushang.station == nil or aushang.station == mitarbeiter.station + if passt then + local schon = MySQL.scalar.await([[ + SELECT 1 FROM medical_aushang_kenntnis + WHERE aushang = ? AND `character` = ? + ]], { aushang.id, mitarbeiter.charakterId }) + + if not schon then + Hinweise.Erstelle({ charakterId = mitarbeiter.charakterId }, { + kategorie = 'system', + prioritaet = stufe == 'abgelaufen' and 'hoch' or 'normal', + titel = stufe == 'abgelaufen' + and 'Kenntnisnahme überfällig' or 'Kenntnisnahme offen', + text = ('„%s" — bitte zur Kenntnis nehmen.'):format(aushang.titel), + zielTyp = 'aushang', zielId = aushang.id, + }) + end + end + end + end + end + end +end) + +-- --------------------------------------------------------------------- +-- Aufräumen abgeschlossener Vorgänge +-- --------------------------------------------------------------------- +Kern.Abonniere('aufnahme.entlassen', function(aufnahmeId) + gemeldet['warte_' .. tostring(aufnahmeId)] = nil +end) + +Kern.Abonniere('aufnahme.aufgenommen', function(aufnahmeId) + gemeldet['warte_' .. tostring(aufnahmeId)] = nil +end) + +Kern.Abonniere('operation.abgeschlossen', function(operationId) + gemeldet['op_' .. tostring(operationId)] = nil +end) + +Kern.Abonniere('behandlung.abgeschlossen', function(behandlungId) + gemeldet['doku_' .. tostring(behandlungId)] = nil +end) + +Kern.Abonniere('labor.befundet', function(laborId) + gemeldet['labor_' .. tostring(laborId)] = nil +end) diff --git a/resources/[aether]/aether-medical/server/kern.lua b/resources/[aether]/aether-medical/server/kern.lua new file mode 100644 index 0000000..6cdb2c4 --- /dev/null +++ b/resources/[aether]/aether-medical/server/kern.lua @@ -0,0 +1,241 @@ +-- ===================================================================== +-- aether-medical — Kern +-- +-- Der Kern kennt keine Medizin. Er stellt bereit: +-- * eine Modul-Registry (Plugin-System) +-- * einen Ereignisbus zwischen den Modulen +-- * ein Handler-Register, an das die Oberfläche Anfragen schickt +-- +-- Fachmodule registrieren sich selbst und können jederzeit ergänzt +-- werden, ohne dass der Kern angefasst werden muss. +-- ===================================================================== + +Medizin = Medizin or {} +Kern = {} + +-- Registrierte Fachmodule: [id] = { id, name, icon, recht, reihenfolge, aktiv } +local module = {} +-- Handler, die die Oberfläche aufrufen darf: [modulId][aktion] = funktion +local handler = {} +-- Abonnenten des Ereignisbusses: [ereignis] = { funktion, ... } +local abonnenten = {} + +-- --------------------------------------------------------------------- +-- Modul-Registry +-- --------------------------------------------------------------------- + +--- Registriert ein Fachmodul. +--- @param beschreibung table { +--- id eindeutige Kennung +--- name Anzeigename in der Seitenleiste +--- icon Symbol +--- recht Recht zum Öffnen — Kennung, Liste oder nil (für alle) +--- reihenfolge Sortierung in der Seitenleiste +--- aktiv Standardmäßig aktiv? +--- } +--- @return boolean +function Kern.RegistriereModul(beschreibung) + if type(beschreibung) ~= 'table' or not beschreibung.id then + Aether.Fehler('Modul ohne Kennung kann nicht registriert werden.') + return false + end + + if module[beschreibung.id] then + Aether.Fehler(('Modul "%s" ist bereits registriert.'):format(beschreibung.id)) + return false + end + + module[beschreibung.id] = { + id = beschreibung.id, + name = beschreibung.name or beschreibung.id, + icon = beschreibung.icon or '▪', + recht = beschreibung.recht, + reihenfolge = beschreibung.reihenfolge or 100, + aktiv = beschreibung.aktiv ~= false, + beschreibung = beschreibung.beschreibung, + } + + handler[beschreibung.id] = handler[beschreibung.id] or {} + Aether.Debug(('Medizin-Modul registriert: %s'):format(beschreibung.id)) + return true +end + +--- Registriert einen Handler, den die Oberfläche aufrufen kann. +--- @param modulId string Modul +--- @param aktion string Name der Aktion +--- @param recht string|nil Erforderliches Recht (nil = nur Modulrecht) +--- @param funktion function fun(quelle, mitarbeiter, daten): ergebnis +function Kern.RegistriereAktion(modulId, aktion, recht, funktion) + handler[modulId] = handler[modulId] or {} + handler[modulId][aktion] = { recht = recht, funktion = funktion } +end + +--- Prüft das Öffnungsrecht eines Moduls. +--- `recht` darf eine Kennung oder eine Liste sein — bei einer Liste +--- genügt EIN Recht daraus. Das brauchen Module, die mehrere Bereiche +--- mit je eigenem Recht unter einem Dach zusammenfassen. +--- @param mitarbeiter table Mitarbeiterdaten +--- @param recht string|table|nil Erforderliches Recht +--- @return boolean +function Kern.DarfOeffnen(mitarbeiter, recht) + if not recht then return true end + + if type(recht) == 'table' then + for _, einzeln in ipairs(recht) do + if Rechte.Hat(mitarbeiter, einzeln) then return true end + end + return false + end + + return Rechte.Hat(mitarbeiter, recht) +end + +--- Liefert alle Module, die ein Mitarbeiter sehen darf +--- @param mitarbeiter table Mitarbeiterdaten (mit Rechten) +--- @return table Liste +function Kern.SichtbareModule(mitarbeiter) + local liste = {} + for _, modul in pairs(module) do + if modul.aktiv and Kern.DarfOeffnen(mitarbeiter, modul.recht) then + liste[#liste + 1] = { + id = modul.id, name = modul.name, icon = modul.icon, + reihenfolge = modul.reihenfolge, beschreibung = modul.beschreibung, + } + end + end + table.sort(liste, function(a, b) return a.reihenfolge < b.reihenfolge end) + return liste +end + +--- Liefert alle registrierten Module, auch die abgeschalteten. +--- Nur für die Administration gedacht. +--- @return table Liste +function Kern.AlleModule() + local liste = {} + for _, modul in pairs(module) do + -- Wie viele Aktionen hängen an diesem Modul? + local aktionen = 0 + for _ in pairs(handler[modul.id] or {}) do aktionen = aktionen + 1 end + + liste[#liste + 1] = { + id = modul.id, name = modul.name, icon = modul.icon, + recht = modul.recht, reihenfolge = modul.reihenfolge, + beschreibung = modul.beschreibung, + aktiv = modul.aktiv, aktionen = aktionen, + } + end + table.sort(liste, function(a, b) return a.reihenfolge < b.reihenfolge end) + return liste +end + +--- Gibt es dieses Modul? +--- @param modulId string Modul +--- @return boolean +function Kern.KenntModul(modulId) + return module[modulId] ~= nil +end + +--- Schaltet ein Modul an oder aus (Administration) +--- @param modulId string Modul +--- @param aktiv boolean Neuer Zustand +function Kern.SetzeModulAktiv(modulId, aktiv) + if module[modulId] then + module[modulId].aktiv = aktiv and true or false + end +end + +--- Führt eine Aktion aus — inklusive Rechteprüfung. +--- Wird von der Oberfläche über ein einziges Netz-Event aufgerufen. +--- @param quelle number Server-ID +--- @param mitarbeiter table Mitarbeiterdaten +--- @param modulId string Modul +--- @param aktion string Aktion +--- @param daten table Nutzdaten +--- @return any Ergebnis, string|nil Fehler +function Kern.FuehreAus(quelle, mitarbeiter, modulId, aktion, daten) + local modul = module[modulId] + if not modul or not modul.aktiv then + return nil, 'Dieses Modul ist nicht verfügbar.' + end + + -- Recht auf das Modul selbst + if not Kern.DarfOeffnen(mitarbeiter, modul.recht) then + return nil, 'Dir fehlt die Berechtigung für diesen Bereich.' + end + + local eintrag = handler[modulId] and handler[modulId][aktion] + if not eintrag then + return nil, 'Unbekannte Aktion.' + end + + -- Zusätzliches Recht auf die einzelne Aktion + if eintrag.recht and not Rechte.Hat(mitarbeiter, eintrag.recht) then + return nil, 'Dir fehlt die Berechtigung für diese Aktion.' + end + + local erfolg, ergebnis, fehler = pcall(eintrag.funktion, quelle, mitarbeiter, daten or {}) + if not erfolg then + Aether.Fehler(('Fehler in %s/%s: %s'):format(modulId, aktion, tostring(ergebnis))) + return nil, 'Interner Fehler im Modul.' + end + + return ergebnis, fehler +end + +-- --------------------------------------------------------------------- +-- Ereignisbus zwischen den Modulen +-- +-- Damit kann ein Modul auf Vorgänge eines anderen reagieren, ohne +-- es zu kennen. Ausgelöst wird zusätzlich ein FiveM-Event, damit +-- auch fremde Ressourcen mithören können. +-- --------------------------------------------------------------------- + +--- Abonniert ein Ereignis +--- @param ereignis string Name +--- @param funktion function Rückruf +function Kern.Abonniere(ereignis, funktion) + abonnenten[ereignis] = abonnenten[ereignis] or {} + abonnenten[ereignis][#abonnenten[ereignis] + 1] = funktion +end + +--- Löst ein Ereignis aus +--- @param ereignis string Name +--- @param ... any Nutzdaten +function Kern.Loese(ereignis, ...) + for _, funktion in ipairs(abonnenten[ereignis] or {}) do + local erfolg, fehler = pcall(funktion, ...) + if not erfolg then + Aether.Fehler(('Fehler im Abonnenten von "%s": %s'):format(ereignis, tostring(fehler))) + end + end + + -- Auch nach aussen melden, damit fremde Ressourcen reagieren können + TriggerEvent('aether:medical:' .. ereignis, ...) +end + +-- --------------------------------------------------------------------- +-- Kennungen +-- --------------------------------------------------------------------- + +--- Erzeugt eine fortlaufende Kennung im Format PRAEFIX-JAHR-NNNN +--- @param praefix string z. B. 'BEH' +--- @param tabelle string Tabelle +--- @param spalte string Spalte mit der Kennung +--- @return string +function Kern.ErzeugeKennung(praefix, tabelle, spalte) + local jahr = os.date('%Y') + local muster = ('%s-%s-%%'):format(praefix, jahr) + + local anzahl = MySQL.scalar.await( + ('SELECT COUNT(*) FROM %s WHERE %s LIKE ?'):format(tabelle, spalte), { muster }) or 0 + + -- Bei einer Kollision einfach weiterzählen + for versuch = 1, 50 do + local kandidat = ('%s-%s-%04d'):format(praefix, jahr, anzahl + versuch) + local belegt = MySQL.scalar.await( + ('SELECT 1 FROM %s WHERE %s = ?'):format(tabelle, spalte), { kandidat }) + if not belegt then return kandidat end + end + + return ('%s-%s-%s'):format(praefix, jahr, tostring(os.time()):sub(-5)) +end diff --git a/resources/[aether]/aether-medical/server/main.lua b/resources/[aether]/aether-medical/server/main.lua new file mode 100644 index 0000000..f74fe4c --- /dev/null +++ b/resources/[aether]/aether-medical/server/main.lua @@ -0,0 +1,241 @@ +-- ===================================================================== +-- aether-medical — Server-Schnittstelle +-- +-- Ein einziges Netz-Event bedient alle Module. Jede Anfrage durchläuft +-- Dienstprüfung und Rechteprüfung, bevor sie ein Modul erreicht. +-- ===================================================================== + +local Core = exports['aether-core'] + +--- Baut das Datenpaket für die Oberfläche +--- @param quelle number Server-ID +--- @param mitarbeiter table Mitarbeiter im Dienst +--- @return table +local function BaueTabletDaten(quelle, mitarbeiter) + return { + klinik = Medizin.Config.Klinik, + kuerzel = Medizin.Config.Kuerzel, + module = Kern.SichtbareModule(mitarbeiter), + mitarbeiter = { + name = mitarbeiter.name, + personalnummer = mitarbeiter.personalnummer, + rolle = mitarbeiter.rolle, + rolleName = mitarbeiter.rolleName, + station = mitarbeiter.station, + funktion = mitarbeiter.funktion, + status = mitarbeiter.status, + leitstelle = Dienst.Leitstelle() == quelle, + }, + status = Medizin.Config.Dienststatus, + funktionen = Medizin.Config.Dienstfunktionen, + stationen = Medizin.Config.Stationen, + } +end + +-- --------------------------------------------------------------------- +-- Oberfläche öffnen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:medical:oeffnen', function() + local quelle = source + + local mitarbeiter = Dienst.Hole(quelle) + if not mitarbeiter then + -- Noch nicht im Dienst: Personalakte prüfen und Anmeldung anbieten + local charakterId = Core:HoleCharakterId(quelle) + if not charakterId then return end + + local geprueft, fehler = Dienst.LadeMitarbeiter(quelle, charakterId) + if not geprueft then + TriggerClientEvent('aether:medical:fehler', quelle, fehler) + return + end + + -- Nur Funktionen anbieten, für die auch das Recht vorliegt + local waehlbar = {} + for _, eintrag in ipairs(Medizin.Config.Dienstfunktionen) do + if not eintrag.recht or Rechte.Hat(geprueft, eintrag.recht) then + waehlbar[#waehlbar + 1] = { id = eintrag.id, name = eintrag.name } + end + end + + TriggerClientEvent('aether:medical:anmeldung', quelle, { + name = geprueft.name, + personalnummer = geprueft.personalnummer, + rolleName = geprueft.rolleName, + station = geprueft.station, + klinik = Medizin.Config.Klinik, + funktionen = waehlbar, + }) + return + end + + TriggerClientEvent('aether:medical:zeige', quelle, BaueTabletDaten(quelle, mitarbeiter)) +end) + +-- --------------------------------------------------------------------- +-- Dienst +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:medical:dienstAntreten', function(funktion) + local quelle = source + local mitarbeiter, fehler = Dienst.Antreten(quelle, + type(funktion) == 'string' and funktion or nil) + + if not mitarbeiter then + TriggerClientEvent('aether:medical:fehler', quelle, fehler) + return + end + + TriggerClientEvent('aether:medical:zeige', quelle, BaueTabletDaten(quelle, mitarbeiter)) +end) + +RegisterNetEvent('aether:medical:dienstBeenden', function() + local quelle = source + Dienst.Beenden(quelle) + TriggerClientEvent('aether:medical:schliessen', quelle) +end) + +RegisterNetEvent('aether:medical:status', function(status) + local quelle = source + if type(status) ~= 'string' then return end + Dienst.SetzeStatus(quelle, status) +end) + +RegisterNetEvent('aether:medical:funktion', function(funktion) + local quelle = source + if type(funktion) ~= 'string' then return end + + local erfolg, fehler = Dienst.SetzeFunktion(quelle, funktion) + if not erfolg and fehler then + TriggerClientEvent('aether:medical:fehler', quelle, fehler) + end +end) + +RegisterNetEvent('aether:medical:leitstelle', function(zielQuelle) + local quelle = source + local erfolg, fehler = Dienst.SetzeLeitstelle(quelle, + type(zielQuelle) == 'number' and zielQuelle or nil) + if not erfolg and fehler then + TriggerClientEvent('aether:medical:fehler', quelle, fehler) + end +end) + +-- --------------------------------------------------------------------- +-- Zentraler Modul-Aufruf +-- +-- Die Oberfläche schickt { modul, aktion, daten, ruf }. Der `ruf` ist +-- eine laufende Nummer, damit die Antwort zugeordnet werden kann. +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:medical:aufruf', function(modulId, aktion, daten, ruf) + local quelle = source + + if type(modulId) ~= 'string' or type(aktion) ~= 'string' then return end + + local mitarbeiter = Dienst.Hole(quelle) + if not mitarbeiter then + TriggerClientEvent('aether:medical:antwort', quelle, ruf, nil, + 'Du bist nicht im Dienst.') + return + end + + local ergebnis, fehler = Kern.FuehreAus(quelle, mitarbeiter, modulId, aktion, daten) + TriggerClientEvent('aether:medical:antwort', quelle, ruf, ergebnis, fehler) +end) + +-- --------------------------------------------------------------------- +-- Eingehende Notrufe +-- +-- Sie erzeugen einen Einsatz mit Status „offen". Zugewiesen wird +-- ausschliesslich von der Leitstelle — nie automatisch. +-- +-- `EinsatzEingang` stellt das Einsatzmodul bereit. Solange es nicht +-- geladen ist, wird ein Notruf schlicht nicht angenommen, statt +-- irgendwo ins Leere zu laufen. +-- --------------------------------------------------------------------- + +--- Nimmt einen Notruf entgegen, sofern das Einsatzmodul geladen ist +--- @param daten table Einsatzdaten +--- @return number|nil Einsatz-ID +local function NotrufAnnehmen(daten) + if type(EinsatzEingang) ~= 'function' then + Aether.Fehler('Notruf verworfen: Das Einsatzmodul ist nicht geladen.') + return nil + end + return EinsatzEingang(daten) +end + +--- Notruf aus dem Handy (112) +AddEventHandler('aether:phone:notruf', function(quelle, fraktionId, nummer) + if fraktionId ~= 'lsmc' and fraktionId ~= 'medic' and fraktionId ~= 'ems' then return end + + local position = GetEntityCoords(GetPlayerPed(quelle)) + NotrufAnnehmen({ + titel = 'Notruf über Telefon', + kategorie = 'notruf', + prioritaet = 2, + ort = 'Position des Anrufers', + beschreibung = ('Eingehender Anruf von %s.'):format(nummer or 'unbekannt'), + position = { x = position.x, y = position.y, z = position.z }, + quelle = 'notruf', + }) +end) + +-- ===================================================================== +-- EXPORTS — Schnittstelle für externe Systeme +-- +-- Damit lassen sich Notrufe und Hinweise von aussen einspeisen, ohne +-- dass am Kern etwas geändert werden muss. Keiner dieser Exports +-- behandelt, diagnostiziert oder entlässt jemanden. +-- ===================================================================== + +--- Meldet einen Notfall an die Klinik. +--- Er erscheint als offener Einsatz — zugewiesen wird er nie automatisch. +exports('MeldeNotfall', function(daten) + if type(daten) ~= 'table' or not daten.titel then return nil end + return NotrufAnnehmen(daten) +end) + +--- Erstellt einen Hinweis für alle im Dienst +exports('MeldeHinweis', function(daten) + if type(daten) ~= 'table' or not daten.titel then return false end + Hinweise.Erstelle(nil, daten) + return true +end) + +--- Ist ein Spieler als Mediziner im Dienst? +exports('IstImDienst', function(quelle) + return Dienst.Hole(quelle) ~= nil +end) + +--- Wie viele Mediziner sind gerade im Dienst? +exports('AnzahlImDienst', function() + local anzahl = 0 + for _ in pairs(Dienst.AlleImDienst()) do anzahl = anzahl + 1 end + return anzahl +end) + +--- Liefert die Dienstdaten eines Mitarbeiters (ohne Rechtetabelle) +exports('HoleMitarbeiter', function(quelle) + local mitarbeiter = Dienst.Hole(quelle) + if not mitarbeiter then return nil end + return { + charakterId = mitarbeiter.charakterId, + name = mitarbeiter.name, + personalnummer = mitarbeiter.personalnummer, + rolle = mitarbeiter.rolle, + rolleName = mitarbeiter.rolleName, + station = mitarbeiter.station, + funktion = mitarbeiter.funktion, + status = mitarbeiter.status, + } +end) + +--- Erlaubt fremden Ressourcen, ein eigenes Modul zu registrieren +exports('RegistriereModul', function(beschreibung) + return Kern.RegistriereModul(beschreibung) +end) + +--- Erlaubt fremden Ressourcen, eine Aktion beizusteuern +exports('RegistriereAktion', function(modulId, aktion, recht, funktion) + Kern.RegistriereAktion(modulId, aktion, recht, funktion) + return true +end) diff --git a/resources/[aether]/aether-medical/server/module/behandlung.lua b/resources/[aether]/aether-medical/server/module/behandlung.lua new file mode 100644 index 0000000..4f18103 --- /dev/null +++ b/resources/[aether]/aether-medical/server/module/behandlung.lua @@ -0,0 +1,673 @@ +-- ===================================================================== +-- aether-medical / Modul: Behandlungsverwaltung +-- +-- Das System speichert AUSSCHLIESSLICH die Dokumentation. +-- +-- Es heilt nicht, es diagnostiziert nicht, es schlägt keine Diagnose +-- vor und es leitet aus Symptomen nichts ab. Jedes Feld ist ein +-- Freitext, den ein Mediziner schreibt. +-- +-- Auch die Medikamentengabe wird hier nur DOKUMENTIERT. Das System +-- verabreicht nichts und verordnet nichts — es bucht den Bestand +-- nach, weil ein Mensch etwas entnommen hat. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'behandlung', name = 'Behandlungen', icon = '📋', + reihenfolge = 5, recht = 'behandlung.dokumentieren', + beschreibung = 'Untersuchung, Diagnose, Maßnahmen und Medikamentengaben', +}) + +-- --------------------------------------------------------------------- +-- Grundbestand an Medikamenten +-- +-- Wird einmalig angelegt, damit sich eine Gabe überhaupt dokumentieren +-- lässt. Die Pflege übernimmt später die Medikamentenverwaltung. +-- Die Standarddosierung ist ein VORSCHLAG und nichts weiter. +-- --------------------------------------------------------------------- +local GRUNDMEDIKAMENTE = { + -- kennung, name, wirkstoff, kategorie, dosierung, einheit, bestand, mindest, btm + { 'ANA-01', 'Novalgin', 'Metamizol', 'analgetika', '1 g i.v.', 'Ampulle', 40, 10, 0 }, + { 'ANA-02', 'Morphin 10 mg', 'Morphin', 'analgetika', '2–10 mg i.v.', 'Ampulle', 12, 4, 1 }, + { 'ANA-03', 'Ibuprofen 600', 'Ibuprofen', 'analgetika', '600 mg p.o.', 'Tablette', 90, 20, 0 }, + { 'ANT-01', 'Amoxicillin', 'Amoxicillin', 'antibiotika', '1 g p.o.', 'Tablette', 50, 15, 0 }, + { 'ANT-02', 'Cefuroxim', 'Cefuroxim', 'antibiotika', '1,5 g i.v.', 'Ampulle', 20, 6, 0 }, + { 'KRE-01', 'Adrenalin 1 mg', 'Epinephrin', 'kreislauf', '1 mg i.v.', 'Ampulle', 25, 8, 0 }, + { 'KRE-02', 'Amiodaron', 'Amiodaron', 'kreislauf', '300 mg i.v.', 'Ampulle', 10, 3, 0 }, + { 'KRE-03', 'Acetylsalicylsäure', 'ASS', 'kreislauf', '250 mg i.v.', 'Ampulle', 18, 6, 0 }, + { 'NAR-01', 'Propofol', 'Propofol', 'narkose', 'nach Wirkung', 'Ampulle', 15, 5, 1 }, + { 'NAR-02', 'Midazolam', 'Midazolam', 'narkose', '2–5 mg i.v.', 'Ampulle', 14, 5, 1 }, + { 'NAR-03', 'Ketamin', 'Esketamin', 'narkose', 'nach Wirkung', 'Ampulle', 10, 4, 1 }, + { 'INF-01', 'Ringer-Lactat', '—', 'infusion', '500–1000 ml', 'Beutel', 60, 20, 0 }, + { 'INF-02', 'NaCl 0,9 %', 'Kochsalz', 'infusion', '500 ml', 'Beutel', 80, 25, 0 }, + { 'VER-01', 'Kompressen steril', '—', 'verband', '—', 'Packung', 120, 30, 0 }, + { 'VER-02', 'Verbandpäckchen', '—', 'verband', '—', 'Stück', 100, 25, 0 }, + { 'VER-03', 'Druckverband', '—', 'verband', '—', 'Stück', 40, 12, 0 }, + { 'SON-01', 'Sauerstoff', 'O₂', 'sonstiges', '2–15 l/min', 'Flasche', 16, 5, 0 }, +} + +CreateThread(function() + Wait(2000) + + local vorhanden = tonumber(MySQL.scalar.await('SELECT COUNT(*) FROM medical_medikament')) or 0 + if vorhanden > 0 then return end + + for reihenfolge, eintrag in ipairs(GRUNDMEDIKAMENTE) do + MySQL.insert.await([[ + INSERT INTO medical_medikament + (kennung, name, wirkstoff, kategorie, dosierung_standard, einheit, + bestand, mindestbestand, betaeubungsmittel, lagerort, sortierung) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ]], { + eintrag[1], eintrag[2], eintrag[3], eintrag[4], eintrag[5], eintrag[6], + eintrag[7], eintrag[8], eintrag[9], + eintrag[9] == 1 and 'BTM-Schrank' or 'Apotheke', + reihenfolge, + }) + end + + Aether.Debug('Grundbestand an Medikamenten angelegt.') +end) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Begrenzt einen Text und liefert nil bei leerer Eingabe +local function Text(wert, laenge) + if type(wert) ~= 'string' then return nil end + local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '') + if sauber == '' then return nil end + return sauber:sub(1, laenge) +end + +--- Name eines Mitarbeiters für die Anzeige +local function NameVon(charakterId) + if not charakterId then return 'unbekannt' end + local zeilen = MySQL.query.await([[ + SELECT c.firstname, c.lastname, p.personalnummer + FROM aether_characters c + LEFT JOIN medical_personal p ON p.`character` = c.id + WHERE c.id = ? + ]], { charakterId }) + if not zeilen or not zeilen[1] then return 'unbekannt' end + return zeilen[1].firstname .. ' ' .. zeilen[1].lastname +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('behandlung', 'uebersicht', nil, function(quelle, mitarbeiter, daten) + local ordner = tostring(daten.ordner or 'laufend') + local bedingungen, werte = {}, {} + + if ordner == 'meine' then + bedingungen[#bedingungen + 1] = 'b.erstellt_von = ?' + werte[#werte + 1] = mitarbeiter.charakterId + elseif ordner == 'abgeschlossen' then + bedingungen[#bedingungen + 1] = "b.status = 'abgeschlossen'" + else + bedingungen[#bedingungen + 1] = "b.status = 'laufend'" + end + + if type(daten.begriff) == 'string' and #daten.begriff >= 2 then + bedingungen[#bedingungen + 1] = + '(b.kennung LIKE ? OR b.diagnose LIKE ? OR c.firstname LIKE ? OR c.lastname LIKE ?)' + local muster = '%' .. daten.begriff .. '%' + for _ = 1, 4 do werte[#werte + 1] = muster end + end + + local liste = MySQL.query.await(([[ + SELECT b.id, b.kennung, b.patient, b.aufnahme, b.einsatz, + b.diagnose, b.massnahmen, b.status, + b.erstellt_am, b.erstellt_von, b.abgeschlossen_am, + TIMESTAMPDIFF(MINUTE, b.erstellt_am, NOW()) AS offenSeit, + c.firstname, c.lastname, c.dob, + m.allergien, m.hinweis, + e.nummer AS einsatzNummer, + (SELECT COUNT(*) FROM medical_behandlung_medikament g + WHERE g.behandlung = b.id) AS gaben + FROM medical_behandlung b + JOIN aether_characters c ON c.id = b.patient + LEFT JOIN medical_patient m ON m.`character` = b.patient + LEFT JOIN medical_einsatz e ON e.id = b.einsatz + WHERE %s + ORDER BY b.id DESC + LIMIT 120 + ]]):format(table.concat(bedingungen, ' AND ')), werte) or {} + + for _, eintrag in ipairs(liste) do + eintrag.patientName = eintrag.firstname .. ' ' .. eintrag.lastname + eintrag.geburt = tostring(eintrag.dob):sub(1, 10) + eintrag.autor = NameVon(eintrag.erstellt_von) + eintrag.eigene = eintrag.erstellt_von == mitarbeiter.charakterId + eintrag.gaben = tonumber(eintrag.gaben) or 0 + eintrag.firstname, eintrag.lastname, eintrag.dob = nil, nil, nil + eintrag.erstellt_von = nil + end + + return { + ordner = ordner, + liste = liste, + zaehler = { + laufend = tonumber(MySQL.scalar.await( + "SELECT COUNT(*) FROM medical_behandlung WHERE status = 'laufend'")) or 0, + meine = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_behandlung + WHERE status = 'laufend' AND erstellt_von = ? + ]], { mitarbeiter.charakterId })) or 0, + heute = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_behandlung + WHERE status = 'abgeschlossen' AND DATE(abgeschlossen_am) = CURDATE() + ]])) or 0, + }, + rechte = { + dokumentieren = Rechte.Hat(mitarbeiter, 'behandlung.dokumentieren'), + diagnose = Rechte.Hat(mitarbeiter, 'behandlung.diagnose'), + abschliessen = Rechte.Hat(mitarbeiter, 'behandlung.abschliessen'), + medikament = Rechte.Hat(mitarbeiter, 'medikament.ausgeben'), + btm = Rechte.Hat(mitarbeiter, 'medikament.btm'), + }, + } +end) + +Kern.RegistriereAktion('behandlung', 'oeffnen', nil, function(quelle, mitarbeiter, daten) + local behandlungId = tonumber(daten.id) + if not behandlungId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT b.*, c.firstname, c.lastname, c.dob, + m.blutgruppe, m.allergien, m.vorerkrankungen, m.dauermedikation, + m.hinweis, m.lichtbild, + e.nummer AS einsatzNummer, e.titel AS einsatzTitel, + a.grund AS aufnahmeGrund, a.status AS aufnahmeStatus + FROM medical_behandlung b + JOIN aether_characters c ON c.id = b.patient + LEFT JOIN medical_patient m ON m.`character` = b.patient + LEFT JOIN medical_einsatz e ON e.id = b.einsatz + LEFT JOIN medical_aufnahme a ON a.id = b.aufnahme + WHERE b.id = ? + ]], { behandlungId }) + + if not zeilen or not zeilen[1] then return nil, 'Behandlung nicht gefunden.' end + local b = zeilen[1] + + -- Dokumentierte Medikamentengaben + local gaben = MySQL.query.await([[ + SELECT g.id, g.dosierung, g.menge, g.gegeben_am, g.gegeben_von, g.hinweis, + m.id AS medikamentId, m.name, m.wirkstoff, m.einheit, m.betaeubungsmittel + FROM medical_behandlung_medikament g + JOIN medical_medikament m ON m.id = g.medikament + WHERE g.behandlung = ? + ORDER BY g.id DESC + ]], { behandlungId }) or {} + + for _, gabe in ipairs(gaben) do + gabe.autor = NameVon(gabe.gegeben_von) + gabe.eigene = gabe.gegeben_von == mitarbeiter.charakterId + gabe.btm = gabe.betaeubungsmittel == 1 + gabe.betaeubungsmittel, gabe.gegeben_von = nil, nil + end + + -- Operationen zu diesem Patienten, damit die Doku vollständig wird + local operationen = MySQL.query.await([[ + SELECT id, kennung, art, status, beginn, ende + FROM medical_operation + WHERE patient = ? ORDER BY id DESC LIMIT 10 + ]], { b.patient }) or {} + + -- Laborergebnisse zu diesem Patienten + local labor = MySQL.query.await([[ + SELECT id, kennung, art, status, ergebnis, befund, auftrag_am + FROM medical_labor + WHERE patient = ? ORDER BY id DESC LIMIT 10 + ]], { b.patient }) or {} + + return { + behandlung = { + id = b.id, kennung = b.kennung, patient = b.patient, + patientName = b.firstname .. ' ' .. b.lastname, + geburt = tostring(b.dob):sub(1, 10), + aufnahme = b.aufnahme, aufnahmeGrund = b.aufnahmeGrund, + aufnahmeStatus = b.aufnahmeStatus, + einsatz = b.einsatz, einsatzNummer = b.einsatzNummer, + einsatzTitel = b.einsatzTitel, + symptome = b.symptome, untersuchung = b.untersuchung, + diagnose = b.diagnose, massnahmen = b.massnahmen, + empfehlung = b.empfehlung, nachsorge = b.nachsorge, + status = b.status, + erstelltAm = b.erstellt_am, + erstelltVon = NameVon(b.erstellt_von), + istMeine = b.erstellt_von == mitarbeiter.charakterId, + abgeschlossenAm = b.abgeschlossen_am, + abgeschlossenVon = b.abgeschlossen_von + and NameVon(b.abgeschlossen_von) or nil, + }, + stamm = { + blutgruppe = b.blutgruppe, allergien = b.allergien, + vorerkrankungen = b.vorerkrankungen, dauermedikation = b.dauermedikation, + hinweis = b.hinweis, lichtbild = b.lichtbild, + }, + gaben = gaben, + operationen = operationen, + labor = labor, + rechte = { + dokumentieren = Rechte.Hat(mitarbeiter, 'behandlung.dokumentieren'), + diagnose = Rechte.Hat(mitarbeiter, 'behandlung.diagnose'), + abschliessen = Rechte.Hat(mitarbeiter, 'behandlung.abschliessen'), + medikament = Rechte.Hat(mitarbeiter, 'medikament.ausgeben'), + btm = Rechte.Hat(mitarbeiter, 'medikament.btm'), + bestand = Rechte.Hat(mitarbeiter, 'medikament.bestand'), + }, + } +end) + +-- ===================================================================== +-- ANLEGEN +-- ===================================================================== + +--- Patientensuche mit laufender Aufnahme, damit die Doku andocken kann +Kern.RegistriereAktion('behandlung', 'patientSuche', 'behandlung.dokumentieren', function(quelle, mitarbeiter, daten) + local begriff = type(daten.begriff) == 'string' and daten.begriff or '' + if #begriff < 2 then return { treffer = {} } end + + local muster = '%' .. begriff .. '%' + local treffer = MySQL.query.await([[ + SELECT c.id, c.firstname, c.lastname, c.dob, + m.blutgruppe, m.allergien, m.hinweis, + (SELECT a.id FROM medical_aufnahme a + WHERE a.patient = c.id AND a.status IN ('wartend', 'aufgenommen') + ORDER BY a.id DESC LIMIT 1) AS aufnahme, + (SELECT a.grund FROM medical_aufnahme a + WHERE a.patient = c.id AND a.status IN ('wartend', 'aufgenommen') + ORDER BY a.id DESC LIMIT 1) AS aufnahmeGrund + FROM aether_characters c + LEFT JOIN medical_patient m ON m.`character` = c.id + WHERE c.deleted = 0 + AND (c.firstname LIKE ? OR c.lastname LIKE ? + OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? OR c.dob LIKE ?) + ORDER BY c.lastname, c.firstname + LIMIT 20 + ]], { muster, muster, muster, muster }) or {} + + for _, eintrag in ipairs(treffer) do + eintrag.dob = tostring(eintrag.dob):sub(1, 10) + end + + -- Laufende Einsätze, an denen der Mitarbeiter beteiligt ist + local einsaetze = MySQL.query.await([[ + SELECT e.id, e.nummer, e.titel + FROM medical_einsatz e + JOIN medical_einsatz_kraft k ON k.einsatz = e.id AND k.bis IS NULL + WHERE k.`character` = ? AND e.status <> 'abgeschlossen' + ORDER BY e.id DESC LIMIT 10 + ]], { mitarbeiter.charakterId }) or {} + + return { treffer = treffer, einsaetze = einsaetze } +end) + +Kern.RegistriereAktion('behandlung', 'anlegen', 'behandlung.dokumentieren', function(quelle, mitarbeiter, daten) + local patientId = tonumber(daten.patient) + if not patientId then return nil, 'Kein Patient gewählt.' end + + local person = MySQL.query.await( + 'SELECT firstname, lastname FROM aether_characters WHERE id = ? AND deleted = 0', + { patientId }) + if not person or not person[1] then return nil, 'Diese Person ist nicht erfasst.' end + + -- Aufnahme und Einsatz sind freiwillig, müssen aber existieren + local aufnahmeId = tonumber(daten.aufnahme) + if aufnahmeId then + local gueltig = MySQL.scalar.await( + 'SELECT id FROM medical_aufnahme WHERE id = ? AND patient = ?', + { aufnahmeId, patientId }) + if not gueltig then return nil, 'Diese Aufnahme gehört nicht zu dem Patienten.' end + end + + local einsatzId = tonumber(daten.einsatz) + if einsatzId then + local gueltig = MySQL.scalar.await( + 'SELECT id FROM medical_einsatz WHERE id = ?', { einsatzId }) + if not gueltig then return nil, 'Unbekannter Einsatz.' end + end + + local kennung = Kern.ErzeugeKennung('BEH', 'medical_behandlung', 'kennung') + + local id = MySQL.insert.await([[ + INSERT INTO medical_behandlung + (kennung, patient, aufnahme, einsatz, symptome, untersuchung, status, erstellt_von) + VALUES (?, ?, ?, ?, ?, ?, 'laufend', ?) + ]], { + kennung, patientId, aufnahmeId, einsatzId, + Text(daten.symptome, 4000), + Text(daten.untersuchung, 4000), + mitarbeiter.charakterId, + }) + + Protokoll.Schreibe(mitarbeiter, 'behandlung', 'Behandlung angelegt', + { typ = 'behandlung', id = id }, + { kennung = kennung, patient = patientId, aufnahme = aufnahmeId, einsatz = einsatzId }) + + Kern.Loese('behandlung.angelegt', id, mitarbeiter) + + return { id = id, kennung = kennung } +end) + +-- ===================================================================== +-- DOKUMENTIEREN +-- +-- Die Diagnose ist ein eigenes Recht. Wer sie nicht hat, kann alles +-- andere schreiben — das Feld bleibt dann unverändert. +-- ===================================================================== + +Kern.RegistriereAktion('behandlung', 'speichern', 'behandlung.dokumentieren', function(quelle, mitarbeiter, daten) + local behandlungId = tonumber(daten.id) + if not behandlungId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await( + 'SELECT id, kennung, status, diagnose FROM medical_behandlung WHERE id = ?', + { behandlungId }) + if not zeilen or not zeilen[1] then return nil, 'Behandlung nicht gefunden.' end + + local behandlung = zeilen[1] + if behandlung.status == 'abgeschlossen' then + return nil, 'Eine abgeschlossene Behandlung lässt sich nicht mehr ändern.' + end + + -- Diagnose nur mit dem passenden Recht + local darfDiagnose = Rechte.Hat(mitarbeiter, 'behandlung.diagnose') + local neueDiagnose = darfDiagnose and Text(daten.diagnose, 4000) or behandlung.diagnose + + if not darfDiagnose and Text(daten.diagnose, 4000) ~= nil + and Text(daten.diagnose, 4000) ~= behandlung.diagnose then + return nil, 'Eine Diagnose darfst du nicht eintragen.' + end + + MySQL.update.await([[ + UPDATE medical_behandlung + SET symptome = ?, untersuchung = ?, diagnose = ?, + massnahmen = ?, empfehlung = ?, nachsorge = ? + WHERE id = ? + ]], { + Text(daten.symptome, 4000), + Text(daten.untersuchung, 4000), + neueDiagnose, + Text(daten.massnahmen, 4000), + Text(daten.empfehlung, 4000), + Text(daten.nachsorge, 4000), + behandlungId, + }) + + Protokoll.Schreibe(mitarbeiter, 'behandlung', 'Dokumentation gespeichert', + { typ = 'behandlung', id = behandlungId }, + { kennung = behandlung.kennung, + diagnoseGeaendert = neueDiagnose ~= behandlung.diagnose }) + + return { ok = true } +end) + +Kern.RegistriereAktion('behandlung', 'abschliessen', 'behandlung.abschliessen', function(quelle, mitarbeiter, daten) + local behandlungId = tonumber(daten.id) + if not behandlungId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT id, kennung, status, diagnose, massnahmen, untersuchung + FROM medical_behandlung WHERE id = ? + ]], { behandlungId }) + if not zeilen or not zeilen[1] then return nil, 'Behandlung nicht gefunden.' end + + local behandlung = zeilen[1] + if behandlung.status == 'abgeschlossen' then + return nil, 'Diese Behandlung ist bereits abgeschlossen.' + end + + -- Eine leere Dokumentation abzuschliessen ergibt keinen Sinn. + -- Geprüft wird nur, DASS etwas dasteht — nicht, was. + local leer = not behandlung.untersuchung and not behandlung.massnahmen + and not behandlung.diagnose + if leer then + return nil, 'Die Dokumentation ist leer. Bitte zuerst etwas eintragen.' + end + + MySQL.update.await([[ + UPDATE medical_behandlung + SET status = 'abgeschlossen', abgeschlossen_von = ?, abgeschlossen_am = NOW() + WHERE id = ? + ]], { mitarbeiter.charakterId, behandlungId }) + + Protokoll.Schreibe(mitarbeiter, 'behandlung', 'Behandlung abgeschlossen', + { typ = 'behandlung', id = behandlungId }, + { kennung = behandlung.kennung, ohneDiagnose = behandlung.diagnose == nil }, + Text(daten.vermerk, 255)) + + Kern.Loese('behandlung.abgeschlossen', behandlungId, mitarbeiter) + + return { ok = true, ohneDiagnose = behandlung.diagnose == nil } +end) + +--- Wieder öffnen, falls etwas nachzutragen ist — immer mit Begründung +Kern.RegistriereAktion('behandlung', 'wiedereroeffnen', 'behandlung.abschliessen', function(quelle, mitarbeiter, daten) + local behandlungId = tonumber(daten.id) + if not behandlungId then return nil, 'Ungültige Kennung.' end + + local grund = Text(daten.grund, 255) + if not grund then return nil, 'Bitte begründen, warum die Doku wieder geöffnet wird.' end + + local zeilen = MySQL.query.await( + 'SELECT id, kennung, status FROM medical_behandlung WHERE id = ?', { behandlungId }) + if not zeilen or not zeilen[1] then return nil, 'Behandlung nicht gefunden.' end + if zeilen[1].status ~= 'abgeschlossen' then + return nil, 'Diese Behandlung läuft bereits.' + end + + MySQL.update.await([[ + UPDATE medical_behandlung + SET status = 'laufend', abgeschlossen_von = NULL, abgeschlossen_am = NULL + WHERE id = ? + ]], { behandlungId }) + + Protokoll.Schreibe(mitarbeiter, 'behandlung', 'Behandlung wieder geöffnet', + { typ = 'behandlung', id = behandlungId }, + { kennung = zeilen[1].kennung }, grund) + + return { ok = true } +end) + +-- ===================================================================== +-- MEDIKAMENTENGABE +-- +-- Dokumentation einer Gabe, die ein Mediziner vorgenommen hat. +-- Das System gibt nichts aus und verordnet nichts. +-- ===================================================================== + +--- Auswahlliste der Medikamente +Kern.RegistriereAktion('behandlung', 'medikamente', 'medikament.ausgeben', function(quelle, mitarbeiter, daten) + local medikamente = MySQL.query.await([[ + SELECT id, kennung, name, wirkstoff, kategorie, dosierung_standard, + einheit, bestand, mindestbestand, betaeubungsmittel, hinweis, lagerort, + verfaellt_am, DATEDIFF(verfaellt_am, CURDATE()) AS tageBisVerfall + FROM medical_medikament + WHERE aktiv = 1 + ORDER BY kategorie, sortierung, name + ]]) or {} + + local darfBtm = Rechte.Hat(mitarbeiter, 'medikament.btm') + + for _, eintrag in ipairs(medikamente) do + eintrag.btm = eintrag.betaeubungsmittel == 1 + -- Betäubungsmittel bleiben sichtbar, sind aber ohne Recht nicht wählbar + eintrag.waehlbar = not eintrag.btm or darfBtm + -- Abgelaufene Praeparate werden markiert, aber nicht gesperrt: + -- ob sie verwendbar sind, beurteilt ein Mensch. + local tage = tonumber(eintrag.tageBisVerfall) + eintrag.abgelaufen = tage ~= nil and tage < 0 + eintrag.betaeubungsmittel = nil + end + + return { + medikamente = medikamente, + kategorien = Medizin.Config.Medikamentkategorien, + darfBtm = darfBtm, + } +end) + +Kern.RegistriereAktion('behandlung', 'medikamentGeben', 'medikament.ausgeben', function(quelle, mitarbeiter, daten) + local behandlungId = tonumber(daten.id) + local medikamentId = tonumber(daten.medikament) + local menge = math.floor(tonumber(daten.menge) or 0) + + if not behandlungId or not medikamentId then return nil, 'Unvollständige Angaben.' end + if menge < 1 or menge > 999 then return nil, 'Ungültige Menge.' end + + local behandlung = MySQL.query.await( + 'SELECT id, kennung, patient, status FROM medical_behandlung WHERE id = ?', + { behandlungId }) + if not behandlung or not behandlung[1] then return nil, 'Behandlung nicht gefunden.' end + if behandlung[1].status == 'abgeschlossen' then + return nil, 'Die Behandlung ist abgeschlossen. Bitte zuerst wieder öffnen.' + end + + local zeilen = MySQL.query.await([[ + SELECT id, kennung, name, einheit, bestand, mindestbestand, betaeubungsmittel + FROM medical_medikament WHERE id = ? AND aktiv = 1 + ]], { medikamentId }) + if not zeilen or not zeilen[1] then return nil, 'Unbekanntes Medikament.' end + + local medikament = zeilen[1] + + -- Betäubungsmittel erfordern ein eigenes Recht + if medikament.betaeubungsmittel == 1 and not Rechte.Hat(mitarbeiter, 'medikament.btm') then + return nil, 'Betäubungsmittel darfst du nicht dokumentieren.' + end + + -- Reicht der Bestand? Falls nicht, wird die Gabe trotzdem dokumentiert, + -- aber nur auf ausdrücklichen Wunsch und ohne Bestandsbuchung. + -- Eine Dokumentation darf nie an einer Lagerzahl scheitern. + local ohneBestand = daten.ohneBestand == true + if medikament.bestand < menge and not ohneBestand then + return nil, ('Im Bestand sind nur %d %s. Du kannst die Gabe trotzdem dokumentieren — dann ohne Bestandsbuchung.') + :format(medikament.bestand, medikament.einheit) + end + + local gabeId = MySQL.insert.await([[ + INSERT INTO medical_behandlung_medikament + (behandlung, medikament, dosierung, menge, gegeben_von, hinweis) + VALUES (?, ?, ?, ?, ?, ?) + ]], { + behandlungId, medikamentId, + Text(daten.dosierung, 64), menge, mitarbeiter.charakterId, + ohneBestand + and ((Text(daten.hinweis, 200) or '') .. ' [ohne Bestandsbuchung]') + or Text(daten.hinweis, 255), + }) + + -- Bestand nachführen, weil jemand etwas entnommen hat + if not ohneBestand then + local neuerBestand = medikament.bestand - menge + + MySQL.update.await('UPDATE medical_medikament SET bestand = ? WHERE id = ?', + { neuerBestand, medikamentId }) + + MySQL.insert.await([[ + INSERT INTO medical_medikament_buchung + (medikament, art, menge, bestand_danach, patient, grund, gebucht_von) + VALUES (?, 'ausgabe', ?, ?, ?, ?, ?) + ]], { + medikamentId, menge, neuerBestand, behandlung[1].patient, + 'Gabe zu ' .. behandlung[1].kennung, mitarbeiter.charakterId, + }) + + -- Mindestbestand erreicht? Erinnern, nicht nachbestellen. + if neuerBestand <= medikament.mindestbestand then + Hinweise.Erstelle(nil, { + kategorie = 'medikament', + prioritaet = neuerBestand <= 0 and 'hoch' or 'normal', + titel = neuerBestand <= 0 and 'Medikament nicht mehr vorrätig' + or 'Medikament fast leer', + text = ('%s — noch %d %s.') + :format(medikament.name, neuerBestand, medikament.einheit), + zielTyp = 'medikament', zielId = medikamentId, + }) + end + end + + Protokoll.Schreibe(mitarbeiter, 'behandlung', 'Medikamentengabe dokumentiert', + { typ = 'behandlung', id = behandlungId }, + { medikament = medikament.kennung, menge = menge, + btm = medikament.betaeubungsmittel == 1, ohneBestand = ohneBestand }) + + Kern.Loese('medikament.gegeben', gabeId, medikamentId, mitarbeiter) + + return { id = gabeId } +end) + +--- Fehleintrag zurücknehmen. Der Bestand wird als Korrektur zurückgebucht. +Kern.RegistriereAktion('behandlung', 'medikamentZuruecknehmen', 'medikament.ausgeben', function(quelle, mitarbeiter, daten) + local gabeId = tonumber(daten.gabe) + if not gabeId then return nil, 'Ungültige Kennung.' end + + local grund = Text(daten.grund, 255) + if not grund then return nil, 'Bitte begründen, warum der Eintrag zurückgenommen wird.' end + + local zeilen = MySQL.query.await([[ + SELECT g.id, g.behandlung, g.medikament, g.menge, g.gegeben_von, g.hinweis, + m.name, m.bestand, m.einheit, b.status + FROM medical_behandlung_medikament g + JOIN medical_medikament m ON m.id = g.medikament + JOIN medical_behandlung b ON b.id = g.behandlung + WHERE g.id = ? + ]], { gabeId }) + if not zeilen or not zeilen[1] then return nil, 'Eintrag nicht gefunden.' end + + local gabe = zeilen[1] + if gabe.status == 'abgeschlossen' then + return nil, 'Die Behandlung ist abgeschlossen. Bitte zuerst wieder öffnen.' + end + + -- Nur der eigene Eintrag, ausser man verwaltet den Bestand + if gabe.gegeben_von ~= mitarbeiter.charakterId + and not Rechte.Hat(mitarbeiter, 'medikament.bestand') then + return nil, 'Du kannst nur eigene Einträge zurücknehmen.' + end + + MySQL.query.await('DELETE FROM medical_behandlung_medikament WHERE id = ?', { gabeId }) + + -- Nur zurückbuchen, wenn seinerzeit auch gebucht wurde + local wurdeGebucht = not (gabe.hinweis and gabe.hinweis:find('ohne Bestandsbuchung', 1, true)) + + if wurdeGebucht then + local neuerBestand = gabe.bestand + gabe.menge + + MySQL.update.await('UPDATE medical_medikament SET bestand = ? WHERE id = ?', + { neuerBestand, gabe.medikament }) + + MySQL.insert.await([[ + INSERT INTO medical_medikament_buchung + (medikament, art, menge, bestand_danach, grund, gebucht_von) + VALUES (?, 'korrektur', ?, ?, ?, ?) + ]], { gabe.medikament, gabe.menge, neuerBestand, + 'Rücknahme: ' .. grund, mitarbeiter.charakterId }) + end + + Protokoll.Schreibe(mitarbeiter, 'behandlung', 'Medikamentengabe zurückgenommen', + { typ = 'behandlung', id = gabe.behandlung }, + { medikament = gabe.name, menge = gabe.menge, zurueckgebucht = wurdeGebucht }, grund) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Zählt offene Dokumentationen eines Mitarbeiters +--- @param charakterId number Charakter +--- @return number +exports('OffeneDokumentationen', function(charakterId) + if not tonumber(charakterId) then return 0 end + return tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_behandlung + WHERE status = 'laufend' AND erstellt_von = ? + ]], { tonumber(charakterId) })) or 0 +end) diff --git a/resources/[aether]/aether-medical/server/module/bericht.lua b/resources/[aether]/aether-medical/server/module/bericht.lua new file mode 100644 index 0000000..5dc0ff0 --- /dev/null +++ b/resources/[aether]/aether-medical/server/module/bericht.lua @@ -0,0 +1,782 @@ +-- ===================================================================== +-- aether-medical / Modul: Berichte +-- +-- Ein Bericht ist ein Dokument mit einer Unterschrift darunter. +-- +-- Das System schreibt keinen Satz davon. Es formuliert nichts vor, +-- fasst nichts zusammen und leitet aus keiner Behandlung selbst eine +-- Diagnose ab. Wer Text aus einer Behandlung übernehmen will, löst +-- das ausdrücklich aus — und was dann im Bericht steht, verantwortet +-- er mit seinem Namen. +-- +-- Der Weg vom Entwurf zur Freigabe wird Schritt für Schritt von +-- Menschen gegangen. Kein Bericht reift von selbst, keine Frist gibt +-- ihn frei, und ein freigegebener Bericht wird nie wieder geändert. +-- ===================================================================== + +-- Lesen darf, wer Akten lesen darf. Schreiben und Freigeben verlangen +-- die jeweils eigenen Rechte. +Kern.RegistriereModul({ + id = 'bericht', name = 'Berichte', icon = '📄', + reihenfolge = 14, recht = 'akte.lesen', + beschreibung = 'Berichtsentwürfe, Freigabe und Unterschriften', +}) + +-- Die inhaltlichen Felder eines Berichts, in der Reihenfolge des Bogens +local FELDER = { + { id = 'symptome', name = 'Symptome und Schilderung' }, + { id = 'untersuchung', name = 'Untersuchungsbefund' }, + { id = 'diagnose', name = 'Diagnose' }, + { id = 'massnahmen', name = 'Maßnahmen' }, + { id = 'empfehlung', name = 'Empfehlung' }, +} + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Begrenzt einen Text und liefert nil bei leerer Eingabe +--- @param wert any Eingabe +--- @param laenge number Maximale Zeichenzahl +--- @return string|nil +local function Text(wert, laenge) + if type(wert) ~= 'string' then return nil end + local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '') + if sauber == '' then return nil end + return sauber:sub(1, laenge) +end + +--- Name eines Charakters für die Anzeige +--- @param charakterId number|nil Charakter +--- @return string +local function NameVon(charakterId) + if not charakterId then return 'unbekannt' end + local zeilen = MySQL.query.await( + 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId }) + if not zeilen or not zeilen[1] then return 'unbekannt' end + return zeilen[1].firstname .. ' ' .. zeilen[1].lastname +end + +--- Liest eine JSON-Liste aus der Datenbank +--- @param roh string|nil JSON +--- @return table Liste +local function AlsListe(roh) + if not roh then return {} end + local erfolg, liste = pcall(json.decode, roh) + if erfolg and type(liste) == 'table' then return liste end + return {} +end + +--- Zeitstempel für Einträge in JSON-Feldern +--- @return string +local function Jetzt() + return os.date('%Y-%m-%d %H:%M:%S') +end + +--- Baut einen Unterschrifteneintrag +--- @param mitarbeiter table Unterzeichnender +--- @param art string verfasser/freigabe/gegenzeichnung +--- @return table +local function Unterschrift(mitarbeiter, art) + return { + charakterId = mitarbeiter.charakterId, + name = mitarbeiter.name, + personalnummer = mitarbeiter.personalnummer, + rolle = mitarbeiter.rolleName, + art = art, + am = Jetzt(), + } +end + +--- Prüft eine Adresse für eine Anlage +--- @param wert any Eingabe +--- @return string|nil +local function Adresse(wert) + local roh = Text(wert, 500) + if not roh then return nil end + if not roh:match('^https?://') then return nil end + return roh +end + +--- Lädt einen Bericht mit seinen Begleitdaten +--- @param berichtId number Bericht +--- @return table|nil +local function LadeBericht(berichtId) + local zeilen = MySQL.query.await([[ + SELECT b.*, + c.firstname, c.lastname, c.dob, + beh.kennung AS behandlungKennung, + ein.nummer AS einsatzNummer, ein.titel AS einsatzTitel + FROM medical_bericht b + LEFT JOIN aether_characters c ON c.id = b.patient + LEFT JOIN medical_behandlung beh ON beh.id = b.behandlung + LEFT JOIN medical_einsatz ein ON ein.id = b.einsatz + WHERE b.id = ? + ]], { berichtId }) + return zeilen and zeilen[1] or nil +end + +--- Darf dieser Mitarbeiter den Inhalt ändern? +--- Nur der Verfasser, und nur solange der Bericht ein Entwurf ist. +--- @param eintrag table Berichtszeile +--- @param mitarbeiter table Handelnder +--- @return boolean, string|nil Grund der Ablehnung +local function DarfAendern(eintrag, mitarbeiter) + if eintrag.status == 'freigegeben' then + return false, 'Ein freigegebener Bericht wird nicht mehr geändert.' + end + if eintrag.status == 'eingereicht' then + return false, 'Der Bericht liegt zur Freigabe vor. Dafür erst zurückziehen.' + end + if eintrag.erstellt_von ~= mitarbeiter.charakterId then + return false, 'Diesen Entwurf hat jemand anderes geschrieben.' + end + return true, nil +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('bericht', 'uebersicht', nil, function(quelle, mitarbeiter, daten) + local ordner = tostring(daten.ordner or 'meine') + local ich = mitarbeiter.charakterId + + local bedingung, werte + if ordner == 'freigabe' then + bedingung = "b.status = 'eingereicht'" + werte = {} + elseif ordner == 'freigegeben' then + bedingung = "b.status = 'freigegeben'" + werte = {} + elseif ordner == 'alle' then + bedingung = '1 = 1' + werte = {} + else + -- Eigene Entwürfe: was noch niemand ausser dem Verfasser gesehen hat + bedingung = "b.status = 'entwurf' AND b.erstellt_von = ?" + werte = { ich } + end + + -- Freitextsuche über Nummer, Titel und Patientenname + local begriff = Text(daten.suche, 60) + if begriff then + local muster = '%' .. begriff .. '%' + bedingung = ('(%s) AND (b.nummer LIKE ? OR b.titel LIKE ? OR CONCAT(c.firstname, " ", c.lastname) LIKE ?)') + :format(bedingung) + werte[#werte + 1] = muster + werte[#werte + 1] = muster + werte[#werte + 1] = muster + end + + local liste = MySQL.query.await(([[ + SELECT b.id, b.nummer, b.titel, b.patient, b.behandlung, b.einsatz, + b.status, b.erstellt_von, b.erstellt_am, b.diagnose, + b.unterschriften, + c.firstname, c.lastname, + beh.kennung AS behandlungKennung, + ein.nummer AS einsatzNummer + FROM medical_bericht b + LEFT JOIN aether_characters c ON c.id = b.patient + LEFT JOIN medical_behandlung beh ON beh.id = b.behandlung + LEFT JOIN medical_einsatz ein ON ein.id = b.einsatz + WHERE %s + ORDER BY b.id DESC + LIMIT 150 + ]]):format(bedingung), werte) or {} + + for _, eintrag in ipairs(liste) do + eintrag.patientName = eintrag.firstname + and (eintrag.firstname .. ' ' .. eintrag.lastname) or nil + eintrag.autor = NameVon(eintrag.erstellt_von) + eintrag.vonMir = eintrag.erstellt_von == ich + eintrag.unterschriften = #AlsListe(eintrag.unterschriften) + -- Nur ein Hinweis darauf, ob überhaupt eine Diagnose im Bericht steht + eintrag.hatDiagnose = eintrag.diagnose ~= nil and eintrag.diagnose ~= '' + eintrag.firstname, eintrag.lastname = nil, nil + eintrag.erstellt_von, eintrag.diagnose = nil, nil + end + + local function Zaehle(sql, parameter) + return tonumber(MySQL.scalar.await(sql, parameter)) or 0 + end + + local zaehler = { + meine = Zaehle([[ + SELECT COUNT(*) FROM medical_bericht + WHERE status = 'entwurf' AND erstellt_von = ? + ]], { ich }), + freigabe = Zaehle("SELECT COUNT(*) FROM medical_bericht WHERE status = 'eingereicht'"), + freigegeben = Zaehle([[ + SELECT COUNT(*) FROM medical_bericht + WHERE status = 'freigegeben' AND DATE(erstellt_am) = CURDATE() + ]]), + -- Eigene Berichte, die noch bei der Freigabe liegen + meineOffen = Zaehle([[ + SELECT COUNT(*) FROM medical_bericht + WHERE status = 'eingereicht' AND erstellt_von = ? + ]], { ich }), + } + + return { + ordner = ordner, + liste = liste, + zaehler = zaehler, + status = Medizin.Config.Berichtstatus, + felder = FELDER, + rechte = { + erstellen = Rechte.Hat(mitarbeiter, 'bericht.erstellen'), + freigeben = Rechte.Hat(mitarbeiter, 'bericht.freigeben'), + }, + } +end) + +-- ===================================================================== +-- EINZELNER BERICHT +-- ===================================================================== + +Kern.RegistriereAktion('bericht', 'oeffnen', nil, function(quelle, mitarbeiter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local eintrag = LadeBericht(berichtId) + if not eintrag then return nil, 'Bericht nicht gefunden.' end + + -- Ein fremder Entwurf ist noch niemandes Dokument ausser dem des Verfassers + if eintrag.status == 'entwurf' and eintrag.erstellt_von ~= mitarbeiter.charakterId then + return nil, 'Dieser Entwurf ist noch nicht eingereicht.' + end + + -- Schreiben darf nur, wer den Entwurf verfasst hat und das Recht dazu hat + local aenderbar = DarfAendern(eintrag, mitarbeiter) + local darfAendern = aenderbar and Rechte.Hat(mitarbeiter, 'bericht.erstellen') + + local unterschriften = AlsListe(eintrag.unterschriften) + local anlagen = AlsListe(eintrag.anlagen) + + -- Steht Verfasser und Freigabe in einer Person? Das wird gezeigt, + -- nicht verhindert. + local selbstFreigabe = false + for _, zeile in ipairs(unterschriften) do + if zeile.art == 'freigabe' and zeile.charakterId == eintrag.erstellt_von then + selbstFreigabe = true + end + end + + local spur = Protokoll.Lies({ zielTyp = 'bericht', zielId = berichtId, limit = 40 }) + for _, zeile in ipairs(spur) do + zeile.autor = zeile.firstname + and (zeile.firstname .. ' ' .. zeile.lastname) or 'System' + zeile.firstname, zeile.lastname, zeile.details = nil, nil, nil + end + + return { + bericht = { + id = eintrag.id, nummer = eintrag.nummer, titel = eintrag.titel, + status = eintrag.status, + patient = eintrag.patient, + patientName = eintrag.firstname + and (eintrag.firstname .. ' ' .. eintrag.lastname) or nil, + patientGeburt = eintrag.dob and tostring(eintrag.dob):sub(1, 10) or nil, + behandlung = eintrag.behandlung, + behandlungKennung = eintrag.behandlungKennung, + einsatz = eintrag.einsatz, + einsatzNummer = eintrag.einsatzNummer, + einsatzTitel = eintrag.einsatzTitel, + symptome = eintrag.symptome, untersuchung = eintrag.untersuchung, + diagnose = eintrag.diagnose, massnahmen = eintrag.massnahmen, + empfehlung = eintrag.empfehlung, + autor = NameVon(eintrag.erstellt_von), + vonMir = eintrag.erstellt_von == mitarbeiter.charakterId, + erstelltAm = eintrag.erstellt_am, + selbstFreigabe = selbstFreigabe, + }, + unterschriften = unterschriften, + anlagen = anlagen, + spur = spur, + felder = FELDER, + status = Medizin.Config.Berichtstatus, + rechte = { + erstellen = Rechte.Hat(mitarbeiter, 'bericht.erstellen'), + freigeben = Rechte.Hat(mitarbeiter, 'bericht.freigeben'), + aendern = darfAendern, + }, + } +end) + +-- ===================================================================== +-- ANLEGEN UND SCHREIBEN +-- ===================================================================== + +--- Patientensuche und die Vorgänge, an die ein Bericht hängen kann +Kern.RegistriereAktion('bericht', 'quellen', 'bericht.erstellen', function(quelle, mitarbeiter, daten) + local begriff = type(daten.begriff) == 'string' and daten.begriff or '' + local patientId = tonumber(daten.patient) + + local treffer = {} + if #begriff >= 2 then + local muster = '%' .. begriff .. '%' + treffer = MySQL.query.await([[ + SELECT c.id, c.firstname, c.lastname, c.dob, p.blutgruppe + FROM aether_characters c + LEFT JOIN medical_patient p ON p.`character` = c.id + WHERE c.deleted = 0 + AND (c.firstname LIKE ? OR c.lastname LIKE ? + OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? OR c.dob LIKE ?) + ORDER BY c.lastname, c.firstname + LIMIT 20 + ]], { muster, muster, muster, muster }) or {} + + for _, eintrag in ipairs(treffer) do + eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname + eintrag.dob = tostring(eintrag.dob):sub(1, 10) + eintrag.firstname, eintrag.lastname = nil, nil + end + end + + -- Behandlungen und Einsätze des gewählten Patienten + local behandlungen, einsaetze = {}, {} + if patientId then + behandlungen = MySQL.query.await([[ + SELECT id, kennung, diagnose, status, erstellt_am + FROM medical_behandlung + WHERE patient = ? + ORDER BY id DESC LIMIT 25 + ]], { patientId }) or {} + + einsaetze = MySQL.query.await([[ + SELECT e.id, e.nummer, e.titel, e.kategorie, e.erstellt_am + FROM medical_einsatz e + WHERE EXISTS (SELECT 1 FROM medical_behandlung b + WHERE b.einsatz = e.id AND b.patient = ?) + ORDER BY e.id DESC LIMIT 15 + ]], { patientId }) or {} + end + + return { treffer = treffer, behandlungen = behandlungen, einsaetze = einsaetze } +end) + +Kern.RegistriereAktion('bericht', 'anlegen', 'bericht.erstellen', function(quelle, mitarbeiter, daten) + local titel = Text(daten.titel, 160) + if not titel then return nil, 'Bitte einen Titel angeben.' end + + -- Patient, Behandlung und Einsatz sind freiwillig — nicht jeder + -- Bericht gehört zu einem einzelnen Vorgang + local patientId = tonumber(daten.patient) + if patientId then + local person = MySQL.scalar.await( + 'SELECT id FROM aether_characters WHERE id = ? AND deleted = 0', { patientId }) + if not person then return nil, 'Diese Person ist nicht erfasst.' end + end + + local behandlungId = tonumber(daten.behandlung) + if behandlungId then + local behandlung = MySQL.query.await( + 'SELECT id, patient FROM medical_behandlung WHERE id = ?', { behandlungId }) + if not behandlung or not behandlung[1] then return nil, 'Unbekannte Behandlung.' end + -- Die Behandlung bestimmt den Patienten, wenn keiner gewählt wurde + patientId = patientId or behandlung[1].patient + end + + local einsatzId = tonumber(daten.einsatz) + if einsatzId then + local einsatz = MySQL.scalar.await( + 'SELECT id FROM medical_einsatz WHERE id = ?', { einsatzId }) + if not einsatz then return nil, 'Unbekannter Einsatz.' end + end + + local nummer = Kern.ErzeugeKennung('BER', 'medical_bericht', 'nummer') + + local berichtId = MySQL.insert.await([[ + INSERT INTO medical_bericht + (nummer, patient, behandlung, einsatz, titel, status, erstellt_von) + VALUES (?, ?, ?, ?, ?, 'entwurf', ?) + ]], { nummer, patientId, behandlungId, einsatzId, titel, mitarbeiter.charakterId }) + + Protokoll.Schreibe(mitarbeiter, 'bericht', 'Bericht angelegt', + { typ = 'bericht', id = berichtId }, + { nummer = nummer, titel = titel, patient = patientId, + behandlung = behandlungId, einsatz = einsatzId }) + + Kern.Loese('bericht.angelegt', berichtId, mitarbeiter) + + return { id = berichtId, nummer = nummer } +end) + +Kern.RegistriereAktion('bericht', 'speichern', 'bericht.erstellen', function(quelle, mitarbeiter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local eintrag = LadeBericht(berichtId) + if not eintrag then return nil, 'Bericht nicht gefunden.' end + + local darf, fehler = DarfAendern(eintrag, mitarbeiter) + if not darf then return nil, fehler end + + local titel = Text(daten.titel, 160) or eintrag.titel + + MySQL.update.await([[ + UPDATE medical_bericht + SET titel = ?, symptome = ?, untersuchung = ?, diagnose = ?, + massnahmen = ?, empfehlung = ? + WHERE id = ? + ]], { + titel, + Text(daten.symptome, 4000), + Text(daten.untersuchung, 4000), + Text(daten.diagnose, 4000), + Text(daten.massnahmen, 4000), + Text(daten.empfehlung, 4000), + berichtId, + }) + + Protokoll.Schreibe(mitarbeiter, 'bericht', 'Entwurf gespeichert', + { typ = 'bericht', id = berichtId }, { nummer = eintrag.nummer }) + + return { ok = true } +end) + +--- Übernimmt den Wortlaut einer Behandlung in den Entwurf. +--- +--- Das ist ein Kopiervorgang, den ein Mensch auslöst — das System +--- fasst nichts zusammen und formuliert nichts um. Vorhandener Text +--- im Bericht wird nicht überschrieben, sondern bleibt stehen. +Kern.RegistriereAktion('bericht', 'uebernehmen', 'bericht.erstellen', function(quelle, mitarbeiter, daten) + local berichtId = tonumber(daten.id) + local behandlungId = tonumber(daten.behandlung) + + if not berichtId then return nil, 'Ungültige Kennung.' end + if not behandlungId then return nil, 'Keine Behandlung gewählt.' end + + local eintrag = LadeBericht(berichtId) + if not eintrag then return nil, 'Bericht nicht gefunden.' end + + local darf, fehler = DarfAendern(eintrag, mitarbeiter) + if not darf then return nil, fehler end + + local zeilen = MySQL.query.await([[ + SELECT id, kennung, patient, symptome, untersuchung, diagnose, + massnahmen, empfehlung + FROM medical_behandlung WHERE id = ? + ]], { behandlungId }) + if not zeilen or not zeilen[1] then return nil, 'Behandlung nicht gefunden.' end + + local behandlung = zeilen[1] + + -- Nur leere Felder füllen: was der Verfasser geschrieben hat, bleibt + local uebernommen = {} + local werte = {} + + for _, feld in ipairs(FELDER) do + local vorhanden = Text(eintrag[feld.id], 4000) + local quelle_ = Text(behandlung[feld.id], 4000) + + if not vorhanden and quelle_ then + werte[feld.id] = quelle_ + uebernommen[#uebernommen + 1] = feld.name + else + werte[feld.id] = vorhanden + end + end + + if #uebernommen == 0 then + return nil, 'Es gibt nichts zu übernehmen — alle Felder sind bereits gefüllt oder die Behandlung ist leer.' + end + + MySQL.update.await([[ + UPDATE medical_bericht + SET symptome = ?, untersuchung = ?, diagnose = ?, massnahmen = ?, + empfehlung = ?, behandlung = COALESCE(behandlung, ?), + patient = COALESCE(patient, ?) + WHERE id = ? + ]], { werte.symptome, werte.untersuchung, werte.diagnose, werte.massnahmen, + werte.empfehlung, behandlungId, behandlung.patient, berichtId }) + + Protokoll.Schreibe(mitarbeiter, 'bericht', 'Wortlaut aus Behandlung übernommen', + { typ = 'bericht', id = berichtId }, + { nummer = eintrag.nummer, behandlung = behandlung.kennung, + felder = uebernommen }) + + return { ok = true, uebernommen = uebernommen } +end) + +-- ===================================================================== +-- ANLAGEN +-- ===================================================================== + +Kern.RegistriereAktion('bericht', 'anlage', 'bericht.erstellen', function(quelle, mitarbeiter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local eintrag = LadeBericht(berichtId) + if not eintrag then return nil, 'Bericht nicht gefunden.' end + + local darf, fehler = DarfAendern(eintrag, mitarbeiter) + if not darf then return nil, fehler end + + local anlagen = AlsListe(eintrag.anlagen) + local entfernen = tonumber(daten.entfernen) + + if entfernen then + -- Entfernen ist nur im Entwurf möglich; ab der Einreichung + -- gehört die Anlage zum Dokument + if entfernen < 1 or entfernen > #anlagen then + return nil, 'Diese Anlage gibt es nicht.' + end + + local weg = table.remove(anlagen, entfernen) + + Protokoll.Schreibe(mitarbeiter, 'bericht', 'Anlage entfernt', + { typ = 'bericht', id = berichtId }, + { nummer = eintrag.nummer, titel = weg and weg.titel or nil }) + else + local titel = Text(daten.titel, 120) + local adresse = Adresse(daten.adresse) + + if not titel then return nil, 'Bitte die Anlage benennen.' end + if not adresse then return nil, 'Die Adresse muss mit http:// oder https:// beginnen.' end + if #anlagen >= 12 then return nil, 'Mehr als zwölf Anlagen sind nicht vorgesehen.' end + + anlagen[#anlagen + 1] = { + titel = titel, adresse = adresse, + von = mitarbeiter.name, am = Jetzt(), + } + + Protokoll.Schreibe(mitarbeiter, 'bericht', 'Anlage hinzugefügt', + { typ = 'bericht', id = berichtId }, + { nummer = eintrag.nummer, titel = titel }) + end + + MySQL.update.await('UPDATE medical_bericht SET anlagen = ? WHERE id = ?', + { #anlagen > 0 and json.encode(anlagen) or nil, berichtId }) + + return { ok = true, anzahl = #anlagen } +end) + +-- ===================================================================== +-- EINREICHEN UND ZURÜCKZIEHEN +-- ===================================================================== + +Kern.RegistriereAktion('bericht', 'einreichen', 'bericht.erstellen', function(quelle, mitarbeiter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local eintrag = LadeBericht(berichtId) + if not eintrag then return nil, 'Bericht nicht gefunden.' end + + local darf, fehler = DarfAendern(eintrag, mitarbeiter) + if not darf then return nil, fehler end + + -- Ein leerer Bericht ist kein Bericht. Welche Felder fehlen, wird + -- nur benannt — die Einreichung hält das nicht auf. + local gefuellt, leer = 0, {} + for _, feld in ipairs(FELDER) do + if Text(eintrag[feld.id], 4000) then gefuellt = gefuellt + 1 + else leer[#leer + 1] = feld.name end + end + + if gefuellt == 0 then + return nil, 'Der Bericht enthält noch keinen Text.' + end + + local unterschriften = AlsListe(eintrag.unterschriften) + unterschriften[#unterschriften + 1] = Unterschrift(mitarbeiter, 'verfasser') + + MySQL.update.await([[ + UPDATE medical_bericht SET status = 'eingereicht', unterschriften = ? + WHERE id = ? + ]], { json.encode(unterschriften), berichtId }) + + Protokoll.Schreibe(mitarbeiter, 'bericht', 'Bericht eingereicht', + { typ = 'bericht', id = berichtId }, + { nummer = eintrag.nummer, leereFelder = leer }) + + -- Wer freigeben darf, erfährt davon + for _, person in pairs(Dienst.AlleImDienst()) do + if Rechte.Hat(person, 'bericht.freigeben') + and person.charakterId ~= mitarbeiter.charakterId then + Hinweise.Erstelle({ charakterId = person.charakterId }, { + kategorie = 'doku', prioritaet = 'normal', + titel = 'Bericht liegt zur Freigabe', + text = ('%s — %s, von %s.') + :format(eintrag.nummer, eintrag.titel, mitarbeiter.name), + zielTyp = 'bericht', zielId = berichtId, + }) + end + end + + Kern.Loese('bericht.eingereicht', berichtId, mitarbeiter) + + return { ok = true, leereFelder = leer } +end) + +Kern.RegistriereAktion('bericht', 'zurueckziehen', 'bericht.erstellen', function(quelle, mitarbeiter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local grund = Text(daten.grund, 255) + if not grund then return nil, 'Bitte angeben, warum der Bericht zurückgeht.' end + + local eintrag = LadeBericht(berichtId) + if not eintrag then return nil, 'Bericht nicht gefunden.' end + + if eintrag.status ~= 'eingereicht' then + return nil, 'Nur ein eingereichter Bericht lässt sich zurückziehen.' + end + if eintrag.erstellt_von ~= mitarbeiter.charakterId then + return nil, 'Zurückziehen kann nur, wer den Bericht geschrieben hat.' + end + + -- Die geleistete Unterschrift bleibt stehen: sie wurde geleistet. + -- Dass der Bericht danach wieder geändert wurde, steht im Protokoll. + MySQL.update.await("UPDATE medical_bericht SET status = 'entwurf' WHERE id = ?", + { berichtId }) + + Protokoll.Schreibe(mitarbeiter, 'bericht', 'Bericht zurückgezogen', + { typ = 'bericht', id = berichtId }, { nummer = eintrag.nummer }, grund) + + Kern.Loese('bericht.zurueckgezogen', berichtId, mitarbeiter) + + return { ok = true } +end) + +-- ===================================================================== +-- FREIGABE +-- +-- Die Freigabe ist eine Unterschrift. Sie geschieht nie automatisch, +-- nie nach Ablauf einer Frist und nie durch das System. +-- ===================================================================== + +Kern.RegistriereAktion('bericht', 'freigeben', 'bericht.freigeben', function(quelle, mitarbeiter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local eintrag = LadeBericht(berichtId) + if not eintrag then return nil, 'Bericht nicht gefunden.' end + + if eintrag.status ~= 'eingereicht' then + return nil, 'Nur ein eingereichter Bericht wird freigegeben.' + end + + local unterschriften = AlsListe(eintrag.unterschriften) + unterschriften[#unterschriften + 1] = Unterschrift(mitarbeiter, 'freigabe') + + -- Verfasser und Freigabe in einer Person ist möglich und wird + -- festgehalten — verhindert wird es nicht. + local selbstFreigabe = eintrag.erstellt_von == mitarbeiter.charakterId + + MySQL.update.await([[ + UPDATE medical_bericht SET status = 'freigegeben', unterschriften = ? + WHERE id = ? + ]], { json.encode(unterschriften), berichtId }) + + Protokoll.Schreibe(mitarbeiter, 'bericht', 'Bericht freigegeben', + { typ = 'bericht', id = berichtId }, + { nummer = eintrag.nummer, selbstFreigabe = selbstFreigabe }, + Text(daten.vermerk, 255)) + + if not selbstFreigabe then + Hinweise.Erstelle({ charakterId = eintrag.erstellt_von }, { + kategorie = 'doku', prioritaet = 'normal', + titel = 'Dein Bericht wurde freigegeben', + text = ('%s — %s, freigegeben von %s.') + :format(eintrag.nummer, eintrag.titel, mitarbeiter.name), + zielTyp = 'bericht', zielId = berichtId, + }) + end + + Kern.Loese('bericht.freigegeben', berichtId, mitarbeiter) + + return { ok = true, selbstFreigabe = selbstFreigabe } +end) + +Kern.RegistriereAktion('bericht', 'zurueckweisen', 'bericht.freigeben', function(quelle, mitarbeiter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local grund = Text(daten.grund, 500) + if not grund or #grund < 5 then + return nil, 'Eine Zurückweisung braucht eine Begründung.' + end + + local eintrag = LadeBericht(berichtId) + if not eintrag then return nil, 'Bericht nicht gefunden.' end + + if eintrag.status ~= 'eingereicht' then + return nil, 'Nur ein eingereichter Bericht lässt sich zurückweisen.' + end + + MySQL.update.await("UPDATE medical_bericht SET status = 'entwurf' WHERE id = ?", + { berichtId }) + + Protokoll.Schreibe(mitarbeiter, 'bericht', 'Bericht zurückgewiesen', + { typ = 'bericht', id = berichtId }, { nummer = eintrag.nummer }, grund) + + Hinweise.Erstelle({ charakterId = eintrag.erstellt_von }, { + kategorie = 'doku', prioritaet = 'hoch', + titel = 'Bericht zurückgewiesen', + text = ('%s — %s: %s'):format(eintrag.nummer, mitarbeiter.name, grund:sub(1, 120)), + zielTyp = 'bericht', zielId = berichtId, + }) + + Kern.Loese('bericht.zurueckgewiesen', berichtId, mitarbeiter) + + return { ok = true } +end) + +--- Zeichnet einen Bericht gegen. +--- Eine Gegenzeichnung ändert nichts am Wortlaut — sie fügt nur eine +--- weitere Unterschrift hinzu. Zurücknehmen lässt sie sich nicht. +Kern.RegistriereAktion('bericht', 'gegenzeichnen', 'bericht.freigeben', function(quelle, mitarbeiter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local eintrag = LadeBericht(berichtId) + if not eintrag then return nil, 'Bericht nicht gefunden.' end + + if eintrag.status == 'entwurf' then + return nil, 'Ein Entwurf wird nicht gegengezeichnet.' + end + + local unterschriften = AlsListe(eintrag.unterschriften) + + -- Zweimal dieselbe Unterschrift sagt nichts Zusätzliches + for _, zeile in ipairs(unterschriften) do + if zeile.charakterId == mitarbeiter.charakterId + and zeile.art == 'gegenzeichnung' then + return nil, 'Du hast diesen Bericht bereits gegengezeichnet.' + end + end + + unterschriften[#unterschriften + 1] = Unterschrift(mitarbeiter, 'gegenzeichnung') + + MySQL.update.await('UPDATE medical_bericht SET unterschriften = ? WHERE id = ?', + { json.encode(unterschriften), berichtId }) + + Protokoll.Schreibe(mitarbeiter, 'bericht', 'Bericht gegengezeichnet', + { typ = 'bericht', id = berichtId }, { nummer = eintrag.nummer }, + Text(daten.vermerk, 255)) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Liefert die freigegebenen Berichte eines Patienten. +--- Reine Auskunft — keine Ressource kann darüber einen Bericht +--- schreiben, ändern oder freigeben. Entwürfe bleiben aussen vor. +--- @param patientId number Charakter +--- @return table Liste +exports('HoleBerichte', function(patientId) + if not tonumber(patientId) then return {} end + + return MySQL.query.await([[ + SELECT nummer, titel, diagnose, erstellt_am + FROM medical_bericht + WHERE patient = ? AND status = 'freigegeben' + ORDER BY id DESC + LIMIT 50 + ]], { tonumber(patientId) }) or {} +end) diff --git a/resources/[aether]/aether-medical/server/module/blutbank.lua b/resources/[aether]/aether-medical/server/module/blutbank.lua new file mode 100644 index 0000000..e2074d9 --- /dev/null +++ b/resources/[aether]/aether-medical/server/module/blutbank.lua @@ -0,0 +1,690 @@ +-- ===================================================================== +-- aether-medical / Modul: Blutbank +-- +-- Ein Lagerbuch für Blutkonserven. Nichts weiter. +-- +-- Das System transfundiert nicht, wählt keine Konserve aus und +-- verwirft keine. Eine abgelaufene Konserve bleibt so lange im +-- Bestand, bis ein Mensch sie herausnimmt — das Ablaufdatum ist +-- eine Erinnerung, keine Sperre. +-- +-- Die Verträglichkeitstabelle ist eine Nachschlagehilfe. Passt eine +-- Konserve laut Tabelle nicht zum Empfänger, wird das deutlich +-- angezeigt und mitprotokolliert — verhindert wird es nicht. Ob +-- transfundiert wird, entscheidet ausschliesslich ein Arzt. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'blutbank', name = 'Blutbank', icon = '🩸', + reihenfolge = 11, recht = 'blut.sehen', + beschreibung = 'Bestand, Reservierungen, Ausgabe und Verfallsdaten', +}) + +-- Status und ihre erlaubten Übergänge. Jeder Schritt braucht einen +-- Menschen — es gibt keinen Weg, den das System von selbst geht. +local UEBERGAENGE = { + vorraetig = { reserviert = true, ausgegeben = true, verworfen = true }, + reserviert = { vorraetig = true, ausgegeben = true, verworfen = true }, + ausgegeben = {}, + verworfen = {}, +} + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Begrenzt einen Text und liefert nil bei leerer Eingabe +--- @param wert any Eingabe +--- @param laenge number Maximale Zeichenzahl +--- @return string|nil +local function Text(wert, laenge) + if type(wert) ~= 'string' then return nil end + local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '') + if sauber == '' then return nil end + return sauber:sub(1, laenge) +end + +--- Name eines Charakters für die Anzeige +--- @param charakterId number|nil Charakter +--- @return string +local function NameVon(charakterId) + if not charakterId then return 'unbekannt' end + local zeilen = MySQL.query.await( + 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId }) + if not zeilen or not zeilen[1] then return 'unbekannt' end + return zeilen[1].firstname .. ' ' .. zeilen[1].lastname +end + +--- Prüft eine Blutgruppe gegen die Konfiguration +--- @param wert any Eingabe +--- @return string|nil Gültige Blutgruppe +local function Blutgruppe(wert) + local gesucht = tostring(wert or '') + for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do + if gruppe == gesucht then return gruppe end + end + return nil +end + +--- Prüft einen Lagerort gegen die Konfiguration +--- @param wert any Eingabe +--- @return string|nil Gültiger Lagerort +local function Lagerort(wert) + local gesucht = tostring(wert or '') + for _, ort in ipairs(Medizin.Config.Blutlagerorte) do + if ort == gesucht then return ort end + end + return nil +end + +--- Prüft ein Datum im Format JJJJ-MM-TT +--- @param wert any Eingabe +--- @return string|nil +local function Datum(wert) + local roh = Text(wert, 10) + if not roh then return nil end + if not roh:match('^%d%d%d%d%-%d%d%-%d%d$') then return nil end + return roh +end + +--- Passt eine Konserve laut Nachschlagetabelle zum Empfänger? +--- +--- Liefert `true`, wenn die Kombination in der Tabelle steht, und +--- `nil`, wenn sich dazu nichts sagen lässt — etwa weil die +--- Blutgruppe des Patienten nie bestimmt wurde. Der Rückgabewert +--- sperrt nichts; er wird nur angezeigt und protokolliert. +--- @param empfaenger string|nil Blutgruppe des Patienten +--- @param konserve string Blutgruppe der Konserve +--- @return boolean|nil +local function Vertraeglich(empfaenger, konserve) + if not empfaenger or empfaenger == '' then return nil end + + local erlaubt = Medizin.Config.Blutvertraeglichkeit[empfaenger] + if not erlaubt then return nil end + + for _, gruppe in ipairs(erlaubt) do + if gruppe == konserve then return true end + end + return false +end + +--- Lädt eine Konserve mit den Daten des Empfängers +--- @param konserveId number Konserve +--- @return table|nil +local function LadeKonserve(konserveId) + local zeilen = MySQL.query.await([[ + SELECT b.*, + DATEDIFF(b.verfaellt_am, CURDATE()) AS tage, + c.firstname, c.lastname, + p.blutgruppe AS empfaengerGruppe + FROM medical_blut b + LEFT JOIN aether_characters c ON c.id = b.reserviert_fuer + LEFT JOIN medical_patient p ON p.`character` = b.reserviert_fuer + WHERE b.id = ? + ]], { konserveId }) + + return zeilen and zeilen[1] or nil +end + +--- Prüft, ob ein Statuswechsel überhaupt vorgesehen ist +--- @param von string Aktueller Status +--- @param nach string Gewünschter Status +--- @return boolean +local function DarfWechseln(von, nach) + return (UEBERGAENGE[von] or {})[nach] == true +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('blutbank', 'uebersicht', nil, function(quelle, mitarbeiter, daten) + local ordner = tostring(daten.ordner or 'vorraetig') + local vorlauf = Medizin.Config.Erinnerungen.blutVorlaufTage + + -- Welche Status gehören in diesen Ordner? + local bedingung, werte + if ordner == 'reserviert' then + bedingung = "b.status = 'reserviert'" + elseif ordner == 'archiv' then + bedingung = "b.status IN ('ausgegeben', 'verworfen')" + else + bedingung = "b.status = 'vorraetig'" + end + werte = {} + + if daten.gruppe and daten.gruppe ~= '' then + bedingung = bedingung .. ' AND b.blutgruppe = ?' + werte[#werte + 1] = tostring(daten.gruppe) + end + + -- Im Bestand steht die älteste Konserve oben. Das ist eine + -- Sortierung, keine Vorgabe — welche genommen wird, entscheidet + -- weiterhin ein Mensch. + local sortierung = ordner == 'archiv' + and 'b.id DESC' + or 'b.verfaellt_am IS NULL, b.verfaellt_am, b.konserve' + + local liste = MySQL.query.await(([[ + SELECT b.id, b.konserve, b.blutgruppe, b.menge_ml, b.lagerort, b.status, + b.eingelagert_am, b.eingelagert_von, b.verfaellt_am, + b.reserviert_fuer, b.reserviert_von, + b.ausgegeben_von, b.ausgegeben_am, + DATEDIFF(b.verfaellt_am, CURDATE()) AS tage, + c.firstname, c.lastname, + p.blutgruppe AS empfaengerGruppe + FROM medical_blut b + LEFT JOIN aether_characters c ON c.id = b.reserviert_fuer + LEFT JOIN medical_patient p ON p.`character` = b.reserviert_fuer + WHERE %s + ORDER BY %s + LIMIT 200 + ]]):format(bedingung, sortierung), werte) or {} + + for _, eintrag in ipairs(liste) do + eintrag.tage = eintrag.verfaellt_am and tonumber(eintrag.tage) or nil + -- Abgelaufen heisst hier nur: das Datum ist vorbei. Sonst nichts. + eintrag.abgelaufen = eintrag.tage ~= nil and eintrag.tage <= 0 + eintrag.laeuftBaldAb = eintrag.tage ~= nil + and eintrag.tage > 0 and eintrag.tage <= vorlauf + + eintrag.empfaenger = eintrag.firstname + and (eintrag.firstname .. ' ' .. eintrag.lastname) or nil + + -- Bewusst ohne "and ... or": ein `false` darf hier nicht zu `nil` + -- werden, sonst verschwindet genau die Abweichung, um die es geht. + if eintrag.reserviert_fuer then + eintrag.vertraeglich = Vertraeglich(eintrag.empfaengerGruppe, eintrag.blutgruppe) + end + + eintrag.eingelagertVon = NameVon(eintrag.eingelagert_von) + eintrag.reserviertVon = eintrag.reserviert_von + and NameVon(eintrag.reserviert_von) or nil + eintrag.ausgegebenVon = eintrag.ausgegeben_von + and NameVon(eintrag.ausgegeben_von) or nil + + eintrag.firstname, eintrag.lastname = nil, nil + eintrag.eingelagert_von, eintrag.reserviert_von, eintrag.ausgegeben_von = + nil, nil, nil + end + + -- Bestand je Blutgruppe + local bestand = {} + for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do + local zeilen = MySQL.query.await([[ + SELECT status, COUNT(*) AS anzahl + FROM medical_blut + WHERE blutgruppe = ? AND status IN ('vorraetig', 'reserviert') + GROUP BY status + ]], { gruppe }) or {} + + local eintrag = { gruppe = gruppe, vorraetig = 0, reserviert = 0 } + for _, zeile in ipairs(zeilen) do + eintrag[zeile.status] = tonumber(zeile.anzahl) or 0 + end + + eintrag.baldAb = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_blut + WHERE blutgruppe = ? AND status IN ('vorraetig', 'reserviert') + AND verfaellt_am IS NOT NULL + AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY) + ]], { gruppe, vorlauf })) or 0 + + eintrag.knapp = eintrag.vorraetig <= Medizin.Config.Erinnerungen.blutMindestbestand + bestand[#bestand + 1] = eintrag + end + + -- Zählwerte über das ganze Lager + local function Zaehle(sql, parameter) + return tonumber(MySQL.scalar.await(sql, parameter)) or 0 + end + + local zaehler = { + vorraetig = Zaehle("SELECT COUNT(*) FROM medical_blut WHERE status = 'vorraetig'"), + reserviert = Zaehle("SELECT COUNT(*) FROM medical_blut WHERE status = 'reserviert'"), + baldAb = Zaehle([[ + SELECT COUNT(*) FROM medical_blut + WHERE status IN ('vorraetig', 'reserviert') AND verfaellt_am IS NOT NULL + AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY) + AND verfaellt_am >= CURDATE() + ]], { vorlauf }), + abgelaufen = Zaehle([[ + SELECT COUNT(*) FROM medical_blut + WHERE status IN ('vorraetig', 'reserviert') + AND verfaellt_am IS NOT NULL AND verfaellt_am < CURDATE() + ]]), + ausgegebenHeute = Zaehle([[ + SELECT COUNT(*) FROM medical_blut + WHERE status = 'ausgegeben' AND DATE(ausgegeben_am) = CURDATE() + ]]), + } + + return { + ordner = ordner, + liste = liste, + bestand = bestand, + zaehler = zaehler, + gruppen = Medizin.Config.Blutgruppen, + status = Medizin.Config.Blutstatus, + lagerorte = Medizin.Config.Blutlagerorte, + vertraeglichkeit = Medizin.Config.Blutvertraeglichkeit, + vorlaufTage = vorlauf, + mindestbestand = Medizin.Config.Erinnerungen.blutMindestbestand, + rechte = { + verwalten = Rechte.Hat(mitarbeiter, 'blut.verwalten'), + }, + } +end) + +-- ===================================================================== +-- EINZELNE KONSERVE +-- ===================================================================== + +Kern.RegistriereAktion('blutbank', 'konserve', nil, function(quelle, mitarbeiter, daten) + local konserveId = tonumber(daten.id) + if not konserveId then return nil, 'Ungültige Kennung.' end + + local eintrag = LadeKonserve(konserveId) + if not eintrag then return nil, 'Konserve nicht gefunden.' end + + local tage = eintrag.verfaellt_am and tonumber(eintrag.tage) or nil + + -- Auch hier gilt: `false` ist eine Aussage und darf nicht zu `nil` werden + local passt = nil + if eintrag.reserviert_fuer then + passt = Vertraeglich(eintrag.empfaengerGruppe, eintrag.blutgruppe) + end + + -- Der Weg der Konserve steht im Protokoll und wird nur gelesen + local spur = Protokoll.Lies({ zielTyp = 'blut', zielId = konserveId, limit = 50 }) + + for _, zeile in ipairs(spur) do + zeile.autor = zeile.firstname + and (zeile.firstname .. ' ' .. zeile.lastname) or 'System' + zeile.firstname, zeile.lastname = nil, nil + zeile.details = nil + end + + return { + konserve = { + id = eintrag.id, kennung = eintrag.konserve, + blutgruppe = eintrag.blutgruppe, menge = eintrag.menge_ml, + lagerort = eintrag.lagerort, status = eintrag.status, + eingelagertAm = eintrag.eingelagert_am, + eingelagertVon = NameVon(eintrag.eingelagert_von), + verfaelltAm = eintrag.verfaellt_am, tage = tage, + abgelaufen = tage ~= nil and tage <= 0, + empfaenger = eintrag.reserviert_fuer, + empfaengerName = eintrag.firstname + and (eintrag.firstname .. ' ' .. eintrag.lastname) or nil, + empfaengerGruppe = eintrag.empfaengerGruppe, + vertraeglich = passt, + reserviertVon = eintrag.reserviert_von + and NameVon(eintrag.reserviert_von) or nil, + ausgegebenAm = eintrag.ausgegeben_am, + ausgegebenVon = eintrag.ausgegeben_von + and NameVon(eintrag.ausgegeben_von) or nil, + }, + spur = spur, + rechte = { + verwalten = Rechte.Hat(mitarbeiter, 'blut.verwalten'), + }, + } +end) + +-- ===================================================================== +-- EINLAGERN +-- ===================================================================== + +Kern.RegistriereAktion('blutbank', 'einlagern', 'blut.verwalten', function(quelle, mitarbeiter, daten) + local gruppe = Blutgruppe(daten.blutgruppe) + if not gruppe then return nil, 'Unbekannte Blutgruppe.' end + + local menge = math.floor(tonumber(daten.menge) or 0) + if menge < 50 or menge > 1000 then + return nil, 'Eine Konserve fasst zwischen 50 und 1000 ml.' + end + + local anzahl = math.floor(tonumber(daten.anzahl) or 1) + if anzahl < 1 or anzahl > 20 then + return nil, 'Es lassen sich 1 bis 20 Konserven auf einmal einlagern.' + end + + local lagerort = Lagerort(daten.lagerort) + if not lagerort then return nil, 'Unbekannter Lagerort.' end + + -- Ein Verfallsdatum ist freiwillig; ohne eines erinnert später nichts + local verfaellt = Datum(daten.verfaellt) + if daten.verfaellt and daten.verfaellt ~= '' and not verfaellt then + return nil, 'Das Verfallsdatum braucht die Form JJJJ-MM-TT.' + end + + local angelegt = {} + + for _ = 1, anzahl do + local kennung = Kern.ErzeugeKennung('BK', 'medical_blut', 'konserve') + + local id = MySQL.insert.await([[ + INSERT INTO medical_blut + (konserve, blutgruppe, menge_ml, lagerort, eingelagert_von, verfaellt_am) + VALUES (?, ?, ?, ?, ?, ?) + ]], { kennung, gruppe, menge, lagerort, mitarbeiter.charakterId, verfaellt }) + + angelegt[#angelegt + 1] = { id = id, kennung = kennung } + + Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Konserve eingelagert', + { typ = 'blut', id = id }, + { konserve = kennung, blutgruppe = gruppe, menge = menge, + lagerort = lagerort, verfaellt = verfaellt }, + Text(daten.vermerk, 255)) + end + + Kern.Loese('blut.eingelagert', gruppe, anzahl, mitarbeiter) + + return { angelegt = angelegt, anzahl = #angelegt } +end) + +-- ===================================================================== +-- RESERVIEREN +-- +-- Eine Reservierung legt eine Konserve für einen Patienten beiseite. +-- Sie transfundiert nichts und verpflichtet zu nichts. +-- ===================================================================== + +--- Personensuche für die Reservierung +Kern.RegistriereAktion('blutbank', 'patientSuche', 'blut.verwalten', function(quelle, mitarbeiter, daten) + local begriff = type(daten.begriff) == 'string' and daten.begriff or '' + if #begriff < 2 then return { treffer = {} } end + + local muster = '%' .. begriff .. '%' + local treffer = MySQL.query.await([[ + SELECT c.id, c.firstname, c.lastname, c.dob, + p.blutgruppe, p.allergien, p.hinweis, + (SELECT a.status FROM medical_aufnahme a + WHERE a.patient = c.id AND a.status IN ('wartend', 'aufgenommen') + LIMIT 1) AS laufend + FROM aether_characters c + LEFT JOIN medical_patient p ON p.`character` = c.id + WHERE c.deleted = 0 + AND (c.firstname LIKE ? OR c.lastname LIKE ? + OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? OR c.dob LIKE ?) + ORDER BY c.lastname, c.firstname + LIMIT 20 + ]], { muster, muster, muster, muster }) or {} + + for _, eintrag in ipairs(treffer) do + eintrag.dob = tostring(eintrag.dob):sub(1, 10) + end + + return { treffer = treffer } +end) + +Kern.RegistriereAktion('blutbank', 'reservieren', 'blut.verwalten', function(quelle, mitarbeiter, daten) + local konserveId = tonumber(daten.id) + local patientId = tonumber(daten.patient) + + if not konserveId then return nil, 'Ungültige Kennung.' end + if not patientId then return nil, 'Kein Patient gewählt.' end + + local eintrag = LadeKonserve(konserveId) + if not eintrag then return nil, 'Konserve nicht gefunden.' end + + if not DarfWechseln(eintrag.status, 'reserviert') then + return nil, ('Eine Konserve im Status "%s" lässt sich nicht reservieren.') + :format(eintrag.status) + end + + local person = MySQL.query.await([[ + SELECT c.firstname, c.lastname, p.blutgruppe + FROM aether_characters c + LEFT JOIN medical_patient p ON p.`character` = c.id + WHERE c.id = ? AND c.deleted = 0 + ]], { patientId }) + if not person or not person[1] then return nil, 'Diese Person ist nicht erfasst.' end + + -- Die Tabelle sagt etwas dazu — entschieden wird trotzdem hier oben + local passt = Vertraeglich(person[1].blutgruppe, eintrag.blutgruppe) + + MySQL.update.await([[ + UPDATE medical_blut + SET status = 'reserviert', reserviert_fuer = ?, reserviert_von = ? + WHERE id = ? + ]], { patientId, mitarbeiter.charakterId, konserveId }) + + Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Konserve reserviert', + { typ = 'blut', id = konserveId }, + { konserve = eintrag.konserve, blutgruppe = eintrag.blutgruppe, + patient = patientId, patientGruppe = person[1].blutgruppe, + lautTabelleVertraeglich = passt }, + Text(daten.vermerk, 255)) + + -- Eine Abweichung von der Tabelle wird deutlich gemeldet, nicht verhindert + if passt == false then + Hinweise.Erstelle(nil, { + kategorie = 'blut', prioritaet = 'hoch', + titel = 'Reservierung weicht von der Verträglichkeitstabelle ab', + text = ('%s (%s) für %s %s mit Blutgruppe %s — bitte prüfen.') + :format(eintrag.konserve, eintrag.blutgruppe, + person[1].firstname, person[1].lastname, person[1].blutgruppe), + zielTyp = 'blut', zielId = konserveId, + }) + end + + Kern.Loese('blut.reserviert', konserveId, patientId, mitarbeiter) + + return { ok = true, vertraeglich = passt } +end) + +Kern.RegistriereAktion('blutbank', 'reservierungLoesen', 'blut.verwalten', function(quelle, mitarbeiter, daten) + local konserveId = tonumber(daten.id) + if not konserveId then return nil, 'Ungültige Kennung.' end + + local grund = Text(daten.grund, 255) + if not grund then return nil, 'Bitte angeben, warum die Reservierung entfällt.' end + + local eintrag = LadeKonserve(konserveId) + if not eintrag then return nil, 'Konserve nicht gefunden.' end + if eintrag.status ~= 'reserviert' then + return nil, 'Diese Konserve ist nicht reserviert.' + end + + MySQL.update.await([[ + UPDATE medical_blut + SET status = 'vorraetig', reserviert_fuer = NULL, reserviert_von = NULL + WHERE id = ? + ]], { konserveId }) + + Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Reservierung aufgehoben', + { typ = 'blut', id = konserveId }, + { konserve = eintrag.konserve, patient = eintrag.reserviert_fuer }, grund) + + Kern.Loese('blut.freigegeben', konserveId, mitarbeiter) + + return { ok = true } +end) + +-- ===================================================================== +-- AUSGABE +-- +-- Dokumentiert, dass eine Konserve das Lager verlassen hat. Was mit +-- ihr geschieht, entscheidet und verantwortet der Arzt am Patienten. +-- ===================================================================== + +Kern.RegistriereAktion('blutbank', 'ausgeben', 'blut.verwalten', function(quelle, mitarbeiter, daten) + local konserveId = tonumber(daten.id) + if not konserveId then return nil, 'Ungültige Kennung.' end + + local eintrag = LadeKonserve(konserveId) + if not eintrag then return nil, 'Konserve nicht gefunden.' end + + if not DarfWechseln(eintrag.status, 'ausgegeben') then + return nil, ('Eine Konserve im Status "%s" lässt sich nicht ausgeben.') + :format(eintrag.status) + end + + -- Ohne Reservierung muss bei der Ausgabe gesagt werden, für wen + local patientId = tonumber(daten.patient) or eintrag.reserviert_fuer + if not patientId then return nil, 'Bitte angeben, für welchen Patienten.' end + + local vermerk = Text(daten.vermerk, 255) + if not vermerk then return nil, 'Bitte einen Vermerk zur Ausgabe hinterlassen.' end + + local person = MySQL.query.await([[ + SELECT c.firstname, c.lastname, p.blutgruppe + FROM aether_characters c + LEFT JOIN medical_patient p ON p.`character` = c.id + WHERE c.id = ? AND c.deleted = 0 + ]], { patientId }) + if not person or not person[1] then return nil, 'Diese Person ist nicht erfasst.' end + + local passt = Vertraeglich(person[1].blutgruppe, eintrag.blutgruppe) + local tage = eintrag.verfaellt_am and tonumber(eintrag.tage) or nil + local abgelaufen = tage ~= nil and tage <= 0 + + MySQL.update.await([[ + UPDATE medical_blut + SET status = 'ausgegeben', reserviert_fuer = ?, + ausgegeben_von = ?, ausgegeben_am = NOW() + WHERE id = ? + ]], { patientId, mitarbeiter.charakterId, konserveId }) + + Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Konserve ausgegeben', + { typ = 'blut', id = konserveId }, + { konserve = eintrag.konserve, blutgruppe = eintrag.blutgruppe, + patient = patientId, patientGruppe = person[1].blutgruppe, + lautTabelleVertraeglich = passt, abgelaufen = abgelaufen }, vermerk) + + if passt == false then + Hinweise.Erstelle(nil, { + kategorie = 'blut', prioritaet = 'kritisch', + titel = 'Ausgabe weicht von der Verträglichkeitstabelle ab', + text = ('%s (%s) an %s %s mit Blutgruppe %s — ausgegeben von %s.') + :format(eintrag.konserve, eintrag.blutgruppe, + person[1].firstname, person[1].lastname, + person[1].blutgruppe, mitarbeiter.name), + zielTyp = 'blut', zielId = konserveId, + }) + end + + if abgelaufen then + Hinweise.Erstelle(nil, { + kategorie = 'blut', prioritaet = 'hoch', + titel = 'Abgelaufene Konserve ausgegeben', + text = ('%s war seit %d Tagen über dem Datum — ausgegeben von %s.') + :format(eintrag.konserve, math.abs(tage), mitarbeiter.name), + zielTyp = 'blut', zielId = konserveId, + }) + end + + Kern.Loese('blut.ausgegeben', konserveId, patientId, mitarbeiter) + + return { ok = true, vertraeglich = passt, abgelaufen = abgelaufen } +end) + +-- ===================================================================== +-- VERWERFEN +-- +-- Auch abgelaufene Konserven verschwinden nicht von selbst. Ob eine +-- Konserve noch brauchbar ist, ist eine fachliche Beurteilung — +-- keine Datumsrechnung. +-- ===================================================================== + +Kern.RegistriereAktion('blutbank', 'verwerfen', 'blut.verwalten', function(quelle, mitarbeiter, daten) + local konserveId = tonumber(daten.id) + if not konserveId then return nil, 'Ungültige Kennung.' end + + local grund = Text(daten.grund, 255) + if not grund or #grund < 3 then + return nil, 'Das Verwerfen einer Konserve erfordert eine Begründung.' + end + + local eintrag = LadeKonserve(konserveId) + if not eintrag then return nil, 'Konserve nicht gefunden.' end + + if not DarfWechseln(eintrag.status, 'verworfen') then + return nil, ('Eine Konserve im Status "%s" lässt sich nicht verwerfen.') + :format(eintrag.status) + end + + local warReserviert = eintrag.status == 'reserviert' + + MySQL.update.await("UPDATE medical_blut SET status = 'verworfen' WHERE id = ?", + { konserveId }) + + Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Konserve verworfen', + { typ = 'blut', id = konserveId }, + { konserve = eintrag.konserve, blutgruppe = eintrag.blutgruppe, + vorherigerStatus = eintrag.status, + verfaellt = eintrag.verfaellt_am }, grund) + + -- Eine verworfene Reservierung darf niemandem entgehen + if warReserviert and eintrag.reserviert_fuer then + Hinweise.Erstelle(nil, { + kategorie = 'blut', prioritaet = 'hoch', + titel = 'Reservierte Konserve verworfen', + text = ('%s war für %s %s zurückgelegt.') + :format(eintrag.konserve, eintrag.firstname or '', eintrag.lastname or ''), + zielTyp = 'blut', zielId = konserveId, + }) + end + + Kern.Loese('blut.verworfen', konserveId, mitarbeiter) + + return { ok = true } +end) + +-- ===================================================================== +-- LAGERORT +-- ===================================================================== + +Kern.RegistriereAktion('blutbank', 'umlagern', 'blut.verwalten', function(quelle, mitarbeiter, daten) + local konserveId = tonumber(daten.id) + if not konserveId then return nil, 'Ungültige Kennung.' end + + local lagerort = Lagerort(daten.lagerort) + if not lagerort then return nil, 'Unbekannter Lagerort.' end + + local eintrag = LadeKonserve(konserveId) + if not eintrag then return nil, 'Konserve nicht gefunden.' end + if eintrag.status ~= 'vorraetig' and eintrag.status ~= 'reserviert' then + return nil, 'Diese Konserve liegt nicht mehr im Lager.' + end + + MySQL.update.await('UPDATE medical_blut SET lagerort = ? WHERE id = ?', + { lagerort, konserveId }) + + Protokoll.Schreibe(mitarbeiter, 'blutbank', 'Konserve umgelagert', + { typ = 'blut', id = konserveId }, + { konserve = eintrag.konserve, von = eintrag.lagerort, nach = lagerort }, + Text(daten.vermerk, 255)) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Liefert den Bestand je Blutgruppe. +--- Reine Auskunft — keine Ressource kann darüber eine Konserve +--- reservieren, ausgeben oder verwerfen. +--- @return table Liste { blutgruppe, vorraetig, reserviert } +exports('HoleBlutbestand', function() + local liste = {} + + for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do + liste[#liste + 1] = { + blutgruppe = gruppe, + vorraetig = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_blut + WHERE blutgruppe = ? AND status = 'vorraetig' + ]], { gruppe })) or 0, + reserviert = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_blut + WHERE blutgruppe = ? AND status = 'reserviert' + ]], { gruppe })) or 0, + } + end + + return liste +end) diff --git a/resources/[aether]/aether-medical/server/module/dashboard.lua b/resources/[aether]/aether-medical/server/module/dashboard.lua new file mode 100644 index 0000000..e67d715 --- /dev/null +++ b/resources/[aether]/aether-medical/server/module/dashboard.lua @@ -0,0 +1,226 @@ +-- ===================================================================== +-- aether-medical / Modul: Übersicht (Dashboard) +-- +-- Zeigt den Stand der Klinik. Jede Zahl wird bei jedem Aufruf frisch +-- aus der Datenbank gelesen — es gibt keine fest verdrahteten Werte +-- und keine Kennzahl, die eine Handlung auslöst. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'dashboard', name = 'Übersicht', icon = '⬛', + reihenfolge = 1, + beschreibung = 'Lage der Klinik auf einen Blick', +}) + +--- Zählt und liefert immer eine Zahl zurück +--- @param sql string Abfrage +--- @param werte table|nil Parameter +--- @return number +local function Zaehle(sql, werte) + return tonumber(MySQL.scalar.await(sql, werte or {})) or 0 +end + +Kern.RegistriereAktion('dashboard', 'laden', nil, function(quelle, mitarbeiter) + local charakterId = mitarbeiter.charakterId + + -- --- Dienst --- + local imDienst, notaerzte, aufRtw, imOp = 0, 0, 0, 0 + local leitstelleName = nil + + for zielQuelle, eintrag in pairs(Dienst.AlleImDienst()) do + imDienst = imDienst + 1 + if eintrag.funktion == 'notarzt' then notaerzte = notaerzte + 1 end + if eintrag.funktion == 'rtw' then aufRtw = aufRtw + 1 end + if eintrag.funktion == 'op' then imOp = imOp + 1 end + if Dienst.Leitstelle() == zielQuelle then leitstelleName = eintrag.name end + end + + -- --- Einsätze --- + local einsaetze = { + offen = Zaehle("SELECT COUNT(*) FROM medical_einsatz WHERE status = 'offen'"), + laufend = Zaehle("SELECT COUNT(*) FROM medical_einsatz WHERE status = 'laufend'"), + dringend = Zaehle([[ + SELECT COUNT(*) FROM medical_einsatz + WHERE status IN ('offen', 'laufend') AND prioritaet <= 2 + ]]), + } + + -- Die jüngsten offenen Einsätze für die Kurzliste + einsaetze.liste = MySQL.query.await([[ + SELECT id, nummer, titel, kategorie, prioritaet, ort, status, erstellt_am + FROM medical_einsatz + WHERE status IN ('offen', 'laufend') + ORDER BY prioritaet, id DESC + LIMIT 6 + ]]) or {} + + -- --- Patienten --- + local patienten = { + wartend = Zaehle("SELECT COUNT(*) FROM medical_aufnahme WHERE status = 'wartend'"), + aufgenommen = Zaehle("SELECT COUNT(*) FROM medical_aufnahme WHERE status = 'aufgenommen'"), + intensiv = Zaehle('SELECT COUNT(*) FROM medical_intensiv WHERE beendet_am IS NULL'), + kritisch = Zaehle([[ + SELECT COUNT(*) FROM medical_intensiv + WHERE beendet_am IS NULL AND zustand = 'kritisch' + ]]), + } + + -- Die längste Wartezeit in der Notaufnahme + patienten.laengsteWartezeit = Zaehle([[ + SELECT COALESCE(MAX(TIMESTAMPDIFF(MINUTE, aufgenommen_am, NOW())), 0) + FROM medical_aufnahme WHERE status = 'wartend' + ]]) + + -- Wartende Patienten für die Kurzliste + patienten.warteliste = MySQL.query.await([[ + SELECT a.id, a.patient, a.grund, a.prioritaet, + TIMESTAMPDIFF(MINUTE, a.aufgenommen_am, NOW()) AS wartet, + c.firstname, c.lastname + FROM medical_aufnahme a + JOIN aether_characters c ON c.id = a.patient + WHERE a.status = 'wartend' + ORDER BY a.prioritaet, a.aufgenommen_am + LIMIT 6 + ]]) or {} + + -- --- Belegung --- + local bettenGesamt = Zaehle([[ + SELECT COALESCE(SUM(betten), 0) FROM medical_zimmer + WHERE art = 'bett' AND status <> 'gesperrt' + ]]) + local bettenBelegt = Zaehle([[ + SELECT COUNT(*) FROM medical_belegung b + JOIN medical_zimmer z ON z.id = b.zimmer + WHERE b.frei_am IS NULL AND z.art = 'bett' + ]]) + + local belegung = { + bettenGesamt = bettenGesamt, + bettenBelegt = bettenBelegt, + bettenFrei = math.max(0, bettenGesamt - bettenBelegt), + behandlungFrei = Zaehle([[ + SELECT COUNT(*) FROM medical_zimmer + WHERE art = 'behandlung' AND status = 'frei' + ]]), + behandlungGesamt = Zaehle("SELECT COUNT(*) FROM medical_zimmer WHERE art = 'behandlung'"), + opSaeleGesamt = Zaehle("SELECT COUNT(*) FROM medical_zimmer WHERE art = 'op'"), + opLaufend = Zaehle("SELECT COUNT(*) FROM medical_operation WHERE status = 'laufend'"), + opGeplant = Zaehle([[ + SELECT COUNT(*) FROM medical_operation + WHERE status = 'geplant' AND geplant_fuer >= NOW() + ]]), + } + + -- --- Offene Arbeit --- + local offen = { + dokumentationen = Zaehle("SELECT COUNT(*) FROM medical_behandlung WHERE status = 'laufend'"), + eigeneDoku = Zaehle([[ + SELECT COUNT(*) FROM medical_behandlung + WHERE status = 'laufend' AND erstellt_von = ? + ]], { charakterId }), + laborOffen = Zaehle([[ + SELECT COUNT(*) FROM medical_labor WHERE status IN ('offen', 'in_arbeit') + ]]), + laborUnbefundet = Zaehle([[ + SELECT COUNT(*) FROM medical_labor WHERE status = 'fertig' AND befund IS NULL + ]]), + berichteEntwurf = Zaehle([[ + SELECT COUNT(*) FROM medical_bericht + WHERE status = 'entwurf' AND erstellt_von = ? + ]], { charakterId }), + } + + -- --- Kritische Warnungen --- + -- Bewusst nur Bestands- und Fristenlage. Kein Patientenzustand, + -- den das System selbst bewertet hätte. + local warnungen = {} + + local leereMedikamente = Zaehle([[ + SELECT COUNT(*) FROM medical_medikament WHERE aktiv = 1 AND bestand <= 0 + ]]) + if leereMedikamente > 0 then + warnungen[#warnungen + 1] = { + art = 'kritisch', titel = 'Medikamente nicht vorrätig', + text = ('%d Positionen sind auf null.'):format(leereMedikamente), + ziel = 'medikament', + } + end + + local knappeMedikamente = Zaehle([[ + SELECT COUNT(*) FROM medical_medikament + WHERE aktiv = 1 AND bestand > 0 AND bestand <= mindestbestand + ]]) + if knappeMedikamente > 0 then + warnungen[#warnungen + 1] = { + art = 'warn', titel = 'Medikamente unter Mindestbestand', + text = ('%d Positionen sollten nachbestellt werden.'):format(knappeMedikamente), + ziel = 'medikament', + } + end + + local ablaufendesBlut = Zaehle([[ + SELECT COUNT(*) FROM medical_blut + WHERE status IN ('vorraetig', 'reserviert') AND verfaellt_am IS NOT NULL + AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY) + ]], { Medizin.Config.Erinnerungen.blutVorlaufTage }) + if ablaufendesBlut > 0 then + warnungen[#warnungen + 1] = { + art = 'warn', titel = 'Blutkonserven laufen ab', + text = ('%d Konserven erreichen bald ihr Ablaufdatum.'):format(ablaufendesBlut), + ziel = 'blutbank', + } + end + + if patienten.kritisch > 0 then + warnungen[#warnungen + 1] = { + art = 'kritisch', titel = 'Kritische Intensivpatienten', + text = ('%d Patienten sind als kritisch eingetragen.'):format(patienten.kritisch), + ziel = 'intensiv', + } + end + + if einsaetze.offen > 0 and imDienst == 0 then + warnungen[#warnungen + 1] = { + art = 'kritisch', titel = 'Offene Einsätze ohne Besetzung', + text = 'Es ist niemand im Dienst.', + ziel = 'leitstelle', + } + end + + if not leitstelleName and einsaetze.offen > 0 then + warnungen[#warnungen + 1] = { + art = 'warn', titel = 'Leitstelle unbesetzt', + text = 'Offene Einsätze werden zurzeit von niemandem verteilt.', + ziel = 'leitstelle', + } + end + + return { + mitarbeiter = { + name = mitarbeiter.name, rolleName = mitarbeiter.rolleName, + station = mitarbeiter.station, funktion = mitarbeiter.funktion, + }, + dienst = { + imDienst = imDienst, notaerzte = notaerzte, + aufRtw = aufRtw, imOp = imOp, + leitstelle = leitstelleName, + liste = Dienst.BaueListe(), + }, + einsaetze = einsaetze, + patienten = patienten, + belegung = belegung, + offen = offen, + warnungen = warnungen, + hinweise = Hinweise.Lade(charakterId, 15), + stationen = Medizin.Config.Stationen, + } +end) + +--- Markiert einen Hinweis als gelesen +Kern.RegistriereAktion('dashboard', 'hinweisGelesen', nil, function(quelle, mitarbeiter, daten) + local id = tonumber(daten.id) + if not id then return nil, 'Ungültige Kennung.' end + + Hinweise.MarkiereGelesen(mitarbeiter.charakterId, id) + return { ok = true } +end) diff --git a/resources/[aether]/aether-medical/server/module/einsatz.lua b/resources/[aether]/aether-medical/server/module/einsatz.lua new file mode 100644 index 0000000..508acf0 --- /dev/null +++ b/resources/[aether]/aether-medical/server/module/einsatz.lua @@ -0,0 +1,633 @@ +-- ===================================================================== +-- aether-medical / Modul: Leitstelle und Einsatzverwaltung +-- +-- Ein Notruf erzeugt einen OFFENEN Einsatz. Mehr nicht. +-- +-- Das System alarmiert niemanden gezielt, schickt keine Einheit los +-- und schliesst keinen Einsatz ab. Die Leitstelle entscheidet, wen sie +-- anfragt — und selbst eine Anfrage ist nur eine Bitte: die Kraft muss +-- dem Einsatz weiterhin selbst beitreten. +-- +-- Der Status „laufend" ist keine Entscheidung, sondern die Aufzeichnung +-- einer Tatsache: es ist jemand gebunden. Abgeschlossen wird immer +-- von Hand. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'einsatz', name = 'Leitstelle', icon = '🚑', + reihenfolge = 4, recht = 'einsatz.erstellen', + beschreibung = 'Notrufe, Einsätze und Disposition', +}) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Begrenzt einen Text und liefert nil bei leerer Eingabe +local function Text(wert, laenge) + if type(wert) ~= 'string' then return nil end + local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '') + if sauber == '' then return nil end + return sauber:sub(1, laenge) +end + +--- Prüft eine Prioritätsstufe +local function Prioritaet(wert) + local stufe = tonumber(wert) or 3 + if stufe < 1 or stufe > 4 then return 3 end + return math.floor(stufe) +end + +--- Prüft eine Einsatzkategorie +--- @param kennung any Eingabe +--- @return string +local function Kategorie(kennung) + for _, eintrag in ipairs(Medizin.Config.Einsatzkategorien) do + if eintrag.id == kennung then return eintrag.id end + end + return 'sonstiges' +end + +--- Name eines Mitarbeiters für die Anzeige +local function NameVon(charakterId) + if not charakterId then return 'System' end + local zeilen = MySQL.query.await([[ + SELECT c.firstname, c.lastname, p.personalnummer + FROM aether_characters c + LEFT JOIN medical_personal p ON p.`character` = c.id + WHERE c.id = ? + ]], { charakterId }) + if not zeilen or not zeilen[1] then return 'unbekannt' end + return zeilen[1].firstname .. ' ' .. zeilen[1].lastname +end + +--- Wie viele Kräfte sind einem Einsatz gerade zugeordnet? +--- @param einsatzId number Einsatz +--- @return number +local function AnzahlKraefte(einsatzId) + return tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_einsatz_kraft WHERE einsatz = ? AND bis IS NULL', + { einsatzId })) or 0 +end + +--- Hält den Status im Einklang mit der Tatsache, ob jemand gebunden ist. +--- Das ist Buchführung, keine Entscheidung: ein abgeschlossener Einsatz +--- wird hier nie wieder geöffnet. +--- @param einsatzId number Einsatz +local function StatusNachKraeften(einsatzId) + local zeilen = MySQL.query.await( + 'SELECT status FROM medical_einsatz WHERE id = ?', { einsatzId }) + if not zeilen or not zeilen[1] then return end + if zeilen[1].status == 'abgeschlossen' then return end + + local neu = AnzahlKraefte(einsatzId) > 0 and 'laufend' or 'offen' + if neu ~= zeilen[1].status then + MySQL.update.await('UPDATE medical_einsatz SET status = ? WHERE id = ?', + { neu, einsatzId }) + end +end + +--- Meldet allen im Dienst, dass sich etwas geändert hat +local function Melden() + for quelle in pairs(Dienst.AlleImDienst()) do + TriggerClientEvent('aether:medical:einsatzAenderung', quelle) + end +end + +-- ===================================================================== +-- EINGANG +-- +-- Einziger Weg, auf dem ein Einsatz von aussen entsteht. Global, damit +-- server/main.lua und andere Ressourcen ihn erreichen. +-- ===================================================================== + +--- Nimmt einen Notfall entgegen und legt ihn als offenen Einsatz ab +--- @param daten table { titel, kategorie?, prioritaet?, ort?, beschreibung?, position?, quelle? } +--- @return number|nil Einsatz-ID +function EinsatzEingang(daten) + if type(daten) ~= 'table' or not daten.titel then return nil end + + local nummer = Kern.ErzeugeKennung('EIN', 'medical_einsatz', 'nummer') + local position = daten.position or {} + local prioritaet = Prioritaet(daten.prioritaet) + local kategorie = Kategorie(daten.kategorie) + + local id = MySQL.insert.await([[ + INSERT INTO medical_einsatz + (nummer, titel, kategorie, prioritaet, ort, pos_x, pos_y, pos_z, + beschreibung, status, quelle, erstellt_von) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'offen', ?, ?) + ]], { + nummer, + tostring(daten.titel):sub(1, 160), + kategorie, prioritaet, + daten.ort and tostring(daten.ort):sub(1, 160) or nil, + position.x, position.y, position.z, + daten.beschreibung and tostring(daten.beschreibung):sub(1, 4000) or nil, + daten.quelle or 'system', + daten.erstelltVon, + }) + + -- Alle im Dienst erfahren davon. Ausgewählt wird niemand. + Hinweise.Erstelle(nil, { + kategorie = 'einsatz', + prioritaet = prioritaet == 1 and 'kritisch' or prioritaet == 2 and 'hoch' or 'normal', + titel = ('Neuer Einsatz · %s'):format(nummer), + text = ('%s%s'):format(daten.titel, + daten.ort and (' — ' .. daten.ort) or ''), + zielTyp = 'einsatz', zielId = id, + }) + + Kern.Loese('einsatz.eingegangen', id, kategorie) + Melden() + + return id +end + +-- --------------------------------------------------------------------- +-- Notruf aus dem Verletztenzustand (aether-death) +-- +-- Ein am Boden liegender Spieler setzt einen Notruf ab. Daraus wird +-- ein offener Einsatz — niemand wird alarmiert oder hingeschickt. +-- --------------------------------------------------------------------- +AddEventHandler('aether:death:notruf', function(quelle, daten) + if type(daten) ~= 'table' then return end + + EinsatzEingang({ + titel = 'Bewusstlose Person', + kategorie = 'bewusstlos', + prioritaet = 1, + ort = 'Position des Anrufers', + beschreibung = ('%s liegt verletzt am Boden und hat den Rettungsdienst gerufen.') + :format(daten.name or 'Eine Person'), + position = daten.position, + quelle = 'notruf', + }) +end) + +-- ===================================================================== +-- ÜBERSICHT UND LEITSTELLE +-- ===================================================================== + +Kern.RegistriereAktion('einsatz', 'uebersicht', nil, function(quelle, mitarbeiter, daten) + local ordner = tostring(daten.ordner or 'aktuell') + + local bedingung + if ordner == 'archiv' then + bedingung = "e.status = 'abgeschlossen'" + else + bedingung = "e.status IN ('offen', 'laufend')" + end + + local einsaetze = MySQL.query.await(([[ + SELECT e.id, e.nummer, e.titel, e.kategorie, e.prioritaet, e.ort, + e.pos_x, e.pos_y, e.pos_z, e.status, e.quelle, + e.erstellt_am, e.erstellt_von, e.geschlossen_am, e.geschlossen_von, + TIMESTAMPDIFF(MINUTE, e.erstellt_am, NOW()) AS offenSeit, + (SELECT COUNT(*) FROM medical_einsatz_kraft k + WHERE k.einsatz = e.id AND k.bis IS NULL) AS kraefte, + (SELECT COUNT(*) FROM medical_einsatz_kraft k + WHERE k.einsatz = e.id AND k.bis IS NULL AND k.`character` = ?) AS ichDabei + FROM medical_einsatz e + WHERE %s + ORDER BY e.prioritaet, e.id DESC + LIMIT 120 + ]]):format(bedingung), { mitarbeiter.charakterId }) or {} + + local kategorieNamen, kategorieFarben = {}, {} + for _, eintrag in ipairs(Medizin.Config.Einsatzkategorien) do + kategorieNamen[eintrag.id] = eintrag.name + kategorieFarben[eintrag.id] = eintrag.farbe + end + + for _, einsatz in ipairs(einsaetze) do + einsatz.kategorieName = kategorieNamen[einsatz.kategorie] or einsatz.kategorie + einsatz.farbe = kategorieFarben[einsatz.kategorie] + einsatz.kraefte = tonumber(einsatz.kraefte) or 0 + einsatz.ichDabei = tonumber(einsatz.ichDabei) > 0 + einsatz.erstelltVon = NameVon(einsatz.erstellt_von) + einsatz.geschlossenVon = einsatz.geschlossen_von + and NameVon(einsatz.geschlossen_von) or nil + einsatz.erstellt_von, einsatz.geschlossen_von = nil, nil + end + + -- Verfügbare Kräfte für die Disposition + local kraefte = {} + for zielQuelle, eintrag in pairs(Dienst.AlleImDienst()) do + -- Woran ist diese Kraft gerade gebunden? + local gebunden = MySQL.query.await([[ + SELECT e.id, e.nummer FROM medical_einsatz_kraft k + JOIN medical_einsatz e ON e.id = k.einsatz + WHERE k.`character` = ? AND k.bis IS NULL AND e.status <> 'abgeschlossen' + LIMIT 1 + ]], { eintrag.charakterId }) + + kraefte[#kraefte + 1] = { + quelle = zielQuelle, + charakterId = eintrag.charakterId, + name = eintrag.name, + personalnummer = eintrag.personalnummer, + rolleName = eintrag.rolleName, + station = eintrag.station, + funktion = eintrag.funktion, + status = eintrag.status, + leitstelle = Dienst.Leitstelle() == zielQuelle, + gebundenAn = gebunden and gebunden[1] and gebunden[1].nummer or nil, + gebundenId = gebunden and gebunden[1] and gebunden[1].id or nil, + } + end + + table.sort(kraefte, function(a, b) return a.personalnummer < b.personalnummer end) + + return { + ordner = ordner, + einsaetze = einsaetze, + kraefte = kraefte, + kategorien = Medizin.Config.Einsatzkategorien, + status = Medizin.Config.Dienststatus, + funktionen = Medizin.Config.Dienstfunktionen, + istLeitstelle = Dienst.Leitstelle() == quelle, + leitstelleName = (function() + local leit = Dienst.Leitstelle() + local eintrag = leit and Dienst.Hole(leit) + return eintrag and eintrag.name or nil + end)(), + zaehler = { + offen = tonumber(MySQL.scalar.await( + "SELECT COUNT(*) FROM medical_einsatz WHERE status = 'offen'")) or 0, + laufend = tonumber(MySQL.scalar.await( + "SELECT COUNT(*) FROM medical_einsatz WHERE status = 'laufend'")) or 0, + heute = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_einsatz + WHERE status = 'abgeschlossen' AND DATE(geschlossen_am) = CURDATE() + ]])) or 0, + }, + rechte = { + erstellen = Rechte.Hat(mitarbeiter, 'einsatz.erstellen'), + zuweisen = Rechte.Hat(mitarbeiter, 'einsatz.zuweisen'), + schliessen = Rechte.Hat(mitarbeiter, 'einsatz.schliessen'), + leitstelle = Rechte.Hat(mitarbeiter, 'dienst.leitstelle'), + }, + } +end) + +Kern.RegistriereAktion('einsatz', 'oeffnen', nil, function(quelle, mitarbeiter, daten) + local einsatzId = tonumber(daten.id) + if not einsatzId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await('SELECT * FROM medical_einsatz WHERE id = ?', { einsatzId }) + if not zeilen or not zeilen[1] then return nil, 'Einsatz nicht gefunden.' end + + local einsatz = zeilen[1] + + local kategorieName = einsatz.kategorie + for _, eintrag in ipairs(Medizin.Config.Einsatzkategorien) do + if eintrag.id == einsatz.kategorie then kategorieName = eintrag.name end + end + + -- Beteiligte Kräfte, auch die bereits abgerückten + local kraefte = MySQL.query.await([[ + SELECT k.`character`, k.seit, k.bis, + c.firstname, c.lastname, p.personalnummer, p.rolle + FROM medical_einsatz_kraft k + LEFT JOIN aether_characters c ON c.id = k.`character` + LEFT JOIN medical_personal p ON p.`character` = k.`character` + WHERE k.einsatz = ? + ORDER BY k.seit + ]], { einsatzId }) or {} + + local ichDabei = false + for _, kraft in ipairs(kraefte) do + kraft.name = (kraft.firstname or 'Unbekannt') .. ' ' .. (kraft.lastname or '') + local rolle = Medizin.HoleRolle(kraft.rolle) + kraft.rolleName = rolle and rolle.name or kraft.rolle + kraft.aktiv = kraft.bis == nil + kraft.firstname, kraft.lastname = nil, nil + if kraft.aktiv and kraft.character == mitarbeiter.charakterId then ichDabei = true end + end + + -- Notizen zum Einsatzverlauf + local notizen = MySQL.query.await([[ + SELECT n.id, n.text, n.am, n.von, c.firstname, c.lastname + FROM medical_einsatz_notiz n + LEFT JOIN aether_characters c ON c.id = n.von + WHERE n.einsatz = ? + ORDER BY n.id DESC LIMIT 60 + ]], { einsatzId }) or {} + + for _, notiz in ipairs(notizen) do + notiz.autor = (notiz.firstname or 'Unbekannt') .. ' ' .. (notiz.lastname or '') + notiz.firstname, notiz.lastname = nil, nil + end + + -- Dokumentation, die auf diesen Einsatz verweist + local behandlungen = MySQL.query.await([[ + SELECT b.id, b.kennung, b.status, b.diagnose, b.erstellt_am, + c.firstname, c.lastname + FROM medical_behandlung b + LEFT JOIN aether_characters c ON c.id = b.patient + WHERE b.einsatz = ? + ORDER BY b.id DESC LIMIT 20 + ]], { einsatzId }) or {} + + for _, behandlung in ipairs(behandlungen) do + behandlung.patientName = (behandlung.firstname or 'Unbekannt') + .. ' ' .. (behandlung.lastname or '') + behandlung.firstname, behandlung.lastname = nil, nil + end + + return { + einsatz = { + id = einsatz.id, nummer = einsatz.nummer, titel = einsatz.titel, + kategorie = einsatz.kategorie, kategorieName = kategorieName, + prioritaet = einsatz.prioritaet, ort = einsatz.ort, + beschreibung = einsatz.beschreibung, status = einsatz.status, + quelle = einsatz.quelle, + position = einsatz.pos_x and + { x = einsatz.pos_x, y = einsatz.pos_y, z = einsatz.pos_z } or nil, + erstelltAm = einsatz.erstellt_am, + erstelltVon = NameVon(einsatz.erstellt_von), + geschlossenAm = einsatz.geschlossen_am, + geschlossenVon = einsatz.geschlossen_von + and NameVon(einsatz.geschlossen_von) or nil, + }, + kraefte = kraefte, + notizen = notizen, + behandlungen = behandlungen, + ichDabei = ichDabei, + rechte = { + zuweisen = Rechte.Hat(mitarbeiter, 'einsatz.zuweisen'), + schliessen = Rechte.Hat(mitarbeiter, 'einsatz.schliessen'), + }, + } +end) + +-- ===================================================================== +-- EINSATZ ANLEGEN +-- ===================================================================== + +Kern.RegistriereAktion('einsatz', 'erstellen', 'einsatz.erstellen', function(quelle, mitarbeiter, daten) + local titel = Text(daten.titel, 160) + if not titel or #titel < 3 then return nil, 'Bitte einen Einsatztitel angeben.' end + + local id = EinsatzEingang({ + titel = titel, + kategorie = daten.kategorie, + prioritaet = daten.prioritaet, + ort = Text(daten.ort, 160), + beschreibung = Text(daten.beschreibung, 4000), + position = daten.position, + quelle = 'manuell', + erstelltVon = mitarbeiter.charakterId, + }) + + if not id then return nil, 'Der Einsatz konnte nicht angelegt werden.' end + + Protokoll.Schreibe(mitarbeiter, 'einsatz', 'Einsatz angelegt', + { typ = 'einsatz', id = id }, + { kategorie = Kategorie(daten.kategorie), prioritaet = Prioritaet(daten.prioritaet) }) + + return { id = id } +end) + +-- ===================================================================== +-- BEITRETEN UND VERLASSEN +-- +-- Jede Kraft entscheidet selbst. Auch eine Anfrage der Leitstelle +-- bindet niemanden — sie muss angenommen werden. +-- ===================================================================== + +Kern.RegistriereAktion('einsatz', 'beitreten', nil, function(quelle, mitarbeiter, daten) + local einsatzId = tonumber(daten.id) + if not einsatzId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await( + 'SELECT id, nummer, titel, status FROM medical_einsatz WHERE id = ?', { einsatzId }) + if not zeilen or not zeilen[1] then return nil, 'Einsatz nicht gefunden.' end + if zeilen[1].status == 'abgeschlossen' then + return nil, 'Dieser Einsatz ist bereits abgeschlossen.' + end + + -- Erneutes Beitreten nach dem Abrücken ist erlaubt + MySQL.query.await([[ + INSERT INTO medical_einsatz_kraft (einsatz, `character`) VALUES (?, ?) + ON DUPLICATE KEY UPDATE bis = NULL, seit = NOW() + ]], { einsatzId, mitarbeiter.charakterId }) + + StatusNachKraeften(einsatzId) + + Protokoll.Schreibe(mitarbeiter, 'einsatz', 'Einsatz übernommen', + { typ = 'einsatz', id = einsatzId }, { nummer = zeilen[1].nummer }) + + Kern.Loese('einsatz.beigetreten', einsatzId, mitarbeiter) + Melden() + + return { ok = true, position = true } +end) + +Kern.RegistriereAktion('einsatz', 'verlassen', nil, function(quelle, mitarbeiter, daten) + local einsatzId = tonumber(daten.id) + if not einsatzId then return nil, 'Ungültige Kennung.' end + + MySQL.update.await([[ + UPDATE medical_einsatz_kraft SET bis = NOW() + WHERE einsatz = ? AND `character` = ? AND bis IS NULL + ]], { einsatzId, mitarbeiter.charakterId }) + + StatusNachKraeften(einsatzId) + + Protokoll.Schreibe(mitarbeiter, 'einsatz', 'Einsatz verlassen', + { typ = 'einsatz', id = einsatzId }, nil, Text(daten.grund, 255)) + + Kern.Loese('einsatz.verlassen', einsatzId, mitarbeiter) + Melden() + + return { ok = true } +end) + +-- ===================================================================== +-- DISPOSITION +-- +-- Die Leitstelle FRAGT eine Kraft an. Sie zwingt niemanden — der +-- Einsatz erscheint beim Angefragten mit Wegpunkt, beitreten muss +-- er selbst. +-- ===================================================================== + +Kern.RegistriereAktion('einsatz', 'anfragen', 'einsatz.zuweisen', function(quelle, mitarbeiter, daten) + local einsatzId = tonumber(daten.id) + local zielQuelle = tonumber(daten.quelle) + + if not einsatzId or not zielQuelle then return nil, 'Unvollständige Angaben.' end + + local ziel = Dienst.Hole(zielQuelle) + if not ziel then return nil, 'Diese Kraft ist nicht im Dienst.' end + + local zeilen = MySQL.query.await( + 'SELECT id, nummer, titel, ort, pos_x, pos_y, pos_z, status FROM medical_einsatz WHERE id = ?', + { einsatzId }) + if not zeilen or not zeilen[1] then return nil, 'Einsatz nicht gefunden.' end + + local einsatz = zeilen[1] + if einsatz.status == 'abgeschlossen' then + return nil, 'Dieser Einsatz ist bereits abgeschlossen.' + end + + -- Bereits dabei? Dann ist eine Anfrage sinnlos + local dabei = MySQL.scalar.await([[ + SELECT 1 FROM medical_einsatz_kraft + WHERE einsatz = ? AND `character` = ? AND bis IS NULL + ]], { einsatzId, ziel.charakterId }) + if dabei then return nil, 'Diese Kraft ist dem Einsatz bereits zugeordnet.' end + + TriggerClientEvent('aether:medical:einsatzZuweisung', zielQuelle, { + id = einsatz.id, + nummer = einsatz.nummer, + titel = einsatz.titel, + ort = einsatz.ort, + von = mitarbeiter.name, + position = einsatz.pos_x and + { x = einsatz.pos_x, y = einsatz.pos_y, z = einsatz.pos_z } or nil, + }) + + Hinweise.Erstelle({ charakterId = ziel.charakterId }, { + kategorie = 'einsatz', prioritaet = 'hoch', + titel = ('Anfrage der Leitstelle · %s'):format(einsatz.nummer), + text = ('%s bittet dich um Übernahme: %s%s') + :format(mitarbeiter.name, einsatz.titel, + einsatz.ort and (' — ' .. einsatz.ort) or ''), + zielTyp = 'einsatz', zielId = einsatz.id, + }) + + Protokoll.Schreibe(mitarbeiter, 'einsatz', 'Kraft angefragt', + { typ = 'einsatz', id = einsatzId }, + { nummer = einsatz.nummer, ziel = ziel.personalnummer }) + + return { ok = true, name = ziel.name } +end) + +-- ===================================================================== +-- VERLAUF UND ABSCHLUSS +-- ===================================================================== + +Kern.RegistriereAktion('einsatz', 'notiz', nil, function(quelle, mitarbeiter, daten) + local einsatzId = tonumber(daten.id) + local text = Text(daten.text, 1000) + + if not einsatzId then return nil, 'Ungültige Kennung.' end + if not text or #text < 2 then return nil, 'Die Notiz ist zu kurz.' end + + local vorhanden = MySQL.scalar.await( + 'SELECT id FROM medical_einsatz WHERE id = ?', { einsatzId }) + if not vorhanden then return nil, 'Einsatz nicht gefunden.' end + + local id = MySQL.insert.await( + 'INSERT INTO medical_einsatz_notiz (einsatz, text, von) VALUES (?, ?, ?)', + { einsatzId, text, mitarbeiter.charakterId }) + + Melden() + + return { id = id } +end) + +--- Priorität nachträglich ändern — immer mit Begründung +Kern.RegistriereAktion('einsatz', 'prioritaet', 'einsatz.zuweisen', function(quelle, mitarbeiter, daten) + local einsatzId = tonumber(daten.id) + if not einsatzId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await( + 'SELECT id, nummer, prioritaet, status FROM medical_einsatz WHERE id = ?', { einsatzId }) + if not zeilen or not zeilen[1] then return nil, 'Einsatz nicht gefunden.' end + if zeilen[1].status == 'abgeschlossen' then + return nil, 'Dieser Einsatz ist abgeschlossen.' + end + + local neu = Prioritaet(daten.prioritaet) + local grund = Text(daten.grund, 255) + if not grund then return nil, 'Bitte begründen, warum sich die Priorität ändert.' end + + MySQL.update.await('UPDATE medical_einsatz SET prioritaet = ? WHERE id = ?', + { neu, einsatzId }) + + Protokoll.Schreibe(mitarbeiter, 'einsatz', 'Priorität geändert', + { typ = 'einsatz', id = einsatzId }, + { nummer = zeilen[1].nummer, von = zeilen[1].prioritaet, nach = neu }, grund) + + Melden() + + return { ok = true } +end) + +Kern.RegistriereAktion('einsatz', 'schliessen', 'einsatz.schliessen', function(quelle, mitarbeiter, daten) + local einsatzId = tonumber(daten.id) + if not einsatzId then return nil, 'Ungültige Kennung.' end + + local vermerk = Text(daten.vermerk, 4000) + if not vermerk or #vermerk < 5 then + return nil, 'Ein Abschluss erfordert einen Abschlussvermerk.' + end + + local zeilen = MySQL.query.await( + 'SELECT id, nummer, titel, status FROM medical_einsatz WHERE id = ?', { einsatzId }) + if not zeilen or not zeilen[1] then return nil, 'Einsatz nicht gefunden.' end + + local einsatz = zeilen[1] + if einsatz.status == 'abgeschlossen' then + return nil, 'Dieser Einsatz ist bereits abgeschlossen.' + end + + -- Der Abschlussvermerk wird als Notiz Teil des Verlaufs + MySQL.insert.await( + 'INSERT INTO medical_einsatz_notiz (einsatz, text, von) VALUES (?, ?, ?)', + { einsatzId, 'Abschluss: ' .. vermerk, mitarbeiter.charakterId }) + + -- Noch gebundene Kräfte werden abgemeldet — der Einsatz ist vorbei + MySQL.update.await( + 'UPDATE medical_einsatz_kraft SET bis = NOW() WHERE einsatz = ? AND bis IS NULL', + { einsatzId }) + + MySQL.update.await([[ + UPDATE medical_einsatz + SET status = 'abgeschlossen', geschlossen_von = ?, geschlossen_am = NOW() + WHERE id = ? + ]], { mitarbeiter.charakterId, einsatzId }) + + -- Gibt es schon eine Dokumentation zu diesem Einsatz? + local dokumentiert = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_behandlung WHERE einsatz = ?', { einsatzId })) or 0 + + Protokoll.Schreibe(mitarbeiter, 'einsatz', 'Einsatz abgeschlossen', + { typ = 'einsatz', id = einsatzId }, + { nummer = einsatz.nummer, dokumentationen = dokumentiert }, vermerk) + + -- Erinnern, nicht anlegen: das System erzeugt keine Behandlungsdoku + if dokumentiert == 0 then + Hinweise.Erstelle({ charakterId = mitarbeiter.charakterId }, { + kategorie = 'doku', prioritaet = 'normal', + titel = 'Dokumentation fehlt', + text = ('Zu %s (%s) ist noch keine Behandlung dokumentiert.') + :format(einsatz.nummer, einsatz.titel), + zielTyp = 'einsatz', zielId = einsatzId, + }) + end + + Kern.Loese('einsatz.abgeschlossen', einsatzId, mitarbeiter) + Melden() + + return { ok = true, dokumentiert = dokumentiert } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Liefert die offenen und laufenden Einsätze +--- @return table +exports('HoleOffeneEinsaetze', function() + return MySQL.query.await([[ + SELECT id, nummer, titel, kategorie, prioritaet, ort, status, + pos_x, pos_y, pos_z, erstellt_am + FROM medical_einsatz + WHERE status IN ('offen', 'laufend') + ORDER BY prioritaet, id DESC + ]]) or {} +end) diff --git a/resources/[aether]/aether-medical/server/module/fahrzeug.lua b/resources/[aether]/aether-medical/server/module/fahrzeug.lua new file mode 100644 index 0000000..9d486d6 --- /dev/null +++ b/resources/[aether]/aether-medical/server/module/fahrzeug.lua @@ -0,0 +1,651 @@ +-- ===================================================================== +-- aether-medical / Modul: Fuhrpark +-- +-- Ein Fahrzeugbuch. Es fährt nichts, gibt nichts aus und ruft keine +-- Werkstatt. +-- +-- Zwei Sorten von Angaben stehen hier nebeneinander, und sie werden +-- streng auseinandergehalten: +-- +-- * GEMELDET — Tankstand, Zustand und Position kommen aus dem Spiel, +-- während jemand fährt. Das sind Tatsachen, keine Entscheidungen. +-- Sie werden nur festgeschrieben und mit ihrem Alter angezeigt. +-- +-- * EINGETRAGEN — Einsatzstatus, Wartungsstatus und Zuweisung setzt +-- ausschliesslich ein Mensch. Kein gemeldeter Wert ändert sie je: +-- ein leerer Tank stellt kein Fahrzeug ausser Dienst, und ein +-- Schaden schickt keines in die Werkstatt. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'fahrzeug', name = 'Fuhrpark', icon = '🚐', + reihenfolge = 12, recht = 'fahrzeug.sehen', + beschreibung = 'Fahrzeuge, Status, Zuweisung und Wartung', +}) + +-- --------------------------------------------------------------------- +-- Grundbestand +-- +-- Damit sich überhaupt etwas zuweisen lässt. Die Pflege übernimmt +-- danach die Fuhrparkverwaltung. +-- --------------------------------------------------------------------- +local GRUNDFAHRZEUGE = { + -- kennzeichen, modell, bezeichnung, art, station, ausstattung + { 'LSMC 001', 'ambulance', 'RTW 1', 'rtw', 'notaufnahme', + 'Defibrillator, Beatmungsgerät, Notfallrucksack, Trage, Sauerstoff' }, + { 'LSMC 002', 'ambulance', 'RTW 2', 'rtw', 'notaufnahme', + 'Defibrillator, Notfallrucksack, Trage, Sauerstoff' }, + { 'LSMC 010', 'ambulance', 'NEF 1', 'nef', 'notaufnahme', + 'Notfallrucksack, Notfallmedikamente, Perfusor' }, + { 'LSMC 020', 'ambulance', 'KTW 1', 'ktw', 'rettung', + 'Trage, Sauerstoff' }, +} + +CreateThread(function() + Wait(2500) + + local vorhanden = tonumber(MySQL.scalar.await('SELECT COUNT(*) FROM medical_fahrzeug')) or 0 + if vorhanden > 0 then return end + + for _, eintrag in ipairs(GRUNDFAHRZEUGE) do + MySQL.insert.await([[ + INSERT INTO medical_fahrzeug + (kennzeichen, modell, bezeichnung, art, station, ausstattung) + VALUES (?, ?, ?, ?, ?, ?) + ]], { eintrag[1], eintrag[2], eintrag[3], eintrag[4], eintrag[5], eintrag[6] }) + end + + Aether.Debug('Grundbestand an Fahrzeugen angelegt.') +end) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Begrenzt einen Text und liefert nil bei leerer Eingabe +--- @param wert any Eingabe +--- @param laenge number Maximale Zeichenzahl +--- @return string|nil +local function Text(wert, laenge) + if type(wert) ~= 'string' then return nil end + local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '') + if sauber == '' then return nil end + return sauber:sub(1, laenge) +end + +--- Name eines Charakters für die Anzeige +--- @param charakterId number|nil Charakter +--- @return string +local function NameVon(charakterId) + if not charakterId then return 'unbekannt' end + local zeilen = MySQL.query.await( + 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId }) + if not zeilen or not zeilen[1] then return 'unbekannt' end + return zeilen[1].firstname .. ' ' .. zeilen[1].lastname +end + +--- Prüft eine Kennung gegen eine Konfigurationsliste +--- @param liste table Liste aus Medizin.Config +--- @param wert any Eingabe +--- @return string|nil Gültige Kennung +local function AusListe(liste, wert) + local gesucht = tostring(wert or '') + for _, eintrag in ipairs(liste) do + if eintrag.id == gesucht then return eintrag.id end + end + return nil +end + +--- Bringt ein Kennzeichen auf eine vergleichbare Schreibweise. +--- Das Spiel liefert Kennzeichen mit Leerzeichen aufgefüllt. +--- @param wert any Eingabe +--- @return string|nil +local function Kennzeichen(wert) + local sauber = Text(wert, 12) + if not sauber then return nil end + return sauber:upper() +end + +--- Anzeigename aus einer Konfigurationsliste +--- @param liste table Liste aus Medizin.Config +--- @param kennung string Kennung +--- @return string +local function NameAusListe(liste, kennung) + for _, eintrag in ipairs(liste) do + if eintrag.id == kennung then return eintrag.name end + end + return tostring(kennung) +end + +--- Lädt ein Fahrzeug +--- @param fahrzeugId number Fahrzeug +--- @return table|nil +local function LadeFahrzeug(fahrzeugId) + local zeilen = MySQL.query.await([[ + SELECT f.*, + TIMESTAMPDIFF(MINUTE, f.gemeldet_am, NOW()) AS meldungAlter + FROM medical_fahrzeug f + WHERE f.id = ? + ]], { fahrzeugId }) + return zeilen and zeilen[1] or nil +end + +--- Bereitet die gemeldeten Werte für die Anzeige auf. +--- Wichtig ist dabei nicht der Wert allein, sondern sein Alter: eine +--- alte Meldung sagt nichts über den jetzigen Zustand. +--- @param eintrag table Fahrzeugzeile +local function BereiteMeldung(eintrag) + eintrag.tank = eintrag.tank and math.floor(tonumber(eintrag.tank) + 0.5) or nil + eintrag.zustand = eintrag.zustand and math.floor(tonumber(eintrag.zustand) + 0.5) or nil + eintrag.meldungAlter = eintrag.gemeldet_am and (tonumber(eintrag.meldungAlter) or 0) or nil + eintrag.meldungVeraltet = eintrag.meldungAlter ~= nil + and eintrag.meldungAlter > Medizin.Config.Erinnerungen.fahrzeugMeldungAltNach + + eintrag.letzterFahrer = eintrag.letzter_fahrer + and NameVon(eintrag.letzter_fahrer) or nil + eintrag.zugewiesenAn = eintrag.zugewiesen_an + and NameVon(eintrag.zugewiesen_an) or nil + + eintrag.artName = NameAusListe(Medizin.Config.Fahrzeugarten, eintrag.art) + eintrag.statusName = NameAusListe(Medizin.Config.Fahrzeugstatus, eintrag.status) + eintrag.wartungName = NameAusListe(Medizin.Config.Wartungsstatus, eintrag.wartung) +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('fahrzeug', 'uebersicht', nil, function(quelle, mitarbeiter, daten) + local ordner = tostring(daten.ordner or 'dienst') + + -- Welche Status gehören in diesen Ordner? + local bedingung, werte = nil, {} + if ordner == 'werkstatt' then + bedingung = "f.status = 'werkstatt' OR f.wartung <> 'ok'" + elseif ordner == 'ausser' then + bedingung = "f.status = 'ausser_dienst'" + else + bedingung = "f.status IN ('verfuegbar', 'im_einsatz')" + end + + if daten.art and daten.art ~= '' then + bedingung = ('(%s) AND f.art = ?'):format(bedingung) + werte[#werte + 1] = tostring(daten.art) + end + + local liste = MySQL.query.await(([[ + SELECT f.*, + TIMESTAMPDIFF(MINUTE, f.gemeldet_am, NOW()) AS meldungAlter + FROM medical_fahrzeug f + WHERE %s + ORDER BY f.art, f.bezeichnung + LIMIT 200 + ]]):format(bedingung), werte) or {} + + for _, eintrag in ipairs(liste) do + BereiteMeldung(eintrag) + -- Rohdaten der Position bleiben dem Client erspart, ausser als Wegpunkt + eintrag.hatPosition = eintrag.pos_x ~= nil and eintrag.pos_y ~= nil + end + + local function Zaehle(sql, parameter) + return tonumber(MySQL.scalar.await(sql, parameter)) or 0 + end + + local zaehler = { + verfuegbar = Zaehle("SELECT COUNT(*) FROM medical_fahrzeug WHERE status = 'verfuegbar'"), + imEinsatz = Zaehle("SELECT COUNT(*) FROM medical_fahrzeug WHERE status = 'im_einsatz'"), + werkstatt = Zaehle([[ + SELECT COUNT(*) FROM medical_fahrzeug + WHERE status = 'werkstatt' OR wartung <> 'ok' + ]]), + ausserDienst = Zaehle( + "SELECT COUNT(*) FROM medical_fahrzeug WHERE status = 'ausser_dienst'"), + zugewiesen = Zaehle( + 'SELECT COUNT(*) FROM medical_fahrzeug WHERE zugewiesen_an IS NOT NULL'), + } + + return { + ordner = ordner, + liste = liste, + zaehler = zaehler, + arten = Medizin.Config.Fahrzeugarten, + status = Medizin.Config.Fahrzeugstatus, + wartungsstatus = Medizin.Config.Wartungsstatus, + stationen = Medizin.Config.Stationen, + ausstattung = Medizin.Config.Fahrzeugausstattung, + grenzen = { + tank = Medizin.Config.Erinnerungen.fahrzeugTankUnter, + zustand = Medizin.Config.Erinnerungen.fahrzeugZustandUnter, + meldung = Medizin.Config.Erinnerungen.fahrzeugMeldungAltNach, + }, + rechte = { + zuweisen = Rechte.Hat(mitarbeiter, 'fahrzeug.zuweisen'), + wartung = Rechte.Hat(mitarbeiter, 'fahrzeug.wartung'), + pflegen = Rechte.Hat(mitarbeiter, 'fahrzeug.pflegen'), + }, + } +end) + +-- ===================================================================== +-- EINZELNES FAHRZEUG +-- ===================================================================== + +Kern.RegistriereAktion('fahrzeug', 'oeffnen', nil, function(quelle, mitarbeiter, daten) + local fahrzeugId = tonumber(daten.id) + if not fahrzeugId then return nil, 'Ungültige Kennung.' end + + local eintrag = LadeFahrzeug(fahrzeugId) + if not eintrag then return nil, 'Fahrzeug nicht gefunden.' end + + BereiteMeldung(eintrag) + + -- Der Weg des Fahrzeugs steht im Protokoll und wird nur gelesen + local spur = Protokoll.Lies({ zielTyp = 'fahrzeug', zielId = fahrzeugId, limit = 40 }) + + for _, zeile in ipairs(spur) do + zeile.autor = zeile.firstname + and (zeile.firstname .. ' ' .. zeile.lastname) or 'System' + zeile.firstname, zeile.lastname, zeile.details = nil, nil, nil + end + + return { + fahrzeug = { + id = eintrag.id, kennzeichen = eintrag.kennzeichen, + modell = eintrag.modell, bezeichnung = eintrag.bezeichnung, + art = eintrag.art, artName = eintrag.artName, + station = eintrag.station, + status = eintrag.status, statusName = eintrag.statusName, + wartung = eintrag.wartung, wartungName = eintrag.wartungName, + ausstattung = eintrag.ausstattung, notiz = eintrag.notiz, + tank = eintrag.tank, zustand = eintrag.zustand, + gemeldetAm = eintrag.gemeldet_am, + meldungAlter = eintrag.meldungAlter, + meldungVeraltet = eintrag.meldungVeraltet, + letzterFahrer = eintrag.letzterFahrer, + zugewiesen = eintrag.zugewiesen_an, + zugewiesenAn = eintrag.zugewiesenAn, + position = (eintrag.pos_x and eintrag.pos_y) + and { x = eintrag.pos_x, y = eintrag.pos_y, z = eintrag.pos_z } or nil, + }, + spur = spur, + arten = Medizin.Config.Fahrzeugarten, + status = Medizin.Config.Fahrzeugstatus, + wartungsstatus = Medizin.Config.Wartungsstatus, + stationen = Medizin.Config.Stationen, + ausstattung = Medizin.Config.Fahrzeugausstattung, + rechte = { + zuweisen = Rechte.Hat(mitarbeiter, 'fahrzeug.zuweisen'), + wartung = Rechte.Hat(mitarbeiter, 'fahrzeug.wartung'), + pflegen = Rechte.Hat(mitarbeiter, 'fahrzeug.pflegen'), + }, + } +end) + +-- ===================================================================== +-- EINSATZSTATUS +-- +-- Wird immer eingetragen. Weder Telemetrie noch ein laufender Einsatz +-- setzt ihn — auch ein Fahrzeug mit leerem Tank bleibt "verfügbar", +-- bis jemand etwas anderes einträgt. +-- ===================================================================== + +Kern.RegistriereAktion('fahrzeug', 'status', 'fahrzeug.zuweisen', function(quelle, mitarbeiter, daten) + local fahrzeugId = tonumber(daten.id) + if not fahrzeugId then return nil, 'Ungültige Kennung.' end + + local neu = AusListe(Medizin.Config.Fahrzeugstatus, daten.status) + if not neu then return nil, 'Unbekannter Fahrzeugstatus.' end + + local eintrag = LadeFahrzeug(fahrzeugId) + if not eintrag then return nil, 'Fahrzeug nicht gefunden.' end + + -- Ein Fahrzeug aus dem Dienst zu nehmen ist Sache der Wartung + if (neu == 'werkstatt' or neu == 'ausser_dienst') + and not Rechte.Hat(mitarbeiter, 'fahrzeug.wartung') then + return nil, 'Fahrzeuge aus dem Dienst nehmen darf nur, wer die Wartung führt.' + end + + local grund = Text(daten.grund, 255) + if (neu == 'werkstatt' or neu == 'ausser_dienst') and not grund then + return nil, 'Bitte angeben, warum das Fahrzeug nicht mehr fährt.' + end + + MySQL.update.await('UPDATE medical_fahrzeug SET status = ? WHERE id = ?', + { neu, fahrzeugId }) + + Protokoll.Schreibe(mitarbeiter, 'fahrzeug', 'Fahrzeugstatus gesetzt', + { typ = 'fahrzeug', id = fahrzeugId }, + { kennzeichen = eintrag.kennzeichen, bezeichnung = eintrag.bezeichnung, + von = eintrag.status, nach = neu }, grund) + + -- Ein Fahrzeug, das jemandem zugewiesen war, geht nicht stillschweigend + if (neu == 'werkstatt' or neu == 'ausser_dienst') and eintrag.zugewiesen_an then + Hinweise.Erstelle({ charakterId = eintrag.zugewiesen_an }, { + kategorie = 'system', prioritaet = 'normal', + titel = 'Dein Fahrzeug wurde aus dem Dienst genommen', + text = ('%s (%s) — %s.'):format(eintrag.bezeichnung, eintrag.kennzeichen, + grund or 'ohne weitere Angabe'), + zielTyp = 'fahrzeug', zielId = fahrzeugId, + }) + end + + Kern.Loese('fahrzeug.status', fahrzeugId, neu, mitarbeiter) + + return { ok = true } +end) + +-- ===================================================================== +-- ZUWEISUNG +-- +-- Ein Fahrzeug wird jemandem zugeteilt oder freigegeben. Das öffnet +-- keine Tür, gibt keinen Schlüssel aus und spawnt nichts. +-- ===================================================================== + +--- Wer ist gerade im Dienst? +Kern.RegistriereAktion('fahrzeug', 'kollegen', 'fahrzeug.zuweisen', function(quelle, mitarbeiter, daten) + local liste = {} + + for _, eintrag in pairs(Dienst.AlleImDienst()) do + liste[#liste + 1] = { + charakterId = eintrag.charakterId, + name = eintrag.name, + rolleName = eintrag.rolleName, + personalnummer = eintrag.personalnummer, + funktion = eintrag.funktion, + status = eintrag.status, + selbst = eintrag.charakterId == mitarbeiter.charakterId, + } + end + + table.sort(liste, function(a, b) + return tostring(a.personalnummer) < tostring(b.personalnummer) + end) + + return { liste = liste } +end) + +Kern.RegistriereAktion('fahrzeug', 'zuweisen', 'fahrzeug.zuweisen', function(quelle, mitarbeiter, daten) + local fahrzeugId = tonumber(daten.id) + if not fahrzeugId then return nil, 'Ungültige Kennung.' end + + local eintrag = LadeFahrzeug(fahrzeugId) + if not eintrag then return nil, 'Fahrzeug nicht gefunden.' end + + -- Ohne Ziel wird die Zuweisung aufgehoben + local zielId = tonumber(daten.charakter) + + if zielId then + if eintrag.status == 'werkstatt' or eintrag.status == 'ausser_dienst' then + return nil, ('%s steht auf "%s" und lässt sich nicht zuweisen.') + :format(eintrag.bezeichnung, eintrag.statusName or eintrag.status) + end + + local zielQuelle = Dienst.QuelleVonCharakter(zielId) + if not zielQuelle or not Dienst.Hole(zielQuelle) then + return nil, 'Diese Person ist nicht im Dienst.' + end + + -- Ein Fahrzeug gehört immer nur einer Person + if eintrag.zugewiesen_an and eintrag.zugewiesen_an ~= zielId then + local vorher = NameVon(eintrag.zugewiesen_an) + if not Text(daten.grund, 255) then + return nil, ('%s ist bereits %s zugewiesen — bitte einen Grund angeben.') + :format(eintrag.bezeichnung, vorher) + end + end + else + local grund = Text(daten.grund, 255) + if not grund then return nil, 'Bitte angeben, warum die Zuweisung entfällt.' end + end + + MySQL.update.await('UPDATE medical_fahrzeug SET zugewiesen_an = ? WHERE id = ?', + { zielId, fahrzeugId }) + + Protokoll.Schreibe(mitarbeiter, 'fahrzeug', + zielId and 'Fahrzeug zugewiesen' or 'Zuweisung aufgehoben', + { typ = 'fahrzeug', id = fahrzeugId }, + { kennzeichen = eintrag.kennzeichen, bezeichnung = eintrag.bezeichnung, + vorher = eintrag.zugewiesen_an, nachher = zielId }, + Text(daten.grund, 255)) + + -- Die betroffene Person erfährt es unmittelbar + if zielId and zielId ~= mitarbeiter.charakterId then + Hinweise.Erstelle({ charakterId = zielId }, { + kategorie = 'system', prioritaet = 'normal', + titel = 'Dir wurde ein Fahrzeug zugewiesen', + text = ('%s (%s) — von %s.'):format(eintrag.bezeichnung, + eintrag.kennzeichen, mitarbeiter.name), + zielTyp = 'fahrzeug', zielId = fahrzeugId, + }) + end + + -- Wer es vorher hatte, ebenfalls + if eintrag.zugewiesen_an and eintrag.zugewiesen_an ~= zielId + and eintrag.zugewiesen_an ~= mitarbeiter.charakterId then + Hinweise.Erstelle({ charakterId = eintrag.zugewiesen_an }, { + kategorie = 'system', prioritaet = 'normal', + titel = 'Deine Fahrzeugzuweisung wurde geändert', + text = ('%s (%s) ist dir nicht mehr zugewiesen.') + :format(eintrag.bezeichnung, eintrag.kennzeichen), + zielTyp = 'fahrzeug', zielId = fahrzeugId, + }) + end + + Kern.Loese('fahrzeug.zugewiesen', fahrzeugId, zielId, mitarbeiter) + + return { ok = true } +end) + +-- ===================================================================== +-- WARTUNG +-- +-- Der Wartungsstatus ist eine Beurteilung. Ein gemeldeter Schaden +-- setzt ihn nicht — er erinnert nur daran, hinzusehen. +-- ===================================================================== + +Kern.RegistriereAktion('fahrzeug', 'wartung', 'fahrzeug.wartung', function(quelle, mitarbeiter, daten) + local fahrzeugId = tonumber(daten.id) + if not fahrzeugId then return nil, 'Ungültige Kennung.' end + + local neu = AusListe(Medizin.Config.Wartungsstatus, daten.wartung) + if not neu then return nil, 'Unbekannter Wartungsstatus.' end + + local eintrag = LadeFahrzeug(fahrzeugId) + if not eintrag then return nil, 'Fahrzeug nicht gefunden.' end + + -- Ein leeres Feld löscht die Notiz, ein fehlendes lässt sie stehen + local notiz = eintrag.notiz + if daten.notiz ~= nil then notiz = Text(daten.notiz, 255) end + + MySQL.update.await('UPDATE medical_fahrzeug SET wartung = ?, notiz = ? WHERE id = ?', + { neu, notiz, fahrzeugId }) + + Protokoll.Schreibe(mitarbeiter, 'fahrzeug', 'Wartungsstatus gesetzt', + { typ = 'fahrzeug', id = fahrzeugId }, + { kennzeichen = eintrag.kennzeichen, von = eintrag.wartung, nach = neu }, notiz) + + -- Fällige Wartung meldet das Haus, damit niemand ahnungslos losfährt + if neu ~= 'ok' and eintrag.wartung == 'ok' then + Hinweise.Erstelle(nil, { + kategorie = 'system', prioritaet = 'normal', + titel = ('Wartung eingetragen · %s'):format(eintrag.bezeichnung), + text = ('%s (%s) — %s.'):format(eintrag.bezeichnung, eintrag.kennzeichen, + NameAusListe(Medizin.Config.Wartungsstatus, neu)), + zielTyp = 'fahrzeug', zielId = fahrzeugId, + }) + end + + Kern.Loese('fahrzeug.wartung', fahrzeugId, neu, mitarbeiter) + + return { ok = true } +end) + +-- ===================================================================== +-- FUHRPARK PFLEGEN +-- ===================================================================== + +Kern.RegistriereAktion('fahrzeug', 'speichern', 'fahrzeug.pflegen', function(quelle, mitarbeiter, daten) + local fahrzeugId = tonumber(daten.id) + + local kennzeichen = Kennzeichen(daten.kennzeichen) + local modell = Text(daten.modell, 48) + local bezeichnung = Text(daten.bezeichnung, 64) + local art = AusListe(Medizin.Config.Fahrzeugarten, daten.art) + + if not kennzeichen then return nil, 'Bitte ein Kennzeichen angeben.' end + if not modell then return nil, 'Bitte das Fahrzeugmodell angeben.' end + if not bezeichnung then return nil, 'Bitte eine Bezeichnung angeben, z. B. RTW 1.' end + if not art then return nil, 'Unbekannte Fahrzeugart.' end + + -- Kennzeichen müssen eindeutig bleiben: die Telemetrie findet das + -- Fahrzeug ausschliesslich darüber wieder. + local kollision = MySQL.scalar.await( + 'SELECT id FROM medical_fahrzeug WHERE kennzeichen = ? AND id <> ?', + { kennzeichen, fahrzeugId or 0 }) + if kollision then + return nil, ('Das Kennzeichen %s ist bereits vergeben.'):format(kennzeichen) + end + + -- Eine Station ist freiwillig, muss aber eine bekannte sein + local station = nil + if Text(daten.station, 32) then + station = AusListe(Medizin.Config.Stationen, daten.station) + if not station then return nil, 'Unbekannte Station.' end + end + + local ausstattung = Text(daten.ausstattung, 512) + + if fahrzeugId then + local vorher = LadeFahrzeug(fahrzeugId) + if not vorher then return nil, 'Fahrzeug nicht gefunden.' end + + MySQL.update.await([[ + UPDATE medical_fahrzeug + SET kennzeichen = ?, modell = ?, bezeichnung = ?, art = ?, + station = ?, ausstattung = ? + WHERE id = ? + ]], { kennzeichen, modell, bezeichnung, art, station, ausstattung, fahrzeugId }) + + Protokoll.Schreibe(mitarbeiter, 'fahrzeug', 'Fahrzeug bearbeitet', + { typ = 'fahrzeug', id = fahrzeugId }, + { kennzeichen = kennzeichen, bezeichnung = bezeichnung, art = art, + vorherigesKennzeichen = vorher.kennzeichen }) + else + fahrzeugId = MySQL.insert.await([[ + INSERT INTO medical_fahrzeug + (kennzeichen, modell, bezeichnung, art, station, ausstattung) + VALUES (?, ?, ?, ?, ?, ?) + ]], { kennzeichen, modell, bezeichnung, art, station, ausstattung }) + + Protokoll.Schreibe(mitarbeiter, 'fahrzeug', 'Fahrzeug aufgenommen', + { typ = 'fahrzeug', id = fahrzeugId }, + { kennzeichen = kennzeichen, bezeichnung = bezeichnung, art = art }) + end + + Kern.Loese('fahrzeug.geaendert', fahrzeugId, mitarbeiter) + + return { id = fahrzeugId } +end) + +--- Entfernt ein Fahrzeug endgültig. +--- Möglich erst, wenn es ausser Dienst steht — ausmustern ist ein +--- eigener Schritt und keine Nebenwirkung des Löschens. +Kern.RegistriereAktion('fahrzeug', 'entfernen', 'fahrzeug.pflegen', function(quelle, mitarbeiter, daten) + local fahrzeugId = tonumber(daten.id) + if not fahrzeugId then return nil, 'Ungültige Kennung.' end + + local grund = Text(daten.grund, 255) + if not grund then return nil, 'Bitte einen Grund angeben.' end + + local eintrag = LadeFahrzeug(fahrzeugId) + if not eintrag then return nil, 'Fahrzeug nicht gefunden.' end + + if eintrag.status ~= 'ausser_dienst' then + return nil, 'Nur ein Fahrzeug außer Dienst lässt sich aus dem Fuhrpark nehmen.' + end + + MySQL.query.await('DELETE FROM medical_fahrzeug WHERE id = ?', { fahrzeugId }) + + Protokoll.Schreibe(mitarbeiter, 'fahrzeug', 'Fahrzeug aus dem Fuhrpark genommen', + { typ = 'fahrzeug', id = fahrzeugId }, + { kennzeichen = eintrag.kennzeichen, bezeichnung = eintrag.bezeichnung }, grund) + + Kern.Loese('fahrzeug.geaendert', fahrzeugId, mitarbeiter) + + return { ok = true } +end) + +-- ===================================================================== +-- TELEMETRIE +-- +-- Der Client meldet, was das Spiel hergibt, solange jemand fährt. +-- Hier landen ausschliesslich Tatsachen — kein gemeldeter Wert ändert +-- Status, Wartung oder Zuweisung. Wer daraus etwas ableitet, ist ein +-- Mensch. +-- ===================================================================== + +RegisterNetEvent('aether:medical:fahrzeugMelden', function(meldung) + local quelle = source + + -- Nur wer im Dienst ist, meldet für den Fuhrpark + local mitarbeiter = Dienst.Hole(quelle) + if not mitarbeiter then return end + if type(meldung) ~= 'table' then return end + + local kennzeichen = Kennzeichen(meldung.kennzeichen) + if not kennzeichen then return end + + -- Fremde Fahrzeuge werden stillschweigend verworfen + local fahrzeug = MySQL.query.await( + 'SELECT id FROM medical_fahrzeug WHERE kennzeichen = ?', { kennzeichen }) + if not fahrzeug or not fahrzeug[1] then return end + + --- Bringt einen gemeldeten Wert auf 0 bis 100 + --- @param wert any Rohwert aus dem Spiel + --- @param spanne number Wertebereich, den das Spiel liefert + --- @return number|nil + local function Prozent(wert, spanne) + local zahl = tonumber(wert) + if not zahl then return nil end + local anteil = (zahl / spanne) * 100 + return math.max(0, math.min(100, anteil)) + end + + MySQL.update.await([[ + UPDATE medical_fahrzeug + SET tank = ?, zustand = ?, pos_x = ?, pos_y = ?, pos_z = ?, + letzter_fahrer = ?, gemeldet_am = NOW() + WHERE id = ? + ]], { + Prozent(meldung.tank, 100), -- Tankstand kommt bereits als Prozent + Prozent(meldung.zustand, 1000), -- Karosseriezustand reicht von 0 bis 1000 + tonumber(meldung.x), tonumber(meldung.y), tonumber(meldung.z), + mitarbeiter.charakterId, + fahrzeug[1].id, + }) +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Liefert den Fuhrpark als Auskunft. +--- Reine Anzeige — keine Ressource kann darüber ein Fahrzeug zuweisen, +--- seinen Status setzen oder es in die Werkstatt schicken. +--- @param nurVerfuegbar boolean|nil Nur einsatzbereite Fahrzeuge +--- @return table Liste +exports('HoleFuhrpark', function(nurVerfuegbar) + local bedingung = nurVerfuegbar + and "WHERE status = 'verfuegbar' AND wartung = 'ok'" or '' + + local zeilen = MySQL.query.await(([[ + SELECT kennzeichen, bezeichnung, art, station, status, wartung, + tank, zustand, gemeldet_am + FROM medical_fahrzeug + %s + ORDER BY art, bezeichnung + ]]):format(bedingung)) or {} + + return zeilen +end) diff --git a/resources/[aether]/aether-medical/server/module/intensiv.lua b/resources/[aether]/aether-medical/server/module/intensiv.lua new file mode 100644 index 0000000..bb1fdf0 --- /dev/null +++ b/resources/[aether]/aether-medical/server/module/intensiv.lua @@ -0,0 +1,694 @@ +-- ===================================================================== +-- aether-medical / Modul: Intensivstation +-- +-- Ein Intensivaufenthalt ist hier eine Akte, kein Monitor. +-- +-- Das System misst nichts. Es kennt keinen Blutdruck, keine Sättigung +-- und keinen Alarm. Der Zustand eines Patienten steht genau deshalb +-- im Datensatz, weil ein Mediziner ihn dort eingetragen hat — er +-- ändert sich nie von selbst, weder durch Zeitablauf noch durch +-- irgendeine Rechnung. +-- +-- Auch Geräte werden nur benannt, nicht gesteuert. Und ein Aufenthalt +-- endet erst, wenn jemand ihn beendet: nicht mit der Entlassung, +-- nicht mit dem Bett und nicht mit dem Dienstende. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'intensiv', name = 'Intensiv', icon = '📈', + reihenfolge = 10, recht = 'intensiv.sehen', + beschreibung = 'Intensivaufenthalte, Zustand, Geräte und Verlauf', +}) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Begrenzt einen Text und liefert nil bei leerer Eingabe +--- @param wert any Eingabe +--- @param laenge number Maximale Zeichenzahl +--- @return string|nil +local function Text(wert, laenge) + if type(wert) ~= 'string' then return nil end + local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '') + if sauber == '' then return nil end + return sauber:sub(1, laenge) +end + +--- Name eines Mitarbeiters für die Anzeige +--- @param charakterId number|nil Charakter +--- @return string +local function NameVon(charakterId) + if not charakterId then return 'unbekannt' end + local zeilen = MySQL.query.await( + 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId }) + if not zeilen or not zeilen[1] then return 'unbekannt' end + return zeilen[1].firstname .. ' ' .. zeilen[1].lastname +end + +--- Prüft einen Zustand gegen die Konfiguration +--- @param wert any Eingabe +--- @return string|nil Gültige Kennung +local function Zustand(wert) + local gesucht = tostring(wert or '') + for _, eintrag in ipairs(Medizin.Config.Intensivzustaende) do + if eintrag.id == gesucht then return eintrag.id end + end + return nil +end + +--- Anzeigename eines Zustands +--- @param kennung string Zustand +--- @return string +local function ZustandName(kennung) + for _, eintrag in ipairs(Medizin.Config.Intensivzustaende) do + if eintrag.id == kennung then return eintrag.name end + end + return tostring(kennung) +end + +--- Baut die Sortierreihenfolge der Zustände für MySQL. +--- Sortiert wird nach dem, was Menschen eingetragen haben — das System +--- bewertet dabei nichts, es stellt nur das Eingetragene nach oben. +--- @return string FIELD(...)-Ausdruck +local function ZustandSortierung() + local teile = {} + for _, eintrag in ipairs(Medizin.Config.Intensivzustaende) do + -- Die Kennungen stammen aus der eigenen Konfiguration + teile[#teile + 1] = ("'%s'"):format(eintrag.id:gsub("'", '')) + end + return ('FIELD(i.zustand, %s)'):format(table.concat(teile, ', ')) +end + +--- Lädt einen Intensivaufenthalt mit allen Begleitdaten +--- @param intensivId number Aufenthalt +--- @return table|nil Zeile +local function LadeAufenthalt(intensivId) + local zeilen = MySQL.query.await([[ + SELECT i.*, + TIMESTAMPDIFF(MINUTE, i.aufgenommen_am, + COALESCE(i.beendet_am, NOW())) AS liegtSeit, + c.firstname, c.lastname, c.dob, + z.kennung AS zimmerKennung, z.art AS zimmerArt, + s.name AS stationName, + a.status AS aufnahmeStatus, a.grund AS aufnahmeGrund + FROM medical_intensiv i + JOIN aether_characters c ON c.id = i.patient + LEFT JOIN medical_zimmer z ON z.id = i.zimmer + LEFT JOIN medical_station s ON s.id = z.station + LEFT JOIN medical_aufnahme a ON a.id = i.aufnahme + WHERE i.id = ? + ]], { intensivId }) + + return zeilen and zeilen[1] or nil +end + +--- Schreibt einen Verlaufseintrag. +--- Der Text stammt immer von einem Menschen — das System formuliert +--- hier nichts und fasst nichts zusammen. +--- @param intensivId number Aufenthalt +--- @param text string Wortlaut +--- @param charakterId number Verfasser +--- @return number Eintrag +local function SchreibeVerlauf(intensivId, text, charakterId) + return MySQL.insert.await( + 'INSERT INTO medical_intensiv_verlauf (intensiv, text, von) VALUES (?, ?, ?)', + { intensivId, text, charakterId }) +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('intensiv', 'uebersicht', nil, function(quelle, mitarbeiter, daten) + local ordner = tostring(daten.ordner or 'laufend') + local beendet = ordner == 'beendet' + + local liste = MySQL.query.await(([[ + SELECT i.id, i.patient, i.aufnahme, i.zimmer, i.zustand, i.geraete, i.notiz, + i.verantwortlich, i.aufgenommen_am, i.aufgenommen_von, + i.beendet_am, i.beendet_von, + TIMESTAMPDIFF(MINUTE, i.aufgenommen_am, + COALESCE(i.beendet_am, NOW())) AS liegtSeit, + TIMESTAMPDIFF(MINUTE, COALESCE( + (SELECT MAX(v.am) FROM medical_intensiv_verlauf v WHERE v.intensiv = i.id), + i.aufgenommen_am), NOW()) AS stillSeit, + (SELECT COUNT(*) FROM medical_intensiv_verlauf v + WHERE v.intensiv = i.id) AS eintraege, + c.firstname, c.lastname, c.dob, + p.blutgruppe, p.allergien, p.hinweis AS warnhinweis, + z.kennung AS zimmerKennung, s.name AS stationName + FROM medical_intensiv i + JOIN aether_characters c ON c.id = i.patient + LEFT JOIN medical_patient p ON p.`character` = i.patient + LEFT JOIN medical_zimmer z ON z.id = i.zimmer + LEFT JOIN medical_station s ON s.id = z.station + WHERE i.beendet_am IS %s NULL + ORDER BY %s + LIMIT 100 + ]]):format(beendet and 'NOT' or '', + beendet and 'i.beendet_am DESC' or (ZustandSortierung() .. ', i.aufgenommen_am'))) or {} + + local stille = Medizin.Config.Erinnerungen.intensivStilleNach + + for _, eintrag in ipairs(liste) do + eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname + eintrag.geburt = tostring(eintrag.dob):sub(1, 10) + eintrag.zustandName = ZustandName(eintrag.zustand) + eintrag.liegtSeit = tonumber(eintrag.liegtSeit) or 0 + eintrag.stillSeit = tonumber(eintrag.stillSeit) or 0 + eintrag.eintraege = tonumber(eintrag.eintraege) or 0 + eintrag.verantwortlichName = eintrag.verantwortlich + and NameVon(eintrag.verantwortlich) or nil + eintrag.aufgenommenVon = NameVon(eintrag.aufgenommen_von) + eintrag.beendetVon = eintrag.beendet_von and NameVon(eintrag.beendet_von) or nil + -- Nur eine Erinnerung, keine Bewertung der Betreuung + eintrag.langeStill = not beendet and eintrag.stillSeit >= stille + eintrag.firstname, eintrag.lastname, eintrag.dob = nil, nil, nil + eintrag.aufgenommen_von, eintrag.beendet_von = nil, nil + end + + -- Zählwerte über die laufenden Aufenthalte + local zaehler = { + laufend = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_intensiv WHERE beendet_am IS NULL')) or 0, + kritisch = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_intensiv + WHERE beendet_am IS NULL AND zustand = 'kritisch' + ]])) or 0, + ohneArzt = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_intensiv + WHERE beendet_am IS NULL AND verantwortlich IS NULL + ]])) or 0, + beendetHeute = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_intensiv + WHERE beendet_am IS NOT NULL AND DATE(beendet_am) = CURDATE() + ]])) or 0, + } + + -- Wie viele laufende Aufenthalte haben lange keinen Eintrag bekommen? + zaehler.langeStill = tonumber(MySQL.scalar.await(([[ + SELECT COUNT(*) FROM medical_intensiv i + WHERE i.beendet_am IS NULL + AND COALESCE( + (SELECT MAX(v.am) FROM medical_intensiv_verlauf v WHERE v.intensiv = i.id), + i.aufgenommen_am) < DATE_SUB(NOW(), INTERVAL ? MINUTE) + ]]), { stille })) or 0 + + return { + ordner = ordner, + liste = liste, + zaehler = zaehler, + zustaende = Medizin.Config.Intensivzustaende, + stilleNach = stille, + rechte = { + betreuen = Rechte.Hat(mitarbeiter, 'intensiv.betreuen'), + }, + } +end) + +-- ===================================================================== +-- EINZELANSICHT +-- ===================================================================== + +Kern.RegistriereAktion('intensiv', 'oeffnen', nil, function(quelle, mitarbeiter, daten) + local intensivId = tonumber(daten.id) + if not intensivId then return nil, 'Ungültige Kennung.' end + + local eintrag = LadeAufenthalt(intensivId) + if not eintrag then return nil, 'Intensivaufenthalt nicht gefunden.' end + + -- Stammdaten, die am Bett gebraucht werden + local stamm = MySQL.query.await([[ + SELECT blutgruppe, allergien, vorerkrankungen, dauermedikation, + hinweis, notfallkontakt, lichtbild + FROM medical_patient WHERE `character` = ? + ]], { eintrag.patient }) + stamm = stamm and stamm[1] or {} + + -- Der Verlauf — jüngster Eintrag zuerst + local verlauf = MySQL.query.await([[ + SELECT v.id, v.text, v.am, v.von, c.firstname, c.lastname + FROM medical_intensiv_verlauf v + LEFT JOIN aether_characters c ON c.id = v.von + WHERE v.intensiv = ? + ORDER BY v.id DESC + LIMIT 100 + ]], { intensivId }) or {} + + for _, zeile in ipairs(verlauf) do + zeile.autor = zeile.firstname and (zeile.firstname .. ' ' .. zeile.lastname) or 'unbekannt' + zeile.firstname, zeile.lastname, zeile.von = nil, nil, nil + end + + return { + aufenthalt = { + id = eintrag.id, patient = eintrag.patient, aufnahme = eintrag.aufnahme, + name = eintrag.firstname .. ' ' .. eintrag.lastname, + geburt = tostring(eintrag.dob):sub(1, 10), + zustand = eintrag.zustand, zustandName = ZustandName(eintrag.zustand), + geraete = eintrag.geraete, notiz = eintrag.notiz, + zimmer = eintrag.zimmer, zimmerKennung = eintrag.zimmerKennung, + zimmerArt = eintrag.zimmerArt, stationName = eintrag.stationName, + aufnahmeStatus = eintrag.aufnahmeStatus, + aufnahmeGrund = eintrag.aufnahmeGrund, + liegtSeit = tonumber(eintrag.liegtSeit) or 0, + verantwortlich = eintrag.verantwortlich, + verantwortlichName = eintrag.verantwortlich + and NameVon(eintrag.verantwortlich) or nil, + aufgenommenAm = eintrag.aufgenommen_am, + aufgenommenVon = NameVon(eintrag.aufgenommen_von), + beendetAm = eintrag.beendet_am, + beendetVon = eintrag.beendet_von and NameVon(eintrag.beendet_von) or nil, + laufend = eintrag.beendet_am == nil, + }, + stamm = stamm, + verlauf = verlauf, + zustaende = Medizin.Config.Intensivzustaende, + geraeteliste = Medizin.Config.Intensivgeraete, + rechte = { + betreuen = Rechte.Hat(mitarbeiter, 'intensiv.betreuen'), + }, + } +end) + +-- ===================================================================== +-- AUFNAHME AUF DIE INTENSIVSTATION +-- +-- Wer auf Intensiv kommt, entscheidet ein Arzt. Kein Zustand, keine +-- Diagnose und kein Wert bringt jemanden von selbst hierher. +-- ===================================================================== + +--- Aufgenommene Patienten ohne laufenden Intensivaufenthalt +--- sowie die Zimmer, in denen eine Betreuung stattfinden könnte. +Kern.RegistriereAktion('intensiv', 'kandidaten', 'intensiv.betreuen', function(quelle, mitarbeiter, daten) + local patienten = MySQL.query.await([[ + SELECT a.id AS aufnahme, a.patient, a.grund, a.prioritaet, a.station, + TIMESTAMPDIFF(MINUTE, a.aufgenommen_am, NOW()) AS imHausSeit, + c.firstname, c.lastname, c.dob, + p.blutgruppe, p.allergien, p.hinweis AS warnhinweis, + z.id AS zimmer, z.kennung AS zimmerKennung, + s.name AS stationName + FROM medical_aufnahme a + JOIN aether_characters c ON c.id = a.patient + LEFT JOIN medical_patient p ON p.`character` = a.patient + LEFT JOIN medical_zimmer z ON z.id = a.zimmer + LEFT JOIN medical_station s ON s.id = z.station + WHERE a.status = 'aufgenommen' + AND NOT EXISTS ( + SELECT 1 FROM medical_intensiv i + WHERE i.patient = a.patient AND i.beendet_am IS NULL) + ORDER BY a.prioritaet, a.aufgenommen_am + LIMIT 100 + ]]) or {} + + for _, eintrag in ipairs(patienten) do + eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname + eintrag.geburt = tostring(eintrag.dob):sub(1, 10) + eintrag.imHausSeit = tonumber(eintrag.imHausSeit) or 0 + eintrag.firstname, eintrag.lastname, eintrag.dob = nil, nil, nil + end + + -- Intensivplätze zur Auswahl. Das Bett selbst verwaltet die + -- Stationsverwaltung — hier wird nur festgehalten, wo betreut wird. + local zimmer = MySQL.query.await([[ + SELECT z.id, z.kennung, z.art, z.status, z.betten, z.hinweis, + s.name AS stationName, + (SELECT COUNT(*) FROM medical_belegung b + WHERE b.zimmer = z.id AND b.frei_am IS NULL) AS belegt + FROM medical_zimmer z + JOIN medical_station s ON s.id = z.station + WHERE s.aktiv = 1 AND z.art IN ('intensiv', 'isolierung') + ORDER BY s.sortierung, z.kennung + ]]) or {} + + for _, eintrag in ipairs(zimmer) do + eintrag.belegt = tonumber(eintrag.belegt) or 0 + eintrag.betten = tonumber(eintrag.betten) or 1 + end + + return { patienten = patienten, zimmer = zimmer } +end) + +Kern.RegistriereAktion('intensiv', 'aufnehmen', 'intensiv.betreuen', function(quelle, mitarbeiter, daten) + local aufnahmeId = tonumber(daten.aufnahme) + if not aufnahmeId then return nil, 'Kein Patient gewählt.' end + + local zustand = Zustand(daten.zustand) + if not zustand then return nil, 'Bitte einen Zustand eintragen.' end + + local notiz = Text(daten.notiz, 1000) + if not notiz or #notiz < 5 then + return nil, 'Bitte festhalten, warum der Patient auf die Intensivstation kommt.' + end + + local aufnahmen = MySQL.query.await([[ + SELECT a.id, a.patient, a.status, a.zimmer, c.firstname, c.lastname + FROM medical_aufnahme a + JOIN aether_characters c ON c.id = a.patient + WHERE a.id = ? + ]], { aufnahmeId }) + if not aufnahmen or not aufnahmen[1] then return nil, 'Aufnahme nicht gefunden.' end + + local aufnahme = aufnahmen[1] + if aufnahme.status ~= 'aufgenommen' then + return nil, 'Nur aufgenommene Patienten lassen sich auf die Intensivstation legen.' + end + + -- Zwei laufende Aufenthalte für denselben Patienten ergeben keinen Sinn + local laufend = MySQL.scalar.await( + 'SELECT id FROM medical_intensiv WHERE patient = ? AND beendet_am IS NULL', + { aufnahme.patient }) + if laufend then + return nil, 'Für diesen Patienten läuft bereits ein Intensivaufenthalt.' + end + + -- Zimmer ist freiwillig: die Betreuung kann auch ohne festen Platz beginnen + local zimmerId = tonumber(daten.zimmer) + if zimmerId then + local zimmer = MySQL.query.await( + 'SELECT id FROM medical_zimmer WHERE id = ?', { zimmerId }) + if not zimmer or not zimmer[1] then return nil, 'Unbekanntes Zimmer.' end + end + + -- Die Verantwortung übernimmt nur, wer das ausdrücklich angibt + local verantwortlich = daten.selbst == true and mitarbeiter.charakterId or nil + + local geraete = Text(daten.geraete, 512) + + local intensivId = MySQL.insert.await([[ + INSERT INTO medical_intensiv + (patient, aufnahme, zimmer, geraete, verantwortlich, zustand, notiz, aufgenommen_von) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ]], { aufnahme.patient, aufnahmeId, zimmerId, geraete, verantwortlich, + zustand, notiz, mitarbeiter.charakterId }) + + Protokoll.Schreibe(mitarbeiter, 'intensiv', 'Intensivaufenthalt eröffnet', + { typ = 'intensiv', id = intensivId }, + { patient = aufnahme.patient, aufnahme = aufnahmeId, + zimmer = zimmerId, zustand = zustand, + verantwortlich = verantwortlich }, notiz) + + Hinweise.Erstelle(nil, { + kategorie = 'patient', + prioritaet = zustand == 'kritisch' and 'hoch' or 'normal', + titel = ('Intensivaufnahme · %s'):format(ZustandName(zustand)), + text = ('%s %s wird ab jetzt auf der Intensivstation betreut.') + :format(aufnahme.firstname, aufnahme.lastname), + zielTyp = 'intensiv', zielId = intensivId, + }) + + -- Ohne zuständigen Arzt bleibt der Aufenthalt sichtbar offen + if not verantwortlich then + Hinweise.Erstelle(nil, { + kategorie = 'patient', prioritaet = 'hoch', + titel = 'Intensivpatient ohne zuständigen Arzt', + text = ('Für %s %s ist noch niemand eingetragen.') + :format(aufnahme.firstname, aufnahme.lastname), + zielTyp = 'intensiv', zielId = intensivId, + }) + end + + Kern.Loese('intensiv.aufgenommen', intensivId, mitarbeiter) + + return { id = intensivId } +end) + +-- ===================================================================== +-- ZUSTAND +-- +-- Der Zustand ist eine ärztliche Einschätzung und sonst nichts. Er +-- wird von Hand gesetzt, mit Begründung, und der Wortlaut dieser +-- Begründung wandert unverändert in den Verlauf. +-- ===================================================================== + +Kern.RegistriereAktion('intensiv', 'zustand', 'intensiv.betreuen', function(quelle, mitarbeiter, daten) + local intensivId = tonumber(daten.id) + if not intensivId then return nil, 'Ungültige Kennung.' end + + local neu = Zustand(daten.zustand) + if not neu then return nil, 'Unbekannter Zustand.' end + + local begruendung = Text(daten.grund, 1000) + if not begruendung or #begruendung < 3 then + return nil, 'Bitte begründen, worauf sich die Einschätzung stützt.' + end + + local eintrag = LadeAufenthalt(intensivId) + if not eintrag then return nil, 'Intensivaufenthalt nicht gefunden.' end + if eintrag.beendet_am then return nil, 'Dieser Aufenthalt ist bereits beendet.' end + + MySQL.update.await('UPDATE medical_intensiv SET zustand = ? WHERE id = ?', + { neu, intensivId }) + + -- Der Wortlaut des Arztes bleibt der Wortlaut des Arztes + SchreibeVerlauf(intensivId, begruendung, mitarbeiter.charakterId) + + Protokoll.Schreibe(mitarbeiter, 'intensiv', 'Zustand eingetragen', + { typ = 'intensiv', id = intensivId }, + { patient = eintrag.patient, von = eintrag.zustand, nach = neu }, begruendung) + + -- Eine Verschlechterung auf kritisch erfährt das ganze Haus + if neu == 'kritisch' and eintrag.zustand ~= 'kritisch' then + Hinweise.Erstelle(nil, { + kategorie = 'patient', prioritaet = 'kritisch', + titel = 'Intensivpatient als kritisch eingetragen', + text = ('%s %s — eingetragen von %s.') + :format(eintrag.firstname, eintrag.lastname, mitarbeiter.name), + zielTyp = 'intensiv', zielId = intensivId, + }) + end + + Kern.Loese('intensiv.zustand', intensivId, neu, mitarbeiter) + + return { ok = true } +end) + +-- ===================================================================== +-- VERLAUF +-- ===================================================================== + +Kern.RegistriereAktion('intensiv', 'verlauf', 'intensiv.betreuen', function(quelle, mitarbeiter, daten) + local intensivId = tonumber(daten.id) + if not intensivId then return nil, 'Ungültige Kennung.' end + + local text = Text(daten.text, 1000) + if not text or #text < 3 then return nil, 'Bitte einen Text eingeben.' end + + local eintrag = LadeAufenthalt(intensivId) + if not eintrag then return nil, 'Intensivaufenthalt nicht gefunden.' end + if eintrag.beendet_am then + return nil, 'Zu einem beendeten Aufenthalt lässt sich nichts mehr nachtragen.' + end + + local verlaufId = SchreibeVerlauf(intensivId, text, mitarbeiter.charakterId) + + Protokoll.Schreibe(mitarbeiter, 'intensiv', 'Verlaufseintrag geschrieben', + { typ = 'intensiv', id = intensivId }, + { patient = eintrag.patient, eintrag = verlaufId }) + + Kern.Loese('intensiv.verlauf', intensivId, verlaufId, mitarbeiter) + + return { id = verlaufId } +end) + +-- ===================================================================== +-- GERÄTE +-- +-- Eine Liste von Namen. Das System schliesst nichts an, trennt nichts +-- und überwacht nichts — es weiss nur, was jemand notiert hat. +-- ===================================================================== + +Kern.RegistriereAktion('intensiv', 'geraete', 'intensiv.betreuen', function(quelle, mitarbeiter, daten) + local intensivId = tonumber(daten.id) + if not intensivId then return nil, 'Ungültige Kennung.' end + + local eintrag = LadeAufenthalt(intensivId) + if not eintrag then return nil, 'Intensivaufenthalt nicht gefunden.' end + if eintrag.beendet_am then return nil, 'Dieser Aufenthalt ist bereits beendet.' end + + -- Leere Eingabe heisst: es ist nichts mehr angeschlossen + local geraete = Text(daten.geraete, 512) + + MySQL.update.await('UPDATE medical_intensiv SET geraete = ? WHERE id = ?', + { geraete, intensivId }) + + Protokoll.Schreibe(mitarbeiter, 'intensiv', 'Geräte eingetragen', + { typ = 'intensiv', id = intensivId }, + { patient = eintrag.patient, vorher = eintrag.geraete, nachher = geraete }) + + return { ok = true } +end) + +-- ===================================================================== +-- ZUSTÄNDIGKEIT +-- +-- Verantwortung wird übernommen oder übertragen, nie zugeteilt. +-- ===================================================================== + +--- Wer im Dienst darf einen Intensivaufenthalt verantworten? +Kern.RegistriereAktion('intensiv', 'betreuer', 'intensiv.betreuen', function(quelle, mitarbeiter, daten) + local liste = {} + + for _, eintrag in pairs(Dienst.AlleImDienst()) do + if Rechte.Hat(eintrag, 'intensiv.betreuen') then + liste[#liste + 1] = { + charakterId = eintrag.charakterId, + name = eintrag.name, + rolleName = eintrag.rolleName, + personalnummer = eintrag.personalnummer, + funktion = eintrag.funktion, + status = eintrag.status, + selbst = eintrag.charakterId == mitarbeiter.charakterId, + } + end + end + + table.sort(liste, function(a, b) return tostring(a.name) < tostring(b.name) end) + + return { liste = liste } +end) + +Kern.RegistriereAktion('intensiv', 'verantwortlich', 'intensiv.betreuen', function(quelle, mitarbeiter, daten) + local intensivId = tonumber(daten.id) + if not intensivId then return nil, 'Ungültige Kennung.' end + + local eintrag = LadeAufenthalt(intensivId) + if not eintrag then return nil, 'Intensivaufenthalt nicht gefunden.' end + if eintrag.beendet_am then return nil, 'Dieser Aufenthalt ist bereits beendet.' end + + -- Ohne Ziel wird die Zuständigkeit niedergelegt + local zielId = tonumber(daten.charakter) + + if zielId then + local zielQuelle = Dienst.QuelleVonCharakter(zielId) + local ziel = zielQuelle and Dienst.Hole(zielQuelle) + if not ziel then return nil, 'Diese Person ist nicht im Dienst.' end + if not Rechte.Hat(ziel, 'intensiv.betreuen') then + return nil, 'Diese Person darf keinen Intensivaufenthalt verantworten.' + end + else + local grund = Text(daten.grund, 255) + if not grund then + return nil, 'Bitte angeben, warum die Zuständigkeit niedergelegt wird.' + end + end + + MySQL.update.await('UPDATE medical_intensiv SET verantwortlich = ? WHERE id = ?', + { zielId, intensivId }) + + Protokoll.Schreibe(mitarbeiter, 'intensiv', + zielId and 'Zuständigkeit übernommen oder übertragen' or 'Zuständigkeit niedergelegt', + { typ = 'intensiv', id = intensivId }, + { patient = eintrag.patient, vorher = eintrag.verantwortlich, nachher = zielId }, + Text(daten.grund, 255)) + + if zielId and zielId ~= mitarbeiter.charakterId then + -- Die betroffene Person erfährt es unmittelbar + Hinweise.Erstelle({ charakterId = zielId }, { + kategorie = 'patient', prioritaet = 'hoch', + titel = 'Du bist jetzt für einen Intensivpatienten zuständig', + text = ('%s %s — eingetragen von %s.') + :format(eintrag.firstname, eintrag.lastname, mitarbeiter.name), + zielTyp = 'intensiv', zielId = intensivId, + }) + end + + if not zielId then + Hinweise.Erstelle(nil, { + kategorie = 'patient', prioritaet = 'hoch', + titel = 'Intensivpatient ohne zuständigen Arzt', + text = ('Für %s %s ist niemand mehr eingetragen.') + :format(eintrag.firstname, eintrag.lastname), + zielTyp = 'intensiv', zielId = intensivId, + }) + end + + return { ok = true } +end) + +-- ===================================================================== +-- AUFENTHALT BEENDEN +-- +-- Beendet ausschliesslich die Intensivbetreuung. Der Patient bleibt +-- aufgenommen, sein Bett bleibt belegt, und keine Behandlung wird +-- dadurch abgeschlossen. +-- ===================================================================== + +Kern.RegistriereAktion('intensiv', 'beenden', 'intensiv.betreuen', function(quelle, mitarbeiter, daten) + local intensivId = tonumber(daten.id) + if not intensivId then return nil, 'Ungültige Kennung.' end + + local grund = Text(daten.grund, 1000) + if not grund or #grund < 5 then + return nil, 'Das Ende der Intensivbetreuung erfordert eine Begründung.' + end + + local eintrag = LadeAufenthalt(intensivId) + if not eintrag then return nil, 'Intensivaufenthalt nicht gefunden.' end + if eintrag.beendet_am then return nil, 'Dieser Aufenthalt ist bereits beendet.' end + + -- Ein kritischer Zustand hält nicht auf, wird aber festgehalten + local warKritisch = eintrag.zustand == 'kritisch' + + MySQL.update.await([[ + UPDATE medical_intensiv + SET beendet_am = NOW(), beendet_von = ? + WHERE id = ? + ]], { mitarbeiter.charakterId, intensivId }) + + -- Die Begründung schliesst den Verlauf mit den Worten des Arztes ab + SchreibeVerlauf(intensivId, grund, mitarbeiter.charakterId) + + Protokoll.Schreibe(mitarbeiter, 'intensiv', 'Intensivaufenthalt beendet', + { typ = 'intensiv', id = intensivId }, + { patient = eintrag.patient, zustand = eintrag.zustand, + warKritisch = warKritisch }, grund) + + Hinweise.Erstelle(nil, { + kategorie = 'patient', + prioritaet = warKritisch and 'hoch' or 'normal', + titel = 'Intensivbetreuung beendet', + text = ('%s %s — der Patient bleibt aufgenommen, Bett und Behandlungen sind unberührt.') + :format(eintrag.firstname, eintrag.lastname), + zielTyp = eintrag.aufnahme and 'aufnahme' or 'intensiv', + zielId = eintrag.aufnahme or intensivId, + }) + + Kern.Loese('intensiv.beendet', intensivId, mitarbeiter) + + return { ok = true, warKritisch = warKritisch } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Liegt dieser Patient gerade auf der Intensivstation? +--- Reine Auskunft — keine Ressource kann darüber jemanden aufnehmen, +--- entlassen oder seinen Zustand ändern. +--- @param patientId number Charakter +--- @return table|nil { id, zustand, zimmer, seit, verantwortlich } +exports('HoleIntensivaufenthalt', function(patientId) + if not tonumber(patientId) then return nil end + + local zeilen = MySQL.query.await([[ + SELECT i.id, i.zustand, i.aufgenommen_am, i.verantwortlich, + z.kennung AS zimmer + FROM medical_intensiv i + LEFT JOIN medical_zimmer z ON z.id = i.zimmer + WHERE i.patient = ? AND i.beendet_am IS NULL + ORDER BY i.id DESC LIMIT 1 + ]], { tonumber(patientId) }) + + if not zeilen or not zeilen[1] then return nil end + + return { + id = zeilen[1].id, + zustand = zeilen[1].zustand, + zimmer = zeilen[1].zimmer, + seit = zeilen[1].aufgenommen_am, + verantwortlich = zeilen[1].verantwortlich + and NameVon(zeilen[1].verantwortlich) or nil, + } +end) diff --git a/resources/[aether]/aether-medical/server/module/kommunikation.lua b/resources/[aether]/aether-medical/server/module/kommunikation.lua new file mode 100644 index 0000000..a1f5c53 --- /dev/null +++ b/resources/[aether]/aether-medical/server/module/kommunikation.lua @@ -0,0 +1,760 @@ +-- ===================================================================== +-- aether-medical / Modul: Nachrichten und Aushänge +-- +-- Ein Postfach und ein Schwarzes Brett. +-- +-- Was hier steht, hat jemand geschrieben und mit seinem Namen +-- unterschrieben. Deshalb gilt durchgehend: +-- +-- * Eine abgeschickte Nachricht wird nicht mehr geändert und nicht +-- gelöscht. Sie ist raus. +-- * Ein Aushang wird archiviert, nie entfernt — auch ein +-- zurückgenommener bleibt nachlesbar. +-- * Eine Kenntnisnahme bestätigt nur, dass jemand gelesen hat. +-- Niemand kann sie für einen anderen setzen, und sie ändert +-- nichts ausser sich selbst. +-- * Eine Frist läuft ab, ohne dass etwas geschieht. Das System +-- erinnert daran und wartet. +-- ===================================================================== + +-- Lesen darf jeder im Dienst: was gelesen werden muss, soll nicht an +-- einem Recht scheitern. Geschrieben wird nur mit Recht. +Kern.RegistriereModul({ + id = 'kommunikation', name = 'Nachrichten', icon = '✉', + reihenfolge = 13, recht = nil, + beschreibung = 'Postfach, Rundmeldungen und Schwarzes Brett', +}) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Begrenzt einen Text und liefert nil bei leerer Eingabe +--- @param wert any Eingabe +--- @param laenge number Maximale Zeichenzahl +--- @return string|nil +local function Text(wert, laenge) + if type(wert) ~= 'string' then return nil end + local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '') + if sauber == '' then return nil end + return sauber:sub(1, laenge) +end + +--- Name eines Charakters für die Anzeige +--- @param charakterId number|nil Charakter +--- @return string +local function NameVon(charakterId) + if not charakterId then return 'unbekannt' end + local zeilen = MySQL.query.await( + 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId }) + if not zeilen or not zeilen[1] then return 'unbekannt' end + return zeilen[1].firstname .. ' ' .. zeilen[1].lastname +end + +--- Anzeigename einer Station aus der Konfiguration +--- @param kennung string|nil Stationskennung +--- @return string +local function StationName(kennung) + if not kennung then return 'gesamte Klinik' end + for _, station in ipairs(Medizin.Config.Stationen) do + if station.id == kennung then return station.name end + end + return tostring(kennung) +end + +--- Prüft eine Kennung gegen eine Konfigurationsliste +--- @param liste table Liste aus Medizin.Config +--- @param wert any Eingabe +--- @return string|nil +local function AusListe(liste, wert) + local gesucht = tostring(wert or '') + for _, eintrag in ipairs(liste) do + if eintrag.id == gesucht then return eintrag.id end + end + return nil +end + +--- Beschreibt, an wen eine Nachricht ging +--- @param eintrag table Nachrichtenzeile +--- @return string +local function EmpfaengerName(eintrag) + if eintrag.empf_typ == 'rundmeldung' then return 'Alle im Haus' end + if eintrag.empf_typ == 'station' then + return 'Station ' .. StationName(eintrag.empfaenger) + end + return NameVon(tonumber(eintrag.empfaenger)) +end + +--- Kürzt einen Text für die Listenansicht +--- @param wert string|nil Volltext +--- @param laenge number Zeichen +--- @return string +local function Vorschau(wert, laenge) + local roh = tostring(wert or ''):gsub('%s+', ' ') + if #roh <= laenge then return roh end + return roh:sub(1, laenge) .. '…' +end + +--- Erreicht diese Nachricht den genannten Mitarbeiter? +--- @param eintrag table Nachrichtenzeile +--- @param mitarbeiter table Mitarbeiter +--- @return boolean +local function IstEmpfaenger(eintrag, mitarbeiter) + if eintrag.empf_typ == 'rundmeldung' then return true end + if eintrag.empf_typ == 'station' then + return eintrag.empfaenger == mitarbeiter.station + end + return tonumber(eintrag.empfaenger) == mitarbeiter.charakterId +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('kommunikation', 'uebersicht', nil, function(quelle, mitarbeiter, daten) + local ordner = tostring(daten.ordner or 'posteingang') + local ich = mitarbeiter.charakterId + local liste = {} + + if ordner == 'gesendet' then + liste = MySQL.query.await([[ + SELECT n.id, n.empfaenger, n.empf_typ, n.betreff, n.text, n.gesendet, + (SELECT COUNT(*) FROM medical_nachricht_gelesen g + WHERE g.nachricht = n.id) AS gelesenVon + FROM medical_nachricht n + WHERE n.absender = ? + ORDER BY n.id DESC + LIMIT 100 + ]], { ich }) or {} + + for _, eintrag in ipairs(liste) do + eintrag.empfaengerName = EmpfaengerName(eintrag) + eintrag.vorschau = Vorschau(eintrag.text, 140) + eintrag.gelesenVon = tonumber(eintrag.gelesenVon) or 0 + eintrag.text = nil + end + + elseif ordner == 'aushang' or ordner == 'archiv' then + local aktiv = ordner == 'aushang' and 1 or 0 + + liste = MySQL.query.await([[ + SELECT a.id, a.titel, a.text, a.kategorie, a.station, a.angeheftet, + a.kenntnispflicht, a.frist, a.erstellt_von, a.erstellt_am, + a.archiviert_von, a.archiviert_am, + TIMESTAMPDIFF(MINUTE, NOW(), a.frist) AS restMinuten, + (SELECT COUNT(*) FROM medical_aushang_kenntnis k + WHERE k.aushang = a.id) AS kenntnisse, + (SELECT 1 FROM medical_aushang_kenntnis k + WHERE k.aushang = a.id AND k.`character` = ?) AS bestaetigt + FROM medical_aushang a + WHERE a.aktiv = ? AND (a.station IS NULL OR a.station = ?) + ORDER BY a.angeheftet DESC, a.erstellt_am DESC + LIMIT 100 + ]], { ich, aktiv, mitarbeiter.station }) or {} + + for _, eintrag in ipairs(liste) do + eintrag.autor = NameVon(eintrag.erstellt_von) + eintrag.stationName = StationName(eintrag.station) + eintrag.angeheftet = tonumber(eintrag.angeheftet) == 1 + eintrag.kenntnispflicht = tonumber(eintrag.kenntnispflicht) == 1 + eintrag.kenntnisse = tonumber(eintrag.kenntnisse) or 0 + eintrag.bestaetigt = eintrag.bestaetigt ~= nil + eintrag.restMinuten = eintrag.frist and (tonumber(eintrag.restMinuten) or 0) or nil + -- Eine abgelaufene Frist ist ein Zustand, keine Folge + eintrag.fristAbgelaufen = eintrag.restMinuten ~= nil and eintrag.restMinuten <= 0 + eintrag.vorschau = Vorschau(eintrag.text, 200) + eintrag.archiviertVon = eintrag.archiviert_von + and NameVon(eintrag.archiviert_von) or nil + eintrag.erstellt_von, eintrag.archiviert_von = nil, nil + eintrag.text = nil + end + + else + liste = MySQL.query.await([[ + SELECT n.id, n.absender, n.empfaenger, n.empf_typ, n.betreff, n.text, n.gesendet, + c.firstname, c.lastname, + (SELECT 1 FROM medical_nachricht_gelesen g + WHERE g.nachricht = n.id AND g.`character` = ?) AS gelesen + FROM medical_nachricht n + JOIN aether_characters c ON c.id = n.absender + WHERE n.empf_typ = 'rundmeldung' + OR (n.empf_typ = 'person' AND n.empfaenger = ?) + OR (n.empf_typ = 'station' AND n.empfaenger = ?) + ORDER BY n.id DESC + LIMIT 100 + ]], { ich, tostring(ich), mitarbeiter.station or '' }) or {} + + for _, eintrag in ipairs(liste) do + eintrag.absenderName = eintrag.firstname .. ' ' .. eintrag.lastname + eintrag.empfaengerName = EmpfaengerName(eintrag) + eintrag.vorschau = Vorschau(eintrag.text, 140) + eintrag.gelesen = eintrag.gelesen ~= nil + eintrag.anMich = eintrag.empf_typ == 'person' + eintrag.firstname, eintrag.lastname, eintrag.text = nil, nil, nil + end + end + + -- Zählwerte für die Reiter + local ungelesen = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_nachricht n + WHERE (n.empf_typ = 'rundmeldung' + OR (n.empf_typ = 'person' AND n.empfaenger = ?) + OR (n.empf_typ = 'station' AND n.empfaenger = ?)) + AND NOT EXISTS (SELECT 1 FROM medical_nachricht_gelesen g + WHERE g.nachricht = n.id AND g.`character` = ?) + ]], { tostring(ich), mitarbeiter.station or '', ich })) or 0 + + -- Offene Kenntnisnahmen: was mich betrifft und was ich noch nicht bestätigt habe + local offeneKenntnis = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_aushang a + WHERE a.aktiv = 1 AND a.kenntnispflicht = 1 + AND (a.station IS NULL OR a.station = ?) + AND NOT EXISTS (SELECT 1 FROM medical_aushang_kenntnis k + WHERE k.aushang = a.id AND k.`character` = ?) + ]], { mitarbeiter.station, ich })) or 0 + + local zaehler = { + ungelesen = ungelesen, + gesendet = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_nachricht WHERE absender = ?', { ich })) or 0, + aushaenge = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_aushang + WHERE aktiv = 1 AND (station IS NULL OR station = ?) + ]], { mitarbeiter.station })) or 0, + offeneKenntnis = offeneKenntnis, + } + + return { + ordner = ordner, + liste = liste, + zaehler = zaehler, + kategorien = Medizin.Config.Aushangkategorien, + stationen = Medizin.Config.Stationen, + meineStation = mitarbeiter.station, + rechte = { + senden = Rechte.Hat(mitarbeiter, 'nachricht.senden'), + rundmeldung = Rechte.Hat(mitarbeiter, 'nachricht.rundmeldung'), + schreiben = Rechte.Hat(mitarbeiter, 'aushang.schreiben'), + archivieren = Rechte.Hat(mitarbeiter, 'aushang.archivieren'), + }, + } +end) + +-- ===================================================================== +-- NACHRICHT LESEN +-- +-- Als gelesen gilt sie, weil jemand sie geöffnet hat — nicht, weil +-- Zeit vergangen ist. +-- ===================================================================== + +Kern.RegistriereAktion('kommunikation', 'nachricht', nil, function(quelle, mitarbeiter, daten) + local nachrichtId = tonumber(daten.id) + if not nachrichtId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT n.*, c.firstname, c.lastname + FROM medical_nachricht n + JOIN aether_characters c ON c.id = n.absender + WHERE n.id = ? + ]], { nachrichtId }) + if not zeilen or not zeilen[1] then return nil, 'Nachricht nicht gefunden.' end + + local eintrag = zeilen[1] + + -- Fremde Post bleibt fremd + local darfLesen = eintrag.absender == mitarbeiter.charakterId + or IstEmpfaenger(eintrag, mitarbeiter) + if not darfLesen then + return nil, 'Diese Nachricht ist nicht an dich gerichtet.' + end + + -- Nur Empfänger quittieren, nicht der Absender selbst + if IstEmpfaenger(eintrag, mitarbeiter) then + MySQL.insert.await([[ + INSERT IGNORE INTO medical_nachricht_gelesen (nachricht, `character`) + VALUES (?, ?) + ]], { nachrichtId, mitarbeiter.charakterId }) + end + + -- Wer hat sie gelesen? Für den Absender die wichtigste Angabe. + local leser = {} + if eintrag.absender == mitarbeiter.charakterId then + leser = MySQL.query.await([[ + SELECT g.gelesen_am, c.firstname, c.lastname + FROM medical_nachricht_gelesen g + JOIN aether_characters c ON c.id = g.`character` + WHERE g.nachricht = ? + ORDER BY g.gelesen_am + ]], { nachrichtId }) or {} + + for _, zeile in ipairs(leser) do + zeile.name = zeile.firstname .. ' ' .. zeile.lastname + zeile.firstname, zeile.lastname = nil, nil + end + end + + return { + nachricht = { + id = eintrag.id, + absender = eintrag.absender, + absenderName = eintrag.firstname .. ' ' .. eintrag.lastname, + empfaengerName = EmpfaengerName(eintrag), + empfTyp = eintrag.empf_typ, + betreff = eintrag.betreff, + text = eintrag.text, + gesendet = eintrag.gesendet, + vonMir = eintrag.absender == mitarbeiter.charakterId, + }, + leser = leser, + rechte = { + senden = Rechte.Hat(mitarbeiter, 'nachricht.senden'), + }, + } +end) + +Kern.RegistriereAktion('kommunikation', 'gelesen', nil, function(quelle, mitarbeiter, daten) + local nachrichtId = tonumber(daten.id) + if not nachrichtId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await( + 'SELECT id, empfaenger, empf_typ FROM medical_nachricht WHERE id = ?', { nachrichtId }) + if not zeilen or not zeilen[1] then return nil, 'Nachricht nicht gefunden.' end + + if not IstEmpfaenger(zeilen[1], mitarbeiter) then + return nil, 'Diese Nachricht ist nicht an dich gerichtet.' + end + + MySQL.insert.await([[ + INSERT IGNORE INTO medical_nachricht_gelesen (nachricht, `character`) + VALUES (?, ?) + ]], { nachrichtId, mitarbeiter.charakterId }) + + return { ok = true } +end) + +-- ===================================================================== +-- NACHRICHT SCHREIBEN +-- +-- Abgeschickt ist abgeschickt: es gibt hier bewusst kein Bearbeiten +-- und kein Löschen. +-- ===================================================================== + +--- Mögliche Empfänger: das Personal und die Stationen +Kern.RegistriereAktion('kommunikation', 'empfaenger', 'nachricht.senden', function(quelle, mitarbeiter, daten) + local personal = MySQL.query.await([[ + SELECT p.`character` AS charakterId, p.personalnummer, p.rolle, p.station, + c.firstname, c.lastname + FROM medical_personal p + JOIN aether_characters c ON c.id = p.`character` + WHERE p.aktiv = 1 AND c.deleted = 0 + ORDER BY p.personalnummer + LIMIT 200 + ]]) or {} + + -- Wer gerade im Dienst ist, wird gekennzeichnet — erreichbar sind alle + local imDienst = {} + for _, eintrag in pairs(Dienst.AlleImDienst()) do + imDienst[eintrag.charakterId] = true + end + + for _, eintrag in ipairs(personal) do + eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname + eintrag.rolleName = Medizin.HoleRolle(eintrag.rolle) + and Medizin.HoleRolle(eintrag.rolle).name or eintrag.rolle + eintrag.stationName = StationName(eintrag.station) + eintrag.imDienst = imDienst[eintrag.charakterId] == true + eintrag.selbst = eintrag.charakterId == mitarbeiter.charakterId + eintrag.firstname, eintrag.lastname = nil, nil + end + + return { + personal = personal, + stationen = Medizin.Config.Stationen, + rechte = { + rundmeldung = Rechte.Hat(mitarbeiter, 'nachricht.rundmeldung'), + }, + } +end) + +Kern.RegistriereAktion('kommunikation', 'senden', 'nachricht.senden', function(quelle, mitarbeiter, daten) + local typ = tostring(daten.typ or 'person') + if typ ~= 'person' and typ ~= 'station' and typ ~= 'rundmeldung' then + return nil, 'Unbekannte Empfängerart.' + end + + -- Eine Rundmeldung erreicht das ganze Haus und braucht ein eigenes Recht + if typ == 'rundmeldung' and not Rechte.Hat(mitarbeiter, 'nachricht.rundmeldung') then + return nil, 'Rundmeldungen an alle darfst du nicht senden.' + end + + local text = Text(daten.text, 4000) + if not text or #text < 2 then return nil, 'Bitte einen Text schreiben.' end + + local betreff = Text(daten.betreff, 160) + + -- Empfänger prüfen und in die gespeicherte Schreibweise bringen + local ziel, empfaengerName + if typ == 'rundmeldung' then + ziel = 'alle' + empfaengerName = 'Alle im Haus' + + elseif typ == 'station' then + local station = AusListe(Medizin.Config.Stationen, daten.ziel) + if not station then return nil, 'Unbekannte Station.' end + ziel = station + empfaengerName = 'Station ' .. StationName(station) + + else + local zielId = tonumber(daten.ziel) + if not zielId then return nil, 'Kein Empfänger gewählt.' end + + local person = MySQL.scalar.await( + 'SELECT `character` FROM medical_personal WHERE `character` = ? AND aktiv = 1', + { zielId }) + if not person then return nil, 'Diese Person gehört nicht zum Personal.' end + + ziel = tostring(zielId) + empfaengerName = NameVon(zielId) + end + + local nachrichtId = MySQL.insert.await([[ + INSERT INTO medical_nachricht (absender, empfaenger, empf_typ, betreff, text) + VALUES (?, ?, ?, ?, ?) + ]], { mitarbeiter.charakterId, ziel, typ, betreff, text }) + + Protokoll.Schreibe(mitarbeiter, 'kommunikation', 'Nachricht gesendet', + { typ = 'nachricht', id = nachrichtId }, + { empfaengerTyp = typ, empfaenger = ziel, betreff = betreff }) + + -- Die Betroffenen erfahren es sofort — gelesen wird trotzdem von Hand + local kopfzeile = betreff or Vorschau(text, 60) + + if typ == 'person' then + Hinweise.Erstelle({ charakterId = tonumber(ziel) }, { + kategorie = 'system', prioritaet = 'normal', + titel = ('Nachricht von %s'):format(mitarbeiter.name), + text = kopfzeile, + zielTyp = 'nachricht', zielId = nachrichtId, + }) + else + -- Station und Rundmeldung: nur ansprechen, wen sie erreicht + for _, eintrag in pairs(Dienst.AlleImDienst()) do + local passt = typ == 'rundmeldung' or eintrag.station == ziel + if passt and eintrag.charakterId ~= mitarbeiter.charakterId then + Hinweise.Erstelle({ charakterId = eintrag.charakterId }, { + kategorie = 'system', + prioritaet = typ == 'rundmeldung' and 'hoch' or 'normal', + titel = typ == 'rundmeldung' + and ('Rundmeldung von %s'):format(mitarbeiter.name) + or ('Stationsnachricht von %s'):format(mitarbeiter.name), + text = kopfzeile, + zielTyp = 'nachricht', zielId = nachrichtId, + }) + end + end + end + + Kern.Loese('nachricht.gesendet', nachrichtId, typ, mitarbeiter) + + return { id = nachrichtId, empfaenger = empfaengerName } +end) + +-- ===================================================================== +-- SCHWARZES BRETT +-- ===================================================================== + +Kern.RegistriereAktion('kommunikation', 'aushang', nil, function(quelle, mitarbeiter, daten) + local aushangId = tonumber(daten.id) + if not aushangId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT a.*, + TIMESTAMPDIFF(MINUTE, NOW(), a.frist) AS restMinuten, + (SELECT 1 FROM medical_aushang_kenntnis k + WHERE k.aushang = a.id AND k.`character` = ?) AS bestaetigt + FROM medical_aushang a + WHERE a.id = ? + ]], { mitarbeiter.charakterId, aushangId }) + if not zeilen or not zeilen[1] then return nil, 'Aushang nicht gefunden.' end + + local eintrag = zeilen[1] + + -- Wer hat gelesen und wer noch nicht? + local kenntnis = MySQL.query.await([[ + SELECT k.bestaetigt_am, c.firstname, c.lastname, p.personalnummer + FROM medical_aushang_kenntnis k + JOIN aether_characters c ON c.id = k.`character` + LEFT JOIN medical_personal p ON p.`character` = k.`character` + WHERE k.aushang = ? + ORDER BY k.bestaetigt_am + ]], { aushangId }) or {} + + for _, zeile in ipairs(kenntnis) do + zeile.name = zeile.firstname .. ' ' .. zeile.lastname + zeile.firstname, zeile.lastname = nil, nil + end + + -- Wen der Aushang betrifft, der aber noch nicht bestätigt hat. + -- Reine Auskunft — niemand wird dadurch zu etwas gezwungen. + local offen = {} + if tonumber(eintrag.kenntnispflicht) == 1 then + offen = MySQL.query.await([[ + SELECT p.personalnummer, p.station, c.firstname, c.lastname + FROM medical_personal p + JOIN aether_characters c ON c.id = p.`character` + WHERE p.aktiv = 1 + AND (? IS NULL OR p.station = ?) + AND NOT EXISTS (SELECT 1 FROM medical_aushang_kenntnis k + WHERE k.aushang = ? AND k.`character` = p.`character`) + ORDER BY p.personalnummer + LIMIT 100 + ]], { eintrag.station, eintrag.station, aushangId }) or {} + + for _, zeile in ipairs(offen) do + zeile.name = zeile.firstname .. ' ' .. zeile.lastname + zeile.firstname, zeile.lastname = nil, nil + end + end + + local restMinuten = eintrag.frist and (tonumber(eintrag.restMinuten) or 0) or nil + + return { + aushang = { + id = eintrag.id, titel = eintrag.titel, text = eintrag.text, + kategorie = eintrag.kategorie, + station = eintrag.station, stationName = StationName(eintrag.station), + angeheftet = tonumber(eintrag.angeheftet) == 1, + kenntnispflicht = tonumber(eintrag.kenntnispflicht) == 1, + frist = eintrag.frist, restMinuten = restMinuten, + fristAbgelaufen = restMinuten ~= nil and restMinuten <= 0, + autor = NameVon(eintrag.erstellt_von), + erstelltAm = eintrag.erstellt_am, + aktiv = tonumber(eintrag.aktiv) == 1, + archiviertAm = eintrag.archiviert_am, + archiviertVon = eintrag.archiviert_von + and NameVon(eintrag.archiviert_von) or nil, + bestaetigt = eintrag.bestaetigt ~= nil, + -- Ein Aushang, den schon jemand quittiert hat, wird im Wortlaut + -- nicht mehr angefasst — sonst hätte jemand etwas anderes gelesen + textGesperrt = #kenntnis > 0, + }, + kenntnis = kenntnis, + offen = offen, + kategorien = Medizin.Config.Aushangkategorien, + stationen = Medizin.Config.Stationen, + rechte = { + schreiben = Rechte.Hat(mitarbeiter, 'aushang.schreiben'), + archivieren = Rechte.Hat(mitarbeiter, 'aushang.archivieren'), + }, + } +end) + +Kern.RegistriereAktion('kommunikation', 'aushangSchreiben', 'aushang.schreiben', function(quelle, mitarbeiter, daten) + local aushangId = tonumber(daten.id) + + local titel = Text(daten.titel, 160) + local text = Text(daten.text, 8000) + local kategorie = AusListe(Medizin.Config.Aushangkategorien, daten.kategorie) + + if not titel then return nil, 'Bitte einen Titel angeben.' end + if not text or #text < 5 then return nil, 'Bitte einen Text schreiben.' end + if not kategorie then return nil, 'Unbekannte Kategorie.' end + + -- Ohne Station gilt der Aushang für das ganze Haus + local station = nil + if Text(daten.station, 32) then + station = AusListe(Medizin.Config.Stationen, daten.station) + if not station then return nil, 'Unbekannte Station.' end + end + + local angeheftet = daten.angeheftet == true and 1 or 0 + local kenntnispflicht = daten.kenntnispflicht == true and 1 or 0 + + -- Eine Frist erinnert nur. Sie sperrt nichts und verfällt folgenlos. + local frist = Text(daten.frist, 20) + if frist and not frist:match('^%d%d%d%d%-%d%d%-%d%d[ T]%d%d:%d%d') then + return nil, 'Die Frist braucht die Form JJJJ-MM-TT SS:MM.' + end + if frist then frist = frist:gsub('T', ' '):sub(1, 16) .. ':00' end + + if aushangId then + local vorher = MySQL.query.await( + 'SELECT id, titel, text, aktiv FROM medical_aushang WHERE id = ?', { aushangId }) + if not vorher or not vorher[1] then return nil, 'Aushang nicht gefunden.' end + if tonumber(vorher[1].aktiv) ~= 1 then + return nil, 'Ein archivierter Aushang wird nicht mehr bearbeitet.' + end + + -- Wer schon quittiert hat, hat einen bestimmten Wortlaut gelesen + local quittiert = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_aushang_kenntnis WHERE aushang = ?', + { aushangId })) or 0 + + if quittiert > 0 and (vorher[1].titel ~= titel or vorher[1].text ~= text) then + return nil, ('%d Person(en) haben diesen Wortlaut bereits zur Kenntnis genommen. Bitte archivieren und neu veröffentlichen.') + :format(quittiert) + end + + MySQL.update.await([[ + UPDATE medical_aushang + SET titel = ?, text = ?, kategorie = ?, station = ?, + angeheftet = ?, kenntnispflicht = ?, frist = ? + WHERE id = ? + ]], { titel, text, kategorie, station, angeheftet, kenntnispflicht, frist, aushangId }) + + Protokoll.Schreibe(mitarbeiter, 'kommunikation', 'Aushang bearbeitet', + { typ = 'aushang', id = aushangId }, + { titel = titel, kategorie = kategorie, station = station, + kenntnispflicht = kenntnispflicht == 1, frist = frist }) + else + aushangId = MySQL.insert.await([[ + INSERT INTO medical_aushang + (titel, text, kategorie, station, angeheftet, kenntnispflicht, + frist, erstellt_von) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ]], { titel, text, kategorie, station, angeheftet, kenntnispflicht, + frist, mitarbeiter.charakterId }) + + Protokoll.Schreibe(mitarbeiter, 'kommunikation', 'Aushang veröffentlicht', + { typ = 'aushang', id = aushangId }, + { titel = titel, kategorie = kategorie, station = station, + kenntnispflicht = kenntnispflicht == 1, frist = frist }) + + -- Nur die ansprechen, die der Aushang betrifft + for _, eintrag in pairs(Dienst.AlleImDienst()) do + local passt = station == nil or eintrag.station == station + if passt then + Hinweise.Erstelle({ charakterId = eintrag.charakterId }, { + kategorie = 'system', + prioritaet = kenntnispflicht == 1 and 'hoch' or 'normal', + titel = ('Neuer Aushang · %s') + :format(kategorie == 'anweisung' and 'Dienstanweisung' or 'Schwarzes Brett'), + text = titel, + zielTyp = 'aushang', zielId = aushangId, + }) + end + end + end + + Kern.Loese('aushang.geaendert', aushangId, mitarbeiter) + + return { id = aushangId } +end) + +Kern.RegistriereAktion('kommunikation', 'anheften', 'aushang.schreiben', function(quelle, mitarbeiter, daten) + local aushangId = tonumber(daten.id) + if not aushangId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await( + 'SELECT id, titel, angeheftet FROM medical_aushang WHERE id = ? AND aktiv = 1', + { aushangId }) + if not zeilen or not zeilen[1] then return nil, 'Aushang nicht gefunden.' end + + local neu = daten.angeheftet == true and 1 or 0 + + MySQL.update.await('UPDATE medical_aushang SET angeheftet = ? WHERE id = ?', + { neu, aushangId }) + + Protokoll.Schreibe(mitarbeiter, 'kommunikation', + neu == 1 and 'Aushang angeheftet' or 'Aushang losgelöst', + { typ = 'aushang', id = aushangId }, { titel = zeilen[1].titel }) + + return { ok = true } +end) + +Kern.RegistriereAktion('kommunikation', 'aushangArchivieren', 'aushang.archivieren', function(quelle, mitarbeiter, daten) + local aushangId = tonumber(daten.id) + if not aushangId then return nil, 'Ungültige Kennung.' end + + local grund = Text(daten.grund, 255) + if not grund then return nil, 'Bitte angeben, warum der Aushang abgenommen wird.' end + + local zeilen = MySQL.query.await( + 'SELECT id, titel, aktiv, kenntnispflicht FROM medical_aushang WHERE id = ?', + { aushangId }) + if not zeilen or not zeilen[1] then return nil, 'Aushang nicht gefunden.' end + if tonumber(zeilen[1].aktiv) ~= 1 then return nil, 'Dieser Aushang hängt nicht mehr.' end + + -- Offene Kenntnisnahmen halten nicht auf, werden aber festgehalten + local offen = 0 + if tonumber(zeilen[1].kenntnispflicht) == 1 then + offen = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_personal p + WHERE p.aktiv = 1 + AND NOT EXISTS (SELECT 1 FROM medical_aushang_kenntnis k + WHERE k.aushang = ? AND k.`character` = p.`character`) + ]], { aushangId })) or 0 + end + + MySQL.update.await([[ + UPDATE medical_aushang + SET aktiv = 0, angeheftet = 0, archiviert_von = ?, archiviert_am = NOW() + WHERE id = ? + ]], { mitarbeiter.charakterId, aushangId }) + + Protokoll.Schreibe(mitarbeiter, 'kommunikation', 'Aushang archiviert', + { typ = 'aushang', id = aushangId }, + { titel = zeilen[1].titel, offeneKenntnisnahmen = offen }, grund) + + Kern.Loese('aushang.archiviert', aushangId, mitarbeiter) + + return { ok = true, offen = offen } +end) + +-- ===================================================================== +-- KENNTNISNAHME +-- +-- Bestätigt ausschliesslich, dass jemand selbst gelesen hat. Sie lässt +-- sich nicht für andere setzen, nicht zurücknehmen und ändert nichts +-- ausser sich selbst. +-- ===================================================================== + +Kern.RegistriereAktion('kommunikation', 'kenntnis', nil, function(quelle, mitarbeiter, daten) + local aushangId = tonumber(daten.id) + if not aushangId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await( + 'SELECT id, titel, station, aktiv FROM medical_aushang WHERE id = ?', { aushangId }) + if not zeilen or not zeilen[1] then return nil, 'Aushang nicht gefunden.' end + + local aushang = zeilen[1] + if tonumber(aushang.aktiv) ~= 1 then + return nil, 'Dieser Aushang hängt nicht mehr.' + end + if aushang.station and aushang.station ~= mitarbeiter.station then + return nil, 'Dieser Aushang betrifft eine andere Station.' + end + + local schon = MySQL.scalar.await([[ + SELECT 1 FROM medical_aushang_kenntnis WHERE aushang = ? AND `character` = ? + ]], { aushangId, mitarbeiter.charakterId }) + if schon then return { ok = true, schonBestaetigt = true } end + + MySQL.insert.await([[ + INSERT INTO medical_aushang_kenntnis (aushang, `character`) VALUES (?, ?) + ]], { aushangId, mitarbeiter.charakterId }) + + Protokoll.Schreibe(mitarbeiter, 'kommunikation', 'Kenntnis genommen', + { typ = 'aushang', id = aushangId }, { titel = aushang.titel }) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Liefert die aktiven Aushänge einer Station. +--- Reine Auskunft — keine Ressource kann darüber etwas veröffentlichen +--- oder eine Kenntnisnahme setzen. +--- @param station string|nil Stationskennung +--- @return table Liste +exports('HoleAushaenge', function(station) + return MySQL.query.await([[ + SELECT a.id, a.titel, a.text, a.kategorie, a.station, + a.angeheftet, a.kenntnispflicht, a.frist, a.erstellt_am + FROM medical_aushang a + WHERE a.aktiv = 1 AND (a.station IS NULL OR a.station = ?) + ORDER BY a.angeheftet DESC, a.erstellt_am DESC + ]], { station }) or {} +end) diff --git a/resources/[aether]/aether-medical/server/module/labor.lua b/resources/[aether]/aether-medical/server/module/labor.lua new file mode 100644 index 0000000..da41c6e --- /dev/null +++ b/resources/[aether]/aether-medical/server/module/labor.lua @@ -0,0 +1,540 @@ +-- ===================================================================== +-- aether-medical / Modul: Labor +-- +-- Das Labor speichert AUSSCHLIESSLICH Ergebnisse. +-- +-- Deshalb sind zwei Dinge strikt getrennt: +-- * `ergebnis` — die gemessenen Werte. Trägt das Labor ein. +-- * `befund` — die Bewertung. Trägt ein Mediziner ein. +-- +-- Das System vergleicht keine Werte mit Referenzbereichen, markiert +-- nichts als auffällig und leitet aus keinem Ergebnis eine Diagnose +-- ab. Ein Messwert ist eine Zahl, keine Aussage. +-- +-- Auch eine im Labor bestimmte Blutgruppe wandert NICHT von selbst in +-- die Stammdaten — das muss ein Mediziner ausdrücklich übernehmen. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'labor', name = 'Labor', icon = '🧪', + reihenfolge = 6, + -- Ein Recht daraus genügt: Auftraggeber, Labor und Befunder teilen sich das Modul + recht = { 'labor.beauftragen', 'labor.bearbeiten', 'labor.befunden' }, + beschreibung = 'Laboraufträge, Ergebnisse und Befunde', +}) + +-- Schreibhilfen je Probenart. Reine Textvorlagen — sie enthalten keine +-- Referenzbereiche und bewerten nichts, sie geben nur die Struktur vor. +local VORLAGEN = { + blut = 'Hb:\nLeukozyten:\nThrombozyten:\nCRP:\nKreatinin:\nNatrium:\nKalium:\n' + .. 'Blutgruppe:\n\nBemerkung des Labors:', + urin = 'Farbe:\nEiweiß:\nGlukose:\nBlut:\nLeukozyten:\nNitrit:\n\nBemerkung des Labors:', + dna = 'Probenart:\nProfil vollständig (ja/nein):\nÜbereinstimmung:\n\nBemerkung des Labors:', + gewebe = 'Entnahmeort:\nMakroskopisch:\nMikroskopisch:\n\nBemerkung des Labors:', + toxikologie = 'Alkohol:\nOpiate:\nAmphetamine:\nCannabinoide:\nBenzodiazepine:\n' + .. '\nBemerkung des Labors:', +} + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Begrenzt einen Text und liefert nil bei leerer Eingabe +local function Text(wert, laenge) + if type(wert) ~= 'string' then return nil end + local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '') + if sauber == '' then return nil end + return sauber:sub(1, laenge) +end + +--- Prüft eine Probenart gegen die Konfiguration +--- @param kennung any Eingabe +--- @return string|nil +local function Probenart(kennung) + for _, eintrag in ipairs(Medizin.Config.Laborarten) do + if eintrag.id == kennung then return eintrag.id end + end + return nil +end + +--- Name eines Mitarbeiters für die Anzeige +local function NameVon(charakterId) + if not charakterId then return 'unbekannt' end + local zeilen = MySQL.query.await([[ + SELECT c.firstname, c.lastname FROM aether_characters c WHERE c.id = ? + ]], { charakterId }) + if not zeilen or not zeilen[1] then return 'unbekannt' end + return zeilen[1].firstname .. ' ' .. zeilen[1].lastname +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('labor', 'uebersicht', nil, function(quelle, mitarbeiter, daten) + local ordner = tostring(daten.ordner or 'offen') + local bedingungen, werte = {}, {} + + if ordner == 'meine' then + bedingungen[#bedingungen + 1] = 'l.auftrag_von = ?' + werte[#werte + 1] = mitarbeiter.charakterId + elseif ordner == 'befund' then + -- Ergebnis liegt vor, die Bewertung fehlt noch + bedingungen[#bedingungen + 1] = "l.status = 'fertig' AND l.befund IS NULL" + elseif ordner == 'archiv' then + bedingungen[#bedingungen + 1] = "(l.befund IS NOT NULL OR l.status = 'verworfen')" + else + bedingungen[#bedingungen + 1] = "l.status IN ('offen', 'in_arbeit')" + end + + if daten.art and daten.art ~= '' then + bedingungen[#bedingungen + 1] = 'l.art = ?' + werte[#werte + 1] = daten.art + end + + if type(daten.begriff) == 'string' and #daten.begriff >= 2 then + bedingungen[#bedingungen + 1] = + '(l.kennung LIKE ? OR c.firstname LIKE ? OR c.lastname LIKE ?)' + local muster = '%' .. daten.begriff .. '%' + for _ = 1, 3 do werte[#werte + 1] = muster end + end + + local liste = MySQL.query.await(([[ + SELECT l.id, l.kennung, l.patient, l.art, l.fragestellung, l.dringend, + l.status, l.auftrag_am, l.auftrag_von, l.bearbeitet_am, + l.ergebnis IS NOT NULL AS hatErgebnis, + l.befund IS NOT NULL AS hatBefund, + TIMESTAMPDIFF(MINUTE, l.auftrag_am, NOW()) AS offenSeit, + c.firstname, c.lastname, c.dob + FROM medical_labor l + JOIN aether_characters c ON c.id = l.patient + WHERE %s + ORDER BY l.dringend DESC, l.id DESC + LIMIT 120 + ]]):format(table.concat(bedingungen, ' AND ')), werte) or {} + + local artNamen = {} + for _, eintrag in ipairs(Medizin.Config.Laborarten) do artNamen[eintrag.id] = eintrag.name end + + for _, eintrag in ipairs(liste) do + eintrag.patientName = eintrag.firstname .. ' ' .. eintrag.lastname + eintrag.geburt = tostring(eintrag.dob):sub(1, 10) + eintrag.artName = artNamen[eintrag.art] or eintrag.art + eintrag.auftragVon = NameVon(eintrag.auftrag_von) + eintrag.eigene = eintrag.auftrag_von == mitarbeiter.charakterId + eintrag.dringend = eintrag.dringend == 1 + eintrag.hatErgebnis = tonumber(eintrag.hatErgebnis) == 1 + eintrag.hatBefund = tonumber(eintrag.hatBefund) == 1 + eintrag.firstname, eintrag.lastname, eintrag.dob = nil, nil, nil + eintrag.auftrag_von = nil + end + + return { + ordner = ordner, + liste = liste, + arten = Medizin.Config.Laborarten, + zaehler = { + offen = tonumber(MySQL.scalar.await( + "SELECT COUNT(*) FROM medical_labor WHERE status IN ('offen', 'in_arbeit')")) or 0, + befund = tonumber(MySQL.scalar.await( + "SELECT COUNT(*) FROM medical_labor WHERE status = 'fertig' AND befund IS NULL")) or 0, + meine = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_labor + WHERE auftrag_von = ? AND status <> 'verworfen' + ]], { mitarbeiter.charakterId })) or 0, + dringend = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_labor + WHERE dringend = 1 AND status IN ('offen', 'in_arbeit') + ]])) or 0, + }, + rechte = { + beauftragen = Rechte.Hat(mitarbeiter, 'labor.beauftragen'), + bearbeiten = Rechte.Hat(mitarbeiter, 'labor.bearbeiten'), + befunden = Rechte.Hat(mitarbeiter, 'labor.befunden'), + stammdaten = Rechte.Hat(mitarbeiter, 'patient.stammdaten'), + }, + } +end) + +Kern.RegistriereAktion('labor', 'oeffnen', nil, function(quelle, mitarbeiter, daten) + local laborId = tonumber(daten.id) + if not laborId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT l.*, c.firstname, c.lastname, c.dob, + m.blutgruppe, m.allergien, m.hinweis + FROM medical_labor l + JOIN aether_characters c ON c.id = l.patient + LEFT JOIN medical_patient m ON m.`character` = l.patient + WHERE l.id = ? + ]], { laborId }) + + if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end + local l = zeilen[1] + + local artName = l.art + for _, eintrag in ipairs(Medizin.Config.Laborarten) do + if eintrag.id == l.art then artName = eintrag.name end + end + + -- Frühere Aufträge desselben Patienten zum Vergleich + local frueher = MySQL.query.await([[ + SELECT id, kennung, art, status, auftrag_am, ergebnis IS NOT NULL AS hatErgebnis + FROM medical_labor + WHERE patient = ? AND id <> ? + ORDER BY id DESC LIMIT 10 + ]], { l.patient, laborId }) or {} + + for _, eintrag in ipairs(frueher) do + eintrag.hatErgebnis = tonumber(eintrag.hatErgebnis) == 1 + end + + return { + auftrag = { + id = l.id, kennung = l.kennung, patient = l.patient, + patientName = l.firstname .. ' ' .. l.lastname, + geburt = tostring(l.dob):sub(1, 10), + art = l.art, artName = artName, + fragestellung = l.fragestellung, + dringend = l.dringend == 1, + status = l.status, + auftragAm = l.auftrag_am, + auftragVon = NameVon(l.auftrag_von), + istMeinAuftrag = l.auftrag_von == mitarbeiter.charakterId, + bearbeitetAm = l.bearbeitet_am, + bearbeitetVon = l.bearbeitet_von and NameVon(l.bearbeitet_von) or nil, + ergebnis = l.ergebnis, + befund = l.befund, + befundAm = l.befund_am, + befundVon = l.befund_von and NameVon(l.befund_von) or nil, + }, + stamm = { + blutgruppe = l.blutgruppe, allergien = l.allergien, hinweis = l.hinweis, + }, + frueher = frueher, + vorlage = VORLAGEN[l.art], + rechte = { + bearbeiten = Rechte.Hat(mitarbeiter, 'labor.bearbeiten'), + befunden = Rechte.Hat(mitarbeiter, 'labor.befunden'), + stammdaten = Rechte.Hat(mitarbeiter, 'patient.stammdaten'), + }, + blutgruppen = Medizin.Config.Blutgruppen, + } +end) + +-- ===================================================================== +-- AUFTRAG STELLEN +-- ===================================================================== + +Kern.RegistriereAktion('labor', 'patientSuche', 'labor.beauftragen', function(quelle, mitarbeiter, daten) + local begriff = type(daten.begriff) == 'string' and daten.begriff or '' + if #begriff < 2 then return { treffer = {} } end + + local muster = '%' .. begriff .. '%' + local treffer = MySQL.query.await([[ + SELECT c.id, c.firstname, c.lastname, c.dob, m.blutgruppe, m.hinweis + FROM aether_characters c + LEFT JOIN medical_patient m ON m.`character` = c.id + WHERE c.deleted = 0 + AND (c.firstname LIKE ? OR c.lastname LIKE ? + OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? OR c.dob LIKE ?) + ORDER BY c.lastname, c.firstname + LIMIT 20 + ]], { muster, muster, muster, muster }) or {} + + for _, eintrag in ipairs(treffer) do + eintrag.dob = tostring(eintrag.dob):sub(1, 10) + end + + return { treffer = treffer } +end) + +Kern.RegistriereAktion('labor', 'beauftragen', 'labor.beauftragen', function(quelle, mitarbeiter, daten) + local patientId = tonumber(daten.patient) + if not patientId then return nil, 'Kein Patient gewählt.' end + + local person = MySQL.query.await( + 'SELECT firstname, lastname FROM aether_characters WHERE id = ? AND deleted = 0', + { patientId }) + if not person or not person[1] then return nil, 'Diese Person ist nicht erfasst.' end + + local art = Probenart(daten.art) + if not art then return nil, 'Unbekannte Probenart.' end + + local fragestellung = Text(daten.fragestellung, 512) + if not fragestellung then + return nil, 'Bitte angeben, wonach gesucht werden soll.' + end + + local dringend = daten.dringend == true + local kennung = Kern.ErzeugeKennung('LAB', 'medical_labor', 'kennung') + + local id = MySQL.insert.await([[ + INSERT INTO medical_labor + (kennung, patient, art, fragestellung, dringend, status, auftrag_von) + VALUES (?, ?, ?, ?, ?, 'offen', ?) + ]], { kennung, patientId, art, fragestellung, dringend and 1 or 0, + mitarbeiter.charakterId }) + + -- Optionale Verknüpfung mit einer Behandlung + local behandlungId = tonumber(daten.behandlung) + if behandlungId then + local gueltig = MySQL.scalar.await( + 'SELECT id FROM medical_behandlung WHERE id = ? AND patient = ?', + { behandlungId, patientId }) + if gueltig then + MySQL.insert.await([[ + INSERT INTO medical_verknuepfung (von_typ, von_id, zu_typ, zu_id, von) + VALUES ('labor', ?, 'behandlung', ?, ?) + ]], { tostring(id), tostring(behandlungId), mitarbeiter.charakterId }) + end + end + + Protokoll.Schreibe(mitarbeiter, 'labor', 'Laborauftrag gestellt', + { typ = 'labor', id = id }, + { kennung = kennung, art = art, patient = patientId, dringend = dringend }) + + -- Das Labor erfährt davon. Bearbeitet wird nichts von selbst. + Hinweise.Erstelle(nil, { + kategorie = 'labor', + prioritaet = dringend and 'hoch' or 'normal', + titel = dringend and ('Dringender Laborauftrag · %s'):format(kennung) + or ('Neuer Laborauftrag · %s'):format(kennung), + text = ('%s %s — %s'):format(person[1].firstname, person[1].lastname, fragestellung), + zielTyp = 'labor', zielId = id, + }) + + Kern.Loese('labor.beauftragt', id, mitarbeiter) + + return { id = id, kennung = kennung } +end) + +-- ===================================================================== +-- BEARBEITUNG IM LABOR +-- ===================================================================== + +Kern.RegistriereAktion('labor', 'annehmen', 'labor.bearbeiten', function(quelle, mitarbeiter, daten) + local laborId = tonumber(daten.id) + if not laborId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await( + 'SELECT id, kennung, status FROM medical_labor WHERE id = ?', { laborId }) + if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end + if zeilen[1].status ~= 'offen' then + return nil, 'Dieser Auftrag ist bereits in Arbeit oder abgeschlossen.' + end + + MySQL.update.await([[ + UPDATE medical_labor + SET status = 'in_arbeit', bearbeitet_von = ?, bearbeitet_am = NOW() + WHERE id = ? + ]], { mitarbeiter.charakterId, laborId }) + + Protokoll.Schreibe(mitarbeiter, 'labor', 'Auftrag in Arbeit genommen', + { typ = 'labor', id = laborId }, { kennung = zeilen[1].kennung }) + + return { ok = true } +end) + +--- Trägt die gemessenen Werte ein. Bewertet wird hier nichts. +Kern.RegistriereAktion('labor', 'ergebnis', 'labor.bearbeiten', function(quelle, mitarbeiter, daten) + local laborId = tonumber(daten.id) + if not laborId then return nil, 'Ungültige Kennung.' end + + local ergebnis = Text(daten.ergebnis, 8000) + if not ergebnis or #ergebnis < 3 then return nil, 'Bitte ein Ergebnis eintragen.' end + + local zeilen = MySQL.query.await([[ + SELECT l.id, l.kennung, l.status, l.dringend, l.auftrag_von, l.art, + c.firstname, c.lastname + FROM medical_labor l + JOIN aether_characters c ON c.id = l.patient + WHERE l.id = ? + ]], { laborId }) + if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end + + local auftrag = zeilen[1] + if auftrag.status == 'verworfen' then + return nil, 'Dieser Auftrag wurde verworfen.' + end + + MySQL.update.await([[ + UPDATE medical_labor + SET ergebnis = ?, status = 'fertig', + bearbeitet_von = COALESCE(bearbeitet_von, ?), + bearbeitet_am = COALESCE(bearbeitet_am, NOW()) + WHERE id = ? + ]], { ergebnis, mitarbeiter.charakterId, laborId }) + + Protokoll.Schreibe(mitarbeiter, 'labor', 'Ergebnis eingetragen', + { typ = 'labor', id = laborId }, { kennung = auftrag.kennung, art = auftrag.art }) + + -- Den Auftraggeber sofort informieren. Bewertet ist damit nichts. + Hinweise.Erstelle({ charakterId = auftrag.auftrag_von }, { + kategorie = 'labor', + prioritaet = auftrag.dringend == 1 and 'hoch' or 'normal', + titel = ('Laborergebnis verfügbar · %s'):format(auftrag.kennung), + text = ('%s %s — die Bewertung steht noch aus.') + :format(auftrag.firstname, auftrag.lastname), + zielTyp = 'labor', zielId = laborId, + }) + + -- Die Fristenprüfung soll das nicht gleich noch einmal melden + Hinweise.AlsGemeldet('labor_' .. laborId, 'fertig') + + Kern.Loese('labor.fertig', laborId, mitarbeiter) + + return { ok = true } +end) + +--- Probe unbrauchbar: Auftrag verwerfen, immer mit Begründung +Kern.RegistriereAktion('labor', 'verwerfen', 'labor.bearbeiten', function(quelle, mitarbeiter, daten) + local laborId = tonumber(daten.id) + if not laborId then return nil, 'Ungültige Kennung.' end + + local grund = Text(daten.grund, 512) + if not grund then return nil, 'Bitte begründen, warum der Auftrag verworfen wird.' end + + local zeilen = MySQL.query.await([[ + SELECT l.id, l.kennung, l.status, l.auftrag_von, c.firstname, c.lastname + FROM medical_labor l + JOIN aether_characters c ON c.id = l.patient + WHERE l.id = ? + ]], { laborId }) + if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end + + local auftrag = zeilen[1] + if auftrag.status == 'verworfen' then + return nil, 'Dieser Auftrag ist bereits verworfen.' + end + + MySQL.update.await([[ + UPDATE medical_labor + SET status = 'verworfen', ergebnis = ?, + bearbeitet_von = COALESCE(bearbeitet_von, ?), + bearbeitet_am = COALESCE(bearbeitet_am, NOW()) + WHERE id = ? + ]], { 'Verworfen: ' .. grund, mitarbeiter.charakterId, laborId }) + + Protokoll.Schreibe(mitarbeiter, 'labor', 'Auftrag verworfen', + { typ = 'labor', id = laborId }, { kennung = auftrag.kennung }, grund) + + Hinweise.Erstelle({ charakterId = auftrag.auftrag_von }, { + kategorie = 'labor', prioritaet = 'normal', + titel = ('Laborauftrag verworfen · %s'):format(auftrag.kennung), + text = ('%s %s — %s. Ein neuer Auftrag muss von Hand gestellt werden.') + :format(auftrag.firstname, auftrag.lastname, grund), + zielTyp = 'labor', zielId = laborId, + }) + + Hinweise.AlsGemeldet('labor_' .. laborId, 'fertig') + + return { ok = true } +end) + +-- ===================================================================== +-- BEFUND +-- +-- Die Bewertung eines Ergebnisses. Sie stammt immer von einem +-- Mediziner — das System schreibt hier nie etwas hinein. +-- ===================================================================== + +Kern.RegistriereAktion('labor', 'befund', 'labor.befunden', function(quelle, mitarbeiter, daten) + local laborId = tonumber(daten.id) + if not laborId then return nil, 'Ungültige Kennung.' end + + local befund = Text(daten.befund, 8000) + if not befund or #befund < 3 then return nil, 'Bitte einen Befund eintragen.' end + + local zeilen = MySQL.query.await( + 'SELECT id, kennung, status, ergebnis FROM medical_labor WHERE id = ?', { laborId }) + if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end + + local auftrag = zeilen[1] + if not auftrag.ergebnis then + return nil, 'Es liegt noch kein Ergebnis vor, das sich bewerten ließe.' + end + + MySQL.update.await([[ + UPDATE medical_labor SET befund = ?, befund_von = ?, befund_am = NOW() + WHERE id = ? + ]], { befund, mitarbeiter.charakterId, laborId }) + + Protokoll.Schreibe(mitarbeiter, 'labor', 'Befund erstellt', + { typ = 'labor', id = laborId }, { kennung = auftrag.kennung }) + + Kern.Loese('labor.befundet', laborId, mitarbeiter) + + return { ok = true } +end) + +-- ===================================================================== +-- BLUTGRUPPE ÜBERNEHMEN +-- +-- Bewusst ein eigener, ausdrücklicher Schritt. Das Labor kann eine +-- Blutgruppe bestimmt haben — in die Stammdaten trägt sie ein +-- Mediziner ein, weil er dafür geradesteht. +-- ===================================================================== + +Kern.RegistriereAktion('labor', 'blutgruppeUebernehmen', 'patient.stammdaten', function(quelle, mitarbeiter, daten) + local laborId = tonumber(daten.id) + if not laborId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT l.id, l.kennung, l.patient, l.ergebnis, c.firstname, c.lastname + FROM medical_labor l + JOIN aether_characters c ON c.id = l.patient + WHERE l.id = ? + ]], { laborId }) + if not zeilen or not zeilen[1] then return nil, 'Laborauftrag nicht gefunden.' end + + local auftrag = zeilen[1] + if not auftrag.ergebnis then + return nil, 'Zu diesem Auftrag liegt kein Ergebnis vor.' + end + + -- Die Blutgruppe wird nicht aus dem Text gelesen, sondern gewählt + local blutgruppe = nil + for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do + if gruppe == daten.blutgruppe then blutgruppe = gruppe break end + end + if not blutgruppe then return nil, 'Bitte eine gültige Blutgruppe wählen.' end + + local alt = MySQL.scalar.await( + 'SELECT blutgruppe FROM medical_patient WHERE `character` = ?', { auftrag.patient }) + + MySQL.query.await([[ + INSERT INTO medical_patient (`character`, blutgruppe, aktualisiert_von, aktualisiert_am) + VALUES (?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE + blutgruppe = VALUES(blutgruppe), + aktualisiert_von = VALUES(aktualisiert_von), + aktualisiert_am = NOW() + ]], { auftrag.patient, blutgruppe, mitarbeiter.charakterId }) + + Protokoll.Schreibe(mitarbeiter, 'labor', 'Blutgruppe in Stammdaten übernommen', + { typ = 'patient', id = auftrag.patient }, + { quelle = auftrag.kennung, von = alt, nach = blutgruppe }) + + Kern.Loese('patient.stammdaten', auftrag.patient, mitarbeiter) + + return { ok = true, blutgruppe = blutgruppe, vorher = alt } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Liefert die Laboraufträge eines Patienten, roh und unbewertet +--- @param patientId number Charakter +--- @return table +exports('HoleLaborwerte', function(patientId) + if not tonumber(patientId) then return {} end + return MySQL.query.await([[ + SELECT kennung, art, status, fragestellung, ergebnis, befund, + auftrag_am, befund_am + FROM medical_labor + WHERE patient = ? + ORDER BY id DESC LIMIT 50 + ]], { tonumber(patientId) }) or {} +end) diff --git a/resources/[aether]/aether-medical/server/module/mdt.lua b/resources/[aether]/aether-medical/server/module/mdt.lua new file mode 100644 index 0000000..f3d7f28 --- /dev/null +++ b/resources/[aether]/aether-medical/server/module/mdt.lua @@ -0,0 +1,735 @@ +-- ===================================================================== +-- aether-medical / Modul: Medizinisches MDT +-- +-- Das Herzstück: Patientensuche, Stammdaten und Patientenakte. +-- +-- Das MDT zeigt an und verknüpft. Es stellt keine Diagnose, es leitet +-- aus Allergien oder Vorerkrankungen keine Warnung her, die eine +-- Behandlung verhindert, und es bewertet keinen Befund. Ein roter +-- Hinweis bedeutet hier immer: ein Mediziner hat ihn eingetragen. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'mdt', name = 'MDT', icon = '🩺', + reihenfolge = 2, recht = 'patient.suchen', + beschreibung = 'Patientensuche, Stammdaten und Akten', +}) + +-- Zulässige Angaben zum Organspendeausweis +local ORGANSTATUS = { ja = 'Spender', nein = 'Kein Spender', unbekannt = 'Unbekannt' } + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Prüft einen Suchbegriff +--- @param begriff any Eingabe der Oberfläche +--- @return boolean +local function SuchbegriffGueltig(begriff) + return type(begriff) == 'string' and #begriff >= 2 +end + +--- Begrenzt einen Text und liefert nil bei leerer Eingabe +--- @param wert any Eingabe +--- @param laenge number Höchstlänge +--- @return string|nil +local function Text(wert, laenge) + if type(wert) ~= 'string' then return nil end + local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '') + if sauber == '' then return nil end + return sauber:sub(1, laenge) +end + +--- Lädt den Namen eines Mitarbeiters für die Anzeige +--- @param charakterId number|nil Charakter +--- @return string +local function NameVon(charakterId) + if not charakterId then return 'unbekannt' end + + local zeilen = MySQL.query.await([[ + SELECT c.firstname, c.lastname, p.personalnummer + FROM aether_characters c + LEFT JOIN medical_personal p ON p.`character` = c.id + WHERE c.id = ? + ]], { charakterId }) + + if not zeilen or not zeilen[1] then return 'unbekannt' end + return zeilen[1].firstname .. ' ' .. zeilen[1].lastname + .. (zeilen[1].personalnummer and (' (' .. zeilen[1].personalnummer .. ')') or '') +end + +-- ===================================================================== +-- PATIENTENSUCHE +-- ===================================================================== + +Kern.RegistriereAktion('mdt', 'suche', 'patient.suchen', function(quelle, mitarbeiter, daten) + if not SuchbegriffGueltig(daten.begriff) then + return nil, 'Bitte mindestens zwei Zeichen eingeben.' + end + + local muster = '%' .. daten.begriff .. '%' + + -- Sucht über Name, Geburtsdatum und Telefonnummer + local treffer = MySQL.query.await([[ + SELECT c.id, c.firstname, c.lastname, c.dob, c.gender, + m.blutgruppe, m.allergien, m.hinweis, m.lichtbild, + t.number AS telefon, + (SELECT COUNT(*) FROM medical_aufnahme a + WHERE a.patient = c.id AND a.status = 'aufgenommen') AS imHaus, + (SELECT COUNT(*) FROM medical_aufnahme a + WHERE a.patient = c.id AND a.status = 'wartend') AS wartet + FROM aether_characters c + LEFT JOIN medical_patient m ON m.`character` = c.id + LEFT JOIN aether_phone t ON t.`character` = c.id + WHERE c.deleted = 0 + AND (c.firstname LIKE ? OR c.lastname LIKE ? + OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? + OR c.dob LIKE ? OR t.number LIKE ?) + ORDER BY c.lastname, c.firstname + LIMIT 40 + ]], { muster, muster, muster, muster, muster }) or {} + + for _, eintrag in ipairs(treffer) do + eintrag.dob = tostring(eintrag.dob):sub(1, 10) + eintrag.imHaus = tonumber(eintrag.imHaus) > 0 + eintrag.wartet = tonumber(eintrag.wartet) > 0 + end + + return { treffer = treffer } +end) + +-- ===================================================================== +-- PATIENTENAKTE +-- ===================================================================== + +Kern.RegistriereAktion('mdt', 'oeffnen', 'patient.suchen', function(quelle, mitarbeiter, daten) + local patientId = tonumber(daten.patient) + if not patientId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT c.id, c.firstname, c.lastname, c.dob, c.gender, c.nationality, + c.job, c.is_dead + FROM aether_characters c + WHERE c.id = ? AND c.deleted = 0 + ]], { patientId }) + + if not zeilen or not zeilen[1] then return nil, 'Diese Person ist nicht erfasst.' end + local person = zeilen[1] + + -- --- Medizinische Stammdaten --- + local stamm = MySQL.query.await( + 'SELECT * FROM medical_patient WHERE `character` = ?', { patientId }) + stamm = stamm and stamm[1] or {} + + -- --- Telefonnummer --- + local telefon = MySQL.scalar.await( + 'SELECT number FROM aether_phone WHERE `character` = ?', { patientId }) + + -- --- Aktuelle Aufnahme, falls vorhanden --- + local aufnahme = MySQL.query.await([[ + SELECT a.id, a.grund, a.prioritaet, a.station, a.status, a.aufgenommen_am, + a.aufgenommen_von, z.kennung AS zimmer + FROM medical_aufnahme a + LEFT JOIN medical_zimmer z ON z.id = a.zimmer + WHERE a.patient = ? AND a.status IN ('wartend', 'aufgenommen') + ORDER BY a.id DESC LIMIT 1 + ]], { patientId }) + aufnahme = aufnahme and aufnahme[1] or nil + + if aufnahme then + aufnahme.aufgenommenVon = NameVon(aufnahme.aufgenommen_von) + aufnahme.aufgenommen_von = nil + end + + -- --- Liegt der Patient auf Intensiv? --- + local intensiv = MySQL.query.await([[ + SELECT i.id, i.zustand, i.geraete, i.verantwortlich, i.aufgenommen_am + FROM medical_intensiv i + WHERE i.patient = ? AND i.beendet_am IS NULL + ORDER BY i.id DESC LIMIT 1 + ]], { patientId }) + intensiv = intensiv and intensiv[1] or nil + + if intensiv then + intensiv.verantwortlichName = NameVon(intensiv.verantwortlich) + intensiv.verantwortlich = nil + end + + -- --- Zählwerte für die Reiter --- + local zaehler = { + behandlungen = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_behandlung WHERE patient = ?', { patientId })) or 0, + operationen = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_operation WHERE patient = ?', { patientId })) or 0, + labor = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_labor WHERE patient = ?', { patientId })) or 0, + akten = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_akte WHERE patient = ?', { patientId })) or 0, + aufnahmen = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_aufnahme WHERE patient = ?', { patientId })) or 0, + notizen = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_patient_notiz WHERE patient = ?', { patientId })) or 0, + } + + return { + patient = { + id = person.id, + name = person.firstname .. ' ' .. person.lastname, + vorname = person.firstname, + nachname = person.lastname, + geburt = tostring(person.dob):sub(1, 10), + geschlecht = person.gender, + herkunft = person.nationality, + beruf = person.job, + telefon = telefon, + -- Nur die Tatsache, dass der Charakter verletzt am Boden liegt. + -- Das System leitet daraus nichts ab und stellt nichts fest. + verletzt = person.is_dead == 1, + }, + stamm = { + blutgruppe = stamm.blutgruppe, + allergien = stamm.allergien, + vorerkrankungen = stamm.vorerkrankungen, + dauermedikation = stamm.dauermedikation, + implantate = stamm.implantate, + organspender = stamm.organspender or 'unbekannt', + organspenderName = ORGANSTATUS[stamm.organspender or 'unbekannt'], + notfallkontakt = stamm.notfallkontakt, + hinweis = stamm.hinweis, + lichtbild = stamm.lichtbild, + gepflegtVon = stamm.aktualisiert_von and NameVon(stamm.aktualisiert_von) or nil, + gepflegtAm = stamm.aktualisiert_am, + }, + aufnahme = aufnahme, + intensiv = intensiv, + zaehler = zaehler, + stationen = Medizin.Config.Stationen, + blutgruppen = Medizin.Config.Blutgruppen, + organstatus = ORGANSTATUS, + rechte = { + stammdaten = Rechte.Hat(mitarbeiter, 'patient.stammdaten'), + hinweis = Rechte.Hat(mitarbeiter, 'patient.hinweis'), + akteLesen = Rechte.Hat(mitarbeiter, 'akte.lesen'), + akteErstellen = Rechte.Hat(mitarbeiter, 'akte.erstellen'), + akteBearbeiten = Rechte.Hat(mitarbeiter, 'akte.bearbeiten'), + akteFreigeben = Rechte.Hat(mitarbeiter, 'akte.freigeben'), + akteSchliessen = Rechte.Hat(mitarbeiter, 'akte.schliessen'), + }, + } +end) + +-- ===================================================================== +-- VERLAUF +-- +-- Alle Vorgänge eines Patienten in einer Zeitleiste. Reine Anzeige — +-- aus der Abfolge wird nichts abgeleitet. +-- ===================================================================== + +Kern.RegistriereAktion('mdt', 'verlauf', 'akte.lesen', function(quelle, mitarbeiter, daten) + local patientId = tonumber(daten.patient) + if not patientId then return nil, 'Ungültige Kennung.' end + + local eintraege = {} + + --- Hängt einen Eintrag an die Zeitleiste + local function Anfuegen(art, zeitpunkt, titel, text, ziel, zielId, marker) + if not zeitpunkt then return end + eintraege[#eintraege + 1] = { + art = art, am = zeitpunkt, titel = titel, text = text, + ziel = ziel, zielId = zielId, marker = marker, + } + end + + -- --- Aufnahmen und Entlassungen --- + local aufnahmen = MySQL.query.await([[ + SELECT id, grund, station, status, aufgenommen_am, aufgenommen_von, + entlassen_am, entlassen_von, entlass_grund + FROM medical_aufnahme WHERE patient = ? + ORDER BY id DESC LIMIT 40 + ]], { patientId }) or {} + + for _, eintrag in ipairs(aufnahmen) do + Anfuegen('aufnahme', eintrag.aufgenommen_am, 'Aufnahme', + eintrag.grund, 'aufnahme', eintrag.id, + 'aufgenommen von ' .. NameVon(eintrag.aufgenommen_von)) + + if eintrag.entlassen_am then + Anfuegen('entlassung', eintrag.entlassen_am, 'Entlassung', + eintrag.entlass_grund, 'aufnahme', eintrag.id, + 'entlassen von ' .. NameVon(eintrag.entlassen_von)) + end + end + + -- --- Behandlungen --- + local behandlungen = MySQL.query.await([[ + SELECT id, kennung, diagnose, massnahmen, status, erstellt_am, erstellt_von + FROM medical_behandlung WHERE patient = ? + ORDER BY id DESC LIMIT 40 + ]], { patientId }) or {} + + for _, eintrag in ipairs(behandlungen) do + Anfuegen('behandlung', eintrag.erstellt_am, + 'Behandlung ' .. eintrag.kennung, + eintrag.diagnose or eintrag.massnahmen or 'Noch keine Diagnose eingetragen', + 'behandlung', eintrag.id, + (eintrag.status == 'laufend' and 'läuft · ' or '') .. NameVon(eintrag.erstellt_von)) + end + + -- --- Operationen --- + local operationen = MySQL.query.await([[ + SELECT id, kennung, art, status, geplant_fuer, beginn, ende, erstellt_von + FROM medical_operation WHERE patient = ? + ORDER BY id DESC LIMIT 20 + ]], { patientId }) or {} + + for _, eintrag in ipairs(operationen) do + Anfuegen('operation', eintrag.beginn or eintrag.geplant_fuer, + 'Operation ' .. eintrag.kennung, eintrag.art, + 'operation', eintrag.id, + eintrag.status .. ' · ' .. NameVon(eintrag.erstellt_von)) + end + + -- --- Laboraufträge --- + local labor = MySQL.query.await([[ + SELECT id, kennung, art, status, fragestellung, auftrag_am, auftrag_von, befund + FROM medical_labor WHERE patient = ? + ORDER BY id DESC LIMIT 30 + ]], { patientId }) or {} + + for _, eintrag in ipairs(labor) do + Anfuegen('labor', eintrag.auftrag_am, + 'Labor ' .. eintrag.kennung .. ' · ' .. eintrag.art, + eintrag.befund or eintrag.fragestellung or 'Ohne Fragestellung', + 'labor', eintrag.id, + eintrag.status .. ' · ' .. NameVon(eintrag.auftrag_von)) + end + + -- --- Verabreichte Medikamente --- + local gaben = MySQL.query.await([[ + SELECT g.id, g.dosierung, g.menge, g.gegeben_am, g.gegeben_von, g.hinweis, + m.name, m.einheit + FROM medical_behandlung_medikament g + JOIN medical_behandlung b ON b.id = g.behandlung + JOIN medical_medikament m ON m.id = g.medikament + WHERE b.patient = ? + ORDER BY g.id DESC LIMIT 40 + ]], { patientId }) or {} + + for _, eintrag in ipairs(gaben) do + Anfuegen('medikament', eintrag.gegeben_am, + 'Medikamentengabe: ' .. eintrag.name, + ('%s %s%s'):format(eintrag.menge, eintrag.einheit, + eintrag.dosierung and (' · ' .. eintrag.dosierung) or ''), + nil, nil, + 'gegeben von ' .. NameVon(eintrag.gegeben_von)) + end + + -- --- Vermerke --- + local notizen = MySQL.query.await([[ + SELECT id, text, von, am FROM medical_patient_notiz + WHERE patient = ? ORDER BY id DESC LIMIT 30 + ]], { patientId }) or {} + + for _, eintrag in ipairs(notizen) do + Anfuegen('notiz', eintrag.am, 'Vermerk', eintrag.text, + nil, nil, NameVon(eintrag.von)) + end + + -- Jüngstes zuerst + table.sort(eintraege, function(a, b) return tostring(a.am) > tostring(b.am) end) + + -- Nur die letzten 60 Einträge übertragen + local gekuerzt = {} + for i = 1, math.min(#eintraege, 60) do gekuerzt[i] = eintraege[i] end + + return { eintraege = gekuerzt, gesamt = #eintraege } +end) + +-- ===================================================================== +-- STAMMDATEN PFLEGEN +-- +-- Alles hier trägt ein Mediziner von Hand ein. Die Blutgruppe ist +-- bewusst ein Freitextfeld aus einer Liste — sie wird NICHT aus einem +-- Laborergebnis übernommen, denn das wäre eine Feststellung, die +-- jemand verantworten muss. +-- ===================================================================== + +Kern.RegistriereAktion('mdt', 'stammdaten', 'patient.stammdaten', function(quelle, mitarbeiter, daten) + local patientId = tonumber(daten.patient) + if not patientId then return nil, 'Ungültige Kennung.' end + + local vorhanden = MySQL.scalar.await( + 'SELECT id FROM aether_characters WHERE id = ? AND deleted = 0', { patientId }) + if not vorhanden then return nil, 'Diese Person ist nicht erfasst.' end + + -- Blutgruppe nur aus der bekannten Liste + local blutgruppe = nil + if daten.blutgruppe and daten.blutgruppe ~= '' then + for _, gruppe in ipairs(Medizin.Config.Blutgruppen) do + if gruppe == daten.blutgruppe then blutgruppe = gruppe break end + end + if not blutgruppe then return nil, 'Unbekannte Blutgruppe.' end + end + + local organspender = ORGANSTATUS[daten.organspender] and daten.organspender or 'unbekannt' + + -- Alten Stand für das Protokoll festhalten + local alt = MySQL.query.await( + 'SELECT blutgruppe, allergien, hinweis FROM medical_patient WHERE `character` = ?', + { patientId }) + alt = alt and alt[1] or {} + + MySQL.query.await([[ + INSERT INTO medical_patient + (`character`, blutgruppe, allergien, vorerkrankungen, dauermedikation, + implantate, organspender, notfallkontakt, lichtbild, + aktualisiert_von, aktualisiert_am) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE + blutgruppe = VALUES(blutgruppe), + allergien = VALUES(allergien), + vorerkrankungen = VALUES(vorerkrankungen), + dauermedikation = VALUES(dauermedikation), + implantate = VALUES(implantate), + organspender = VALUES(organspender), + notfallkontakt = VALUES(notfallkontakt), + lichtbild = VALUES(lichtbild), + aktualisiert_von = VALUES(aktualisiert_von), + aktualisiert_am = NOW() + ]], { + patientId, blutgruppe, + Text(daten.allergien, 512), + Text(daten.vorerkrankungen, 1000), + Text(daten.dauermedikation, 1000), + Text(daten.implantate, 512), + organspender, + Text(daten.notfallkontakt, 160), + Text(daten.lichtbild, 512), + mitarbeiter.charakterId, + }) + + Protokoll.Schreibe(mitarbeiter, 'mdt', 'Stammdaten gepflegt', + { typ = 'patient', id = patientId }, + { blutgruppeAlt = alt.blutgruppe, blutgruppeNeu = blutgruppe }) + + Kern.Loese('patient.stammdaten', patientId, mitarbeiter) + + return { ok = true } +end) + +--- Warnhinweis setzen oder entfernen — immer mit Begründung +Kern.RegistriereAktion('mdt', 'hinweisSetzen', 'patient.hinweis', function(quelle, mitarbeiter, daten) + local patientId = tonumber(daten.patient) + if not patientId then return nil, 'Ungültige Kennung.' end + + local hinweis = Text(daten.hinweis, 512) + local grund = Text(daten.grund, 255) + + if hinweis and not grund then + return nil, 'Bitte begründen, warum der Warnhinweis gesetzt wird.' + end + + MySQL.query.await([[ + INSERT INTO medical_patient (`character`, hinweis, aktualisiert_von, aktualisiert_am) + VALUES (?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE + hinweis = VALUES(hinweis), + aktualisiert_von = VALUES(aktualisiert_von), + aktualisiert_am = NOW() + ]], { patientId, hinweis, mitarbeiter.charakterId }) + + Protokoll.Schreibe(mitarbeiter, 'mdt', + hinweis and 'Warnhinweis gesetzt' or 'Warnhinweis entfernt', + { typ = 'patient', id = patientId }, { hinweis = hinweis }, grund) + + return { ok = true } +end) + +-- ===================================================================== +-- VERMERKE +-- ===================================================================== + +Kern.RegistriereAktion('mdt', 'notizen', 'akte.lesen', function(quelle, mitarbeiter, daten) + local patientId = tonumber(daten.patient) + if not patientId then return nil, 'Ungültige Kennung.' end + + local notizen = MySQL.query.await([[ + SELECT n.id, n.text, n.am, n.von, + c.firstname, c.lastname, p.personalnummer + FROM medical_patient_notiz n + LEFT JOIN aether_characters c ON c.id = n.von + LEFT JOIN medical_personal p ON p.`character` = n.von + WHERE n.patient = ? + ORDER BY n.id DESC LIMIT 60 + ]], { patientId }) or {} + + for _, eintrag in ipairs(notizen) do + eintrag.autor = (eintrag.firstname or 'Unbekannt') .. ' ' .. (eintrag.lastname or '') + eintrag.firstname, eintrag.lastname = nil, nil + eintrag.eigene = eintrag.von == mitarbeiter.charakterId + end + + return { notizen = notizen } +end) + +Kern.RegistriereAktion('mdt', 'notizAnlegen', 'akte.lesen', function(quelle, mitarbeiter, daten) + local patientId = tonumber(daten.patient) + local text = Text(daten.text, 1000) + + if not patientId then return nil, 'Ungültige Kennung.' end + if not text or #text < 3 then return nil, 'Der Vermerk ist zu kurz.' end + + local id = MySQL.insert.await( + 'INSERT INTO medical_patient_notiz (patient, text, von) VALUES (?, ?, ?)', + { patientId, text, mitarbeiter.charakterId }) + + Protokoll.Schreibe(mitarbeiter, 'mdt', 'Vermerk angelegt', + { typ = 'patient', id = patientId }) + + return { id = id } +end) + +-- ===================================================================== +-- AKTEN +-- +-- Jede Änderung erzeugt eine neue Version. Nichts wird überschrieben, +-- die Historie bleibt vollständig einsehbar. +-- ===================================================================== + +Kern.RegistriereAktion('mdt', 'akten', 'akte.lesen', function(quelle, mitarbeiter, daten) + local patientId = tonumber(daten.patient) + if not patientId then return nil, 'Ungültige Kennung.' end + + local akten = MySQL.query.await([[ + SELECT a.id, a.aktenzeichen, a.typ, a.titel, a.status, a.version, + a.erstellt_am, a.erstellt_von, a.freigabe_am, + c.firstname, c.lastname + FROM medical_akte a + LEFT JOIN aether_characters c ON c.id = a.erstellt_von + WHERE a.patient = ? + ORDER BY a.id DESC LIMIT 60 + ]], { patientId }) or {} + + local artNamen = {} + for _, eintrag in ipairs(Medizin.Config.Aktenarten) do artNamen[eintrag.id] = eintrag.name end + + for _, akte in ipairs(akten) do + akte.typName = artNamen[akte.typ] or akte.typ + akte.autor = (akte.firstname or 'Unbekannt') .. ' ' .. (akte.lastname or '') + akte.firstname, akte.lastname = nil, nil + end + + return { akten = akten, aktenarten = Medizin.Config.Aktenarten } +end) + +Kern.RegistriereAktion('mdt', 'akteOeffnen', 'akte.lesen', function(quelle, mitarbeiter, daten) + local akteId = tonumber(daten.id) + if not akteId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT a.*, c.firstname, c.lastname + FROM medical_akte a + LEFT JOIN aether_characters c ON c.id = a.erstellt_von + WHERE a.id = ? + ]], { akteId }) + + if not zeilen or not zeilen[1] then return nil, 'Akte nicht gefunden.' end + local akte = zeilen[1] + + -- Vollständige Versionshistorie + local versionen = MySQL.query.await([[ + SELECT v.version, v.geaendert_am, v.geaendert_von, v.kommentar, + c.firstname, c.lastname + FROM medical_akte_version v + LEFT JOIN aether_characters c ON c.id = v.geaendert_von + WHERE v.akte = ? + ORDER BY v.version DESC + ]], { akteId }) or {} + + for _, version in ipairs(versionen) do + version.autor = (version.firstname or 'Unbekannt') .. ' ' .. (version.lastname or '') + version.firstname, version.lastname = nil, nil + end + + return { + akte = { + id = akte.id, aktenzeichen = akte.aktenzeichen, patient = akte.patient, + typ = akte.typ, titel = akte.titel, status = akte.status, + inhalt = akte.inhalt, version = akte.version, + erstelltAm = akte.erstellt_am, + autor = (akte.firstname or 'Unbekannt') .. ' ' .. (akte.lastname or ''), + freigabeAm = akte.freigabe_am, + freigabeVon = akte.freigabe_von and NameVon(akte.freigabe_von) or nil, + }, + versionen = versionen, + aktenarten = Medizin.Config.Aktenarten, + rechte = { + bearbeiten = Rechte.Hat(mitarbeiter, 'akte.bearbeiten'), + freigeben = Rechte.Hat(mitarbeiter, 'akte.freigeben'), + schliessen = Rechte.Hat(mitarbeiter, 'akte.schliessen'), + }, + } +end) + +--- Liefert den Inhalt einer bestimmten Version +Kern.RegistriereAktion('mdt', 'akteVersion', 'akte.lesen', function(quelle, mitarbeiter, daten) + local akteId = tonumber(daten.id) + local version = tonumber(daten.version) + if not akteId or not version then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT v.version, v.inhalt, v.geaendert_am, v.geaendert_von, v.kommentar + FROM medical_akte_version v + WHERE v.akte = ? AND v.version = ? + ]], { akteId, version }) + + if not zeilen or not zeilen[1] then return nil, 'Diese Version gibt es nicht.' end + + local eintrag = zeilen[1] + eintrag.autor = NameVon(eintrag.geaendert_von) + eintrag.geaendert_von = nil + + return eintrag +end) + +Kern.RegistriereAktion('mdt', 'akteAnlegen', 'akte.erstellen', function(quelle, mitarbeiter, daten) + local patientId = tonumber(daten.patient) + local titel = Text(daten.titel, 160) + + if not patientId then return nil, 'Kein Patient gewählt.' end + if not titel or #titel < 3 then return nil, 'Bitte einen Titel angeben.' end + + -- Aktenart prüfen + local typ = nil + for _, eintrag in ipairs(Medizin.Config.Aktenarten) do + if eintrag.id == daten.typ then typ = eintrag.id break end + end + if not typ then return nil, 'Unbekannte Aktenart.' end + + local inhalt = type(daten.inhalt) == 'string' and daten.inhalt:sub(1, 20000) or nil + local aktenzeichen = Kern.ErzeugeKennung('AKT', 'medical_akte', 'aktenzeichen') + + local id = MySQL.insert.await([[ + INSERT INTO medical_akte + (aktenzeichen, patient, typ, titel, status, inhalt, erstellt_von, version) + VALUES (?, ?, ?, ?, 'entwurf', ?, ?, 1) + ]], { aktenzeichen, patientId, typ, titel, inhalt, mitarbeiter.charakterId }) + + -- Erste Version festhalten + MySQL.insert.await([[ + INSERT INTO medical_akte_version (akte, version, inhalt, geaendert_von, kommentar) + VALUES (?, 1, ?, ?, 'Anlage') + ]], { id, inhalt, mitarbeiter.charakterId }) + + Protokoll.Schreibe(mitarbeiter, 'mdt', 'Akte angelegt', + { typ = 'akte', id = id }, { aktenzeichen = aktenzeichen, patient = patientId }) + + Kern.Loese('akte.angelegt', id, mitarbeiter) + + return { id = id, aktenzeichen = aktenzeichen } +end) + +Kern.RegistriereAktion('mdt', 'akteSpeichern', 'akte.bearbeiten', function(quelle, mitarbeiter, daten) + local akteId = tonumber(daten.id) + if not akteId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await( + 'SELECT id, aktenzeichen, status, version FROM medical_akte WHERE id = ?', { akteId }) + if not zeilen or not zeilen[1] then return nil, 'Akte nicht gefunden.' end + + local akte = zeilen[1] + if akte.status == 'geschlossen' then + return nil, 'Eine geschlossene Akte lässt sich nicht mehr ändern.' + end + + local titel = Text(daten.titel, 160) + if not titel or #titel < 3 then return nil, 'Bitte einen Titel angeben.' end + + local inhalt = type(daten.inhalt) == 'string' and daten.inhalt:sub(1, 20000) or nil + local kommentar = Text(daten.kommentar, 255) + if not kommentar then return nil, 'Bitte kurz angeben, was geändert wurde.' end + + local neueVersion = akte.version + 1 + + MySQL.update.await([[ + UPDATE medical_akte SET titel = ?, inhalt = ?, version = ? WHERE id = ? + ]], { titel, inhalt, neueVersion, akteId }) + + -- Neue Version anhängen — die alte bleibt unangetastet + MySQL.insert.await([[ + INSERT INTO medical_akte_version (akte, version, inhalt, geaendert_von, kommentar) + VALUES (?, ?, ?, ?, ?) + ]], { akteId, neueVersion, inhalt, mitarbeiter.charakterId, kommentar }) + + Protokoll.Schreibe(mitarbeiter, 'mdt', 'Akte bearbeitet', + { typ = 'akte', id = akteId }, + { aktenzeichen = akte.aktenzeichen, version = neueVersion }, kommentar) + + return { version = neueVersion } +end) + +Kern.RegistriereAktion('mdt', 'akteStatus', nil, function(quelle, mitarbeiter, daten) + local akteId = tonumber(daten.id) + local status = tostring(daten.status or '') + + if not akteId then return nil, 'Ungültige Kennung.' end + + -- Welches Recht der Statuswechsel verlangt + local benoetigt = { + offen = 'akte.bearbeiten', + freigegeben = 'akte.freigeben', + geschlossen = 'akte.schliessen', + } + if not benoetigt[status] then return nil, 'Unbekannter Status.' end + if not Rechte.Hat(mitarbeiter, benoetigt[status]) then + return nil, 'Dir fehlt die Berechtigung für diesen Schritt.' + end + + local zeilen = MySQL.query.await( + 'SELECT id, aktenzeichen, status FROM medical_akte WHERE id = ?', { akteId }) + if not zeilen or not zeilen[1] then return nil, 'Akte nicht gefunden.' end + + if zeilen[1].status == 'geschlossen' then + return nil, 'Diese Akte ist bereits geschlossen.' + end + + local grund = Text(daten.grund, 255) + + if status == 'freigegeben' then + MySQL.update.await([[ + UPDATE medical_akte SET status = ?, freigabe_von = ?, freigabe_am = NOW() + WHERE id = ? + ]], { status, mitarbeiter.charakterId, akteId }) + else + MySQL.update.await('UPDATE medical_akte SET status = ? WHERE id = ?', { status, akteId }) + end + + Protokoll.Schreibe(mitarbeiter, 'mdt', 'Aktenstatus geändert', + { typ = 'akte', id = akteId }, + { aktenzeichen = zeilen[1].aktenzeichen, von = zeilen[1].status, nach = status }, grund) + + Kern.Loese('akte.status', akteId, status, mitarbeiter) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Module +-- --------------------------------------------------------------------- + +--- Liefert die Stammdaten eines Patienten, so wie sie eingetragen sind. +--- Ausdrücklich ohne Bewertung: was hier steht, hat ein Mediziner +--- eingetragen, und was fehlt, ist schlicht nicht erhoben. +--- @param patientId number Charakter +--- @return table|nil +exports('HolePatientenStammdaten', function(patientId) + if not tonumber(patientId) then return nil end + + local zeilen = MySQL.query.await([[ + SELECT blutgruppe, allergien, vorerkrankungen, dauermedikation, + implantate, organspender, notfallkontakt, hinweis + FROM medical_patient WHERE `character` = ? + ]], { tonumber(patientId) }) + + return zeilen and zeilen[1] or nil +end) diff --git a/resources/[aether]/aether-medical/server/module/medikament.lua b/resources/[aether]/aether-medical/server/module/medikament.lua new file mode 100644 index 0000000..d0e42ae --- /dev/null +++ b/resources/[aether]/aether-medical/server/module/medikament.lua @@ -0,0 +1,571 @@ +-- ===================================================================== +-- aether-medical / Modul: Medikamentenverwaltung +-- +-- Bestandsführung und Katalogpflege. Nichts weiter. +-- +-- Das System gibt kein Medikament aus, verordnet keines, bestellt +-- nichts nach und entsorgt nichts. Es zählt, was Menschen gebucht +-- haben, und erinnert, wenn ein Bestand knapp wird oder ein Datum +-- abläuft. Was daraufhin geschieht, entscheidet die Apotheke. +-- +-- Abgelaufene Medikamente werden AUFFÄLLIG MARKIERT, aber nicht +-- gesperrt. Ob ein Präparat noch verwendbar ist, ist eine fachliche +-- Beurteilung — keine Datumsrechnung. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'medikament', name = 'Medikamente', icon = '💊', + reihenfolge = 8, recht = 'medikament.sehen', + beschreibung = 'Bestand, Buchungen, Verfallsdaten und BTM-Buch', +}) + +-- Buchungsarten und ihre Wirkung auf den Bestand +local BUCHUNGSARTEN = { + eingang = { name = 'Wareneingang', richtung = 1 }, + ruecknahme = { name = 'Rücknahme', richtung = 1 }, + ausgabe = { name = 'Ausgabe', richtung = -1 }, + verfall = { name = 'Verfall entnommen', richtung = -1 }, + korrektur = { name = 'Korrektur', richtung = 0 }, +} + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Begrenzt einen Text und liefert nil bei leerer Eingabe +local function Text(wert, laenge) + if type(wert) ~= 'string' then return nil end + local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '') + if sauber == '' then return nil end + return sauber:sub(1, laenge) +end + +--- Name eines Mitarbeiters für die Anzeige +local function NameVon(charakterId) + if not charakterId then return 'unbekannt' end + local zeilen = MySQL.query.await( + 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId }) + if not zeilen or not zeilen[1] then return 'unbekannt' end + return zeilen[1].firstname .. ' ' .. zeilen[1].lastname +end + +--- Prüft eine Kategorie gegen die Konfiguration +local function Kategorie(kennung) + for _, eintrag in ipairs(Medizin.Config.Medikamentkategorien) do + if eintrag.id == kennung then return eintrag.id end + end + return 'sonstiges' +end + +-- ===================================================================== +-- BESTANDSÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('medikament', 'uebersicht', nil, function(quelle, mitarbeiter, daten) + local bedingungen, werte = {}, {} + + if daten.stillgelegt == true then + bedingungen[#bedingungen + 1] = 'm.aktiv = 0' + else + bedingungen[#bedingungen + 1] = 'm.aktiv = 1' + end + + if daten.kategorie and daten.kategorie ~= '' then + bedingungen[#bedingungen + 1] = 'm.kategorie = ?' + werte[#werte + 1] = daten.kategorie + end + + -- Schnellfilter der Übersicht + local filter = tostring(daten.filter or '') + if filter == 'knapp' then + bedingungen[#bedingungen + 1] = 'm.bestand <= m.mindestbestand' + elseif filter == 'leer' then + bedingungen[#bedingungen + 1] = 'm.bestand <= 0' + elseif filter == 'verfall' then + bedingungen[#bedingungen + 1] = + 'm.verfaellt_am IS NOT NULL AND m.verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY)' + werte[#werte + 1] = Medizin.Config.Erinnerungen.medikamentVorlaufTage + elseif filter == 'btm' then + bedingungen[#bedingungen + 1] = 'm.betaeubungsmittel = 1' + end + + if type(daten.begriff) == 'string' and #daten.begriff >= 2 then + bedingungen[#bedingungen + 1] = + '(m.kennung LIKE ? OR m.name LIKE ? OR m.wirkstoff LIKE ?)' + local muster = '%' .. daten.begriff .. '%' + for _ = 1, 3 do werte[#werte + 1] = muster end + end + + local liste = MySQL.query.await(([[ + SELECT m.id, m.kennung, m.name, m.wirkstoff, m.kategorie, + m.dosierung_standard, m.einheit, m.bestand, m.mindestbestand, + m.lagerort, m.verfaellt_am, m.hinweis, m.betaeubungsmittel, m.aktiv, + DATEDIFF(m.verfaellt_am, CURDATE()) AS tageBisVerfall, + (SELECT COUNT(*) FROM medical_medikament_buchung b + WHERE b.medikament = m.id AND b.art = 'ausgabe' + AND b.gebucht_am >= DATE_SUB(NOW(), INTERVAL 7 DAY)) AS ausgabenWoche + FROM medical_medikament m + WHERE %s + ORDER BY m.kategorie, m.sortierung, m.name + LIMIT 300 + ]]):format(table.concat(bedingungen, ' AND ')), werte) or {} + + local kategorieNamen = {} + for _, eintrag in ipairs(Medizin.Config.Medikamentkategorien) do + kategorieNamen[eintrag.id] = eintrag.name + end + + for _, eintrag in ipairs(liste) do + eintrag.kategorieName = kategorieNamen[eintrag.kategorie] or eintrag.kategorie + eintrag.btm = eintrag.betaeubungsmittel == 1 + eintrag.aktiv = eintrag.aktiv == 1 + eintrag.knapp = eintrag.bestand <= eintrag.mindestbestand + eintrag.leer = eintrag.bestand <= 0 + local tage = tonumber(eintrag.tageBisVerfall) + eintrag.abgelaufen = tage ~= nil and tage < 0 + eintrag.laeuftAb = tage ~= nil and tage >= 0 + and tage <= Medizin.Config.Erinnerungen.medikamentVorlaufTage + eintrag.betaeubungsmittel = nil + end + + return { + liste = liste, + kategorien = Medizin.Config.Medikamentkategorien, + buchungsarten = BUCHUNGSARTEN, + filter = filter, + stillgelegt = daten.stillgelegt == true, + zaehler = { + gesamt = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_medikament WHERE aktiv = 1')) or 0, + knapp = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_medikament + WHERE aktiv = 1 AND bestand > 0 AND bestand <= mindestbestand + ]])) or 0, + leer = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_medikament WHERE aktiv = 1 AND bestand <= 0')) or 0, + verfall = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_medikament + WHERE aktiv = 1 AND verfaellt_am IS NOT NULL + AND verfaellt_am <= DATE_ADD(CURDATE(), INTERVAL ? DAY) + ]], { Medizin.Config.Erinnerungen.medikamentVorlaufTage })) or 0, + btm = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_medikament WHERE aktiv = 1 AND betaeubungsmittel = 1')) or 0, + }, + rechte = { + bestand = Rechte.Hat(mitarbeiter, 'medikament.bestand'), + btm = Rechte.Hat(mitarbeiter, 'medikament.btm'), + katalog = Rechte.Hat(mitarbeiter, 'katalog.bearbeiten'), + }, + } +end) + +Kern.RegistriereAktion('medikament', 'oeffnen', nil, function(quelle, mitarbeiter, daten) + local medikamentId = tonumber(daten.id) + if not medikamentId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT m.*, DATEDIFF(m.verfaellt_am, CURDATE()) AS tageBisVerfall + FROM medical_medikament m WHERE m.id = ? + ]], { medikamentId }) + if not zeilen or not zeilen[1] then return nil, 'Medikament nicht gefunden.' end + + local m = zeilen[1] + + -- Buchungshistorie + local buchungen = MySQL.query.await([[ + SELECT b.id, b.art, b.menge, b.bestand_danach, b.grund, b.gebucht_am, + b.gebucht_von, b.patient, + c.firstname, c.lastname + FROM medical_medikament_buchung b + LEFT JOIN aether_characters c ON c.id = b.patient + WHERE b.medikament = ? + ORDER BY b.id DESC LIMIT 60 + ]], { medikamentId }) or {} + + for _, buchung in ipairs(buchungen) do + buchung.artName = BUCHUNGSARTEN[buchung.art] and BUCHUNGSARTEN[buchung.art].name + or buchung.art + buchung.autor = NameVon(buchung.gebucht_von) + buchung.patientName = buchung.firstname + and (buchung.firstname .. ' ' .. buchung.lastname) or nil + buchung.firstname, buchung.lastname, buchung.gebucht_von = nil, nil, nil + end + + -- Zuletzt dokumentierte Gaben + local gaben = MySQL.query.await([[ + SELECT g.id, g.menge, g.dosierung, g.gegeben_am, g.gegeben_von, g.hinweis, + b.kennung AS behandlung, c.firstname, c.lastname + FROM medical_behandlung_medikament g + JOIN medical_behandlung b ON b.id = g.behandlung + LEFT JOIN aether_characters c ON c.id = b.patient + WHERE g.medikament = ? + ORDER BY g.id DESC LIMIT 30 + ]], { medikamentId }) or {} + + for _, gabe in ipairs(gaben) do + gabe.autor = NameVon(gabe.gegeben_von) + gabe.patientName = (gabe.firstname or 'Unbekannt') .. ' ' .. (gabe.lastname or '') + gabe.ohneBuchung = gabe.hinweis + and gabe.hinweis:find('ohne Bestandsbuchung', 1, true) ~= nil or false + gabe.firstname, gabe.lastname, gabe.gegeben_von = nil, nil, nil + end + + local tage = tonumber(m.tageBisVerfall) + + return { + medikament = { + id = m.id, kennung = m.kennung, name = m.name, wirkstoff = m.wirkstoff, + kategorie = m.kategorie, dosierung = m.dosierung_standard, + einheit = m.einheit, bestand = m.bestand, mindestbestand = m.mindestbestand, + lagerort = m.lagerort, verfaelltAm = m.verfaellt_am, + tageBisVerfall = tage, + abgelaufen = tage ~= nil and tage < 0, + hinweis = m.hinweis, + btm = m.betaeubungsmittel == 1, + aktiv = m.aktiv == 1, + sortierung = m.sortierung, + }, + buchungen = buchungen, + gaben = gaben, + kategorien = Medizin.Config.Medikamentkategorien, + buchungsarten = BUCHUNGSARTEN, + rechte = { + bestand = Rechte.Hat(mitarbeiter, 'medikament.bestand'), + btm = Rechte.Hat(mitarbeiter, 'medikament.btm'), + katalog = Rechte.Hat(mitarbeiter, 'katalog.bearbeiten'), + }, + } +end) + +-- ===================================================================== +-- BESTAND BUCHEN +-- +-- Jede Buchung ist die Aufzeichnung dessen, was ein Mensch im Schrank +-- getan hat. Das System bewegt nichts von selbst. +-- ===================================================================== + +Kern.RegistriereAktion('medikament', 'buchen', 'medikament.bestand', function(quelle, mitarbeiter, daten) + local medikamentId = tonumber(daten.id) + local art = tostring(daten.art or '') + local menge = math.floor(tonumber(daten.menge) or 0) + + if not medikamentId then return nil, 'Ungültige Kennung.' end + if not BUCHUNGSARTEN[art] then return nil, 'Unbekannte Buchungsart.' end + if art == 'ausgabe' then + return nil, 'Eine Ausgabe entsteht nur aus einer dokumentierten Gabe.' + end + if menge < 1 or menge > 100000 then return nil, 'Ungültige Menge.' end + + local grund = Text(daten.grund, 255) + if not grund then return nil, 'Bitte einen Grund für die Buchung angeben.' end + + local zeilen = MySQL.query.await([[ + SELECT id, kennung, name, einheit, bestand, mindestbestand, betaeubungsmittel + FROM medical_medikament WHERE id = ? + ]], { medikamentId }) + if not zeilen or not zeilen[1] then return nil, 'Medikament nicht gefunden.' end + + local medikament = zeilen[1] + + -- Betäubungsmittel erfordern zusätzlich das eigene Recht + if medikament.betaeubungsmittel == 1 and not Rechte.Hat(mitarbeiter, 'medikament.btm') then + return nil, 'Betäubungsmittel darfst du nicht buchen.' + end + + local richtung = BUCHUNGSARTEN[art].richtung + local neuerBestand + + if art == 'korrektur' then + -- Eine Korrektur setzt den Bestand auf den gezählten Wert + neuerBestand = menge + else + neuerBestand = medikament.bestand + (richtung * menge) + if neuerBestand < 0 then + return nil, ('Das ergäbe einen Bestand von %d. Vorrätig sind %d %s.') + :format(neuerBestand, medikament.bestand, medikament.einheit) + end + end + + MySQL.update.await('UPDATE medical_medikament SET bestand = ? WHERE id = ?', + { neuerBestand, medikamentId }) + + MySQL.insert.await([[ + INSERT INTO medical_medikament_buchung + (medikament, art, menge, bestand_danach, grund, gebucht_von) + VALUES (?, ?, ?, ?, ?, ?) + ]], { medikamentId, art, menge, neuerBestand, grund, mitarbeiter.charakterId }) + + Protokoll.Schreibe(mitarbeiter, 'medikament', + ('Bestand gebucht: %s'):format(BUCHUNGSARTEN[art].name), + { typ = 'medikament', id = medikamentId }, + { kennung = medikament.kennung, menge = menge, + vorher = medikament.bestand, nachher = neuerBestand, + btm = medikament.betaeubungsmittel == 1 }, grund) + + -- Erinnern, wenn es knapp wird — nachbestellt wird nichts + if neuerBestand <= medikament.mindestbestand then + Hinweise.Erstelle(nil, { + kategorie = 'medikament', + prioritaet = neuerBestand <= 0 and 'hoch' or 'normal', + titel = neuerBestand <= 0 and 'Medikament nicht mehr vorrätig' + or 'Medikament unter Mindestbestand', + text = ('%s — noch %d %s.'):format(medikament.name, neuerBestand, + medikament.einheit), + zielTyp = 'medikament', zielId = medikamentId, + }) + end + + Kern.Loese('medikament.gebucht', medikamentId, art, mitarbeiter) + + return { bestand = neuerBestand } +end) + +-- ===================================================================== +-- KATALOG PFLEGEN +-- +-- Werte hier sind Stammdaten und Vorschläge. Die Standarddosierung +-- ist ein Anhaltspunkt, keine Verordnung. +-- ===================================================================== + +Kern.RegistriereAktion('medikament', 'speichern', 'katalog.bearbeiten', function(quelle, mitarbeiter, daten) + local kennung = Text(daten.kennung, 24) + local name = Text(daten.name, 120) + + if not kennung or #kennung < 2 then return nil, 'Bitte eine Kennung angeben.' end + if not name or #name < 2 then return nil, 'Bitte einen Namen angeben.' end + + kennung = kennung:gsub('%s', ''):upper() + + local id = tonumber(daten.id) + local btm = daten.btm == true + + -- Betäubungsmittel darf nur kennzeichnen, wer sie auch buchen darf + if btm and not Rechte.Hat(mitarbeiter, 'medikament.btm') then + return nil, 'Nur mit BTM-Recht lässt sich ein Präparat als Betäubungsmittel führen.' + end + + local belegt = MySQL.scalar.await( + 'SELECT id FROM medical_medikament WHERE kennung = ? AND (? IS NULL OR id <> ?)', + { kennung, id, id }) + if belegt then return nil, 'Diese Kennung ist bereits vergeben.' end + + local mindestbestand = math.max(0, math.floor(tonumber(daten.mindestbestand) or 0)) + local sortierung = math.floor(tonumber(daten.sortierung) or 0) + local verfaellt = Text(daten.verfaelltAm, 10) -- Format JJJJ-MM-TT + + if verfaellt and not verfaellt:match('^%d%d%d%d%-%d%d%-%d%d$') then + return nil, 'Das Verfallsdatum muss im Format JJJJ-MM-TT stehen.' + end + + if id then + local alt = MySQL.query.await( + 'SELECT kennung, name, mindestbestand, verfaellt_am FROM medical_medikament WHERE id = ?', + { id }) + if not alt or not alt[1] then return nil, 'Medikament nicht gefunden.' end + + MySQL.update.await([[ + UPDATE medical_medikament + SET kennung = ?, name = ?, wirkstoff = ?, kategorie = ?, + dosierung_standard = ?, einheit = ?, mindestbestand = ?, + lagerort = ?, verfaellt_am = ?, hinweis = ?, + betaeubungsmittel = ?, sortierung = ? + WHERE id = ? + ]], { + kennung, name, Text(daten.wirkstoff, 120), Kategorie(daten.kategorie), + Text(daten.dosierung, 64), Text(daten.einheit, 24) or 'Stück', + mindestbestand, Text(daten.lagerort, 64), verfaellt, + Text(daten.hinweis, 255), btm and 1 or 0, sortierung, id, + }) + + Protokoll.Schreibe(mitarbeiter, 'medikament', 'Medikament bearbeitet', + { typ = 'medikament', id = id }, + { kennung = kennung, + verfallAlt = alt[1].verfaellt_am, verfallNeu = verfaellt, + mindestAlt = alt[1].mindestbestand, mindestNeu = mindestbestand }) + else + -- Ein neues Präparat startet mit Bestand 0. Der Zugang wird gebucht. + id = MySQL.insert.await([[ + INSERT INTO medical_medikament + (kennung, name, wirkstoff, kategorie, dosierung_standard, einheit, + bestand, mindestbestand, lagerort, verfaellt_am, hinweis, + betaeubungsmittel, sortierung) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?) + ]], { + kennung, name, Text(daten.wirkstoff, 120), Kategorie(daten.kategorie), + Text(daten.dosierung, 64), Text(daten.einheit, 24) or 'Stück', + mindestbestand, Text(daten.lagerort, 64), verfaellt, + Text(daten.hinweis, 255), btm and 1 or 0, sortierung, + }) + + Protokoll.Schreibe(mitarbeiter, 'medikament', 'Medikament angelegt', + { typ = 'medikament', id = id }, { kennung = kennung, btm = btm }) + end + + Kern.Loese('medikament.katalog', id, mitarbeiter) + + return { id = id, kennung = kennung } +end) + +--- Stilllegen statt löschen: alte Gaben bleiben nachvollziehbar +Kern.RegistriereAktion('medikament', 'schalten', 'katalog.bearbeiten', function(quelle, mitarbeiter, daten) + local medikamentId = tonumber(daten.id) + if not medikamentId then return nil, 'Ungültige Kennung.' end + + local aktiv = daten.aktiv == true + + local zeilen = MySQL.query.await( + 'SELECT id, kennung, name, bestand, einheit FROM medical_medikament WHERE id = ?', + { medikamentId }) + if not zeilen or not zeilen[1] then return nil, 'Medikament nicht gefunden.' end + + local medikament = zeilen[1] + + -- Ein Restbestand wird gemeldet, aber nicht weggebucht + if not aktiv and medikament.bestand > 0 then + Hinweise.Erstelle(nil, { + kategorie = 'medikament', prioritaet = 'normal', + titel = 'Stillgelegtes Medikament mit Restbestand', + text = ('%s wurde stillgelegt, im Schrank liegen noch %d %s. Die Entnahme muss gebucht werden.') + :format(medikament.name, medikament.bestand, medikament.einheit), + zielTyp = 'medikament', zielId = medikamentId, + }) + end + + MySQL.update.await('UPDATE medical_medikament SET aktiv = ? WHERE id = ?', + { aktiv and 1 or 0, medikamentId }) + + Protokoll.Schreibe(mitarbeiter, 'medikament', + aktiv and 'Medikament wieder freigegeben' or 'Medikament stillgelegt', + { typ = 'medikament', id = medikamentId }, + { kennung = medikament.kennung, restbestand = medikament.bestand }, + Text(daten.grund, 255)) + + return { ok = true } +end) + +-- ===================================================================== +-- BTM-BUCH +-- +-- Lückenlose Bewegungsliste aller Betäubungsmittel. Reine Auskunft; +-- hier lässt sich nichts ändern. +-- ===================================================================== + +Kern.RegistriereAktion('medikament', 'btmBuch', 'medikament.btm', function(quelle, mitarbeiter, daten) + local medikamentId = tonumber(daten.medikament) + + local bedingungen = { 'm.betaeubungsmittel = 1' } + local werte = {} + + if medikamentId then + bedingungen[#bedingungen + 1] = 'm.id = ?' + werte[#werte + 1] = medikamentId + end + + local bewegungen = MySQL.query.await(([[ + SELECT b.id, b.art, b.menge, b.bestand_danach, b.grund, b.gebucht_am, + b.gebucht_von, b.patient, + m.id AS medikamentId, m.kennung, m.name, m.einheit, + c.firstname, c.lastname + FROM medical_medikament_buchung b + JOIN medical_medikament m ON m.id = b.medikament + LEFT JOIN aether_characters c ON c.id = b.patient + WHERE %s + ORDER BY b.id DESC + LIMIT 200 + ]]):format(table.concat(bedingungen, ' AND ')), werte) or {} + + for _, eintrag in ipairs(bewegungen) do + eintrag.artName = BUCHUNGSARTEN[eintrag.art] and BUCHUNGSARTEN[eintrag.art].name + or eintrag.art + eintrag.autor = NameVon(eintrag.gebucht_von) + eintrag.patientName = eintrag.firstname + and (eintrag.firstname .. ' ' .. eintrag.lastname) or nil + eintrag.firstname, eintrag.lastname, eintrag.gebucht_von = nil, nil, nil + end + + -- Gaben, die ohne Bestandsbuchung dokumentiert wurden. + -- Das ist keine Unregelmässigkeit, sondern ein bewusster Vermerk — + -- er gehört aber ins Buch, damit die Zahlen erklärbar bleiben. + local ohneBuchung = MySQL.query.await([[ + SELECT g.id, g.menge, g.gegeben_am, g.gegeben_von, g.hinweis, + m.kennung, m.name, m.einheit, + bh.kennung AS behandlung, + c.firstname, c.lastname + FROM medical_behandlung_medikament g + JOIN medical_medikament m ON m.id = g.medikament + JOIN medical_behandlung bh ON bh.id = g.behandlung + LEFT JOIN aether_characters c ON c.id = bh.patient + WHERE m.betaeubungsmittel = 1 + AND g.hinweis LIKE '%ohne Bestandsbuchung%' + ORDER BY g.id DESC LIMIT 60 + ]]) or {} + + for _, eintrag in ipairs(ohneBuchung) do + eintrag.autor = NameVon(eintrag.gegeben_von) + eintrag.patientName = (eintrag.firstname or 'Unbekannt') + .. ' ' .. (eintrag.lastname or '') + eintrag.firstname, eintrag.lastname, eintrag.gegeben_von = nil, nil, nil + end + + -- Aktueller Stand aller Betäubungsmittel + local bestaende = MySQL.query.await([[ + SELECT id, kennung, name, einheit, bestand, mindestbestand, lagerort, + verfaellt_am, DATEDIFF(verfaellt_am, CURDATE()) AS tageBisVerfall + FROM medical_medikament + WHERE betaeubungsmittel = 1 AND aktiv = 1 + ORDER BY name + ]]) or {} + + for _, eintrag in ipairs(bestaende) do + local tage = tonumber(eintrag.tageBisVerfall) + eintrag.abgelaufen = tage ~= nil and tage < 0 + end + + return { + bewegungen = bewegungen, + ohneBuchung = ohneBuchung, + bestaende = bestaende, + gewaehlt = medikamentId, + } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Liefert den Bestand eines Medikaments +--- @param kennung string Kennung, z. B. 'ANA-01' +--- @return table|nil +exports('HoleBestand', function(kennung) + if type(kennung) ~= 'string' then return nil end + local zeilen = MySQL.query.await([[ + SELECT kennung, name, einheit, bestand, mindestbestand, verfaellt_am, aktiv + FROM medical_medikament WHERE kennung = ? + ]], { kennung }) + return zeilen and zeilen[1] or nil +end) + +--- Meldet einen Wareneingang aus einer anderen Ressource. +--- Der buchende Mitarbeiter muss im Dienst sein — das System bucht nie +--- aus eigenem Antrieb. +--- @param charakterId number Buchender Mitarbeiter +--- @param kennung string Medikament +--- @param menge number Anzahl +--- @param grund string Begründung +--- @return number|nil Neuer Bestand +exports('BucheEingang', function(charakterId, kennung, menge, grund) + local quelle = Dienst.QuelleVonCharakter(tonumber(charakterId) or 0) + local mitarbeiter = quelle and Dienst.Hole(quelle) + if not mitarbeiter then return nil end + + local medikament = MySQL.scalar.await( + 'SELECT id FROM medical_medikament WHERE kennung = ?', { tostring(kennung) }) + if not medikament then return nil end + + local ergebnis = Kern.FuehreAus(quelle, mitarbeiter, 'medikament', 'buchen', { + id = medikament, art = 'eingang', + menge = menge, grund = grund or 'Wareneingang', + }) + + return ergebnis and ergebnis.bestand or nil +end) diff --git a/resources/[aether]/aether-medical/server/module/operation.lua b/resources/[aether]/aether-medical/server/module/operation.lua new file mode 100644 index 0000000..7401816 --- /dev/null +++ b/resources/[aether]/aether-medical/server/module/operation.lua @@ -0,0 +1,807 @@ +-- ===================================================================== +-- aether-medical / Modul: Operationsverwaltung +-- +-- Operationen werden vollständig manuell dokumentiert. +-- +-- Das System führt keine Operation durch, startet keine, beendet keine +-- und entscheidet nie über einen Ausgang. Der geplante Zeitpunkt ist +-- eine Erinnerung — er beginnt nichts. Beginn und Ende trägt das +-- OP-Team ein, wenn es soweit ist. +-- +-- Überschneidungen im OP-Saal werden GEMELDET, nicht verhindert. +-- Ob zwei Eingriffe im selben Saal ein Problem sind, weiss nur ein +-- Mensch — vielleicht dauert der erste kürzer als geplant. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'operation', name = 'Operationen', icon = '🔪', + reihenfolge = 7, + recht = { 'operation.planen', 'operation.dokumentieren' }, + beschreibung = 'OP-Planung, OP-Team und Operationsbericht', +}) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Begrenzt einen Text und liefert nil bei leerer Eingabe +local function Text(wert, laenge) + if type(wert) ~= 'string' then return nil end + local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '') + if sauber == '' then return nil end + return sauber:sub(1, laenge) +end + +--- Name eines Mitarbeiters für die Anzeige +local function NameVon(charakterId) + if not charakterId then return 'unbekannt' end + local zeilen = MySQL.query.await( + 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId }) + if not zeilen or not zeilen[1] then return 'unbekannt' end + return zeilen[1].firstname .. ' ' .. zeilen[1].lastname +end + +--- Prüft eine Funktion im OP-Team +--- @param kennung any Eingabe +--- @return string +local function TeamFunktion(kennung) + for _, eintrag in ipairs(Medizin.Config.OpFunktionen) do + if eintrag.id == kennung then return eintrag.id end + end + return 'assistenz' +end + +--- Lädt das OP-Team einer Operation +--- @param operationId number Operation +--- @return table +local function LadeTeam(operationId) + local team = MySQL.query.await([[ + SELECT t.`character`, t.funktion, + c.firstname, c.lastname, p.personalnummer, p.rolle + FROM medical_operation_team t + LEFT JOIN aether_characters c ON c.id = t.`character` + LEFT JOIN medical_personal p ON p.`character` = t.`character` + WHERE t.operation = ? + ]], { operationId }) or {} + + local funktionsNamen = {} + for _, eintrag in ipairs(Medizin.Config.OpFunktionen) do + funktionsNamen[eintrag.id] = eintrag.name + end + + for _, mitglied in ipairs(team) do + mitglied.name = (mitglied.firstname or 'Unbekannt') .. ' ' .. (mitglied.lastname or '') + local rolle = Medizin.HoleRolle(mitglied.rolle) + mitglied.rolleName = rolle and rolle.name or mitglied.rolle + mitglied.funktionName = funktionsNamen[mitglied.funktion] or mitglied.funktion + mitglied.firstname, mitglied.lastname = nil, nil + end + + -- Operateur zuerst, dann der Rest + table.sort(team, function(a, b) + if a.funktion == b.funktion then return (a.name or '') < (b.name or '') end + return a.funktion == 'operateur' + end) + + return team +end + +--- Sucht Operationen, die sich im selben Saal zeitlich überschneiden. +--- Reine Auskunft — das Ergebnis verhindert nichts. +--- @param saalId number Zimmer +--- @param geplantFuer string|nil Zeitpunkt +--- @param ohneId number|nil Eigene Operation ausklammern +--- @return table +local function Ueberschneidungen(saalId, geplantFuer, ohneId) + if not saalId then return {} end + + local treffer = MySQL.query.await([[ + SELECT o.id, o.kennung, o.art, o.status, o.geplant_fuer, o.beginn, + c.firstname, c.lastname + FROM medical_operation o + JOIN aether_characters c ON c.id = o.patient + WHERE o.saal = ? + AND o.status IN ('geplant', 'laufend') + AND (? IS NULL OR o.id <> ?) + AND ( + o.status = 'laufend' + OR (? IS NOT NULL AND o.geplant_fuer IS NOT NULL + AND ABS(TIMESTAMPDIFF(MINUTE, o.geplant_fuer, ?)) < 120) + ) + ORDER BY o.geplant_fuer + ]], { saalId, ohneId, ohneId, geplantFuer, geplantFuer }) or {} + + for _, eintrag in ipairs(treffer) do + eintrag.patientName = (eintrag.firstname or '') .. ' ' .. (eintrag.lastname or '') + eintrag.firstname, eintrag.lastname = nil, nil + end + + return treffer +end + +-- ===================================================================== +-- ÜBERSICHT UND OP-PLAN +-- ===================================================================== + +Kern.RegistriereAktion('operation', 'uebersicht', nil, function(quelle, mitarbeiter, daten) + local ordner = tostring(daten.ordner or 'plan') + + local bedingung + if ordner == 'archiv' then + bedingung = "o.status IN ('abgeschlossen', 'abgesagt')" + elseif ordner == 'laufend' then + bedingung = "o.status = 'laufend'" + else + bedingung = "o.status IN ('geplant', 'laufend')" + end + + local liste = MySQL.query.await(([[ + SELECT o.id, o.kennung, o.patient, o.art, o.saal, o.status, + o.geplant_fuer, o.beginn, o.ende, o.erstellt_von, o.komplikationen, + TIMESTAMPDIFF(MINUTE, NOW(), o.geplant_fuer) AS restMinuten, + TIMESTAMPDIFF(MINUTE, o.beginn, COALESCE(o.ende, NOW())) AS dauerMinuten, + c.firstname, c.lastname, c.dob, + m.blutgruppe, m.allergien, m.hinweis, + z.kennung AS saalName, + (SELECT COUNT(*) FROM medical_operation_team t WHERE t.operation = o.id) AS teamGroesse, + (SELECT COUNT(*) FROM medical_operation_team t + WHERE t.operation = o.id AND t.`character` = ?) AS ichDabei + FROM medical_operation o + JOIN aether_characters c ON c.id = o.patient + LEFT JOIN medical_patient m ON m.`character` = o.patient + LEFT JOIN medical_zimmer z ON z.id = o.saal + WHERE %s + ORDER BY + CASE o.status WHEN 'laufend' THEN 0 ELSE 1 END, + o.geplant_fuer IS NULL, o.geplant_fuer, o.id DESC + LIMIT 120 + ]]):format(bedingung), { mitarbeiter.charakterId }) or {} + + for _, eintrag in ipairs(liste) do + eintrag.patientName = eintrag.firstname .. ' ' .. eintrag.lastname + eintrag.geburt = tostring(eintrag.dob):sub(1, 10) + eintrag.erstelltVon = NameVon(eintrag.erstellt_von) + eintrag.ichDabei = tonumber(eintrag.ichDabei) > 0 + eintrag.teamGroesse = tonumber(eintrag.teamGroesse) or 0 + eintrag.hatKomplikationen = eintrag.komplikationen ~= nil + eintrag.firstname, eintrag.lastname, eintrag.dob = nil, nil, nil + eintrag.erstellt_von, eintrag.komplikationen = nil, nil + end + + -- OP-Säle mit ihrer aktuellen Lage + local saele = MySQL.query.await([[ + SELECT z.id, z.kennung, z.status, z.hinweis, s.name AS stationName + FROM medical_zimmer z + JOIN medical_station s ON s.id = z.station + WHERE z.art = 'op' + ORDER BY z.kennung + ]]) or {} + + for _, saal in ipairs(saele) do + local laufend = MySQL.query.await([[ + SELECT o.id, o.kennung, o.art, o.beginn, + TIMESTAMPDIFF(MINUTE, o.beginn, NOW()) AS dauer, + c.firstname, c.lastname + FROM medical_operation o + JOIN aether_characters c ON c.id = o.patient + WHERE o.saal = ? AND o.status = 'laufend' + ORDER BY o.beginn LIMIT 1 + ]], { saal.id }) + + if laufend and laufend[1] then + saal.laufend = { + id = laufend[1].id, kennung = laufend[1].kennung, + art = laufend[1].art, dauer = tonumber(laufend[1].dauer) or 0, + patientName = laufend[1].firstname .. ' ' .. laufend[1].lastname, + } + end + + saal.geplant = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_operation + WHERE saal = ? AND status = 'geplant' + ]], { saal.id })) or 0 + end + + return { + ordner = ordner, + liste = liste, + saele = saele, + funktionen = Medizin.Config.OpFunktionen, + zaehler = { + geplant = tonumber(MySQL.scalar.await( + "SELECT COUNT(*) FROM medical_operation WHERE status = 'geplant'")) or 0, + laufend = tonumber(MySQL.scalar.await( + "SELECT COUNT(*) FROM medical_operation WHERE status = 'laufend'")) or 0, + faellig = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_operation + WHERE status = 'geplant' AND geplant_fuer IS NOT NULL AND geplant_fuer <= NOW() + ]])) or 0, + heute = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_operation + WHERE status = 'abgeschlossen' AND DATE(ende) = CURDATE() + ]])) or 0, + }, + rechte = { + planen = Rechte.Hat(mitarbeiter, 'operation.planen'), + dokumentieren = Rechte.Hat(mitarbeiter, 'operation.dokumentieren'), + }, + } +end) + +Kern.RegistriereAktion('operation', 'oeffnen', nil, function(quelle, mitarbeiter, daten) + local operationId = tonumber(daten.id) + if not operationId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT o.*, c.firstname, c.lastname, c.dob, + m.blutgruppe, m.allergien, m.vorerkrankungen, m.dauermedikation, + m.implantate, m.hinweis, m.lichtbild, + z.kennung AS saalName, + a.grund AS aufnahmeGrund, a.status AS aufnahmeStatus + FROM medical_operation o + JOIN aether_characters c ON c.id = o.patient + LEFT JOIN medical_patient m ON m.`character` = o.patient + LEFT JOIN medical_zimmer z ON z.id = o.saal + LEFT JOIN medical_aufnahme a ON a.id = o.aufnahme + WHERE o.id = ? + ]], { operationId }) + + if not zeilen or not zeilen[1] then return nil, 'Operation nicht gefunden.' end + local o = zeilen[1] + + local team = LadeTeam(operationId) + local ichDabei = false + for _, mitglied in ipairs(team) do + if mitglied.character == mitarbeiter.charakterId then ichDabei = true end + end + + -- Verfügbare OP-Säle für eine Umplanung + local saele = MySQL.query.await([[ + SELECT z.id, z.kennung, z.status FROM medical_zimmer z + WHERE z.art = 'op' ORDER BY z.kennung + ]]) or {} + + return { + operation = { + id = o.id, kennung = o.kennung, patient = o.patient, + patientName = o.firstname .. ' ' .. o.lastname, + geburt = tostring(o.dob):sub(1, 10), + art = o.art, saal = o.saal, saalName = o.saalName, + aufnahme = o.aufnahme, aufnahmeGrund = o.aufnahmeGrund, + geplantFuer = o.geplant_fuer, + beginn = o.beginn, ende = o.ende, + verlauf = o.verlauf, komplikationen = o.komplikationen, + nachsorge = o.nachsorge, + status = o.status, + erstelltAm = o.erstellt_am, + erstelltVon = NameVon(o.erstellt_von), + }, + stamm = { + blutgruppe = o.blutgruppe, allergien = o.allergien, + vorerkrankungen = o.vorerkrankungen, dauermedikation = o.dauermedikation, + implantate = o.implantate, hinweis = o.hinweis, lichtbild = o.lichtbild, + }, + team = team, + ichDabei = ichDabei, + saele = saele, + funktionen = Medizin.Config.OpFunktionen, + -- Zeitgleiche Eingriffe im selben Saal — als Hinweis, nicht als Sperre + ueberschneidungen = Ueberschneidungen(o.saal, o.geplant_fuer, o.id), + rechte = { + planen = Rechte.Hat(mitarbeiter, 'operation.planen'), + dokumentieren = Rechte.Hat(mitarbeiter, 'operation.dokumentieren'), + }, + } +end) + +-- ===================================================================== +-- PLANEN +-- ===================================================================== + +Kern.RegistriereAktion('operation', 'patientSuche', 'operation.planen', function(quelle, mitarbeiter, daten) + local begriff = type(daten.begriff) == 'string' and daten.begriff or '' + if #begriff < 2 then return { treffer = {}, saele = {} } end + + local muster = '%' .. begriff .. '%' + local treffer = MySQL.query.await([[ + SELECT c.id, c.firstname, c.lastname, c.dob, + m.blutgruppe, m.allergien, m.hinweis, + (SELECT a.id FROM medical_aufnahme a + WHERE a.patient = c.id AND a.status = 'aufgenommen' + ORDER BY a.id DESC LIMIT 1) AS aufnahme + FROM aether_characters c + LEFT JOIN medical_patient m ON m.`character` = c.id + WHERE c.deleted = 0 + AND (c.firstname LIKE ? OR c.lastname LIKE ? + OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? OR c.dob LIKE ?) + ORDER BY c.lastname, c.firstname + LIMIT 20 + ]], { muster, muster, muster, muster }) or {} + + for _, eintrag in ipairs(treffer) do + eintrag.dob = tostring(eintrag.dob):sub(1, 10) + end + + local saele = MySQL.query.await([[ + SELECT z.id, z.kennung, z.status, + (SELECT COUNT(*) FROM medical_operation o + WHERE o.saal = z.id AND o.status = 'laufend') AS belegt + FROM medical_zimmer z WHERE z.art = 'op' ORDER BY z.kennung + ]]) or {} + + -- Kollegen im Dienst als mögliche Teammitglieder + local kollegen = {} + for _, eintrag in pairs(Dienst.AlleImDienst()) do + kollegen[#kollegen + 1] = { + charakterId = eintrag.charakterId, + name = eintrag.name, + personalnummer = eintrag.personalnummer, + rolleName = eintrag.rolleName, + } + end + table.sort(kollegen, function(a, b) return a.personalnummer < b.personalnummer end) + + return { treffer = treffer, saele = saele, kollegen = kollegen } +end) + +--- Kollegen im Dienst, die sich ins OP-Team einteilen lassen +Kern.RegistriereAktion('operation', 'kollegen', 'operation.planen', function(quelle, mitarbeiter) + local kollegen = {} + for _, eintrag in pairs(Dienst.AlleImDienst()) do + kollegen[#kollegen + 1] = { + charakterId = eintrag.charakterId, + name = eintrag.name, + personalnummer = eintrag.personalnummer, + rolleName = eintrag.rolleName, + funktion = eintrag.funktion, + } + end + table.sort(kollegen, function(a, b) return a.personalnummer < b.personalnummer end) + + return { kollegen = kollegen, funktionen = Medizin.Config.OpFunktionen } +end) + +Kern.RegistriereAktion('operation', 'planen', 'operation.planen', function(quelle, mitarbeiter, daten) + local patientId = tonumber(daten.patient) + if not patientId then return nil, 'Kein Patient gewählt.' end + + local person = MySQL.query.await( + 'SELECT firstname, lastname FROM aether_characters WHERE id = ? AND deleted = 0', + { patientId }) + if not person or not person[1] then return nil, 'Diese Person ist nicht erfasst.' end + + local art = Text(daten.art, 160) + if not art or #art < 3 then return nil, 'Bitte die Art des Eingriffs angeben.' end + + -- OP-Saal ist freiwillig, muss aber existieren + local saalId = nil + if daten.saal and tonumber(daten.saal) then + local saal = MySQL.query.await( + "SELECT id, kennung, status FROM medical_zimmer WHERE id = ? AND art = 'op'", + { tonumber(daten.saal) }) + if not saal or not saal[1] then return nil, 'Unbekannter OP-Saal.' end + if saal[1].status == 'gesperrt' then + return nil, ('OP-Saal %s ist gesperrt.'):format(saal[1].kennung) + end + saalId = saal[1].id + end + + -- Zeitpunkt in Minuten ab jetzt. 0 heisst: keine Zeitplanung. + local vorlauf = math.floor(tonumber(daten.vorlaufMinuten) or 0) + if vorlauf < 0 or vorlauf > 20160 then + return nil, 'Der Vorlauf muss zwischen 0 Minuten und 14 Tagen liegen.' + end + + local aufnahmeId = tonumber(daten.aufnahme) + if aufnahmeId then + local gueltig = MySQL.scalar.await( + 'SELECT id FROM medical_aufnahme WHERE id = ? AND patient = ?', + { aufnahmeId, patientId }) + if not gueltig then aufnahmeId = nil end + end + + local kennung = Kern.ErzeugeKennung('OP', 'medical_operation', 'kennung') + + local id = MySQL.insert.await([[ + INSERT INTO medical_operation + (kennung, patient, aufnahme, art, saal, geplant_fuer, status, erstellt_von) + VALUES (?, ?, ?, ?, ?, + CASE WHEN ? > 0 THEN DATE_ADD(NOW(), INTERVAL ? MINUTE) ELSE NULL END, + 'geplant', ?) + ]], { kennung, patientId, aufnahmeId, art, saalId, vorlauf, vorlauf, + mitarbeiter.charakterId }) + + -- Der Planende gehört als Operateur ins Team, sofern nicht anders gewünscht + if daten.selbstOperateur ~= false then + MySQL.query.await([[ + INSERT INTO medical_operation_team (operation, `character`, funktion) + VALUES (?, ?, 'operateur') + ON DUPLICATE KEY UPDATE funktion = VALUES(funktion) + ]], { id, mitarbeiter.charakterId }) + end + + -- Weitere Teammitglieder aus der Planung + if type(daten.team) == 'table' then + for _, mitglied in ipairs(daten.team) do + local charakterId = tonumber(mitglied.charakterId) + if charakterId and charakterId ~= mitarbeiter.charakterId then + MySQL.query.await([[ + INSERT INTO medical_operation_team (operation, `character`, funktion) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE funktion = VALUES(funktion) + ]], { id, charakterId, TeamFunktion(mitglied.funktion) }) + end + end + end + + Protokoll.Schreibe(mitarbeiter, 'operation', 'Operation geplant', + { typ = 'operation', id = id }, + { kennung = kennung, patient = patientId, art = art, + saal = saalId, vorlaufMinuten = vorlauf }) + + Hinweise.Erstelle(nil, { + kategorie = 'op', prioritaet = 'normal', + titel = ('Operation geplant · %s'):format(kennung), + text = ('%s %s — %s%s'):format(person[1].firstname, person[1].lastname, art, + vorlauf > 0 and (' (in %d Minuten)'):format(vorlauf) or ' (ohne Zeitangabe)'), + zielTyp = 'operation', zielId = id, + }) + + Kern.Loese('operation.geplant', id, mitarbeiter) + + return { + id = id, kennung = kennung, + ueberschneidungen = saalId and #Ueberschneidungen(saalId, nil, id) or 0, + } +end) + +--- Zeitpunkt oder Saal einer geplanten Operation ändern +Kern.RegistriereAktion('operation', 'umplanen', 'operation.planen', function(quelle, mitarbeiter, daten) + local operationId = tonumber(daten.id) + if not operationId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await( + 'SELECT id, kennung, status, saal, geplant_fuer FROM medical_operation WHERE id = ?', + { operationId }) + if not zeilen or not zeilen[1] then return nil, 'Operation nicht gefunden.' end + + local operation = zeilen[1] + if operation.status ~= 'geplant' then + return nil, 'Nur geplante Operationen lassen sich umplanen.' + end + + local grund = Text(daten.grund, 255) + if not grund then return nil, 'Bitte begründen, warum umgeplant wird.' end + + local saalId = nil + if daten.saal and tonumber(daten.saal) then + local saal = MySQL.query.await( + "SELECT id, kennung, status FROM medical_zimmer WHERE id = ? AND art = 'op'", + { tonumber(daten.saal) }) + if not saal or not saal[1] then return nil, 'Unbekannter OP-Saal.' end + if saal[1].status == 'gesperrt' then + return nil, ('OP-Saal %s ist gesperrt.'):format(saal[1].kennung) + end + saalId = saal[1].id + end + + local vorlauf = math.floor(tonumber(daten.vorlaufMinuten) or 0) + if vorlauf < 0 or vorlauf > 20160 then + return nil, 'Der Vorlauf muss zwischen 0 Minuten und 14 Tagen liegen.' + end + + MySQL.update.await([[ + UPDATE medical_operation + SET saal = ?, + geplant_fuer = CASE WHEN ? > 0 THEN DATE_ADD(NOW(), INTERVAL ? MINUTE) ELSE NULL END + WHERE id = ? + ]], { saalId, vorlauf, vorlauf, operationId }) + + Protokoll.Schreibe(mitarbeiter, 'operation', 'Operation umgeplant', + { typ = 'operation', id = operationId }, + { kennung = operation.kennung, saal = saalId, vorlaufMinuten = vorlauf }, grund) + + Kern.Loese('operation.umgeplant', operationId, mitarbeiter) + + return { ok = true } +end) + +Kern.RegistriereAktion('operation', 'absagen', 'operation.planen', function(quelle, mitarbeiter, daten) + local operationId = tonumber(daten.id) + if not operationId then return nil, 'Ungültige Kennung.' end + + local grund = Text(daten.grund, 512) + if not grund or #grund < 5 then return nil, 'Eine Absage erfordert eine Begründung.' end + + local zeilen = MySQL.query.await([[ + SELECT o.id, o.kennung, o.status, o.art, c.firstname, c.lastname + FROM medical_operation o + JOIN aether_characters c ON c.id = o.patient + WHERE o.id = ? + ]], { operationId }) + if not zeilen or not zeilen[1] then return nil, 'Operation nicht gefunden.' end + + local operation = zeilen[1] + if operation.status == 'abgeschlossen' then + return nil, 'Eine abgeschlossene Operation lässt sich nicht absagen.' + end + if operation.status == 'abgesagt' then + return nil, 'Diese Operation ist bereits abgesagt.' + end + + MySQL.update.await([[ + UPDATE medical_operation + SET status = 'abgesagt', + verlauf = CONCAT(COALESCE(verlauf, ''), ?) + WHERE id = ? + ]], { '\n[Abgesagt] ' .. grund, operationId }) + + Protokoll.Schreibe(mitarbeiter, 'operation', 'Operation abgesagt', + { typ = 'operation', id = operationId }, + { kennung = operation.kennung, warLaufend = operation.status == 'laufend' }, grund) + + Hinweise.Erstelle(nil, { + kategorie = 'op', prioritaet = 'normal', + titel = ('Operation abgesagt · %s'):format(operation.kennung), + text = ('%s %s — %s'):format(operation.firstname, operation.lastname, grund), + zielTyp = 'operation', zielId = operationId, + }) + + Kern.Loese('operation.abgeschlossen', operationId, mitarbeiter) + + return { ok = true } +end) + +-- ===================================================================== +-- OP-TEAM +-- ===================================================================== + +Kern.RegistriereAktion('operation', 'teamSetzen', 'operation.planen', function(quelle, mitarbeiter, daten) + local operationId = tonumber(daten.id) + local charakterId = tonumber(daten.charakterId) + + if not operationId or not charakterId then return nil, 'Unvollständige Angaben.' end + + local operation = MySQL.query.await( + 'SELECT id, kennung, status FROM medical_operation WHERE id = ?', { operationId }) + if not operation or not operation[1] then return nil, 'Operation nicht gefunden.' end + if operation[1].status == 'abgeschlossen' or operation[1].status == 'abgesagt' then + return nil, 'Das Team einer abgeschlossenen Operation lässt sich nicht ändern.' + end + + local personal = MySQL.scalar.await( + 'SELECT `character` FROM medical_personal WHERE `character` = ? AND aktiv = 1', + { charakterId }) + if not personal then return nil, 'Diese Person gehört nicht zum Personal.' end + + local funktion = TeamFunktion(daten.funktion) + + MySQL.query.await([[ + INSERT INTO medical_operation_team (operation, `character`, funktion) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE funktion = VALUES(funktion) + ]], { operationId, charakterId, funktion }) + + Protokoll.Schreibe(mitarbeiter, 'operation', 'OP-Team geändert', + { typ = 'operation', id = operationId }, + { kennung = operation[1].kennung, mitglied = charakterId, funktion = funktion }) + + -- Das Teammitglied erfährt davon; teilnehmen muss es trotzdem selbst + Hinweise.Erstelle({ charakterId = charakterId }, { + kategorie = 'op', prioritaet = 'normal', + titel = ('Du bist im OP-Team · %s'):format(operation[1].kennung), + text = ('Eingeteilt als %s durch %s.') + :format(funktion, mitarbeiter.name), + zielTyp = 'operation', zielId = operationId, + }) + + return { ok = true } +end) + +Kern.RegistriereAktion('operation', 'teamEntfernen', 'operation.planen', function(quelle, mitarbeiter, daten) + local operationId = tonumber(daten.id) + local charakterId = tonumber(daten.charakterId) + + if not operationId or not charakterId then return nil, 'Unvollständige Angaben.' end + + MySQL.query.await( + 'DELETE FROM medical_operation_team WHERE operation = ? AND `character` = ?', + { operationId, charakterId }) + + Protokoll.Schreibe(mitarbeiter, 'operation', 'Aus OP-Team entfernt', + { typ = 'operation', id = operationId }, { mitglied = charakterId }, + Text(daten.grund, 255)) + + return { ok = true } +end) + +--- Selbst dazustossen oder das Team verlassen +Kern.RegistriereAktion('operation', 'teamWechsel', 'operation.dokumentieren', function(quelle, mitarbeiter, daten) + local operationId = tonumber(daten.id) + if not operationId then return nil, 'Ungültige Kennung.' end + + local operation = MySQL.query.await( + 'SELECT id, kennung, status FROM medical_operation WHERE id = ?', { operationId }) + if not operation or not operation[1] then return nil, 'Operation nicht gefunden.' end + if operation[1].status == 'abgeschlossen' or operation[1].status == 'abgesagt' then + return nil, 'Diese Operation ist abgeschlossen.' + end + + if daten.beitreten == false then + MySQL.query.await( + 'DELETE FROM medical_operation_team WHERE operation = ? AND `character` = ?', + { operationId, mitarbeiter.charakterId }) + Protokoll.Schreibe(mitarbeiter, 'operation', 'OP-Team verlassen', + { typ = 'operation', id = operationId }, { kennung = operation[1].kennung }) + return { ok = true, dabei = false } + end + + MySQL.query.await([[ + INSERT INTO medical_operation_team (operation, `character`, funktion) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE funktion = VALUES(funktion) + ]], { operationId, mitarbeiter.charakterId, TeamFunktion(daten.funktion) }) + + Protokoll.Schreibe(mitarbeiter, 'operation', 'OP-Team beigetreten', + { typ = 'operation', id = operationId }, + { kennung = operation[1].kennung, funktion = TeamFunktion(daten.funktion) }) + + return { ok = true, dabei = true } +end) + +-- ===================================================================== +-- DURCHFÜHRUNG +-- +-- Beginn und Ende trägt das OP-Team ein. Der geplante Zeitpunkt +-- startet nichts — er erinnert nur. +-- ===================================================================== + +Kern.RegistriereAktion('operation', 'beginnen', 'operation.dokumentieren', function(quelle, mitarbeiter, daten) + local operationId = tonumber(daten.id) + if not operationId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT o.id, o.kennung, o.status, o.art, o.saal, + c.firstname, c.lastname + FROM medical_operation o + JOIN aether_characters c ON c.id = o.patient + WHERE o.id = ? + ]], { operationId }) + if not zeilen or not zeilen[1] then return nil, 'Operation nicht gefunden.' end + + local operation = zeilen[1] + if operation.status ~= 'geplant' then + return nil, 'Nur eine geplante Operation lässt sich beginnen.' + end + + MySQL.update.await([[ + UPDATE medical_operation SET status = 'laufend', beginn = NOW() WHERE id = ? + ]], { operationId }) + + -- Wer den Beginn einträgt, gehört ins Team + MySQL.query.await([[ + INSERT INTO medical_operation_team (operation, `character`, funktion) + VALUES (?, ?, 'assistenz') + ON DUPLICATE KEY UPDATE funktion = funktion + ]], { operationId, mitarbeiter.charakterId }) + + Protokoll.Schreibe(mitarbeiter, 'operation', 'Operation begonnen', + { typ = 'operation', id = operationId }, { kennung = operation.kennung }) + + Hinweise.Erstelle(nil, { + kategorie = 'op', prioritaet = 'normal', + titel = ('Operation begonnen · %s'):format(operation.kennung), + text = ('%s %s — %s'):format(operation.firstname, operation.lastname, operation.art), + zielTyp = 'operation', zielId = operationId, + }) + + Kern.Loese('operation.begonnen', operationId, mitarbeiter) + + return { ok = true } +end) + +--- Verlauf, Komplikationen und Nachsorge dokumentieren +Kern.RegistriereAktion('operation', 'dokumentieren', 'operation.dokumentieren', function(quelle, mitarbeiter, daten) + local operationId = tonumber(daten.id) + if not operationId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await( + 'SELECT id, kennung, status FROM medical_operation WHERE id = ?', { operationId }) + if not zeilen or not zeilen[1] then return nil, 'Operation nicht gefunden.' end + + if zeilen[1].status == 'abgesagt' then + return nil, 'Diese Operation wurde abgesagt.' + end + + MySQL.update.await([[ + UPDATE medical_operation + SET art = COALESCE(?, art), verlauf = ?, komplikationen = ?, nachsorge = ? + WHERE id = ? + ]], { + Text(daten.art, 160), + Text(daten.verlauf, 8000), + Text(daten.komplikationen, 4000), + Text(daten.nachsorge, 4000), + operationId, + }) + + Protokoll.Schreibe(mitarbeiter, 'operation', 'OP-Bericht gespeichert', + { typ = 'operation', id = operationId }, { kennung = zeilen[1].kennung }) + + return { ok = true } +end) + +Kern.RegistriereAktion('operation', 'beenden', 'operation.dokumentieren', function(quelle, mitarbeiter, daten) + local operationId = tonumber(daten.id) + if not operationId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT o.id, o.kennung, o.status, o.verlauf, o.beginn, + c.firstname, c.lastname + FROM medical_operation o + JOIN aether_characters c ON c.id = o.patient + WHERE o.id = ? + ]], { operationId }) + if not zeilen or not zeilen[1] then return nil, 'Operation nicht gefunden.' end + + local operation = zeilen[1] + if operation.status ~= 'laufend' then + return nil, 'Nur eine laufende Operation lässt sich beenden.' + end + + -- Ein OP-Bericht ohne Verlauf ist kein Bericht. + -- Geprüft wird nur, DASS etwas dasteht — nicht, was. + local verlauf = Text(daten.verlauf, 8000) or operation.verlauf + if not verlauf then + return nil, 'Bitte zuerst den Verlauf dokumentieren.' + end + + MySQL.update.await([[ + UPDATE medical_operation + SET status = 'abgeschlossen', ende = NOW(), + verlauf = ?, komplikationen = ?, nachsorge = ? + WHERE id = ? + ]], { + verlauf, + Text(daten.komplikationen, 4000), + Text(daten.nachsorge, 4000), + operationId, + }) + + local team = LadeTeam(operationId) + + Protokoll.Schreibe(mitarbeiter, 'operation', 'Operation abgeschlossen', + { typ = 'operation', id = operationId }, + { kennung = operation.kennung, teamGroesse = #team, + mitKomplikationen = Text(daten.komplikationen, 4000) ~= nil }) + + Hinweise.Erstelle(nil, { + kategorie = 'op', prioritaet = 'normal', + titel = ('Operation abgeschlossen · %s'):format(operation.kennung), + text = ('%s %s — OP-Saal wird frei.') + :format(operation.firstname, operation.lastname), + zielTyp = 'operation', zielId = operationId, + }) + + Kern.Loese('operation.abgeschlossen', operationId, mitarbeiter) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Liefert die Operationen eines Patienten +--- @param patientId number Charakter +--- @return table +exports('HoleOperationen', function(patientId) + if not tonumber(patientId) then return {} end + return MySQL.query.await([[ + SELECT kennung, art, status, geplant_fuer, beginn, ende, + verlauf, komplikationen, nachsorge + FROM medical_operation + WHERE patient = ? + ORDER BY id DESC LIMIT 50 + ]], { tonumber(patientId) }) or {} +end) diff --git a/resources/[aether]/aether-medical/server/module/patienten.lua b/resources/[aether]/aether-medical/server/module/patienten.lua new file mode 100644 index 0000000..bc699b6 --- /dev/null +++ b/resources/[aether]/aether-medical/server/module/patienten.lua @@ -0,0 +1,806 @@ +-- ===================================================================== +-- aether-medical / Modul: Patientenverwaltung +-- +-- Anmeldung, Warteliste, Aufnahme, Verlegung und Entlassung. +-- +-- Nichts davon geschieht von selbst. Das System führt die Warteliste +-- und zeigt, welche Zimmer frei sind — welcher Patient wann und wohin +-- aufgenommen wird, entscheidet ausschliesslich ein Mediziner. +-- +-- Besonders wichtig: die Dringlichkeit wird NICHT aus der Wartezeit +-- berechnet. Ein Patient, der lange wartet, rutscht nicht automatisch +-- nach oben — das System erinnert nur daran, dass er wartet. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'patienten', name = 'Patienten', icon = '🧍', + reihenfolge = 3, recht = 'patient.suchen', + beschreibung = 'Anmeldung, Warteliste, Belegung und Entlassung', +}) + +-- Dringlichkeitsstufen. Reine Beschriftung — keine Stufe löst etwas aus. +local DRINGLICHKEIT = { + [1] = { name = 'Sofort', hinweis = 'Lebensbedrohlich' }, + [2] = { name = 'Dringend', hinweis = 'Zeitkritisch' }, + [3] = { name = 'Regulär', hinweis = 'Kann warten' }, + [4] = { name = 'Nachrangig', hinweis = 'Nicht akut' }, +} + +-- --------------------------------------------------------------------- +-- Grundbestand an Stationen und Zimmern +-- +-- Wird einmalig angelegt, damit sich überhaupt jemand aufnehmen +-- lässt. Die Verwaltung übernimmt später die Krankenhausverwaltung. +-- --------------------------------------------------------------------- +local GRUNDSTATIONEN = { + -- kennung, name, art, etage, zimmer = { kennung, betten, art } + { 'NA', 'Notaufnahme', 'notaufnahme', 'EG', { + { 'NA-1', 1, 'behandlung' }, { 'NA-2', 1, 'behandlung' }, + { 'NA-3', 1, 'behandlung' }, { 'SCHOCK', 1, 'behandlung' }, + }}, + { 'CH', 'Chirurgie', 'normal', '1. OG', { + { 'CH-101', 2, 'bett' }, { 'CH-102', 2, 'bett' }, { 'CH-103', 1, 'bett' }, + }}, + { 'IN', 'Innere Medizin', 'normal', '1. OG', { + { 'IN-111', 2, 'bett' }, { 'IN-112', 2, 'bett' }, + }}, + { 'ITS', 'Intensivstation', 'intensiv', '2. OG', { + { 'ITS-1', 1, 'intensiv' }, { 'ITS-2', 1, 'intensiv' }, + { 'ITS-3', 1, 'intensiv' }, { 'ISO-1', 1, 'isolierung' }, + }}, + { 'OP', 'OP-Bereich', 'op', '2. OG', { + { 'OP-1', 1, 'op' }, { 'OP-2', 1, 'op' }, + }}, +} + +CreateThread(function() + Wait(1500) + + local vorhanden = tonumber(MySQL.scalar.await('SELECT COUNT(*) FROM medical_station')) or 0 + if vorhanden > 0 then return end + + for reihenfolge, eintrag in ipairs(GRUNDSTATIONEN) do + local stationId = MySQL.insert.await([[ + INSERT INTO medical_station (kennung, name, art, etage, sortierung) + VALUES (?, ?, ?, ?, ?) + ]], { eintrag[1], eintrag[2], eintrag[3], eintrag[4], reihenfolge }) + + for _, zimmer in ipairs(eintrag[5]) do + MySQL.insert.await([[ + INSERT INTO medical_zimmer (station, kennung, betten, art) + VALUES (?, ?, ?, ?) + ]], { stationId, zimmer[1], zimmer[2], zimmer[3] }) + end + end + + Aether.Debug('Grundbestand an Stationen und Zimmern angelegt.') +end) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Begrenzt einen Text und liefert nil bei leerer Eingabe +local function Text(wert, laenge) + if type(wert) ~= 'string' then return nil end + local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '') + if sauber == '' then return nil end + return sauber:sub(1, laenge) +end + +--- Prüft eine Dringlichkeitsstufe +--- @param wert any Eingabe +--- @return number 1 bis 4 +local function Dringlichkeit(wert) + local stufe = tonumber(wert) or 3 + if stufe < 1 or stufe > 4 then return 3 end + return math.floor(stufe) +end + +--- Name eines Mitarbeiters für die Anzeige +local function NameVon(charakterId) + if not charakterId then return 'unbekannt' end + local zeilen = MySQL.query.await([[ + SELECT c.firstname, c.lastname, p.personalnummer + FROM aether_characters c + LEFT JOIN medical_personal p ON p.`character` = c.id + WHERE c.id = ? + ]], { charakterId }) + if not zeilen or not zeilen[1] then return 'unbekannt' end + return zeilen[1].firstname .. ' ' .. zeilen[1].lastname +end + +--- Wie viele Betten eines Zimmers sind gerade belegt? +--- @param zimmerId number Zimmer +--- @return number +local function BelegteBetten(zimmerId) + return tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_belegung WHERE zimmer = ? AND frei_am IS NULL', + { zimmerId })) or 0 +end + +--- Sucht ein freies Bett in einem Zimmer +--- @param zimmerId number Zimmer +--- @param betten number Bettenzahl des Zimmers +--- @return number|nil Bettnummer +local function FreiesBett(zimmerId, betten) + local belegt = MySQL.query.await( + 'SELECT bett FROM medical_belegung WHERE zimmer = ? AND frei_am IS NULL', { zimmerId }) or {} + + local genommen = {} + for _, eintrag in ipairs(belegt) do genommen[tonumber(eintrag.bett)] = true end + + for nummer = 1, betten do + if not genommen[nummer] then return nummer end + end + return nil +end + +--- Gibt alle Betten einer Aufnahme frei +--- @param aufnahmeId number Aufnahme +--- @param mitarbeiter table Handelnder +--- @return table Liste der frei gewordenen Zimmer +local function BettenFreigeben(aufnahmeId, mitarbeiter) + local belegungen = MySQL.query.await([[ + SELECT b.id, b.zimmer, z.kennung, z.betten + FROM medical_belegung b + JOIN medical_zimmer z ON z.id = b.zimmer + WHERE b.aufnahme = ? AND b.frei_am IS NULL + ]], { aufnahmeId }) or {} + + for _, belegung in ipairs(belegungen) do + MySQL.update.await( + 'UPDATE medical_belegung SET frei_am = NOW(), frei_von = ? WHERE id = ?', + { mitarbeiter.charakterId, belegung.id }) + end + + return belegungen +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('patienten', 'uebersicht', nil, function(quelle, mitarbeiter, daten) + local ordner = tostring(daten.ordner or 'warteliste') + + local bedingung, werte + if ordner == 'entlassen' then + bedingung = "a.status = 'entlassen'" + werte = {} + elseif ordner == 'station' then + bedingung = "a.status = 'aufgenommen'" + werte = {} + else + bedingung = "a.status = 'wartend'" + werte = {} + end + + if daten.station and daten.station ~= '' then + bedingung = bedingung .. ' AND a.station = ?' + werte[#werte + 1] = daten.station + end + + local liste = MySQL.query.await(([[ + SELECT a.id, a.patient, a.grund, a.prioritaet, a.station, a.status, + a.aufgenommen_am, a.aufgenommen_von, a.entlassen_am, a.entlassen_von, + a.entlass_grund, + TIMESTAMPDIFF(MINUTE, a.aufgenommen_am, NOW()) AS wartet, + c.firstname, c.lastname, c.dob, + m.blutgruppe, m.allergien, m.hinweis, + z.kennung AS zimmer, s.name AS stationName, + (SELECT COUNT(*) FROM medical_intensiv i + WHERE i.aufnahme = a.id AND i.beendet_am IS NULL) AS aufIntensiv + FROM medical_aufnahme a + JOIN aether_characters c ON c.id = a.patient + LEFT JOIN medical_patient m ON m.`character` = a.patient + LEFT JOIN medical_zimmer z ON z.id = a.zimmer + LEFT JOIN medical_station s ON s.kennung = a.station + WHERE %s + ORDER BY a.prioritaet, a.aufgenommen_am + LIMIT 150 + ]]):format(bedingung), werte) or {} + + for _, eintrag in ipairs(liste) do + eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname + eintrag.geburt = tostring(eintrag.dob):sub(1, 10) + eintrag.firstname, eintrag.lastname, eintrag.dob = nil, nil, nil + eintrag.dringlichkeit = DRINGLICHKEIT[eintrag.prioritaet] + and DRINGLICHKEIT[eintrag.prioritaet].name or '—' + eintrag.aufgenommenVon = NameVon(eintrag.aufgenommen_von) + eintrag.entlassenVon = eintrag.entlassen_von and NameVon(eintrag.entlassen_von) or nil + eintrag.aufIntensiv = tonumber(eintrag.aufIntensiv) > 0 + eintrag.aufgenommen_von, eintrag.entlassen_von = nil, nil + end + + -- Zählwerte für die Reiter + local zaehler = { + wartend = tonumber(MySQL.scalar.await( + "SELECT COUNT(*) FROM medical_aufnahme WHERE status = 'wartend'")) or 0, + aufgenommen = tonumber(MySQL.scalar.await( + "SELECT COUNT(*) FROM medical_aufnahme WHERE status = 'aufgenommen'")) or 0, + entlassenHeute = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_aufnahme + WHERE status = 'entlassen' AND DATE(entlassen_am) = CURDATE() + ]])) or 0, + } + + -- Belegung je Station: was ist frei, was ist belegt + local stationen = MySQL.query.await([[ + SELECT s.id, s.kennung, s.name, s.art, s.etage, + COALESCE(SUM(z.betten), 0) AS betten, + COUNT(z.id) AS zimmer + FROM medical_station s + LEFT JOIN medical_zimmer z ON z.station = s.id AND z.status <> 'gesperrt' + WHERE s.aktiv = 1 + GROUP BY s.id + ORDER BY s.sortierung, s.kennung + ]]) or {} + + for _, station in ipairs(stationen) do + station.belegt = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_belegung b + JOIN medical_zimmer z ON z.id = b.zimmer + WHERE z.station = ? AND b.frei_am IS NULL + ]], { station.id })) or 0 + station.betten = tonumber(station.betten) or 0 + station.frei = math.max(0, station.betten - station.belegt) + end + + return { + ordner = ordner, + liste = liste, + zaehler = zaehler, + stationen = stationen, + dringlichkeit = DRINGLICHKEIT, + rechte = { + aufnehmen = Rechte.Hat(mitarbeiter, 'patient.aufnehmen'), + verlegen = Rechte.Hat(mitarbeiter, 'patient.verlegen'), + entlassen = Rechte.Hat(mitarbeiter, 'patient.entlassen'), + belegen = Rechte.Hat(mitarbeiter, 'station.belegen'), + }, + } +end) + +Kern.RegistriereAktion('patienten', 'oeffnen', nil, function(quelle, mitarbeiter, daten) + local aufnahmeId = tonumber(daten.id) + if not aufnahmeId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT a.*, c.firstname, c.lastname, c.dob, + m.blutgruppe, m.allergien, m.vorerkrankungen, m.dauermedikation, + m.hinweis, m.notfallkontakt, m.lichtbild, + z.kennung AS zimmer, z.art AS zimmerArt, s.name AS stationName + FROM medical_aufnahme a + JOIN aether_characters c ON c.id = a.patient + LEFT JOIN medical_patient m ON m.`character` = a.patient + LEFT JOIN medical_zimmer z ON z.id = a.zimmer + LEFT JOIN medical_station s ON s.kennung = a.station + WHERE a.id = ? + ]], { aufnahmeId }) + + if not zeilen or not zeilen[1] then return nil, 'Aufnahme nicht gefunden.' end + local a = zeilen[1] + + -- Welches Bett belegt der Patient gerade? + local bett = MySQL.query.await([[ + SELECT b.bett, b.belegt_am, b.belegt_von, z.kennung + FROM medical_belegung b + JOIN medical_zimmer z ON z.id = b.zimmer + WHERE b.aufnahme = ? AND b.frei_am IS NULL + LIMIT 1 + ]], { aufnahmeId }) + bett = bett and bett[1] or nil + + -- Laufende Behandlungen zu dieser Aufnahme + local behandlungen = MySQL.query.await([[ + SELECT id, kennung, diagnose, status, erstellt_am, erstellt_von + FROM medical_behandlung WHERE aufnahme = ? + ORDER BY id DESC LIMIT 20 + ]], { aufnahmeId }) or {} + + for _, eintrag in ipairs(behandlungen) do + eintrag.autor = NameVon(eintrag.erstellt_von) + eintrag.erstellt_von = nil + end + + return { + aufnahme = { + id = a.id, patient = a.patient, + name = a.firstname .. ' ' .. a.lastname, + geburt = tostring(a.dob):sub(1, 10), + grund = a.grund, prioritaet = a.prioritaet, + dringlichkeit = DRINGLICHKEIT[a.prioritaet] and DRINGLICHKEIT[a.prioritaet].name or '—', + station = a.station, stationName = a.stationName, + zimmer = a.zimmer, zimmerArt = a.zimmerArt, + bett = bett and bett.bett or nil, + status = a.status, + aufgenommenAm = a.aufgenommen_am, + aufgenommenVon = NameVon(a.aufgenommen_von), + entlassenAm = a.entlassen_am, + entlassenVon = a.entlassen_von and NameVon(a.entlassen_von) or nil, + entlassGrund = a.entlass_grund, + }, + stamm = { + blutgruppe = a.blutgruppe, allergien = a.allergien, + vorerkrankungen = a.vorerkrankungen, dauermedikation = a.dauermedikation, + hinweis = a.hinweis, notfallkontakt = a.notfallkontakt, + lichtbild = a.lichtbild, + }, + behandlungen = behandlungen, + dringlichkeit = DRINGLICHKEIT, + rechte = { + aufnehmen = Rechte.Hat(mitarbeiter, 'patient.aufnehmen'), + verlegen = Rechte.Hat(mitarbeiter, 'patient.verlegen'), + entlassen = Rechte.Hat(mitarbeiter, 'patient.entlassen'), + }, + } +end) + +-- ===================================================================== +-- ANMELDUNG +-- +-- Der erste Schritt: ein Patient meldet sich oder wird gebracht. +-- Er landet auf der Warteliste — mehr passiert nicht. +-- ===================================================================== + +Kern.RegistriereAktion('patienten', 'anmelden', 'patient.aufnehmen', function(quelle, mitarbeiter, daten) + local patientId = tonumber(daten.patient) + local grund = Text(daten.grund, 512) + + if not patientId then return nil, 'Kein Patient gewählt.' end + if not grund or #grund < 3 then return nil, 'Bitte den Grund der Vorstellung angeben.' end + + local person = MySQL.query.await( + 'SELECT firstname, lastname FROM aether_characters WHERE id = ? AND deleted = 0', + { patientId }) + if not person or not person[1] then return nil, 'Diese Person ist nicht erfasst.' end + + -- Doppelte Anmeldung verhindern + local offen = MySQL.scalar.await([[ + SELECT id FROM medical_aufnahme + WHERE patient = ? AND status IN ('wartend', 'aufgenommen') + ]], { patientId }) + if offen then + return nil, 'Für diesen Patienten läuft bereits eine Aufnahme.' + end + + local prioritaet = Dringlichkeit(daten.prioritaet) + + local id = MySQL.insert.await([[ + INSERT INTO medical_aufnahme + (patient, grund, prioritaet, status, aufgenommen_von) + VALUES (?, ?, ?, 'wartend', ?) + ]], { patientId, grund, prioritaet, mitarbeiter.charakterId }) + + Protokoll.Schreibe(mitarbeiter, 'patienten', 'Patient angemeldet', + { typ = 'aufnahme', id = id }, + { patient = patientId, prioritaet = prioritaet }) + + Hinweise.Erstelle(nil, { + kategorie = 'patient', + prioritaet = prioritaet <= 2 and 'hoch' or 'normal', + titel = ('Patient wartet · %s'):format(DRINGLICHKEIT[prioritaet].name), + text = ('%s %s — %s'):format(person[1].firstname, person[1].lastname, grund:sub(1, 80)), + zielTyp = 'aufnahme', zielId = id, + }) + + Kern.Loese('aufnahme.angemeldet', id, mitarbeiter) + + return { id = id } +end) + +--- Personensuche für die Anmeldung +Kern.RegistriereAktion('patienten', 'patientSuche', 'patient.aufnehmen', function(quelle, mitarbeiter, daten) + local begriff = type(daten.begriff) == 'string' and daten.begriff or '' + if #begriff < 2 then return { treffer = {} } end + + local muster = '%' .. begriff .. '%' + local treffer = MySQL.query.await([[ + SELECT c.id, c.firstname, c.lastname, c.dob, + m.blutgruppe, m.allergien, m.hinweis, + (SELECT a.status FROM medical_aufnahme a + WHERE a.patient = c.id AND a.status IN ('wartend', 'aufgenommen') + LIMIT 1) AS laufend + FROM aether_characters c + LEFT JOIN medical_patient m ON m.`character` = c.id + WHERE c.deleted = 0 + AND (c.firstname LIKE ? OR c.lastname LIKE ? + OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? OR c.dob LIKE ?) + ORDER BY c.lastname, c.firstname + LIMIT 20 + ]], { muster, muster, muster, muster }) or {} + + for _, eintrag in ipairs(treffer) do + eintrag.dob = tostring(eintrag.dob):sub(1, 10) + end + + return { treffer = treffer } +end) + +-- ===================================================================== +-- AUFNAHME AUF STATION +-- ===================================================================== + +--- Freie Zimmer einer Station — reine Auskunft, das System wählt keines aus +Kern.RegistriereAktion('patienten', 'freieZimmer', nil, function(quelle, mitarbeiter, daten) + local bedingung, werte = 's.aktiv = 1', {} + + if daten.station and daten.station ~= '' then + bedingung = bedingung .. ' AND s.kennung = ?' + werte[#werte + 1] = daten.station + end + + local zimmer = MySQL.query.await(([[ + SELECT z.id, z.kennung, z.betten, z.art, z.status, z.hinweis, + s.kennung AS station, s.name AS stationName, s.art AS stationArt + FROM medical_zimmer z + JOIN medical_station s ON s.id = z.station + WHERE %s + ORDER BY s.sortierung, z.kennung + ]]):format(bedingung), werte) or {} + + for _, eintrag in ipairs(zimmer) do + eintrag.belegt = BelegteBetten(eintrag.id) + eintrag.frei = math.max(0, eintrag.betten - eintrag.belegt) + -- Verfügbar heisst: nicht gesperrt, nicht in Reinigung, noch Platz + eintrag.verfuegbar = eintrag.status == 'frei' and eintrag.frei > 0 + end + + return { zimmer = zimmer } +end) + +Kern.RegistriereAktion('patienten', 'aufnehmen', 'patient.aufnehmen', function(quelle, mitarbeiter, daten) + local aufnahmeId = tonumber(daten.id) + if not aufnahmeId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT a.id, a.patient, a.status, c.firstname, c.lastname + FROM medical_aufnahme a + JOIN aether_characters c ON c.id = a.patient + WHERE a.id = ? + ]], { aufnahmeId }) + if not zeilen or not zeilen[1] then return nil, 'Aufnahme nicht gefunden.' end + + local aufnahme = zeilen[1] + if aufnahme.status ~= 'wartend' then + return nil, 'Dieser Patient ist bereits aufgenommen oder entlassen.' + end + + -- Station prüfen + local station = MySQL.query.await( + 'SELECT kennung, name FROM medical_station WHERE kennung = ? AND aktiv = 1', + { tostring(daten.station or '') }) + if not station or not station[1] then return nil, 'Unbekannte Station.' end + + -- Zimmer ist freiwillig — ein Patient kann auch ohne Bett aufgenommen werden + local zimmerId, bettNummer = nil, nil + if daten.zimmer and tonumber(daten.zimmer) then + local zimmer = MySQL.query.await([[ + SELECT z.id, z.kennung, z.betten, z.status, s.kennung AS station + FROM medical_zimmer z + JOIN medical_station s ON s.id = z.station + WHERE z.id = ? + ]], { tonumber(daten.zimmer) }) + if not zimmer or not zimmer[1] then return nil, 'Unbekanntes Zimmer.' end + + if zimmer[1].status ~= 'frei' then + return nil, ('Zimmer %s steht nicht zur Verfügung (%s).') + :format(zimmer[1].kennung, zimmer[1].status) + end + + bettNummer = FreiesBett(zimmer[1].id, zimmer[1].betten) + if not bettNummer then + return nil, ('In Zimmer %s ist kein Bett mehr frei.'):format(zimmer[1].kennung) + end + + zimmerId = zimmer[1].id + end + + MySQL.update.await([[ + UPDATE medical_aufnahme + SET status = 'aufgenommen', station = ?, zimmer = ? + WHERE id = ? + ]], { station[1].kennung, zimmerId, aufnahmeId }) + + if zimmerId then + MySQL.insert.await([[ + INSERT INTO medical_belegung (zimmer, bett, patient, aufnahme, belegt_von) + VALUES (?, ?, ?, ?, ?) + ]], { zimmerId, bettNummer, aufnahme.patient, aufnahmeId, mitarbeiter.charakterId }) + end + + Protokoll.Schreibe(mitarbeiter, 'patienten', 'Patient aufgenommen', + { typ = 'aufnahme', id = aufnahmeId }, + { station = station[1].kennung, zimmer = zimmerId, bett = bettNummer }, + Text(daten.vermerk, 255)) + + Kern.Loese('aufnahme.aufgenommen', aufnahmeId, mitarbeiter) + + return { ok = true, bett = bettNummer } +end) + +-- ===================================================================== +-- VERLEGUNG +-- ===================================================================== + +Kern.RegistriereAktion('patienten', 'verlegen', 'patient.verlegen', function(quelle, mitarbeiter, daten) + local aufnahmeId = tonumber(daten.id) + if not aufnahmeId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT a.id, a.patient, a.status, a.station, a.zimmer, + c.firstname, c.lastname + FROM medical_aufnahme a + JOIN aether_characters c ON c.id = a.patient + WHERE a.id = ? + ]], { aufnahmeId }) + if not zeilen or not zeilen[1] then return nil, 'Aufnahme nicht gefunden.' end + + local aufnahme = zeilen[1] + if aufnahme.status ~= 'aufgenommen' then + return nil, 'Nur aufgenommene Patienten lassen sich verlegen.' + end + + local grund = Text(daten.grund, 255) + if not grund then return nil, 'Bitte den Grund der Verlegung angeben.' end + + local station = MySQL.query.await( + 'SELECT kennung, name FROM medical_station WHERE kennung = ? AND aktiv = 1', + { tostring(daten.station or '') }) + if not station or not station[1] then return nil, 'Unbekannte Station.' end + + -- Neues Zimmer prüfen, bevor das alte freigegeben wird + local zimmerId, bettNummer = nil, nil + if daten.zimmer and tonumber(daten.zimmer) then + local zimmer = MySQL.query.await( + 'SELECT id, kennung, betten, status FROM medical_zimmer WHERE id = ?', + { tonumber(daten.zimmer) }) + if not zimmer or not zimmer[1] then return nil, 'Unbekanntes Zimmer.' end + + if zimmer[1].status ~= 'frei' then + return nil, ('Zimmer %s steht nicht zur Verfügung.'):format(zimmer[1].kennung) + end + + -- Verlegung ins selbe Zimmer ist keine Verlegung + if zimmer[1].id == aufnahme.zimmer then + return nil, 'Der Patient liegt bereits in diesem Zimmer.' + end + + bettNummer = FreiesBett(zimmer[1].id, zimmer[1].betten) + if not bettNummer then + return nil, ('In Zimmer %s ist kein Bett mehr frei.'):format(zimmer[1].kennung) + end + + zimmerId = zimmer[1].id + end + + local vorher = BettenFreigeben(aufnahmeId, mitarbeiter) + + MySQL.update.await('UPDATE medical_aufnahme SET station = ?, zimmer = ? WHERE id = ?', + { station[1].kennung, zimmerId, aufnahmeId }) + + if zimmerId then + MySQL.insert.await([[ + INSERT INTO medical_belegung (zimmer, bett, patient, aufnahme, belegt_von) + VALUES (?, ?, ?, ?, ?) + ]], { zimmerId, bettNummer, aufnahme.patient, aufnahmeId, mitarbeiter.charakterId }) + end + + Protokoll.Schreibe(mitarbeiter, 'patienten', 'Patient verlegt', + { typ = 'aufnahme', id = aufnahmeId }, + { vonStation = aufnahme.station, nachStation = station[1].kennung, + zimmer = zimmerId, bett = bettNummer }, grund) + + Hinweise.Erstelle(nil, { + kategorie = 'patient', prioritaet = 'normal', + titel = 'Neue Verlegung', + text = ('%s %s wurde auf %s verlegt.') + :format(aufnahme.firstname, aufnahme.lastname, station[1].name), + zielTyp = 'aufnahme', zielId = aufnahmeId, + }) + + -- Frei gewordene Zimmer melden — das Freigeben zur Reinigung bleibt manuell + for _, belegung in ipairs(vorher) do + if BelegteBetten(belegung.zimmer) == 0 then + Hinweise.Erstelle(nil, { + kategorie = 'patient', prioritaet = 'niedrig', + titel = 'Zimmer wurde frei', + text = ('Zimmer %s ist jetzt leer.'):format(belegung.kennung), + zielTyp = 'zimmer', zielId = belegung.zimmer, + }) + end + end + + Kern.Loese('aufnahme.verlegt', aufnahmeId, mitarbeiter) + + return { ok = true, bett = bettNummer } +end) + +-- ===================================================================== +-- DRINGLICHKEIT +-- +-- Wird ausschliesslich von Hand gesetzt. Wartezeit ändert sie nie. +-- ===================================================================== + +Kern.RegistriereAktion('patienten', 'prioritaet', 'patient.aufnehmen', function(quelle, mitarbeiter, daten) + local aufnahmeId = tonumber(daten.id) + if not aufnahmeId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await( + 'SELECT id, prioritaet, status FROM medical_aufnahme WHERE id = ?', { aufnahmeId }) + if not zeilen or not zeilen[1] then return nil, 'Aufnahme nicht gefunden.' end + if zeilen[1].status == 'entlassen' then + return nil, 'Dieser Vorgang ist bereits abgeschlossen.' + end + + local neu = Dringlichkeit(daten.prioritaet) + local grund = Text(daten.grund, 255) + if not grund then return nil, 'Bitte begründen, warum sich die Dringlichkeit ändert.' end + + MySQL.update.await('UPDATE medical_aufnahme SET prioritaet = ? WHERE id = ?', + { neu, aufnahmeId }) + + Protokoll.Schreibe(mitarbeiter, 'patienten', 'Dringlichkeit geändert', + { typ = 'aufnahme', id = aufnahmeId }, + { von = zeilen[1].prioritaet, nach = neu }, grund) + + return { ok = true } +end) + +-- ===================================================================== +-- ENTLASSUNG +-- +-- Geschieht nie automatisch. Auch nicht, wenn alle Behandlungen +-- abgeschlossen sind — das entscheidet ein Arzt. +-- ===================================================================== + +Kern.RegistriereAktion('patienten', 'entlassen', 'patient.entlassen', function(quelle, mitarbeiter, daten) + local aufnahmeId = tonumber(daten.id) + if not aufnahmeId then return nil, 'Ungültige Kennung.' end + + local grund = Text(daten.grund, 512) + if not grund or #grund < 5 then + return nil, 'Eine Entlassung erfordert eine Begründung.' + end + + local zeilen = MySQL.query.await([[ + SELECT a.id, a.patient, a.status, c.firstname, c.lastname + FROM medical_aufnahme a + JOIN aether_characters c ON c.id = a.patient + WHERE a.id = ? + ]], { aufnahmeId }) + if not zeilen or not zeilen[1] then return nil, 'Aufnahme nicht gefunden.' end + + local aufnahme = zeilen[1] + if aufnahme.status == 'entlassen' then + return nil, 'Dieser Patient ist bereits entlassen.' + end + + -- Offene Punkte NUR melden, nicht verhindern und nicht abschliessen + local offeneBehandlungen = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_behandlung + WHERE aufnahme = ? AND status = 'laufend' + ]], { aufnahmeId })) or 0 + + local aufIntensiv = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_intensiv + WHERE aufnahme = ? AND beendet_am IS NULL + ]], { aufnahmeId })) or 0 + + local freigeworden = BettenFreigeben(aufnahmeId, mitarbeiter) + + MySQL.update.await([[ + UPDATE medical_aufnahme + SET status = 'entlassen', zimmer = NULL, + entlassen_von = ?, entlassen_am = NOW(), entlass_grund = ? + WHERE id = ? + ]], { mitarbeiter.charakterId, grund, aufnahmeId }) + + Protokoll.Schreibe(mitarbeiter, 'patienten', 'Patient entlassen', + { typ = 'aufnahme', id = aufnahmeId }, + { patient = aufnahme.patient, + offeneBehandlungen = offeneBehandlungen, + aufIntensiv = aufIntensiv > 0 }, grund) + + -- Frei gewordene Zimmer melden + for _, belegung in ipairs(freigeworden) do + if BelegteBetten(belegung.zimmer) == 0 then + Hinweise.Erstelle(nil, { + kategorie = 'patient', prioritaet = 'niedrig', + titel = 'Zimmer wurde frei', + text = ('Zimmer %s ist jetzt leer.'):format(belegung.kennung), + zielTyp = 'zimmer', zielId = belegung.zimmer, + }) + end + end + + -- Laufende Dokumentation bleibt offen und wird nur in Erinnerung gerufen + if offeneBehandlungen > 0 then + Hinweise.Erstelle({ charakterId = mitarbeiter.charakterId }, { + kategorie = 'doku', prioritaet = 'normal', + titel = 'Dokumentation noch offen', + text = ('%s %s wurde entlassen — %d Behandlung(en) sind noch nicht abgeschlossen.') + :format(aufnahme.firstname, aufnahme.lastname, offeneBehandlungen), + zielTyp = 'aufnahme', zielId = aufnahmeId, + }) + end + + -- Ein laufender Intensivaufenthalt wird NICHT beendet, nur gemeldet + if aufIntensiv > 0 then + Hinweise.Erstelle(nil, { + kategorie = 'patient', prioritaet = 'hoch', + titel = 'Entlassung trotz laufender Intensivbetreuung', + text = ('%s %s wurde entlassen, der Intensivaufenthalt ist noch offen. Bitte prüfen.') + :format(aufnahme.firstname, aufnahme.lastname), + zielTyp = 'aufnahme', zielId = aufnahmeId, + }) + end + + Kern.Loese('aufnahme.entlassen', aufnahmeId, mitarbeiter) + + return { ok = true, offeneBehandlungen = offeneBehandlungen } +end) + +-- ===================================================================== +-- ZIMMERSTATUS +-- +-- Reinigung und Sperrung setzt immer ein Mensch. Ein leer gewordenes +-- Zimmer wird nicht von selbst wieder als frei gemeldet. +-- ===================================================================== + +Kern.RegistriereAktion('patienten', 'zimmerStatus', 'station.belegen', function(quelle, mitarbeiter, daten) + local zimmerId = tonumber(daten.id) + local status = tostring(daten.status or '') + + if not zimmerId then return nil, 'Ungültige Kennung.' end + + local erlaubt = { frei = true, reinigung = true, gesperrt = true } + if not erlaubt[status] then return nil, 'Unbekannter Zimmerstatus.' end + + local zimmer = MySQL.query.await( + 'SELECT id, kennung, betten FROM medical_zimmer WHERE id = ?', { zimmerId }) + if not zimmer or not zimmer[1] then return nil, 'Zimmer nicht gefunden.' end + + -- Ein belegtes Zimmer lässt sich nicht sperren, ohne die Belegung zu klären + if status == 'gesperrt' and BelegteBetten(zimmerId) > 0 then + return nil, 'In diesem Zimmer liegen noch Patienten.' + end + + MySQL.update.await('UPDATE medical_zimmer SET status = ?, hinweis = ? WHERE id = ?', + { status, Text(daten.hinweis, 255), zimmerId }) + + Protokoll.Schreibe(mitarbeiter, 'patienten', 'Zimmerstatus gesetzt', + { typ = 'zimmer', id = zimmerId }, + { kennung = zimmer[1].kennung, status = status }) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Liegt dieser Patient gerade im Krankenhaus? +--- Reine Auskunft — keine Ressource kann darüber jemanden aufnehmen. +--- @param patientId number Charakter +--- @return table|nil { status, station, zimmer, seit } +exports('HoleAufenthalt', function(patientId) + if not tonumber(patientId) then return nil end + + local zeilen = MySQL.query.await([[ + SELECT a.status, a.station, a.aufgenommen_am, z.kennung AS zimmer + FROM medical_aufnahme a + LEFT JOIN medical_zimmer z ON z.id = a.zimmer + WHERE a.patient = ? AND a.status IN ('wartend', 'aufgenommen') + ORDER BY a.id DESC LIMIT 1 + ]], { tonumber(patientId) }) + + if not zeilen or not zeilen[1] then return nil end + return { + status = zeilen[1].status, + station = zeilen[1].station, + zimmer = zeilen[1].zimmer, + seit = zeilen[1].aufgenommen_am, + } +end) diff --git a/resources/[aether]/aether-medical/server/module/station.lua b/resources/[aether]/aether-medical/server/module/station.lua new file mode 100644 index 0000000..b7daa1e --- /dev/null +++ b/resources/[aether]/aether-medical/server/module/station.lua @@ -0,0 +1,822 @@ +-- ===================================================================== +-- aether-medical / Modul: Stationen und Zimmerbelegung +-- +-- Der Bettenspiegel zeigt, wer wo liegt und was frei ist. Mehr nicht. +-- +-- Das System belegt kein Bett, gibt keines frei, schiebt niemanden von +-- einem Zimmer ins andere und setzt ein Zimmer nach der Entlassung +-- nicht von selbst wieder auf "frei". Jede Zeile hier entsteht, weil +-- ein Mensch sie ausgelöst hat — und trägt dessen Namen. +-- +-- Auch die Reinigung ist eine Entscheidung: ein leer gewordenes +-- Zimmer meldet sich, bleibt aber in dem Status, in dem es war. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'station', name = 'Stationen', icon = '🛏', + reihenfolge = 9, recht = 'station.sehen', + beschreibung = 'Bettenspiegel, Zimmerstatus und Stationsverwaltung', +}) + +-- Zimmerstatus, die von Hand gesetzt werden dürfen +local ZIMMERSTATUS = { + frei = true, + reinigung = true, + gesperrt = true, +} + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Begrenzt einen Text und liefert nil bei leerer Eingabe +--- @param wert any Eingabe +--- @param laenge number Maximale Zeichenzahl +--- @return string|nil +local function Text(wert, laenge) + if type(wert) ~= 'string' then return nil end + local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '') + if sauber == '' then return nil end + return sauber:sub(1, laenge) +end + +--- Name eines Mitarbeiters für die Anzeige +--- @param charakterId number|nil Charakter +--- @return string +local function NameVon(charakterId) + if not charakterId then return 'unbekannt' end + local zeilen = MySQL.query.await( + 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId }) + if not zeilen or not zeilen[1] then return 'unbekannt' end + return zeilen[1].firstname .. ' ' .. zeilen[1].lastname +end + +--- Prüft eine Kennung aus einer Konfigurationsliste +--- @param liste table Liste aus Medizin.Config +--- @param wert any Eingabe +--- @return string|nil Gültige Kennung +local function AusListe(liste, wert) + local gesucht = tostring(wert or '') + for _, eintrag in ipairs(liste) do + if eintrag.id == gesucht then return eintrag.id end + end + return nil +end + +--- Wie viele Betten eines Zimmers sind gerade belegt? +--- @param zimmerId number Zimmer +--- @return number +local function BelegteBetten(zimmerId) + return tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_belegung WHERE zimmer = ? AND frei_am IS NULL', + { zimmerId })) or 0 +end + +--- Liefert die Nummern der belegten Betten eines Zimmers +--- @param zimmerId number Zimmer +--- @return table { [bettNummer] = true } +local function BelegteBettNummern(zimmerId) + local zeilen = MySQL.query.await( + 'SELECT bett FROM medical_belegung WHERE zimmer = ? AND frei_am IS NULL', + { zimmerId }) or {} + + local genommen = {} + for _, eintrag in ipairs(zeilen) do genommen[tonumber(eintrag.bett)] = true end + return genommen +end + +--- Sucht das erste freie Bett eines Zimmers +--- @param zimmerId number Zimmer +--- @param betten number Bettenzahl des Zimmers +--- @return number|nil Bettnummer +local function FreiesBett(zimmerId, betten) + local genommen = BelegteBettNummern(zimmerId) + for nummer = 1, betten do + if not genommen[nummer] then return nummer end + end + return nil +end + +--- Meldet ein leer gewordenes Zimmer — ohne seinen Status anzufassen +--- @param zimmerId number Zimmer +--- @param kennung string Zimmerkennung für den Text +local function MeldeWennLeer(zimmerId, kennung) + if BelegteBetten(zimmerId) > 0 then return end + + Hinweise.Erstelle(nil, { + kategorie = 'patient', prioritaet = 'niedrig', + titel = 'Zimmer wurde frei', + text = ('Zimmer %s ist jetzt leer. Reinigung und Freigabe bleiben eine Entscheidung.') + :format(kennung), + zielTyp = 'zimmer', zielId = zimmerId, + }) +end + +-- ===================================================================== +-- BETTENSPIEGEL +-- +-- Eine reine Auskunft: welche Station, welches Zimmer, welches Bett, +-- wer liegt darin. Das System bewertet die Belegung nicht und schlägt +-- keine Verteilung vor. +-- ===================================================================== + +Kern.RegistriereAktion('station', 'uebersicht', nil, function(quelle, mitarbeiter, daten) + local darfVerwalten = Rechte.Hat(mitarbeiter, 'station.verwalten') + -- Abgeschaltete Stationen sieht nur, wer sie auch pflegen darf + local zeigeInaktive = darfVerwalten and daten.alle == true + + local stationen = MySQL.query.await(([[ + SELECT id, kennung, name, art, etage, aktiv, sortierung + FROM medical_station + %s + ORDER BY sortierung, kennung + ]]):format(zeigeInaktive and '' or 'WHERE aktiv = 1')) or {} + + local zimmerListe = MySQL.query.await([[ + SELECT id, station, kennung, betten, art, status, hinweis + FROM medical_zimmer + ORDER BY kennung + ]]) or {} + + -- Alle laufenden Belegungen in einem Zug — je Zimmer zugeordnet + local belegungen = MySQL.query.await([[ + SELECT b.id, b.zimmer, b.bett, b.patient, b.aufnahme, + b.belegt_am, b.belegt_von, + TIMESTAMPDIFF(MINUTE, b.belegt_am, NOW()) AS liegtSeit, + c.firstname, c.lastname, + a.status AS aufnahmeStatus, a.prioritaet, a.grund, + p.blutgruppe, p.allergien, p.hinweis AS warnhinweis, + (SELECT COUNT(*) FROM medical_intensiv i + WHERE i.aufnahme = b.aufnahme AND i.beendet_am IS NULL) AS aufIntensiv + FROM medical_belegung b + JOIN aether_characters c ON c.id = b.patient + LEFT JOIN medical_aufnahme a ON a.id = b.aufnahme + LEFT JOIN medical_patient p ON p.`character` = b.patient + WHERE b.frei_am IS NULL + ]]) or {} + + -- Belegungen nach Zimmer sortieren, damit die Betten schnell zu füllen sind + local belegungNachZimmer = {} + for _, belegung in ipairs(belegungen) do + belegung.name = belegung.firstname .. ' ' .. belegung.lastname + belegung.belegtVon = NameVon(belegung.belegt_von) + belegung.liegtSeit = tonumber(belegung.liegtSeit) or 0 + belegung.aufIntensiv = (tonumber(belegung.aufIntensiv) or 0) > 0 + belegung.firstname, belegung.lastname, belegung.belegt_von = nil, nil, nil + + local zimmerId = tonumber(belegung.zimmer) + belegungNachZimmer[zimmerId] = belegungNachZimmer[zimmerId] or {} + belegungNachZimmer[zimmerId][tonumber(belegung.bett)] = belegung + end + + -- Zimmer den Stationen zuordnen und jedes Bett einzeln aufführen + local zimmerNachStation = {} + for _, zimmer in ipairs(zimmerListe) do + zimmer.betten = tonumber(zimmer.betten) or 1 + + local imZimmer = belegungNachZimmer[tonumber(zimmer.id)] or {} + zimmer.bettenListe = {} + + for nummer = 1, zimmer.betten do + zimmer.bettenListe[nummer] = { + nummer = nummer, + belegung = imZimmer[nummer], + } + end + + zimmer.belegt = 0 + for _ in pairs(imZimmer) do zimmer.belegt = zimmer.belegt + 1 end + zimmer.frei = math.max(0, zimmer.betten - zimmer.belegt) + -- Verfügbar heisst: nicht gesperrt, nicht in Reinigung, noch Platz + zimmer.verfuegbar = zimmer.status == 'frei' and zimmer.frei > 0 + + local stationId = tonumber(zimmer.station) + zimmerNachStation[stationId] = zimmerNachStation[stationId] or {} + table.insert(zimmerNachStation[stationId], zimmer) + end + + -- Zählwerte über das ganze Haus + local zaehler = { + betten = 0, belegt = 0, frei = 0, + zimmer = 0, gesperrt = 0, reinigung = 0, + } + + for _, station in ipairs(stationen) do + station.zimmer = zimmerNachStation[tonumber(station.id)] or {} + station.aktiv = tonumber(station.aktiv) == 1 + + station.betten, station.belegt, station.frei = 0, 0, 0 + for _, zimmer in ipairs(station.zimmer) do + station.betten = station.betten + zimmer.betten + station.belegt = station.belegt + zimmer.belegt + -- Als frei zählt nur, was auch belegt werden dürfte + if zimmer.status == 'frei' then station.frei = station.frei + zimmer.frei end + + zaehler.zimmer = zaehler.zimmer + 1 + if zimmer.status == 'gesperrt' then zaehler.gesperrt = zaehler.gesperrt + 1 end + if zimmer.status == 'reinigung' then zaehler.reinigung = zaehler.reinigung + 1 end + end + + zaehler.betten = zaehler.betten + station.betten + zaehler.belegt = zaehler.belegt + station.belegt + zaehler.frei = zaehler.frei + station.frei + end + + -- Aufgenommene Patienten, die in keinem Bett liegen. + -- Das ist kein Fehler — es wird nur sichtbar gemacht. + local ohneBett = MySQL.query.await([[ + SELECT a.id, a.patient, a.station, a.grund, a.prioritaet, a.aufgenommen_am, + TIMESTAMPDIFF(MINUTE, a.aufgenommen_am, NOW()) AS wartet, + c.firstname, c.lastname, + s.name AS stationName, + p.blutgruppe, p.allergien, p.hinweis AS warnhinweis + FROM medical_aufnahme a + JOIN aether_characters c ON c.id = a.patient + LEFT JOIN medical_station s ON s.kennung = a.station + LEFT JOIN medical_patient p ON p.`character` = a.patient + WHERE a.status = 'aufgenommen' + AND NOT EXISTS ( + SELECT 1 FROM medical_belegung b + WHERE b.aufnahme = a.id AND b.frei_am IS NULL) + ORDER BY a.prioritaet, a.aufgenommen_am + LIMIT 100 + ]]) or {} + + for _, eintrag in ipairs(ohneBett) do + eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname + eintrag.wartet = tonumber(eintrag.wartet) or 0 + eintrag.firstname, eintrag.lastname = nil, nil + end + + return { + stationen = stationen, + ohneBett = ohneBett, + zaehler = zaehler, + stationsarten = Medizin.Config.Stationsarten, + zimmerarten = Medizin.Config.Zimmerarten, + zimmerstatus = Medizin.Config.Zimmerstatus, + rechte = { + belegen = Rechte.Hat(mitarbeiter, 'station.belegen'), + verwalten = darfVerwalten, + verlegen = Rechte.Hat(mitarbeiter, 'patient.verlegen'), + }, + } +end) + +-- ===================================================================== +-- EINZELNES ZIMMER +-- ===================================================================== + +Kern.RegistriereAktion('station', 'zimmer', nil, function(quelle, mitarbeiter, daten) + local zimmerId = tonumber(daten.id) + if not zimmerId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT z.id, z.kennung, z.betten, z.art, z.status, z.hinweis, + s.id AS stationId, s.kennung AS station, s.name AS stationName, + s.art AS stationArt, s.etage + FROM medical_zimmer z + JOIN medical_station s ON s.id = z.station + WHERE z.id = ? + ]], { zimmerId }) + if not zeilen or not zeilen[1] then return nil, 'Zimmer nicht gefunden.' end + + local zimmer = zeilen[1] + zimmer.betten = tonumber(zimmer.betten) or 1 + + -- Wer liegt gerade darin? + local aktuell = MySQL.query.await([[ + SELECT b.id, b.bett, b.patient, b.aufnahme, b.belegt_am, b.belegt_von, + TIMESTAMPDIFF(MINUTE, b.belegt_am, NOW()) AS liegtSeit, + c.firstname, c.lastname, c.dob, + a.status AS aufnahmeStatus, a.prioritaet, a.grund, + p.blutgruppe, p.allergien, p.hinweis AS warnhinweis + FROM medical_belegung b + JOIN aether_characters c ON c.id = b.patient + LEFT JOIN medical_aufnahme a ON a.id = b.aufnahme + LEFT JOIN medical_patient p ON p.`character` = b.patient + WHERE b.zimmer = ? AND b.frei_am IS NULL + ORDER BY b.bett + ]], { zimmerId }) or {} + + -- Betten einzeln aufführen, damit auch die leeren sichtbar bleiben + local nachBett = {} + for _, belegung in ipairs(aktuell) do + belegung.name = belegung.firstname .. ' ' .. belegung.lastname + belegung.geburt = tostring(belegung.dob):sub(1, 10) + belegung.belegtVon = NameVon(belegung.belegt_von) + belegung.liegtSeit = tonumber(belegung.liegtSeit) or 0 + belegung.firstname, belegung.lastname, belegung.dob = nil, nil, nil + belegung.belegt_von = nil + nachBett[tonumber(belegung.bett)] = belegung + end + + local bettenListe = {} + for nummer = 1, zimmer.betten do + bettenListe[nummer] = { nummer = nummer, belegung = nachBett[nummer] } + end + + -- Frühere Belegungen — die Spur bleibt, auch wenn das Bett längst frei ist + local verlauf = MySQL.query.await([[ + SELECT b.bett, b.belegt_am, b.belegt_von, b.frei_am, b.frei_von, + c.firstname, c.lastname + FROM medical_belegung b + JOIN aether_characters c ON c.id = b.patient + WHERE b.zimmer = ? AND b.frei_am IS NOT NULL + ORDER BY b.frei_am DESC + LIMIT 25 + ]], { zimmerId }) or {} + + for _, eintrag in ipairs(verlauf) do + eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname + eintrag.belegtVon = NameVon(eintrag.belegt_von) + eintrag.freiVon = NameVon(eintrag.frei_von) + eintrag.firstname, eintrag.lastname = nil, nil + eintrag.belegt_von, eintrag.frei_von = nil, nil + end + + return { + zimmer = zimmer, + betten = bettenListe, + verlauf = verlauf, + zimmerarten = Medizin.Config.Zimmerarten, + zimmerstatus = Medizin.Config.Zimmerstatus, + rechte = { + belegen = Rechte.Hat(mitarbeiter, 'station.belegen'), + verwalten = Rechte.Hat(mitarbeiter, 'station.verwalten'), + }, + } +end) + +-- ===================================================================== +-- BETT ZUWEISEN +-- +-- Nur für aufgenommene Patienten, die in keinem Bett liegen. Wer +-- bereits eines hat, wird über die Patientenverwaltung verlegt — +-- damit eine Verlegung auch als Verlegung dokumentiert ist. +-- ===================================================================== + +Kern.RegistriereAktion('station', 'bettZuweisen', 'station.belegen', function(quelle, mitarbeiter, daten) + local aufnahmeId = tonumber(daten.aufnahme) + local zimmerId = tonumber(daten.zimmer) + + if not aufnahmeId then return nil, 'Kein Patient gewählt.' end + if not zimmerId then return nil, 'Kein Zimmer gewählt.' end + + local aufnahmen = MySQL.query.await([[ + SELECT a.id, a.patient, a.status, a.station, a.zimmer, + c.firstname, c.lastname + FROM medical_aufnahme a + JOIN aether_characters c ON c.id = a.patient + WHERE a.id = ? + ]], { aufnahmeId }) + if not aufnahmen or not aufnahmen[1] then return nil, 'Aufnahme nicht gefunden.' end + + local aufnahme = aufnahmen[1] + if aufnahme.status ~= 'aufgenommen' then + return nil, 'Nur aufgenommene Patienten bekommen ein Bett.' + end + + -- Doppelbelegung ausschliessen + local schonBelegt = MySQL.scalar.await( + 'SELECT id FROM medical_belegung WHERE aufnahme = ? AND frei_am IS NULL', { aufnahmeId }) + if schonBelegt then + return nil, 'Der Patient liegt bereits in einem Bett. Eine Verlegung nimmt die Patientenverwaltung vor.' + end + + local zimmerZeilen = MySQL.query.await([[ + SELECT z.id, z.kennung, z.betten, z.art, z.status, + s.kennung AS station, s.name AS stationName, s.aktiv AS stationAktiv + FROM medical_zimmer z + JOIN medical_station s ON s.id = z.station + WHERE z.id = ? + ]], { zimmerId }) + if not zimmerZeilen or not zimmerZeilen[1] then return nil, 'Unbekanntes Zimmer.' end + + local zimmer = zimmerZeilen[1] + if tonumber(zimmer.stationAktiv) ~= 1 then + return nil, 'Diese Station ist abgeschaltet.' + end + if zimmer.status ~= 'frei' then + return nil, ('Zimmer %s steht nicht zur Verfügung (%s).'):format(zimmer.kennung, zimmer.status) + end + + -- Ein Bett auf einer anderen Station ist eine Verlegung und braucht das Recht dazu + local wechseltStation = aufnahme.station ~= nil and aufnahme.station ~= zimmer.station + if wechseltStation and not Rechte.Hat(mitarbeiter, 'patient.verlegen') then + return nil, 'Dieses Bett liegt auf einer anderen Station — dafür fehlt dir das Recht zur Verlegung.' + end + + -- Bettwunsch prüfen, sonst das erste freie Bett nehmen + local bettNummer = tonumber(daten.bett) + local betten = tonumber(zimmer.betten) or 1 + + if bettNummer then + if bettNummer < 1 or bettNummer > betten then + return nil, ('Zimmer %s hat kein Bett %d.'):format(zimmer.kennung, bettNummer) + end + if BelegteBettNummern(zimmerId)[bettNummer] then + return nil, ('Bett %d ist belegt.'):format(bettNummer) + end + else + bettNummer = FreiesBett(zimmerId, betten) + if not bettNummer then + return nil, ('In Zimmer %s ist kein Bett mehr frei.'):format(zimmer.kennung) + end + end + + MySQL.insert.await([[ + INSERT INTO medical_belegung (zimmer, bett, patient, aufnahme, belegt_von) + VALUES (?, ?, ?, ?, ?) + ]], { zimmerId, bettNummer, aufnahme.patient, aufnahmeId, mitarbeiter.charakterId }) + + -- Die Aufnahme führt mit, wo der Patient liegt + MySQL.update.await('UPDATE medical_aufnahme SET station = ?, zimmer = ? WHERE id = ?', + { zimmer.station, zimmerId, aufnahmeId }) + + Protokoll.Schreibe(mitarbeiter, 'station', 'Bett zugewiesen', + { typ = 'zimmer', id = zimmerId }, + { aufnahme = aufnahmeId, patient = aufnahme.patient, + zimmer = zimmer.kennung, bett = bettNummer, + vonStation = aufnahme.station, nachStation = zimmer.station }, + Text(daten.vermerk, 255)) + + -- Ein Stationswechsel wird zusätzlich gemeldet, damit ihn niemand übersieht + if wechseltStation then + Hinweise.Erstelle(nil, { + kategorie = 'patient', prioritaet = 'normal', + titel = 'Patient auf andere Station gelegt', + text = ('%s %s liegt jetzt auf %s, Zimmer %s.') + :format(aufnahme.firstname, aufnahme.lastname, zimmer.stationName, zimmer.kennung), + zielTyp = 'aufnahme', zielId = aufnahmeId, + }) + end + + Kern.Loese('belegung.zugewiesen', aufnahmeId, zimmerId, bettNummer, mitarbeiter) + + return { ok = true, zimmer = zimmer.kennung, bett = bettNummer } +end) + +-- ===================================================================== +-- BETT FREIGEBEN +-- +-- Gibt nur das Bett frei — der Patient bleibt aufgenommen. Entlassen +-- wird ausschliesslich über die Patientenverwaltung. +-- ===================================================================== + +Kern.RegistriereAktion('station', 'bettFreigeben', 'station.belegen', function(quelle, mitarbeiter, daten) + local belegungId = tonumber(daten.id) + if not belegungId then return nil, 'Ungültige Kennung.' end + + local grund = Text(daten.grund, 255) + if not grund or #grund < 3 then + return nil, 'Bitte angeben, warum das Bett frei wird.' + end + + local zeilen = MySQL.query.await([[ + SELECT b.id, b.zimmer, b.bett, b.patient, b.aufnahme, b.frei_am, + z.kennung AS zimmerKennung, + c.firstname, c.lastname + FROM medical_belegung b + JOIN medical_zimmer z ON z.id = b.zimmer + JOIN aether_characters c ON c.id = b.patient + WHERE b.id = ? + ]], { belegungId }) + if not zeilen or not zeilen[1] then return nil, 'Belegung nicht gefunden.' end + + local belegung = zeilen[1] + if belegung.frei_am then return nil, 'Dieses Bett ist bereits frei.' end + + MySQL.update.await( + 'UPDATE medical_belegung SET frei_am = NOW(), frei_von = ? WHERE id = ?', + { mitarbeiter.charakterId, belegungId }) + + -- Die Aufnahme verliert ihr Zimmer, bleibt aber auf ihrer Station + if belegung.aufnahme then + MySQL.update.await( + 'UPDATE medical_aufnahme SET zimmer = NULL WHERE id = ? AND zimmer = ?', + { belegung.aufnahme, belegung.zimmer }) + end + + Protokoll.Schreibe(mitarbeiter, 'station', 'Bett freigegeben', + { typ = 'zimmer', id = belegung.zimmer }, + { belegung = belegungId, patient = belegung.patient, + zimmer = belegung.zimmerKennung, bett = belegung.bett, + aufnahme = belegung.aufnahme }, grund) + + -- Der Patient ist weiterhin im Haus — das darf nicht untergehen + if belegung.aufnahme then + Hinweise.Erstelle(nil, { + kategorie = 'patient', prioritaet = 'normal', + titel = 'Patient ohne Bett', + text = ('%s %s ist weiterhin aufgenommen, liegt aber in keinem Zimmer mehr.') + :format(belegung.firstname, belegung.lastname), + zielTyp = 'aufnahme', zielId = belegung.aufnahme, + }) + end + + MeldeWennLeer(belegung.zimmer, belegung.zimmerKennung) + Kern.Loese('belegung.freigegeben', belegungId, belegung.zimmer, mitarbeiter) + + return { ok = true } +end) + +-- ===================================================================== +-- ZIMMERSTATUS +-- +-- Reinigung und Sperrung setzt immer ein Mensch. Ein leer gewordenes +-- Zimmer wird nicht von selbst wieder als frei gemeldet. +-- ===================================================================== + +Kern.RegistriereAktion('station', 'zimmerStatus', 'station.belegen', function(quelle, mitarbeiter, daten) + local zimmerId = tonumber(daten.id) + local status = tostring(daten.status or '') + + if not zimmerId then return nil, 'Ungültige Kennung.' end + if not ZIMMERSTATUS[status] then return nil, 'Unbekannter Zimmerstatus.' end + + local zeilen = MySQL.query.await( + 'SELECT id, kennung, status, hinweis FROM medical_zimmer WHERE id = ?', { zimmerId }) + if not zeilen or not zeilen[1] then return nil, 'Zimmer nicht gefunden.' end + + local zimmer = zeilen[1] + + -- Ein belegtes Zimmer lässt sich nicht sperren, ohne die Belegung zu klären + if status == 'gesperrt' and BelegteBetten(zimmerId) > 0 then + return nil, 'In diesem Zimmer liegen noch Patienten.' + end + + -- Ein leerer Hinweis löscht den alten; ein fehlendes Feld lässt ihn stehen + local hinweis = zimmer.hinweis + if daten.hinweis ~= nil then hinweis = Text(daten.hinweis, 255) end + + MySQL.update.await('UPDATE medical_zimmer SET status = ?, hinweis = ? WHERE id = ?', + { status, hinweis, zimmerId }) + + Protokoll.Schreibe(mitarbeiter, 'station', 'Zimmerstatus gesetzt', + { typ = 'zimmer', id = zimmerId }, + { kennung = zimmer.kennung, von = zimmer.status, nach = status }, hinweis) + + Kern.Loese('zimmer.status', zimmerId, status, mitarbeiter) + + return { ok = true } +end) + +-- ===================================================================== +-- STATIONSVERWALTUNG +-- +-- Stammdatenpflege. Eine Station wird abgeschaltet, nie gelöscht — +-- sonst verlieren alte Aufnahmen ihren Ort. +-- ===================================================================== + +Kern.RegistriereAktion('station', 'stationSpeichern', 'station.verwalten', function(quelle, mitarbeiter, daten) + local stationId = tonumber(daten.id) + + local kennung = Text(daten.kennung, 24) + local name = Text(daten.name, 120) + local art = AusListe(Medizin.Config.Stationsarten, daten.art) + + if not kennung then return nil, 'Bitte eine Kennung angeben.' end + if not name then return nil, 'Bitte einen Namen angeben.' end + if not art then return nil, 'Unbekannte Stationsart.' end + + kennung = kennung:upper() + + -- Kennungen müssen eindeutig bleiben, weil Aufnahmen darauf verweisen + local kollision = MySQL.scalar.await( + 'SELECT id FROM medical_station WHERE kennung = ? AND id <> ?', + { kennung, stationId or 0 }) + if kollision then return nil, ('Die Kennung %s ist bereits vergeben.'):format(kennung) end + + local etage = Text(daten.etage, 24) + local sortierung = math.floor(tonumber(daten.sortierung) or 0) + + if stationId then + local vorher = MySQL.query.await( + 'SELECT kennung FROM medical_station WHERE id = ?', { stationId }) + if not vorher or not vorher[1] then return nil, 'Station nicht gefunden.' end + + MySQL.update.await([[ + UPDATE medical_station + SET kennung = ?, name = ?, art = ?, etage = ?, sortierung = ? + WHERE id = ? + ]], { kennung, name, art, etage, sortierung, stationId }) + + -- Aufnahmen verweisen über die Kennung — die muss mitwandern + if vorher[1].kennung ~= kennung then + MySQL.update.await('UPDATE medical_aufnahme SET station = ? WHERE station = ?', + { kennung, vorher[1].kennung }) + end + + Protokoll.Schreibe(mitarbeiter, 'station', 'Station bearbeitet', + { typ = 'station', id = stationId }, + { kennung = kennung, name = name, art = art, etage = etage }) + else + stationId = MySQL.insert.await([[ + INSERT INTO medical_station (kennung, name, art, etage, sortierung) + VALUES (?, ?, ?, ?, ?) + ]], { kennung, name, art, etage, sortierung }) + + Protokoll.Schreibe(mitarbeiter, 'station', 'Station angelegt', + { typ = 'station', id = stationId }, + { kennung = kennung, name = name, art = art, etage = etage }) + end + + Kern.Loese('station.geaendert', stationId, mitarbeiter) + + return { id = stationId } +end) + +Kern.RegistriereAktion('station', 'stationAktiv', 'station.verwalten', function(quelle, mitarbeiter, daten) + local stationId = tonumber(daten.id) + if not stationId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await( + 'SELECT id, kennung, name, aktiv FROM medical_station WHERE id = ?', { stationId }) + if not zeilen or not zeilen[1] then return nil, 'Station nicht gefunden.' end + + local aktiv = daten.aktiv == true + + -- Eine Station mit Patienten darin wird nicht abgeschaltet + if not aktiv then + local belegt = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_belegung b + JOIN medical_zimmer z ON z.id = b.zimmer + WHERE z.station = ? AND b.frei_am IS NULL + ]], { stationId })) or 0 + + if belegt > 0 then + return nil, ('Auf dieser Station liegen noch %d Patient(en).'):format(belegt) + end + end + + MySQL.update.await('UPDATE medical_station SET aktiv = ? WHERE id = ?', + { aktiv and 1 or 0, stationId }) + + Protokoll.Schreibe(mitarbeiter, 'station', + aktiv and 'Station eingeschaltet' or 'Station abgeschaltet', + { typ = 'station', id = stationId }, + { kennung = zeilen[1].kennung }, Text(daten.grund, 255)) + + Kern.Loese('station.geaendert', stationId, mitarbeiter) + + return { ok = true } +end) + +-- ===================================================================== +-- ZIMMERVERWALTUNG +-- ===================================================================== + +Kern.RegistriereAktion('station', 'zimmerSpeichern', 'station.verwalten', function(quelle, mitarbeiter, daten) + local zimmerId = tonumber(daten.id) + local stationId = tonumber(daten.station) + + local kennung = Text(daten.kennung, 24) + local art = AusListe(Medizin.Config.Zimmerarten, daten.art) + local betten = math.floor(tonumber(daten.betten) or 0) + + if not kennung then return nil, 'Bitte eine Kennung angeben.' end + if not art then return nil, 'Unbekannte Zimmerart.' end + if betten < 1 or betten > 12 then return nil, 'Ein Zimmer hat 1 bis 12 Betten.' end + + kennung = kennung:upper() + + if zimmerId then + local vorher = MySQL.query.await( + 'SELECT id, station, betten FROM medical_zimmer WHERE id = ?', { zimmerId }) + if not vorher or not vorher[1] then return nil, 'Zimmer nicht gefunden.' end + + -- Die Station eines bestehenden Zimmers bleibt, wie sie ist + stationId = tonumber(vorher[1].station) + + -- Betten lassen sich nicht wegkürzen, solange jemand darin liegt + local genommen = BelegteBettNummern(zimmerId) + for nummer in pairs(genommen) do + if nummer > betten then + return nil, ('Bett %d ist belegt — die Bettenzahl lässt sich nicht verringern.') + :format(nummer) + end + end + end + + if not stationId then return nil, 'Keine Station gewählt.' end + + local station = MySQL.query.await( + 'SELECT id, kennung, name FROM medical_station WHERE id = ?', { stationId }) + if not station or not station[1] then return nil, 'Unbekannte Station.' end + + -- Innerhalb einer Station soll jede Zimmerkennung nur einmal vorkommen + local kollision = MySQL.scalar.await( + 'SELECT id FROM medical_zimmer WHERE station = ? AND kennung = ? AND id <> ?', + { stationId, kennung, zimmerId or 0 }) + if kollision then + return nil, ('Zimmer %s gibt es auf dieser Station bereits.'):format(kennung) + end + + local hinweis = Text(daten.hinweis, 255) + + if zimmerId then + MySQL.update.await([[ + UPDATE medical_zimmer SET kennung = ?, betten = ?, art = ?, hinweis = ? + WHERE id = ? + ]], { kennung, betten, art, hinweis, zimmerId }) + + Protokoll.Schreibe(mitarbeiter, 'station', 'Zimmer bearbeitet', + { typ = 'zimmer', id = zimmerId }, + { kennung = kennung, betten = betten, art = art, station = station[1].kennung }) + else + -- Neue Zimmer starten gesperrt: erst freigeben, dann belegen + zimmerId = MySQL.insert.await([[ + INSERT INTO medical_zimmer (station, kennung, betten, art, status, hinweis) + VALUES (?, ?, ?, ?, 'gesperrt', ?) + ]], { stationId, kennung, betten, art, hinweis }) + + Protokoll.Schreibe(mitarbeiter, 'station', 'Zimmer angelegt', + { typ = 'zimmer', id = zimmerId }, + { kennung = kennung, betten = betten, art = art, station = station[1].kennung }) + end + + Kern.Loese('zimmer.geaendert', zimmerId, mitarbeiter) + + return { id = zimmerId } +end) + +--- Entfernt ein Zimmer, das nie benutzt wurde. +--- Sobald jemand darin gelegen hat, bleibt es bestehen — sonst verlöre +--- der Belegungsverlauf seinen Ort. Dann hilft nur noch "gesperrt". +Kern.RegistriereAktion('station', 'zimmerEntfernen', 'station.verwalten', function(quelle, mitarbeiter, daten) + local zimmerId = tonumber(daten.id) + if not zimmerId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await( + 'SELECT id, kennung, station FROM medical_zimmer WHERE id = ?', { zimmerId }) + if not zeilen or not zeilen[1] then return nil, 'Zimmer nicht gefunden.' end + + local benutzt = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_belegung WHERE zimmer = ?', { zimmerId })) or 0 + if benutzt > 0 then + return nil, 'In diesem Zimmer lagen bereits Patienten — es lässt sich nur noch sperren.' + end + + -- Auch ein nie belegtes Zimmer kann in einer laufenden OP-Planung stehen + local imOpPlan = tonumber(MySQL.scalar.await([[ + SELECT COUNT(*) FROM medical_operation + WHERE saal = ? AND status IN ('geplant', 'laufend') + ]], { zimmerId })) or 0 + if imOpPlan > 0 then + return nil, 'Für diesen Saal sind noch Operationen geplant.' + end + + MySQL.query.await('DELETE FROM medical_zimmer WHERE id = ?', { zimmerId }) + + Protokoll.Schreibe(mitarbeiter, 'station', 'Zimmer entfernt', + { typ = 'zimmer', id = zimmerId }, + { kennung = zeilen[1].kennung }, Text(daten.grund, 255)) + + Kern.Loese('zimmer.geaendert', zimmerId, mitarbeiter) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Liefert den Bettenspiegel als flache Auskunft. +--- Reine Anzeige — keine Ressource kann darüber ein Bett belegen. +--- +--- `betten` ist die gesamte Bettenzahl, `frei` zählt nur, was auch +--- belegt werden dürfte: gesperrte Zimmer und Zimmer in Reinigung +--- bleiben aussen vor. +--- @return table Liste { station, name, betten, belegt, frei } +exports('HoleBettenspiegel', function() + local stationen = MySQL.query.await([[ + SELECT id, kennung AS station, name + FROM medical_station + WHERE aktiv = 1 + ORDER BY sortierung, kennung + ]]) or {} + + for _, eintrag in ipairs(stationen) do + local zimmerListe = MySQL.query.await( + 'SELECT id, betten, status FROM medical_zimmer WHERE station = ?', + { eintrag.id }) or {} + + eintrag.betten, eintrag.belegt, eintrag.frei = 0, 0, 0 + + for _, zimmer in ipairs(zimmerListe) do + local betten = tonumber(zimmer.betten) or 1 + local belegt = BelegteBetten(zimmer.id) + + eintrag.betten = eintrag.betten + betten + eintrag.belegt = eintrag.belegt + belegt + if zimmer.status == 'frei' then + eintrag.frei = eintrag.frei + math.max(0, betten - belegt) + end + end + + eintrag.id = nil + end + + return stationen +end) diff --git a/resources/[aether]/aether-medical/server/module/vorlage.lua b/resources/[aether]/aether-medical/server/module/vorlage.lua new file mode 100644 index 0000000..a6febda --- /dev/null +++ b/resources/[aether]/aether-medical/server/module/vorlage.lua @@ -0,0 +1,366 @@ +-- ===================================================================== +-- aether-medical / Modul: Bausteine (Textvorlagen) +-- +-- Vorformulierte Textblöcke für den Behandlungs- und Berichtsbogen. +-- +-- Ein Baustein ist ein Zettel mit vorbereitetem Text — nicht mehr. +-- Entscheidend ist, was er NICHT tut: +-- +-- * Er schreibt nichts in die Datenbank. Ein Baustein füllt nur das +-- Formular; gespeichert wird danach von Hand, wie immer. +-- * Er überschreibt nichts. Was jemand geschrieben hat, bleibt +-- stehen — der Text wird angehängt, nicht ersetzt. +-- * Er wird nie von selbst eingesetzt. Es gibt keine Vorauswahl +-- nach Diagnose, Einsatzart oder sonst etwas. +-- +-- Damit bleibt der Wortlaut die Verantwortung dessen, der speichert. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'vorlage', name = 'Bausteine', icon = '🧩', + reihenfolge = 15, recht = 'katalog.bearbeiten', + beschreibung = 'Textvorlagen für Behandlungs- und Berichtsbogen', +}) + +-- --------------------------------------------------------------------- +-- Bereiche und ihre Felder +-- +-- Ein Bereich entspricht einem Bogen. Die Feldkennungen sind genau +-- die des jeweiligen Formulars — ein Baustein kann nur füllen, was +-- es dort auch gibt. +-- --------------------------------------------------------------------- +local BEREICHE = { + { id = 'behandlung', name = 'Behandlungsbogen', felder = { + { id = 'symptome', name = 'Symptome / Anamnese' }, + { id = 'untersuchung', name = 'Untersuchung' }, + { id = 'diagnose', name = 'Diagnose' }, + { id = 'massnahmen', name = 'Maßnahmen / Behandlung' }, + { id = 'empfehlung', name = 'Empfehlung' }, + { id = 'nachsorge', name = 'Nachsorge' }, + }}, + { id = 'bericht', name = 'Berichtsbogen', felder = { + { id = 'symptome', name = 'Symptome und Schilderung' }, + { id = 'untersuchung', name = 'Untersuchungsbefund' }, + { id = 'diagnose', name = 'Diagnose' }, + { id = 'massnahmen', name = 'Maßnahmen' }, + { id = 'empfehlung', name = 'Empfehlung' }, + }}, +} + +-- --------------------------------------------------------------------- +-- Grundbestand +-- +-- Damit die Bausteine von Anfang an brauchbar sind. Jeder Text ist +-- ein Gerüst mit Lücken — auszufüllen hat ihn ein Mensch. +-- --------------------------------------------------------------------- +local GRUNDBAUSTEINE = { + { bereich = 'behandlung', name = 'Wundversorgung', sortierung = 1, inhalt = { + untersuchung = 'Lokalisation:\nWundart:\nGröße:\nBlutung:\nDurchblutung, Motorik, Sensibilität distal:', + massnahmen = 'Wundreinigung:\nWundverschluss:\nVerband:\nTetanusschutz:', + nachsorge = 'Verbandwechsel:\nWiedervorstellung bei:\nFadenzug:', + }}, + { bereich = 'behandlung', name = 'Reanimation', sortierung = 2, inhalt = { + symptome = 'Auffindesituation:\nZeitpunkt des Kollapses:\nErsthelfermaßnahmen:', + untersuchung = 'Bewusstsein:\nAtmung:\nInitialer Rhythmus:\nPupillen:', + massnahmen = 'Beginn Thoraxkompression:\nBeatmung:\nDefibrillationen:\nMedikamente:\nROSC um:', + }}, + { bereich = 'behandlung', name = 'Verkehrsunfall — Erstversorgung', sortierung = 3, inhalt = { + symptome = 'Unfallhergang:\nAnschnallgurt:\nAirbag ausgelöst:\nEingeklemmt:', + untersuchung = 'Bodycheck:\nHWS:\nThorax:\nAbdomen:\nBecken:\nExtremitäten:\nVitalwerte:', + massnahmen = 'Immobilisation:\nZugang:\nSchmerztherapie:\nTransportziel:', + }}, + { bereich = 'behandlung', name = 'Routinekontrolle', sortierung = 4, inhalt = { + symptome = 'Anlass der Vorstellung:\nBeschwerden seit letzter Kontrolle:', + untersuchung = 'Allgemeinzustand:\nVitalwerte:\nBefund:', + empfehlung = 'Weiteres Vorgehen:\nNächste Kontrolle:', + }}, + { bereich = 'bericht', name = 'Notarzteinsatzprotokoll', sortierung = 1, inhalt = { + symptome = 'Einsatzmeldung:\nAuffindesituation:\nSchilderung des Patienten:', + untersuchung = 'Erstbefund:\nVitalwerte bei Eintreffen:\nVerlauf während des Transports:', + massnahmen = 'Maßnahmen vor Ort:\nMedikamente:\nÜbergabe an:', + }}, + { bereich = 'bericht', name = 'Verlaufsbericht', sortierung = 2, inhalt = { + symptome = 'Beschwerdebild im Verlauf:', + untersuchung = 'Befunde im Verlauf:\nLaborwerte:', + massnahmen = 'Bisherige Behandlung:\nÄnderungen:', + empfehlung = 'Weiteres Vorgehen:', + }}, + { bereich = 'bericht', name = 'Entlassbericht', sortierung = 3, inhalt = { + diagnose = 'Hauptdiagnose:\nNebendiagnosen:', + massnahmen = 'Durchgeführte Behandlung:\nOperationen:\nMedikation während des Aufenthalts:', + empfehlung = 'Medikation bei Entlassung:\nSchonung:\nWiedervorstellung bei:\nKontrolltermin:', + }}, +} + +CreateThread(function() + Wait(3000) + + local vorhanden = tonumber(MySQL.scalar.await('SELECT COUNT(*) FROM medical_vorlage')) or 0 + if vorhanden > 0 then return end + + for _, baustein in ipairs(GRUNDBAUSTEINE) do + local kennung = Kern.ErzeugeKennung('VOR', 'medical_vorlage', 'kennung') + + MySQL.insert.await([[ + INSERT INTO medical_vorlage (kennung, bereich, name, inhalt, sortierung) + VALUES (?, ?, ?, ?, ?) + ]], { kennung, baustein.bereich, baustein.name, + json.encode(baustein.inhalt), baustein.sortierung }) + end + + Aether.Debug('Grundbestand an Textbausteinen angelegt.') +end) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Begrenzt einen Text und liefert nil bei leerer Eingabe +--- @param wert any Eingabe +--- @param laenge number Maximale Zeichenzahl +--- @return string|nil +local function Text(wert, laenge) + if type(wert) ~= 'string' then return nil end + local sauber = wert:gsub('^%s+', ''):gsub('%s+$', '') + if sauber == '' then return nil end + return sauber:sub(1, laenge) +end + +--- Sucht einen Bereich +--- @param kennung any Bereichskennung +--- @return table|nil +local function HoleBereich(kennung) + local gesucht = tostring(kennung or '') + for _, bereich in ipairs(BEREICHE) do + if bereich.id == gesucht then return bereich end + end + return nil +end + +--- Liest den Inhalt eines Bausteins aus der Datenbank +--- @param roh string|nil JSON +--- @return table [feldId] = text +local function AlsInhalt(roh) + if not roh then return {} end + local erfolg, tabelle = pcall(json.decode, roh) + if erfolg and type(tabelle) == 'table' then return tabelle end + return {} +end + +--- Prüft einen eingereichten Inhalt gegen die Felder eines Bereichs. +--- Felder, die es im Bogen nicht gibt, werden verworfen — ein Baustein +--- soll nur füllen können, was auch existiert. +--- @param bereich table Bereich +--- @param roh any Eingabe +--- @return table Geprüfter Inhalt, number Anzahl gefüllter Felder +local function PruefeInhalt(bereich, roh) + local inhalt, anzahl = {}, 0 + if type(roh) ~= 'table' then return inhalt, 0 end + + for _, feld in ipairs(bereich.felder) do + local wert = Text(roh[feld.id], 4000) + if wert then + inhalt[feld.id] = wert + anzahl = anzahl + 1 + end + end + + return inhalt, anzahl +end + +--- Lädt die einsetzbaren Bausteine eines Bereichs +--- @param bereichId string Bereich +--- @return table Liste +local function LadeBausteine(bereichId) + local zeilen = MySQL.query.await([[ + SELECT id, kennung, name, inhalt, sortierung + FROM medical_vorlage + WHERE bereich = ? AND aktiv = 1 + ORDER BY sortierung, name + ]], { bereichId }) or {} + + for _, zeile in ipairs(zeilen) do + zeile.inhalt = AlsInhalt(zeile.inhalt) + end + + return zeilen +end + +-- ===================================================================== +-- ABRUF AUS DEN BÖGEN +-- +-- Dieselbe Auskunft, in jedem Bogen unter seinem eigenen Modul +-- erreichbar. Sie liefert Text zum Einsetzen und sonst nichts. +-- ===================================================================== + +for _, bereich in ipairs(BEREICHE) do + Kern.RegistriereAktion(bereich.id, 'bausteine', nil, function(quelle, mitarbeiter, daten) + return { + bereich = bereich.id, + bereichName = bereich.name, + felder = bereich.felder, + bausteine = LadeBausteine(bereich.id), + } + end) +end + +-- ===================================================================== +-- PFLEGE +-- ===================================================================== + +Kern.RegistriereAktion('vorlage', 'uebersicht', nil, function(quelle, mitarbeiter, daten) + local gewaehlt = HoleBereich(daten.bereich) or BEREICHE[1] + + local liste = MySQL.query.await([[ + SELECT id, kennung, bereich, name, inhalt, aktiv, sortierung + FROM medical_vorlage + WHERE bereich = ? + ORDER BY sortierung, name + ]], { gewaehlt.id }) or {} + + for _, zeile in ipairs(liste) do + local inhalt = AlsInhalt(zeile.inhalt) + zeile.inhalt = inhalt + zeile.aktiv = tonumber(zeile.aktiv) == 1 + + -- Welche Felder füllt dieser Baustein? + zeile.felder = {} + for _, feld in ipairs(gewaehlt.felder) do + if inhalt[feld.id] then + zeile.felder[#zeile.felder + 1] = feld.name + end + end + end + + -- Zählwerte je Bereich für die Reiterleiste + local bereiche = {} + for _, bereich in ipairs(BEREICHE) do + bereiche[#bereiche + 1] = { + id = bereich.id, name = bereich.name, felder = bereich.felder, + anzahl = tonumber(MySQL.scalar.await( + 'SELECT COUNT(*) FROM medical_vorlage WHERE bereich = ? AND aktiv = 1', + { bereich.id })) or 0, + } + end + + return { + bereich = gewaehlt.id, + felder = gewaehlt.felder, + liste = liste, + bereiche = bereiche, + } +end) + +Kern.RegistriereAktion('vorlage', 'speichern', 'katalog.bearbeiten', function(quelle, mitarbeiter, daten) + local vorlageId = tonumber(daten.id) + + local bereich = HoleBereich(daten.bereich) + if not bereich then return nil, 'Unbekannter Bereich.' end + + local name = Text(daten.name, 120) + if not name then return nil, 'Bitte einen Namen angeben.' end + + local inhalt, gefuellt = PruefeInhalt(bereich, daten.inhalt) + if gefuellt == 0 then + return nil, 'Ein Baustein ohne Text füllt nichts aus.' + end + + local sortierung = math.floor(tonumber(daten.sortierung) or 0) + + if vorlageId then + local vorher = MySQL.query.await( + 'SELECT id, kennung, bereich, name FROM medical_vorlage WHERE id = ?', + { vorlageId }) + if not vorher or not vorher[1] then return nil, 'Baustein nicht gefunden.' end + + -- Der Bereich bleibt: die Felder eines Bogens passen nicht auf einen anderen + MySQL.update.await([[ + UPDATE medical_vorlage SET name = ?, inhalt = ?, sortierung = ? + WHERE id = ? + ]], { name, json.encode(inhalt), sortierung, vorlageId }) + + Protokoll.Schreibe(mitarbeiter, 'vorlage', 'Baustein bearbeitet', + { typ = 'vorlage', id = vorlageId }, + { kennung = vorher[1].kennung, name = name, + bereich = vorher[1].bereich, felder = gefuellt }) + else + local kennung = Kern.ErzeugeKennung('VOR', 'medical_vorlage', 'kennung') + + vorlageId = MySQL.insert.await([[ + INSERT INTO medical_vorlage (kennung, bereich, name, inhalt, sortierung) + VALUES (?, ?, ?, ?, ?) + ]], { kennung, bereich.id, name, json.encode(inhalt), sortierung }) + + Protokoll.Schreibe(mitarbeiter, 'vorlage', 'Baustein angelegt', + { typ = 'vorlage', id = vorlageId }, + { kennung = kennung, name = name, bereich = bereich.id, felder = gefuellt }) + end + + Kern.Loese('vorlage.geaendert', vorlageId, mitarbeiter) + + return { id = vorlageId } +end) + +Kern.RegistriereAktion('vorlage', 'aktiv', 'katalog.bearbeiten', function(quelle, mitarbeiter, daten) + local vorlageId = tonumber(daten.id) + if not vorlageId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await( + 'SELECT id, kennung, name FROM medical_vorlage WHERE id = ?', { vorlageId }) + if not zeilen or not zeilen[1] then return nil, 'Baustein nicht gefunden.' end + + local aktiv = daten.aktiv == true + + MySQL.update.await('UPDATE medical_vorlage SET aktiv = ? WHERE id = ?', + { aktiv and 1 or 0, vorlageId }) + + Protokoll.Schreibe(mitarbeiter, 'vorlage', + aktiv and 'Baustein eingeschaltet' or 'Baustein abgeschaltet', + { typ = 'vorlage', id = vorlageId }, + { kennung = zeilen[1].kennung, name = zeilen[1].name }) + + Kern.Loese('vorlage.geaendert', vorlageId, mitarbeiter) + + return { ok = true } +end) + +--- Entfernt einen Baustein endgültig. +--- Ein Baustein ist Text zum Einsetzen und keine Aufzeichnung über +--- einen Patienten — was damit geschrieben wurde, steht ohnehin in +--- den Bögen und bleibt dort unberührt. +Kern.RegistriereAktion('vorlage', 'entfernen', 'katalog.bearbeiten', function(quelle, mitarbeiter, daten) + local vorlageId = tonumber(daten.id) + if not vorlageId then return nil, 'Ungültige Kennung.' end + + local grund = Text(daten.grund, 255) + if not grund then return nil, 'Bitte einen Grund angeben.' end + + local zeilen = MySQL.query.await( + 'SELECT id, kennung, name, bereich FROM medical_vorlage WHERE id = ?', { vorlageId }) + if not zeilen or not zeilen[1] then return nil, 'Baustein nicht gefunden.' end + + MySQL.query.await('DELETE FROM medical_vorlage WHERE id = ?', { vorlageId }) + + Protokoll.Schreibe(mitarbeiter, 'vorlage', 'Baustein entfernt', + { typ = 'vorlage', id = vorlageId }, + { kennung = zeilen[1].kennung, name = zeilen[1].name, + bereich = zeilen[1].bereich }, grund) + + Kern.Loese('vorlage.geaendert', vorlageId, mitarbeiter) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Liefert die aktiven Bausteine eines Bereichs. +--- Reine Auskunft — keine Ressource kann darüber einen Bogen ausfüllen +--- oder eine Dokumentation speichern. +--- @param bereichId string behandlung/bericht +--- @return table Liste +exports('HoleBausteine', function(bereichId) + if not HoleBereich(bereichId) then return {} end + return LadeBausteine(bereichId) +end) diff --git a/resources/[aether]/aether-medical/server/protokoll.lua b/resources/[aether]/aether-medical/server/protokoll.lua new file mode 100644 index 0000000..4acb45e --- /dev/null +++ b/resources/[aether]/aether-medical/server/protokoll.lua @@ -0,0 +1,66 @@ +-- ===================================================================== +-- aether-medical — Protokoll +-- +-- Jede Entscheidung im System hinterlässt eine Spur: wer, wann, was +-- und warum. Einträge werden nur geschrieben und gelesen, nie +-- geändert oder entfernt. +-- ===================================================================== + +Protokoll = {} + +--- Schreibt einen Protokolleintrag +--- @param mitarbeiter table|nil Handelnder Mitarbeiter +--- @param modul string Modulkennung +--- @param aktion string Was wurde getan +--- @param ziel table|nil { typ, id } +--- @param details table|nil Beliebige Zusatzdaten +--- @param grund string|nil Begründung, falls angegeben +function Protokoll.Schreibe(mitarbeiter, modul, aktion, ziel, details, grund) + MySQL.insert([[ + INSERT INTO medical_protokoll + (`character`, personalnummer, modul, aktion, ziel_typ, ziel_id, details, grund) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ]], { + mitarbeiter and mitarbeiter.charakterId or nil, + mitarbeiter and mitarbeiter.personalnummer or nil, + modul, aktion, + ziel and ziel.typ or nil, + ziel and tostring(ziel.id) or nil, + details and json.encode(details) or nil, + grund, + }) +end + +--- Liest Protokolleinträge mit einfachen Filtern +--- @param filter table { modul?, charakterId?, zielTyp?, zielId?, limit? } +--- @return table +function Protokoll.Lies(filter) + filter = filter or {} + local bedingungen, werte = {}, {} + + if filter.modul then + bedingungen[#bedingungen + 1] = 'modul = ?' + werte[#werte + 1] = filter.modul + end + if filter.charakterId then + bedingungen[#bedingungen + 1] = '`character` = ?' + werte[#werte + 1] = filter.charakterId + end + if filter.zielTyp and filter.zielId then + bedingungen[#bedingungen + 1] = 'ziel_typ = ? AND ziel_id = ?' + werte[#werte + 1] = filter.zielTyp + werte[#werte + 1] = tostring(filter.zielId) + end + + local wo = #bedingungen > 0 and ('WHERE ' .. table.concat(bedingungen, ' AND ')) or '' + werte[#werte + 1] = math.min(filter.limit or 100, 500) + + return MySQL.query.await(([[ + SELECT p.*, c.firstname, c.lastname + FROM medical_protokoll p + LEFT JOIN aether_characters c ON c.id = p.`character` + %s + ORDER BY p.id DESC + LIMIT ? + ]]):format(wo), werte) or {} +end diff --git a/resources/[aether]/aether-medical/server/rechte.lua b/resources/[aether]/aether-medical/server/rechte.lua new file mode 100644 index 0000000..004b2c4 --- /dev/null +++ b/resources/[aether]/aether-medical/server/rechte.lua @@ -0,0 +1,61 @@ +-- ===================================================================== +-- aether-medical — Rechteprüfung +-- +-- Ein Mitarbeiter hat ein Recht, wenn seine Rolle es vorsieht ODER es +-- ihm einzeln gewährt wurde — und es ihm nicht einzeln entzogen wurde. +-- Der Entzug hat immer Vorrang. +-- ===================================================================== + +Rechte = {} + +--- Wandelt eine JSON-Liste in eine Menge um +--- @param roh string|nil JSON-Array +--- @return table { [wert] = true } +local function ZuMenge(roh) + local menge = {} + if not roh then return menge end + + local erfolg, liste = pcall(json.decode, roh) + if erfolg and type(liste) == 'table' then + for _, wert in ipairs(liste) do menge[wert] = true end + end + return menge +end + +--- Baut die vollständige Rechtemenge eines Mitarbeiters +--- @param personal table Zeile aus medical_personal +--- @return table { [rechtId] = true } +function Rechte.Berechne(personal) + local menge = Medizin.RechteDerRolle(personal.rolle) + + -- Einzeln gewährte Rechte ergänzen + for recht in pairs(ZuMenge(personal.rechte_extra)) do + menge[recht] = true + end + + -- Einzeln entzogene Rechte haben Vorrang + for recht in pairs(ZuMenge(personal.rechte_entzug)) do + menge[recht] = nil + end + + return menge +end + +--- Prüft ein einzelnes Recht +--- @param mitarbeiter table Mitarbeiterdaten (mit Feld `rechte`) +--- @param recht string Recht-Kennung +--- @return boolean +function Rechte.Hat(mitarbeiter, recht) + if not mitarbeiter or not mitarbeiter.rechte then return false end + if not recht then return true end + return mitarbeiter.rechte[recht] == true +end + +--- Prüft, ob ein Mitarbeiter über einem anderen steht. +--- Verhindert, dass jemand Gleichrangige oder Höhere verwaltet. +--- @param mitarbeiter table Handelnder +--- @param zielRolle string Rolle der Zielperson +--- @return boolean +function Rechte.StehtUeber(mitarbeiter, zielRolle) + return Medizin.Stufe(mitarbeiter.rolle) > Medizin.Stufe(zielRolle) +end diff --git a/resources/[aether]/aether-medical/shared/config.lua b/resources/[aether]/aether-medical/shared/config.lua new file mode 100644 index 0000000..d6a9052 --- /dev/null +++ b/resources/[aether]/aether-medical/shared/config.lua @@ -0,0 +1,271 @@ +-- ===================================================================== +-- aether-medical — Konfiguration +-- +-- Alles, was ein Serverbetreiber anpassen will, ohne Code zu ändern. +-- Kein Wert hier löst je eine medizinische Maßnahme aus — es sind +-- Kategorien, Orte und Erinnerungsfristen. +-- ===================================================================== + +Medizin = Medizin or {} +Medizin.Config = {} + +-- Klinik +Medizin.Config.Klinik = 'Los Santos Medical Center' +Medizin.Config.Kuerzel = 'LSMC' + +-- Reichweite, in der ein Terminal bedient werden kann (Meter) +Medizin.Config.TerminalWeite = 2.5 + +-- Terminals: Krankenhaus-PCs an festen Orten +Medizin.Config.Terminals = { + { name = 'Notaufnahme', pos = vector3(309.05, -595.20, 43.28) }, + { name = 'Empfang', pos = vector3(298.60, -584.60, 43.26) }, + { name = 'Intensiv', pos = vector3(331.20, -595.30, 43.28) }, + { name = 'Sandy Shores', pos = vector3(1839.60, 3672.90, 34.28) }, + { name = 'Paleto Bay', pos = vector3(-247.60, 6331.30, 32.43) }, +} + +-- Stationen der Klinik. Die Kennung wird im Personaldatensatz gespeichert. +Medizin.Config.Stationen = { + { id = 'notaufnahme', name = 'Notaufnahme', art = 'notaufnahme' }, + { id = 'rettung', name = 'Rettungsdienst', art = 'notaufnahme' }, + { id = 'chirurgie', name = 'Chirurgie', art = 'normal' }, + { id = 'innere', name = 'Innere Medizin', art = 'normal' }, + { id = 'intensiv', name = 'Intensivstation', art = 'intensiv' }, + { id = 'op', name = 'OP-Bereich', art = 'op' }, + { id = 'labor', name = 'Labor', art = 'behandlung' }, + { id = 'leitstelle', name = 'Leitstelle', art = 'normal' }, +} + +-- Arten einer Station. Reine Einordnung — keine Art schränkt ein, +-- welcher Patient dort liegen darf. Das entscheidet die Station selbst. +Medizin.Config.Stationsarten = { + { id = 'notaufnahme', name = 'Notaufnahme' }, + { id = 'normal', name = 'Normalstation' }, + { id = 'intensiv', name = 'Intensivstation' }, + { id = 'op', name = 'OP-Bereich' }, + { id = 'behandlung', name = 'Behandlungsbereich' }, +} + +-- Arten eines Zimmers +Medizin.Config.Zimmerarten = { + { id = 'bett', name = 'Bettenzimmer' }, + { id = 'behandlung', name = 'Behandlungsraum' }, + { id = 'intensiv', name = 'Intensivplatz' }, + { id = 'isolierung', name = 'Isolierzimmer' }, + { id = 'op', name = 'Operationssaal' }, +} + +-- Zimmerstatus. Jeder wird von Hand gesetzt — kein Zimmer wechselt +-- ihn von selbst, auch nicht, wenn der letzte Patient gegangen ist. +Medizin.Config.Zimmerstatus = { + { id = 'frei', name = 'Frei', farbe = '#34d399' }, + { id = 'reinigung', name = 'Reinigung', farbe = '#f59e0b' }, + { id = 'gesperrt', name = 'Gesperrt', farbe = '#ef4444' }, +} + +-- Dienstfunktion: was macht jemand gerade konkret? +Medizin.Config.Dienstfunktionen = { + { id = 'station', name = 'Stationsdienst', recht = nil }, + { id = 'rtw', name = 'Rettungswagen', recht = nil }, + { id = 'notarzt', name = 'Notarzt', recht = 'dienst.notarzt' }, + { id = 'op', name = 'OP-Dienst', recht = 'dienst.op' }, + { id = 'leitstelle', name = 'Leitstelle', recht = 'dienst.leitstelle' }, +} + +-- Dienststatus, den ein Mediziner selbst wählt +Medizin.Config.Dienststatus = { + { id = 'verfuegbar', name = 'Verfügbar', farbe = '#34d399' }, + { id = 'im_einsatz', name = 'Im Einsatz', farbe = '#f59e0b' }, + { id = 'am_patient', name = 'Am Patienten', farbe = '#38bdf8' }, + { id = 'im_op', name = 'Im OP', farbe = '#a78bfa' }, + { id = 'pause', name = 'Pause', farbe = '#8a93a5' }, + { id = 'nicht_frei', name = 'Nicht verfügbar', farbe = '#ef4444' }, +} + +-- Einsatzkategorien der Leitstelle +Medizin.Config.Einsatzkategorien = { + { id = 'notruf', name = 'Notruf 112', farbe = '#ef4444' }, + { id = 'bewusstlos', name = 'Bewusstlose Person', farbe = '#e0503f' }, + { id = 'verkehr', name = 'Verkehrsunfall', farbe = '#f59e0b' }, + { id = 'schuss', name = 'Schussverletzung', farbe = '#ff2e57' }, + { id = 'explosion', name = 'Explosion', farbe = '#f97316' }, + { id = 'grossschaden', name = 'Großschadenslage', farbe = '#dc2626' }, + { id = 'verlegung', name = 'Verlegung', farbe = '#38bdf8' }, + { id = 'polizei', name = 'Polizeianforderung', farbe = '#4aa8ff' }, + { id = 'unterstuetzung', name = 'Unterstützung', farbe = '#a78bfa' }, + { id = 'sonstiges', name = 'Sonstiges', farbe = '#8a93a5' }, +} + +-- Aktenarten +Medizin.Config.Aktenarten = { + { id = 'behandlung', name = 'Behandlungsakte' }, + { id = 'notfall', name = 'Notfallakte' }, + { id = 'op', name = 'OP-Akte' }, + { id = 'befund', name = 'Befundakte' }, + { id = 'pflege', name = 'Pflegebericht' }, + { id = 'gutachten', name = 'Gutachten' }, +} + +-- Stationen eines Berichts auf seinem Weg. Jeder Schritt wird von +-- Hand gegangen — kein Bericht reift von selbst zur Freigabe. +Medizin.Config.Berichtstatus = { + { id = 'entwurf', name = 'Entwurf', farbe = '#8697a4' }, + { id = 'eingereicht', name = 'Eingereicht', farbe = '#f59e0b' }, + { id = 'freigegeben', name = 'Freigegeben', farbe = '#34d399' }, +} + +-- Laborauftragsarten +Medizin.Config.Laborarten = { + { id = 'blut', name = 'Blutprobe' }, + { id = 'dna', name = 'DNA-Probe' }, + { id = 'urin', name = 'Urinprobe' }, + { id = 'gewebe', name = 'Gewebeprobe' }, + { id = 'toxikologie', name = 'Toxikologie' }, +} + +-- Blutgruppen für die Blutbank +Medizin.Config.Blutgruppen = { + '0-', '0+', 'A-', 'A+', 'B-', 'B+', 'AB-', 'AB+', +} + +-- Status einer Blutkonserve. Jeder wird von Hand gesetzt — auch eine +-- abgelaufene Konserve bleibt vorrätig, bis jemand sie verwirft. +Medizin.Config.Blutstatus = { + { id = 'vorraetig', name = 'Vorrätig', farbe = '#34d399' }, + { id = 'reserviert', name = 'Reserviert', farbe = '#f59e0b' }, + { id = 'ausgegeben', name = 'Ausgegeben', farbe = '#38bdf8' }, + { id = 'verworfen', name = 'Verworfen', farbe = '#8a93a5' }, +} + +-- Lagerorte der Blutbank +Medizin.Config.Blutlagerorte = { + 'Blutbank Kühlschrank A', 'Blutbank Kühlschrank B', + 'Notaufnahme', 'OP-Bereich', 'Intensivstation', 'Rettungswagen', +} + +-- Nachschlagetabelle: welche Konserve passt zu welchem Empfänger. +-- +-- Das ist eine AUSKUNFT und nichts weiter. Das System sperrt keine +-- Konserve, wählt keine aus und verhindert keine Ausgabe — es weist +-- nur darauf hin, wenn eine Kombination von der Tabelle abweicht. +-- Ob transfundiert wird, entscheidet immer ein Arzt. +Medizin.Config.Blutvertraeglichkeit = { + ['0-'] = { '0-' }, + ['0+'] = { '0-', '0+' }, + ['A-'] = { '0-', 'A-' }, + ['A+'] = { '0-', '0+', 'A-', 'A+' }, + ['B-'] = { '0-', 'B-' }, + ['B+'] = { '0-', '0+', 'B-', 'B+' }, + ['AB-'] = { '0-', 'A-', 'B-', 'AB-' }, + ['AB+'] = { '0-', '0+', 'A-', 'A+', 'B-', 'B+', 'AB-', 'AB+' }, +} + +-- Kategorien im Medikamentenschrank +Medizin.Config.Medikamentkategorien = { + { id = 'analgetika', name = 'Schmerzmittel' }, + { id = 'antibiotika', name = 'Antibiotika' }, + { id = 'kreislauf', name = 'Kreislauf & Herz' }, + { id = 'narkose', name = 'Narkose & Sedierung' }, + { id = 'infusion', name = 'Infusionen' }, + { id = 'verband', name = 'Verbandmaterial' }, + { id = 'sonstiges', name = 'Sonstiges' }, +} + +-- Zustand eines Intensivpatienten. Wird ausschliesslich von Hand +-- gesetzt — weder eine Messung noch ein Zeitablauf ändert ihn je. +-- Die Reihenfolge bestimmt nur, was in der Liste oben steht. +Medizin.Config.Intensivzustaende = { + { id = 'kritisch', name = 'Kritisch', farbe = '#ef4444' }, + { id = 'stabil', name = 'Stabil', farbe = '#38bdf8' }, + { id = 'besserung', name = 'Besserung', farbe = '#34d399' }, +} + +-- Vorschläge für angeschlossene Geräte. Reine Textbausteine — das +-- System steuert kein Gerät, liest von keinem einen Wert und meldet +-- keinen Alarm. Es hält nur fest, was jemand angeschlossen hat. +Medizin.Config.Intensivgeraete = { + 'Beatmung', 'Monitoring', 'Perfusor', 'Dialyse', 'ZVK', + 'Arterie', 'Magensonde', 'Blasenkatheter', 'Schrittmacher', 'ECMO', +} + +-- Kategorien für Aushänge am Schwarzen Brett. Keine Kategorie löst +-- etwas aus — auch eine Dienstanweisung bleibt Text an einer Wand. +Medizin.Config.Aushangkategorien = { + { id = 'anweisung', name = 'Dienstanweisung', farbe = '#ef4444' }, + { id = 'warnung', name = 'Warnung', farbe = '#f59e0b' }, + { id = 'hygiene', name = 'Hygiene', farbe = '#38bdf8' }, + { id = 'information', name = 'Information', farbe = '#8697a4' }, +} + +-- Arten von Rettungsfahrzeugen +Medizin.Config.Fahrzeugarten = { + { id = 'rtw', name = 'Rettungswagen' }, + { id = 'nef', name = 'Notarzteinsatzfahrzeug' }, + { id = 'ktw', name = 'Krankentransportwagen' }, + { id = 'hubschrauber', name = 'Rettungshubschrauber' }, + { id = 'sonstiges', name = 'Sonstiges' }, +} + +-- Einsatzstatus eines Fahrzeugs. Wird von Hand gesetzt — keine +-- Telemetrie und kein Einsatz ändert ihn je von selbst. +Medizin.Config.Fahrzeugstatus = { + { id = 'verfuegbar', name = 'Verfügbar', farbe = '#34d399' }, + { id = 'im_einsatz', name = 'Im Einsatz', farbe = '#f59e0b' }, + { id = 'werkstatt', name = 'In der Werkstatt', farbe = '#38bdf8' }, + { id = 'ausser_dienst', name = 'Außer Dienst', farbe = '#8a93a5' }, +} + +-- Wartungsstatus. Auch dieser wird ausschliesslich eingetragen. +Medizin.Config.Wartungsstatus = { + { id = 'ok', name = 'In Ordnung' }, + { id = 'faellig', name = 'Wartung fällig' }, + { id = 'werkstatt', name = 'In der Werkstatt' }, +} + +-- Vorschläge für die Ausstattung. Reine Textbausteine — das System +-- prüft nicht nach, ob etwas an Bord ist. +Medizin.Config.Fahrzeugausstattung = { + 'Defibrillator', 'Beatmungsgerät', 'Notfallrucksack', 'Trage', + 'Vakuummatratze', 'Spineboard', 'Sauerstoff', 'Absaugpumpe', + 'Perfusor', 'Notfallmedikamente', +} + +-- Funktionen im OP-Team +Medizin.Config.OpFunktionen = { + { id = 'operateur', name = 'Operateur' }, + { id = 'assistenz', name = 'Assistenz' }, + { id = 'anaesthesie', name = 'Anästhesie' }, + { id = 'pflege', name = 'OP-Pflege' }, +} + +-- --------------------------------------------------------------------- +-- Erinnerungen (NUR Hinweise — keine davon führt etwas aus) +-- --------------------------------------------------------------------- +Medizin.Config.Erinnerungen = { + -- Wie oft die Fristen geprüft werden (Millisekunden) + pruefIntervall = 60000, + -- Nach wie vielen Minuten ein wartender Patient gemeldet wird + warteZeitStufen = { 10, 30, 60 }, + -- Wie viele Minuten vor einer geplanten OP erinnert wird + opVorlauf = { 30, 10 }, + -- Nach wie vielen Minuten eine offene Behandlungsdoku gemeldet wird + dokuOffenNach = 45, + -- Nach wie vielen Minuten ohne Verlaufseintrag ein Intensivpatient + -- in Erinnerung gerufen wird + intensivStilleNach = 30, + -- Wie viele Tage vor Ablauf einer Blutkonserve erinnert wird + blutVorlaufTage = 3, + -- Ab wie wenigen vorrätigen Konserven je Blutgruppe erinnert wird + blutMindestbestand = 2, + -- Ab welchem gemeldeten Tankstand (Prozent) an ein Fahrzeug erinnert wird + fahrzeugTankUnter = 20, + -- Ab welchem gemeldeten Zustand (Prozent) an ein Fahrzeug erinnert wird + fahrzeugZustandUnter = 60, + -- Ab wann eine Fahrzeugmeldung als veraltet gilt (Minuten) + fahrzeugMeldungAltNach = 15, + -- Wie viele Tage vor Ablauf eines Medikaments erinnert wird + medikamentVorlaufTage = 14, + -- Wie viele Minuten vor Ablauf einer Kenntnisnahmefrist erinnert wird + aushangVorwarnung = 60, +} diff --git a/resources/[aether]/aether-medical/shared/rollen.lua b/resources/[aether]/aether-medical/shared/rollen.lua new file mode 100644 index 0000000..45bc829 --- /dev/null +++ b/resources/[aether]/aether-medical/shared/rollen.lua @@ -0,0 +1,234 @@ +-- ===================================================================== +-- aether-medical — Rollen und Rechte +-- +-- Rechte sind einzelne, benannte Schalter. Eine Rolle ist nur ein +-- Vorschlagspaket — jedes Recht kann pro Mitarbeiter zusaetzlich +-- gewaehrt oder entzogen werden (`rechte_extra` / `rechte_entzug`). +-- +-- Ein Recht erlaubt immer nur, etwas zu DOKUMENTIEREN oder zu +-- ORGANISIEREN. Kein Recht laesst das System selbst behandeln. +-- ===================================================================== + +Medizin = Medizin or {} + +-- --------------------------------------------------------------------- +-- Katalog aller Rechte +-- Gruppiert nur fuer die Darstellung in der Administration. +-- --------------------------------------------------------------------- +Medizin.Rechte = { + { gruppe = 'Dienst', eintraege = { + { id = 'dienst.antreten', name = 'Dienst antreten' }, + { id = 'dienst.leitstelle', name = 'Leitstelle übernehmen' }, + { id = 'dienst.notarzt', name = 'Als Notarzt fahren' }, + { id = 'dienst.op', name = 'OP-Dienst übernehmen' }, + }}, + { gruppe = 'Patienten', eintraege = { + { id = 'patient.suchen', name = 'Patienten suchen' }, + { id = 'patient.stammdaten', name = 'Stammdaten pflegen' }, + { id = 'patient.hinweis', name = 'Warnhinweise setzen' }, + { id = 'patient.aufnehmen', name = 'Patienten aufnehmen' }, + { id = 'patient.verlegen', name = 'Patienten verlegen' }, + { id = 'patient.entlassen', name = 'Patienten entlassen' }, + }}, + { gruppe = 'Akten & Berichte', eintraege = { + { id = 'akte.lesen', name = 'Akten einsehen' }, + { id = 'akte.erstellen', name = 'Akten anlegen' }, + { id = 'akte.bearbeiten', name = 'Akten bearbeiten' }, + { id = 'akte.freigeben', name = 'Akten freigeben' }, + { id = 'akte.schliessen', name = 'Akten schließen' }, + { id = 'bericht.erstellen', name = 'Berichte schreiben' }, + { id = 'bericht.freigeben', name = 'Berichte freigeben' }, + }}, + { gruppe = 'Behandlung', eintraege = { + { id = 'behandlung.dokumentieren', name = 'Behandlung dokumentieren' }, + { id = 'behandlung.diagnose', name = 'Diagnose eintragen' }, + { id = 'behandlung.abschliessen', name = 'Behandlung abschließen' }, + { id = 'operation.planen', name = 'Operation planen' }, + { id = 'operation.dokumentieren', name = 'Operation dokumentieren' }, + }}, + { gruppe = 'Medikamente & Labor', eintraege = { + { id = 'medikament.sehen', name = 'Medikamentenbestand einsehen' }, + { id = 'medikament.ausgeben', name = 'Ausgabe dokumentieren' }, + { id = 'medikament.bestand', name = 'Bestand buchen und pflegen' }, + { id = 'medikament.btm', name = 'Betäubungsmittel dokumentieren' }, + { id = 'labor.beauftragen', name = 'Laboraufträge stellen' }, + { id = 'labor.bearbeiten', name = 'Laborergebnisse eintragen' }, + { id = 'labor.befunden', name = 'Befund bewerten' }, + { id = 'blut.sehen', name = 'Blutbank einsehen' }, + { id = 'blut.verwalten', name = 'Blutkonserven verwalten' }, + }}, + { gruppe = 'Einsätze', eintraege = { + { id = 'einsatz.erstellen', name = 'Einsatz anlegen' }, + { id = 'einsatz.zuweisen', name = 'Kräfte zuweisen' }, + { id = 'einsatz.schliessen', name = 'Einsatz abschließen' }, + }}, + { gruppe = 'Klinik', eintraege = { + { id = 'station.sehen', name = 'Stationsbelegung einsehen' }, + { id = 'station.belegen', name = 'Zimmer belegen und freigeben' }, + { id = 'station.verwalten', name = 'Stationen und Zimmer pflegen' }, + { id = 'intensiv.sehen', name = 'Intensivstation einsehen' }, + { id = 'intensiv.betreuen', name = 'Intensivverlauf dokumentieren' }, + { id = 'fahrzeug.sehen', name = 'Fuhrpark einsehen' }, + { id = 'fahrzeug.zuweisen', name = 'Fahrzeuge zuweisen' }, + { id = 'fahrzeug.wartung', name = 'Wartungsstatus setzen' }, + { id = 'fahrzeug.pflegen', name = 'Fuhrpark pflegen' }, + }}, + { gruppe = 'Kommunikation', eintraege = { + { id = 'nachricht.senden', name = 'Nachrichten schreiben' }, + { id = 'nachricht.rundmeldung', name = 'Rundmeldung an alle senden' }, + { id = 'aushang.schreiben', name = 'Aushänge veröffentlichen' }, + { id = 'aushang.archivieren', name = 'Aushänge archivieren' }, + }}, + { gruppe = 'Verwaltung', eintraege = { + { id = 'personal.sehen', name = 'Personalliste einsehen' }, + { id = 'personal.rolle', name = 'Rollen zuweisen' }, + { id = 'personal.rechte', name = 'Einzelrechte vergeben' }, + { id = 'personal.einstellen', name = 'Personal einstellen/entlassen' }, + { id = 'katalog.bearbeiten', name = 'Medikamente und Vorlagen pflegen' }, + { id = 'protokoll.lesen', name = 'Protokoll einsehen' }, + { id = 'statistik.sehen', name = 'Statistik einsehen' }, + { id = 'admin.module', name = 'Module verwalten' }, + }}, +} + +--- Liefert alle Rechte-IDs als flache Liste +--- @return table +function Medizin.AlleRechte() + local liste = {} + for _, gruppe in ipairs(Medizin.Rechte) do + for _, recht in ipairs(gruppe.eintraege) do + liste[#liste + 1] = recht.id + end + end + return liste +end + +-- --------------------------------------------------------------------- +-- Rollen — aufsteigend nach Verantwortung +-- `rechte` ist ein Vorschlagspaket, kein festes Gesetz. +-- `erbt` uebernimmt alle Rechte der genannten Rolle. +-- --------------------------------------------------------------------- +Medizin.Rollen = { + { + id = 'praktikant', name = 'Praktikant', stufe = 0, + rechte = { + 'dienst.antreten', 'patient.suchen', 'akte.lesen', + 'medikament.sehen', 'station.sehen', 'fahrzeug.sehen', + 'nachricht.senden', + }, + }, + { + id = 'rettungssanitaeter', name = 'Rettungssanitäter', stufe = 1, + erbt = 'praktikant', + rechte = { + 'einsatz.erstellen', 'behandlung.dokumentieren', + 'bericht.erstellen', 'akte.erstellen', + }, + }, + { + id = 'notfallsanitaeter', name = 'Notfallsanitäter', stufe = 2, + erbt = 'rettungssanitaeter', + rechte = { + 'patient.aufnehmen', 'patient.stammdaten', + 'medikament.ausgeben', 'labor.beauftragen', + 'einsatz.schliessen', 'akte.bearbeiten', + }, + }, + { + id = 'pflegekraft', name = 'Pflegekraft', stufe = 3, + erbt = 'notfallsanitaeter', + rechte = { + 'station.belegen', 'patient.verlegen', + 'intensiv.sehen', 'blut.sehen', 'medikament.bestand', + 'labor.bearbeiten', + }, + }, + { + id = 'assistenzarzt', name = 'Assistenzarzt', stufe = 4, + erbt = 'pflegekraft', + rechte = { + 'behandlung.diagnose', 'behandlung.abschliessen', + 'patient.hinweis', 'labor.befunden', + 'operation.dokumentieren', 'intensiv.betreuen', + }, + }, + { + id = 'facharzt', name = 'Facharzt', stufe = 5, + erbt = 'assistenzarzt', + rechte = { + 'patient.entlassen', 'operation.planen', + 'medikament.btm', 'blut.verwalten', + 'akte.freigeben', 'bericht.freigeben', + 'dienst.op', 'dienst.notarzt', + }, + }, + { + id = 'oberarzt', name = 'Oberarzt', stufe = 6, + erbt = 'facharzt', + rechte = { + 'akte.schliessen', 'einsatz.zuweisen', 'dienst.leitstelle', + 'fahrzeug.zuweisen', 'fahrzeug.wartung', + 'personal.sehen', 'statistik.sehen', + 'nachricht.rundmeldung', 'aushang.schreiben', + }, + }, + { + id = 'chefarzt', name = 'Chefarzt', stufe = 7, + erbt = 'oberarzt', + rechte = { + 'personal.rolle', 'station.verwalten', 'fahrzeug.pflegen', + 'protokoll.lesen', 'aushang.archivieren', + }, + }, + { + id = 'klinikleitung', name = 'Klinikleitung', stufe = 8, + erbt = 'chefarzt', + rechte = { + 'personal.rechte', 'personal.einstellen', + 'katalog.bearbeiten', 'admin.module', + }, + }, +} + +--- Sucht eine Rolle +--- @param rollenId string Kennung +--- @return table|nil +function Medizin.HoleRolle(rollenId) + for _, rolle in ipairs(Medizin.Rollen) do + if rolle.id == rollenId then return rolle end + end + return nil +end + +--- Sammelt alle Rechte einer Rolle, inklusive geerbter. +--- @param rollenId string Kennung +--- @return table [recht] = true +function Medizin.RechteDerRolle(rollenId) + local menge = {} + -- Schutz gegen versehentliche Ringe in der Vererbung + local besucht = {} + local aktuell = rollenId + + while aktuell and not besucht[aktuell] do + besucht[aktuell] = true + + local rolle = Medizin.HoleRolle(aktuell) + if not rolle then break end + + for _, recht in ipairs(rolle.rechte or {}) do + menge[recht] = true + end + + aktuell = rolle.erbt + end + + return menge +end + +--- Rangstufe einer Rolle +--- @param rollenId string Kennung +--- @return number +function Medizin.Stufe(rollenId) + local rolle = Medizin.HoleRolle(rollenId) + return rolle and rolle.stufe or -1 +end diff --git a/resources/[aether]/aether-phone/client/main.lua b/resources/[aether]/aether-phone/client/main.lua new file mode 100644 index 0000000..4c6559a --- /dev/null +++ b/resources/[aether]/aether-phone/client/main.lua @@ -0,0 +1,185 @@ +-- ===================================================================== +-- Aether Phone — Clientlogik +-- Öffnet das Handy und reicht alle Aktionen an den Server weiter. +-- ===================================================================== + +local istOffen = false +local istGeladen = false + +-- Taste zum Oeffnen (F1) +local TASTE_HANDY = 288 + +local function SendeAnNui(nachricht) + SendNuiMessage(json.encode(nachricht)) +end + +local function ZeigeSpielmeldung(text) + if not text or text == '' then return end + BeginTextCommandThefeedPost('STRING') + AddTextComponentSubstringPlayerName(text) + EndTextCommandThefeedPostTicker(false, true) +end + +--- Schliesst das Handy +local function Schliessen() + if not istOffen then return end + istOffen = false + SetNuiFocus(false, false) + SendeAnNui({ aktion = 'schliessen' }) + TriggerServerEvent('aether:phone:schliessen') + pcall(function() exports['aether-hud']:SetzeSichtbar(true) end) +end + +-- --------------------------------------------------------------------- +-- Ereignisse vom Server +-- --------------------------------------------------------------------- +AddEventHandler('aether:client:bereit', function() + istGeladen = true +end) + +RegisterNetEvent('aether:phone:zeige', function(zustand) + istOffen = true + SetNuiFocus(true, true) + pcall(function() exports['aether-hud']:SetzeSichtbar(false) end) + zustand.aktion = 'oeffnen' + SendeAnNui(zustand) +end) + +RegisterNetEvent('aether:phone:zustand', function(zustand) + zustand.aktion = 'zustand' + SendeAnNui(zustand) +end) + +RegisterNetEvent('aether:phone:fehler', function(text) + if istOffen then + SendeAnNui({ aktion = 'fehler', text = text }) + else + ZeigeSpielmeldung(text) + end +end) + +--- Ergebnis der Entsperrung +RegisterNetEvent('aether:phone:entsperrt', function(erfolg) + SendeAnNui({ aktion = 'entsperrt', erfolg = erfolg }) +end) + +--- Neue Nachricht (auch bei geschlossenem Handy als Hinweis) +RegisterNetEvent('aether:phone:neueNachricht', function(nachricht) + if istOffen then + SendeAnNui({ aktion = 'neueNachricht', nachricht = nachricht }) + else + ZeigeSpielmeldung(('~b~Neue Nachricht~s~ von %s'):format(nachricht.von)) + end +end) + +--- Eingehender Anruf — Handy oeffnet sich automatisch +RegisterNetEvent('aether:phone:eingehenderAnruf', function(vonNummer) + if not istOffen then + istOffen = true + SetNuiFocus(true, true) + pcall(function() exports['aether-hud']:SetzeSichtbar(false) end) + end + SendeAnNui({ aktion = 'eingehenderAnruf', nummer = vonNummer }) +end) + +--- Statusaenderung eines Anrufs +RegisterNetEvent('aether:phone:anrufStatus', function(status, zusatz) + SendeAnNui({ aktion = 'anrufStatus', status = status, zusatz = zusatz }) +end) + +--- Akkustand hat sich geaendert +RegisterNetEvent('aether:phone:akku', function(stand) + SendeAnNui({ aktion = 'akku', stand = stand }) + if stand > 0 and stand <= Handy.AKKU_WARNUNG and not istOffen then + ZeigeSpielmeldung(('~o~Handy-Akku~s~ nur noch %d %%'):format(stand)) + elseif stand <= 0 then + if istOffen then Schliessen() end + ZeigeSpielmeldung('~r~Handy-Akku leer~s~') + end +end) + +-- --------------------------------------------------------------------- +-- Oeffnen per Taste +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(0) + if istGeladen and not istOffen and IsControlJustReleased(0, TASTE_HANDY) then + if not IsEntityDead(PlayerPedId()) then + TriggerServerEvent('aether:phone:oeffnen') + end + end + end +end) + +-- --------------------------------------------------------------------- +-- NUI-Callbacks +-- --------------------------------------------------------------------- +RegisterNUICallback('schliessen', function(_, rueckgabe) + Schliessen() + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('pinEinrichten', function(daten, rueckgabe) + TriggerServerEvent('aether:phone:pinEinrichten', tostring(daten.pin or ''), daten.faceId and true or false) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('entsperren', function(daten, rueckgabe) + TriggerServerEvent('aether:phone:entsperren', tostring(daten.pin or '')) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('faceId', function(_, rueckgabe) + TriggerServerEvent('aether:phone:faceId') + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('kontaktSpeichern', function(daten, rueckgabe) + TriggerServerEvent('aether:phone:kontaktSpeichern', + tostring(daten.name or ''), tostring(daten.nummer or ''), tonumber(daten.id) or 0) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('kontaktLoeschen', function(daten, rueckgabe) + TriggerServerEvent('aether:phone:kontaktLoeschen', tonumber(daten.id) or 0) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('senden', function(daten, rueckgabe) + TriggerServerEvent('aether:phone:senden', tostring(daten.nummer or ''), tostring(daten.text or '')) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('anrufen', function(daten, rueckgabe) + TriggerServerEvent('aether:phone:anrufen', tostring(daten.nummer or '')) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('annehmen', function(_, rueckgabe) + TriggerServerEvent('aether:phone:annehmen') + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('auflegen', function(_, rueckgabe) + TriggerServerEvent('aether:phone:auflegen') + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('einstellungen', function(daten, rueckgabe) + TriggerServerEvent('aether:phone:einstellungen', daten) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('akkuTauschen', function(_, rueckgabe) + TriggerServerEvent('aether:phone:akkuTauschen') + rueckgabe({ ok = true }) +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen +-- --------------------------------------------------------------------- +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + SetNuiFocus(false, false) +end) diff --git a/resources/[aether]/aether-phone/fxmanifest.lua b/resources/[aether]/aether-phone/fxmanifest.lua new file mode 100644 index 0000000..b3c7b64 --- /dev/null +++ b/resources/[aether]/aether-phone/fxmanifest.lua @@ -0,0 +1,35 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +name 'aether-phone' +author 'Jerrit Fritzsche' +description 'AetherOS — Handy mit Telefon, Nachrichten, Kontakten und Bank' +version '0.1.0' + +ui_page 'html/index.html' + +files { + 'html/index.html', +} + +shared_scripts { + '@aether-core/shared/config.lua', + '@aether-core/shared/utils.lua', + 'shared/config.lua', +} + +client_scripts { + 'client/main.lua', +} + +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/main.lua', +} + +dependencies { + 'aether-core', + 'aether-inventory', + 'oxmysql', +} diff --git a/resources/[aether]/aether-phone/html/index.html b/resources/[aether]/aether-phone/html/index.html new file mode 100644 index 0000000..43a39dd --- /dev/null +++ b/resources/[aether]/aether-phone/html/index.html @@ -0,0 +1,2654 @@ + + + + + + + + +AetherOS — RP Handy + + + + + + + +
+
+
+ + +
+
+ 14:32 + + + + + + + 78% + + + +
+ + +
+
Dienstag, 21. Juli
+
14:32
+
+
Tippen zum Entsperren · ← Mitteilung wegwischen
+ +
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
Unbekannt
+
Anruf läuft…
+
?
+
+ + + +
+
+ + +
+ +
+
+ + +
+ +
+ + +
+
🪫
+
Akku leer
+
Setze einen geladenen Akku (Item) ein, um das Handy zu nutzen.
+
+ + +
+
+
+ + + + + + + + diff --git a/resources/[aether]/aether-phone/server/main.lua b/resources/[aether]/aether-phone/server/main.lua new file mode 100644 index 0000000..d8f1ffb --- /dev/null +++ b/resources/[aether]/aether-phone/server/main.lua @@ -0,0 +1,554 @@ +-- ===================================================================== +-- Aether Phone — Serverlogik +-- Telefonnummer, Kontakte, Nachrichten, Anrufe und Sicherheit. +-- Die PIN verlaesst den Server nicht. +-- ===================================================================== + +local Core = exports['aether-core'] +local Inventory = exports['aether-inventory'] + +-- Laufende Anrufe: [anruferQuelle] = { ziel = quelle, nummer = ..., seit = ... } +local aktiveAnrufe = {} + +-- Wer hat sein Handy gerade entsperrt? (fuer Aktionen, die das voraussetzen) +local entsperrt = {} + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +local function CharakterVon(quelle) + return Core:HoleCharakterId(quelle) +end + +--- Erzeugt eine freie Telefonnummer im Format 555-XXXX +--- @return string +local function ErzeugeNummer() + for _ = 1, 50 do + local nummer = ('555-%04d'):format(math.random(0, 9999)) + local belegt = MySQL.scalar.await('SELECT `character` FROM aether_phone WHERE number = ?', { nummer }) + if not belegt then return nummer end + end + -- Notfalls eine Nummer mit anderer Vorwahl + return ('%03d-%04d'):format(math.random(200, 999), math.random(0, 9999)) +end + +--- Laedt (oder erzeugt) den Handy-Datensatz eines Charakters +--- @param charakterId number Charakter-ID +--- @return table +local function HoleGeraet(charakterId) + local zeilen = MySQL.query.await('SELECT * FROM aether_phone WHERE `character` = ?', { charakterId }) + + if zeilen and zeilen[1] then return zeilen[1] end + + local nummer = ErzeugeNummer() + MySQL.insert.await('INSERT INTO aether_phone (`character`, number) VALUES (?, ?)', + { charakterId, nummer }) + Aether.Info(('Telefonnummer %s fuer Charakter #%d vergeben.'):format(nummer, charakterId)) + + local neue = MySQL.query.await('SELECT * FROM aether_phone WHERE `character` = ?', { charakterId }) + return neue[1] +end + +--- Sucht den Charakter zu einer Telefonnummer +--- @param nummer string Telefonnummer +--- @return number|nil Charakter-ID +local function CharakterZuNummer(nummer) + return MySQL.scalar.await('SELECT `character` FROM aether_phone WHERE number = ?', { nummer }) +end + +--- Sucht die Server-ID zu einer Telefonnummer (nur wenn online) +--- @param nummer string Telefonnummer +--- @return number|nil +local function QuelleZuNummer(nummer) + local charakterId = CharakterZuNummer(nummer) + if not charakterId then return nil end + return Core:HoleQuelleNachCharakter(charakterId) +end + +--- Prueft die Hardware: Handy, SIM und Akku muessen vorhanden sein +--- @param quelle number Server-ID +--- @return boolean hatHandy, boolean hatSim +local function PruefeHardware(quelle) + local handy = (Inventory:ZaehleItem(quelle, Handy.ITEM_HANDY) or 0) > 0 + local sim = (Inventory:ZaehleItem(quelle, Handy.ITEM_SIM) or 0) > 0 + return handy, sim +end + +--- Laedt die Kontakte eines Charakters +local function HoleKontakte(charakterId) + local zeilen = MySQL.query.await( + 'SELECT id, name, number FROM aether_phone_contacts WHERE `character` = ? ORDER BY name ASC', + { charakterId }) or {} + + local liste = {} + -- Notrufe stehen immer oben + for nummer, eintrag in pairs(Handy.NOTRUFE) do + liste[#liste + 1] = { id = 0, name = eintrag.name, nummer = nummer, notruf = true } + end + for _, zeile in ipairs(zeilen) do + liste[#liste + 1] = { id = zeile.id, name = zeile.name, nummer = zeile.number } + end + return liste +end + +--- Laedt die Unterhaltungen (letzte Nachricht je Gegenstelle) +local function HoleChats(nummer) + local zeilen = MySQL.query.await([[ + SELECT sender, receiver, text, gelesen, created_at + FROM aether_phone_messages + WHERE sender = ? OR receiver = ? + ORDER BY id DESC + LIMIT 300 + ]], { nummer, nummer }) or {} + + local chats = {} + for _, zeile in ipairs(zeilen) do + local gegenstelle = (zeile.sender == nummer) and zeile.receiver or zeile.sender + chats[gegenstelle] = chats[gegenstelle] or {} + table.insert(chats[gegenstelle], 1, { + von = (zeile.sender == nummer) and 'me' or 'them', + text = zeile.text, + zeit = tostring(zeile.created_at):sub(12, 16), + }) + end + return chats +end + +--- Laedt den Anrufverlauf +local function HoleAnrufe(nummer) + local zeilen = MySQL.query.await([[ + SELECT caller, callee, verpasst, created_at + FROM aether_phone_calls + WHERE caller = ? OR callee = ? + ORDER BY id DESC LIMIT 30 + ]], { nummer, nummer }) or {} + + local liste = {} + for _, zeile in ipairs(zeilen) do + local eingehend = zeile.callee == nummer + liste[#liste + 1] = { + nummer = eingehend and zeile.caller or zeile.callee, + art = eingehend and 'eingehend' or 'ausgehend', + verpasst = zeile.verpasst == 1, + zeit = tostring(zeile.created_at):sub(1, 16), + } + end + return liste +end + +--- Baut den kompletten Zustand fuer die Oberflaeche (ohne PIN!) +--- @param quelle number Server-ID +--- @return table|nil +local function BaueZustand(quelle) + local charakterId = CharakterVon(quelle) + if not charakterId then return nil end + + local person = Core:HoleSpielerInfo(quelle) + local geraet = HoleGeraet(charakterId) + local hatHandy, hatSim = PruefeHardware(quelle) + + local dock = { 'phone', 'messages', 'bank', 'camera' } + local erfolg, entschluesselt = pcall(json.decode, geraet.dock or '') + if erfolg and type(entschluesselt) == 'table' then dock = entschluesselt end + + local freigeschaltet = {} + local ok2, entsch2 = pcall(json.decode, geraet.unlocked or '{}') + if ok2 and type(entsch2) == 'table' then freigeschaltet = entsch2 end + + return { + name = ((person and person.vorname or '') .. ' ' .. (person and person.nachname or '')), + nummer = geraet.number, + kontostand = person and person.konto or 0, + kontonummer = person and person.iban or '—', + theme = geraet.theme, + wallpaper = geraet.wallpaper, + dockApps = dock, + freigeschaltet = freigeschaltet, + handy = hatHandy, + sim = hatSim, + akku = geraet.battery, + -- Die PIN selbst wird NIE geschickt, nur ob eine eingerichtet ist + pinGesetzt = geraet.pin ~= nil, + faceId = geraet.faceid == 1, + kontakte = HoleKontakte(charakterId), + chats = HoleChats(geraet.number), + anrufe = HoleAnrufe(geraet.number), + } +end + +--- Schickt den Zustand an einen Spieler +local function SendeZustand(quelle, hinweis) + local zustand = BaueZustand(quelle) + if not zustand then return end + zustand.hinweis = hinweis + TriggerClientEvent('aether:phone:zustand', quelle, zustand) +end + +local function Ablehnen(quelle, grund) + TriggerClientEvent('aether:phone:fehler', quelle, grund) +end + +-- --------------------------------------------------------------------- +-- Handy oeffnen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:phone:oeffnen', function() + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + + local hatHandy = PruefeHardware(quelle) + if not hatHandy then + Ablehnen(quelle, 'Du hast kein Handy dabei.') + return + end + + local geraet = HoleGeraet(charakterId) + if geraet.battery <= 0 then + Ablehnen(quelle, 'Der Akku ist leer.') + return + end + + local zustand = BaueZustand(quelle) + if zustand then + TriggerClientEvent('aether:phone:zeige', quelle, zustand) + end +end) + +-- --------------------------------------------------------------------- +-- Sicherheit: Ersteinrichtung und Entsperren +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:phone:pinEinrichten', function(pin, faceId) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + + if type(pin) ~= 'string' or not pin:match('^%d%d%d%d$') then + Ablehnen(quelle, 'Der Code muss aus vier Ziffern bestehen.') + return + end + + MySQL.update.await('UPDATE aether_phone SET pin = ?, faceid = ? WHERE `character` = ?', + { pin, faceId and 1 or 0, charakterId }) + + entsperrt[quelle] = true + TriggerClientEvent('aether:phone:entsperrt', quelle, true) +end) + +RegisterNetEvent('aether:phone:entsperren', function(pin) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + + local gespeichert = MySQL.scalar.await( + 'SELECT pin FROM aether_phone WHERE `character` = ?', { charakterId }) + + if gespeichert and gespeichert == pin then + entsperrt[quelle] = true + TriggerClientEvent('aether:phone:entsperrt', quelle, true) + else + entsperrt[quelle] = false + TriggerClientEvent('aether:phone:entsperrt', quelle, false) + end +end) + +--- Entsperren per Face-ID (nur wenn dafuer eingerichtet) +RegisterNetEvent('aether:phone:faceId', function() + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + + local aktiv = MySQL.scalar.await( + 'SELECT faceid FROM aether_phone WHERE `character` = ?', { charakterId }) + + if aktiv == 1 then + entsperrt[quelle] = true + TriggerClientEvent('aether:phone:entsperrt', quelle, true) + else + TriggerClientEvent('aether:phone:entsperrt', quelle, false) + end +end) + +-- --------------------------------------------------------------------- +-- Kontakte +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:phone:kontaktSpeichern', function(name, nummer, kontaktId) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId or not entsperrt[quelle] then return end + + if type(name) ~= 'string' or #name < 1 or #name > 48 then + Ablehnen(quelle, 'Bitte einen Namen angeben.') return + end + if not Handy.NummerGueltig(nummer) then + Ablehnen(quelle, 'Ungültige Telefonnummer.') return + end + + if type(kontaktId) == 'number' and kontaktId > 0 then + MySQL.update.await( + 'UPDATE aether_phone_contacts SET name = ?, number = ? WHERE id = ? AND `character` = ?', + { name, nummer, kontaktId, charakterId }) + else + MySQL.insert.await( + 'INSERT INTO aether_phone_contacts (`character`, name, number) VALUES (?, ?, ?)', + { charakterId, name, nummer }) + end + + SendeZustand(quelle, 'Kontakt gespeichert.') +end) + +RegisterNetEvent('aether:phone:kontaktLoeschen', function(kontaktId) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId or not entsperrt[quelle] then return end + if type(kontaktId) ~= 'number' then return end + + MySQL.update.await('DELETE FROM aether_phone_contacts WHERE id = ? AND `character` = ?', + { kontaktId, charakterId }) + SendeZustand(quelle, 'Kontakt gelöscht.') +end) + +-- --------------------------------------------------------------------- +-- Nachrichten +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:phone:senden', function(anNummer, text) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId or not entsperrt[quelle] then return end + + local hatHandy, hatSim = PruefeHardware(quelle) + if not hatHandy or not hatSim then + Ablehnen(quelle, 'Ohne SIM-Karte kannst du keine Nachrichten senden.') + return + end + + if not Handy.NummerGueltig(anNummer) then + Ablehnen(quelle, 'Ungültige Telefonnummer.') return + end + if type(text) ~= 'string' then return end + + text = text:gsub('^%s+', ''):gsub('%s+$', '') + if #text < 1 or #text > 512 then + Ablehnen(quelle, 'Die Nachricht ist leer oder zu lang.') return + end + + local geraet = HoleGeraet(charakterId) + + MySQL.insert.await( + 'INSERT INTO aether_phone_messages (sender, receiver, text) VALUES (?, ?, ?)', + { geraet.number, anNummer, text }) + + -- Empfaenger benachrichtigen, falls online + local zielQuelle = QuelleZuNummer(anNummer) + if zielQuelle then + TriggerClientEvent('aether:phone:neueNachricht', zielQuelle, { + von = geraet.number, + text = text, + zeit = os.date('%H:%M'), + }) + SendeZustand(zielQuelle) + end + + SendeZustand(quelle) +end) + +-- --------------------------------------------------------------------- +-- Anrufe +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:phone:anrufen', function(nummer) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId or not entsperrt[quelle] then return end + + local hatHandy, hatSim = PruefeHardware(quelle) + if not hatHandy or not hatSim then + Ablehnen(quelle, 'Ohne SIM-Karte kannst du nicht telefonieren.') + return + end + if not Handy.NummerGueltig(nummer) then + Ablehnen(quelle, 'Ungültige Telefonnummer.') return + end + + local geraet = HoleGeraet(charakterId) + if nummer == geraet.number then + Ablehnen(quelle, 'Du kannst dich nicht selbst anrufen.') return + end + + -- Notrufe gehen an die Leitstelle der jeweiligen Fraktion + if Handy.NOTRUFE[nummer] then + TriggerEvent('aether:phone:notruf', quelle, Handy.NOTRUFE[nummer].fraktion, geraet.number) + MySQL.insert('INSERT INTO aether_phone_calls (caller, callee) VALUES (?, ?)', + { geraet.number, nummer }) + TriggerClientEvent('aether:phone:anrufStatus', quelle, 'notruf', + Handy.NOTRUFE[nummer].name) + return + end + + local zielQuelle = QuelleZuNummer(nummer) + if not zielQuelle then + MySQL.insert('INSERT INTO aether_phone_calls (caller, callee, verpasst) VALUES (?, ?, 1)', + { geraet.number, nummer }) + TriggerClientEvent('aether:phone:anrufStatus', quelle, 'nichtErreichbar') + return + end + + -- Ist die Gegenstelle schon im Gespraech? + for _, anruf in pairs(aktiveAnrufe) do + if anruf.ziel == zielQuelle or anruf.anrufer == zielQuelle then + TriggerClientEvent('aether:phone:anrufStatus', quelle, 'besetzt') + return + end + end + + aktiveAnrufe[quelle] = { + anrufer = quelle, ziel = zielQuelle, + nummer = geraet.number, zielNummer = nummer, angenommen = false, + } + + TriggerClientEvent('aether:phone:eingehenderAnruf', zielQuelle, geraet.number) + TriggerClientEvent('aether:phone:anrufStatus', quelle, 'klingelt', nummer) +end) + +RegisterNetEvent('aether:phone:annehmen', function() + local quelle = source + + for anruferQuelle, anruf in pairs(aktiveAnrufe) do + if anruf.ziel == quelle then + anruf.angenommen = true + MySQL.insert('INSERT INTO aether_phone_calls (caller, callee) VALUES (?, ?)', + { anruf.nummer, anruf.zielNummer }) + TriggerClientEvent('aether:phone:anrufStatus', anruferQuelle, 'verbunden') + TriggerClientEvent('aether:phone:anrufStatus', quelle, 'verbunden') + return + end + end +end) + +RegisterNetEvent('aether:phone:auflegen', function() + local quelle = source + + for anruferQuelle, anruf in pairs(aktiveAnrufe) do + if anruferQuelle == quelle or anruf.ziel == quelle then + -- Nicht angenommene Anrufe als verpasst protokollieren + if not anruf.angenommen then + MySQL.insert('INSERT INTO aether_phone_calls (caller, callee, verpasst) VALUES (?, ?, 1)', + { anruf.nummer, anruf.zielNummer }) + end + TriggerClientEvent('aether:phone:anrufStatus', anruferQuelle, 'beendet') + TriggerClientEvent('aether:phone:anrufStatus', anruf.ziel, 'beendet') + aktiveAnrufe[anruferQuelle] = nil + return + end + end +end) + +-- --------------------------------------------------------------------- +-- Einstellungen speichern +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:phone:einstellungen', function(einstellungen) + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId or not entsperrt[quelle] then return end + if type(einstellungen) ~= 'table' then return end + + local theme = (einstellungen.theme == 'light') and 'light' or 'dark' + local wallpaper = tostring(einstellungen.wallpaper or 'wp-aurora'):sub(1, 32) + local dock = type(einstellungen.dockApps) == 'table' and einstellungen.dockApps or nil + + MySQL.update.await([[ + UPDATE aether_phone SET theme = ?, wallpaper = ?, dock = COALESCE(?, dock) + WHERE `character` = ? + ]], { theme, wallpaper, dock and json.encode(dock) or nil, charakterId }) +end) + +-- --------------------------------------------------------------------- +-- Akkuverbrauch +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(Handy.AKKU_INTERVALL) + + for _, quelle in ipairs(GetPlayers()) do + quelle = tonumber(quelle) + local charakterId = CharakterVon(quelle) + + if charakterId then + local hatHandy = PruefeHardware(quelle) + if hatHandy then + local geraet = HoleGeraet(charakterId) + if geraet.battery > 0 then + local neu = math.max(0, geraet.battery - 1) + MySQL.update('UPDATE aether_phone SET battery = ? WHERE `character` = ?', + { neu, charakterId }) + TriggerClientEvent('aether:phone:akku', quelle, neu) + end + end + end + end + end +end) + +--- Akku austauschen: verbraucht ein Akku-Item und laedt auf 100 % +RegisterNetEvent('aether:phone:akkuTauschen', function() + local quelle = source + local charakterId = CharakterVon(quelle) + if not charakterId then return end + + if not Inventory:EntferneItem(quelle, Handy.ITEM_AKKU, 1) then + Ablehnen(quelle, 'Du hast keinen Ersatz-Akku dabei.') + return + end + + MySQL.update.await('UPDATE aether_phone SET battery = 100 WHERE `character` = ?', { charakterId }) + TriggerClientEvent('aether:phone:akku', quelle, 100) + SendeZustand(quelle, 'Akku getauscht.') +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:phone:schliessen', function() + entsperrt[source] = false +end) + +AddEventHandler('playerDropped', function() + local quelle = source + entsperrt[quelle] = nil + + -- Laufende Anrufe beenden + for anruferQuelle, anruf in pairs(aktiveAnrufe) do + if anruferQuelle == quelle or anruf.ziel == quelle then + local anderer = (anruferQuelle == quelle) and anruf.ziel or anruferQuelle + TriggerClientEvent('aether:phone:anrufStatus', anderer, 'beendet') + aktiveAnrufe[anruferQuelle] = nil + end + end +end) + +-- ===================================================================== +-- EXPORTS +-- ===================================================================== + +--- Liefert die Telefonnummer eines Spielers +exports('HoleNummer', function(quelle) + local charakterId = CharakterVon(quelle) + if not charakterId then return nil end + return HoleGeraet(charakterId).number +end) + +--- Schickt eine Nachricht von einer Dienstnummer an einen Spieler +--- (z. B. Rechnung vom EMS, Bestaetigung eines Jobs) +exports('SendeNachricht', function(anNummer, vonNummer, text) + if not Handy.NummerGueltig(anNummer) then return false end + + MySQL.insert('INSERT INTO aether_phone_messages (sender, receiver, text) VALUES (?, ?, ?)', + { vonNummer or 'System', anNummer, tostring(text):sub(1, 512) }) + + local zielQuelle = QuelleZuNummer(anNummer) + if zielQuelle then + TriggerClientEvent('aether:phone:neueNachricht', zielQuelle, { + von = vonNummer or 'System', text = text, zeit = os.date('%H:%M'), + }) + end + return true +end) diff --git a/resources/[aether]/aether-phone/shared/config.lua b/resources/[aether]/aether-phone/shared/config.lua new file mode 100644 index 0000000..269b76d --- /dev/null +++ b/resources/[aether]/aether-phone/shared/config.lua @@ -0,0 +1,33 @@ +-- ===================================================================== +-- Aether Phone — Einstellungen +-- ===================================================================== + +Handy = Handy or {} + +-- Items, die zusammen ein funktionsfaehiges Handy ergeben +Handy.ITEM_HANDY = 'handy' +Handy.ITEM_SIM = 'sim' +Handy.ITEM_AKKU = 'akku' + +-- Akkuverbrauch: alle X Millisekunden ein Prozent +Handy.AKKU_INTERVALL = 180000 -- 3 Minuten +-- Ab diesem Stand warnt das Handy +Handy.AKKU_WARNUNG = 15 + +-- Notrufnummern, die immer erreichbar sind (auch ohne Kontakt) +Handy.NOTRUFE = { + ['110'] = { name = 'Polizei (Notruf)', fraktion = 'lspd' }, + ['112'] = { name = 'Rettungsdienst', fraktion = 'ems' }, +} + +-- Wie viele Nachrichten je Unterhaltung geladen werden +Handy.MAX_NACHRICHTEN = 50 + +--- Prueft, ob eine Telefonnummer das erwartete Format hat +--- @param nummer any Zu pruefender Wert +--- @return boolean +function Handy.NummerGueltig(nummer) + if type(nummer) ~= 'string' then return false end + if Handy.NOTRUFE[nummer] then return true end + return nummer:match('^%d%d%d%-%d%d%d%d$') ~= nil +end diff --git a/resources/[aether]/aether-police/client/main.lua b/resources/[aether]/aether-police/client/main.lua new file mode 100644 index 0000000..ab89c2d --- /dev/null +++ b/resources/[aether]/aether-police/client/main.lua @@ -0,0 +1,376 @@ +-- ===================================================================== +-- aether-police — Clientlogik +-- Öffnet das Tablet und vermittelt zwischen Oberfläche und Server. +-- ===================================================================== + +local istOffen = false +local istGeladen = false +local imDienst = false + +local function SendeAnNui(nachricht) + SendNuiMessage(json.encode(nachricht)) +end + +local function ZeigeSpielmeldung(text) + if not text or text == '' then return end + BeginTextCommandThefeedPost('STRING') + AddTextComponentSubstringPlayerName(text) + EndTextCommandThefeedPostTicker(false, true) +end + +local function Schliessen() + if not istOffen then return end + istOffen = false + SetNuiFocus(false, false) + SendeAnNui({ aktion = 'schliessen' }) + pcall(function() exports['aether-hud']:SetzeSichtbar(true) end) +end + +-- --------------------------------------------------------------------- +-- Ereignisse vom Server +-- --------------------------------------------------------------------- +AddEventHandler('aether:client:bereit', function() + istGeladen = true +end) + +--- Tablet mit vollem Zustand anzeigen +RegisterNetEvent('aether:police:zeige', function(daten) + istOffen = true + imDienst = true + SetNuiFocus(true, true) + pcall(function() exports['aether-hud']:SetzeSichtbar(false) end) + daten.aktion = 'oeffnen' + SendeAnNui(daten) +end) + +--- Anmeldebildschirm (noch nicht im Dienst) +RegisterNetEvent('aether:police:anmeldung', function(daten) + istOffen = true + SetNuiFocus(true, true) + pcall(function() exports['aether-hud']:SetzeSichtbar(false) end) + daten.aktion = 'anmeldung' + SendeAnNui(daten) +end) + +RegisterNetEvent('aether:police:schliessen', function() + imDienst = false + Schliessen() + ZeigeSpielmeldung('Dienst beendet.') +end) + +--- Antwort auf einen Modul-Aufruf +RegisterNetEvent('aether:police:antwort', function(ruf, ergebnis, fehler) + SendeAnNui({ aktion = 'antwort', ruf = ruf, ergebnis = ergebnis, fehler = fehler }) +end) + +--- Dienstliste (Beamte im Dienst, für Dashboard und Karte) +RegisterNetEvent('aether:police:dienstliste', function(liste) + if istOffen then + SendeAnNui({ aktion = 'dienstliste', beamte = liste }) + end +end) + +--- Hinweis / Erinnerung +RegisterNetEvent('aether:police:hinweis', function(hinweis) + if istOffen then + SendeAnNui({ aktion = 'hinweis', hinweis = hinweis }) + else + -- Auch ausserhalb des Tablets erreichbar, damit Fristen nicht untergehen + local praefix = hinweis.prioritaet == 'kritisch' and '~r~' or + hinweis.prioritaet == 'hoch' and '~o~' or '~b~' + ZeigeSpielmeldung(praefix .. hinweis.titel .. '~s~' .. + (hinweis.text and ('\n' .. hinweis.text) or '')) + end +end) + +RegisterNetEvent('aether:police:fehler', function(text) + if istOffen then + SendeAnNui({ aktion = 'fehler', text = text }) + else + ZeigeSpielmeldung(text) + end +end) + +-- --------------------------------------------------------------------- +-- Fahrzeug-Telemetrie +-- +-- Meldet Tatsachen über das gefahrene Dienstfahrzeug: Position, +-- Tankstand, Zustand. Der Server verwirft die Meldung, wenn das +-- Kennzeichen nicht zum Fuhrpark gehört. +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + Wait(30000) -- alle 30 Sekunden genügt + + if imDienst then + local ped = PlayerPedId() + if IsPedInAnyVehicle(ped, false) then + local fahrzeug = GetVehiclePedIsIn(ped, false) + -- Nur melden, wenn man selbst am Steuer sitzt + if GetPedInVehicleSeat(fahrzeug, -1) == ped then + local position = GetEntityCoords(fahrzeug) + TriggerServerEvent('aether:police:fahrzeugMelden', { + kennzeichen = GetVehicleNumberPlateText(fahrzeug), + tank = GetVehicleFuelLevel(fahrzeug), + zustand = GetVehicleBodyHealth(fahrzeug), + x = position.x, y = position.y, z = position.z, + }) + end + end + end + end +end) + +-- --------------------------------------------------------------------- +-- Gewahrsam +-- Der Client führt nur aus, was ein Beamter entschieden hat. +-- --------------------------------------------------------------------- + +--- In eine Zelle verbracht werden +RegisterNetEvent('aether:police:gewahrsamVerbringen', function(daten) + Schliessen() + DoScreenFadeOut(600) + Wait(800) + + local ped = PlayerPedId() + SetEntityCoordsNoOffset(ped, daten.x + 0.0, daten.y + 0.0, daten.z + 0.0, false, false, false) + SetEntityHeading(ped, daten.w or 0.0) + + -- Warten, bis die Umgebung geladen ist + local versuche = 0 + while not HasCollisionLoadedAroundEntity(ped) and versuche < 60 do + Wait(50) + versuche = versuche + 1 + end + + DoScreenFadeIn(800) + + if daten.rest then + ZeigeSpielmeldung(('~y~Gewahrsam~s~ · %s\n%s\nRestzeit laut Akte: %d Minuten') + :format(daten.zelle or '', daten.grund or '', math.max(0, daten.rest))) + else + ZeigeSpielmeldung(('~y~Du wurdest in Gewahrsam genommen~s~ · %s'):format(daten.zelle or '')) + end +end) + +--- Entlassung durch einen Beamten +RegisterNetEvent('aether:police:gewahrsamEntlassen', function(daten) + DoScreenFadeOut(600) + Wait(800) + + local ped = PlayerPedId() + SetEntityCoordsNoOffset(ped, daten.x + 0.0, daten.y + 0.0, daten.z + 0.0, false, false, false) + SetEntityHeading(ped, daten.w or 0.0) + + local versuche = 0 + while not HasCollisionLoadedAroundEntity(ped) and versuche < 60 do + Wait(50) + versuche = versuche + 1 + end + + DoScreenFadeIn(800) + ZeigeSpielmeldung('~g~Du wurdest aus dem Gewahrsam entlassen.~s~') +end) + +-- --------------------------------------------------------------------- +-- Terminals an den Wachen +-- --------------------------------------------------------------------- +CreateThread(function() + for _, terminal in ipairs(Polizei.Config.Terminals) do + local blip = AddBlipForCoord(terminal.pos.x, terminal.pos.y, terminal.pos.z) + SetBlipSprite(blip, 60) + SetBlipColour(blip, 3) + SetBlipScale(blip, 0.8) + SetBlipAsShortRange(blip, true) + BeginTextCommandSetBlipName('STRING') + AddTextComponentString('Polizeiwache ' .. terminal.name) + EndTextCommandSetBlipName(blip) + end + + while true do + local pause = 800 + + if istGeladen and not istOffen then + local position = GetEntityCoords(PlayerPedId()) + + for _, terminal in ipairs(Polizei.Config.Terminals) do + if #(position - terminal.pos) <= Polizei.Config.TerminalWeite then + pause = 0 + SetTextFont(4) + SetTextScale(0.34, 0.34) + SetTextColour(255, 255, 255, 215) + SetTextCentre(true) + SetTextOutline() + SetTextEntry('STRING') + AddTextComponentString('[E] Terminal ' .. terminal.name) + SetDrawOrigin(terminal.pos.x, terminal.pos.y, terminal.pos.z + 0.9, 0) + DrawText(0.0, 0.0) + ClearDrawOrigin() + + if IsControlJustReleased(0, 38) then + TriggerServerEvent('aether:police:oeffnen') + end + break + end + end + end + + Wait(pause) + end +end) + +--- Tablet jederzeit öffnen, sobald man im Dienst ist +RegisterCommand('mdt', function() + if istGeladen and not istOffen then + TriggerServerEvent('aether:police:oeffnen') + end +end, false) +RegisterKeyMapping('mdt', 'Polizei-Tablet öffnen', 'keyboard', 'F5') + +-- --------------------------------------------------------------------- +-- Panikknopf (10-13) +-- --------------------------------------------------------------------- +local panikGesperrtBis = 0 + +RegisterCommand('panik', function() + if not imDienst then return end + + -- Kurze Sperre, damit der Alarm nicht versehentlich mehrfach ausgeht + if GetGameTimer() < panikGesperrtBis then return end + panikGesperrtBis = GetGameTimer() + 10000 + + TriggerServerEvent('aether:police:panik') + ZeigeSpielmeldung('~r~10-13 ausgelöst~s~ — Verstärkung wird alarmiert.') +end, false) +RegisterKeyMapping('panik', 'Panikknopf (10-13)', 'keyboard', 'F7') + +-- --------------------------------------------------------------------- +-- Einsatzmarkierungen auf der Karte +-- --------------------------------------------------------------------- +local einsatzBlips = {} + +--- Entfernt alle Einsatzmarkierungen +local function LeereEinsatzBlips() + for id, blip in pairs(einsatzBlips) do + RemoveBlip(blip) + einsatzBlips[id] = nil + end +end + +--- Setzt Markierungen für die übergebenen Einsätze +local function SetzeEinsatzBlips(einsaetze) + LeereEinsatzBlips() + + for _, einsatz in ipairs(einsaetze or {}) do + if einsatz.pos_x then + local blip = AddBlipForCoord(einsatz.pos_x + 0.0, einsatz.pos_y + 0.0, einsatz.pos_z + 0.0) + SetBlipSprite(blip, 161) + -- Offene Einsätze fallen durch Blinken auf + SetBlipColour(blip, einsatz.status == 'offen' and 1 or 5) + SetBlipScale(blip, einsatz.prioritaet == 1 and 1.0 or 0.8) + SetBlipFlashes(blip, einsatz.status == 'offen' and einsatz.prioritaet <= 2) + BeginTextCommandSetBlipName('STRING') + AddTextComponentString(einsatz.nummer .. ' · ' .. einsatz.titel) + EndTextCommandSetBlipName(blip) + einsatzBlips[einsatz.id] = blip + end + end +end + +--- Es hat sich etwas an den Einsätzen geändert +RegisterNetEvent('aether:police:einsatzAenderung', function() + if istOffen then + SendeAnNui({ aktion = 'einsatzAenderung' }) + end +end) + +--- Panikalarm einer anderen Einheit +RegisterNetEvent('aether:police:panikAlarm', function(daten) + PlaySoundFrontend(-1, 'Lose_1st', 'GTAO_FM_Events_Soundset', true) + ZeigeSpielmeldung(('~r~10-13~s~ — %s (%s) benötigt Verstärkung!') + :format(daten.name, daten.dienstnummer)) + + if daten.position then + SetNewWaypoint(daten.position.x, daten.position.y) + end + + if istOffen then + SendeAnNui({ aktion = 'panikAlarm', daten = daten }) + end +end) + +--- Die Leitstelle bittet um Übernahme eines Einsatzes +RegisterNetEvent('aether:police:einsatzZuweisung', function(daten) + PlaySoundFrontend(-1, 'Event_Start_Text', 'GTAO_FM_Events_Soundset', true) + + if daten.position then + SetNewWaypoint(daten.position.x, daten.position.y) + end + + if istOffen then + SendeAnNui({ aktion = 'einsatzZuweisung', daten = daten }) + else + ZeigeSpielmeldung(('~b~Zuweisung von %s~s~\n%s — %s') + :format(daten.von, daten.nummer, daten.titel)) + end +end) + +-- --------------------------------------------------------------------- +-- NUI-Callbacks +-- --------------------------------------------------------------------- +RegisterNUICallback('schliessen', function(_, rueckgabe) + Schliessen() + rueckgabe({ ok = true }) +end) + +--- Zentraler Modul-Aufruf: die Oberfläche fragt, der Server antwortet +RegisterNUICallback('aufruf', function(daten, rueckgabe) + TriggerServerEvent('aether:police:aufruf', + tostring(daten.modul or ''), tostring(daten.aktion or ''), + daten.daten or {}, daten.ruf) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('dienstAntreten', function(_, rueckgabe) + TriggerServerEvent('aether:police:dienstAntreten') + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('dienstBeenden', function(_, rueckgabe) + TriggerServerEvent('aether:police:dienstBeenden') + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('status', function(daten, rueckgabe) + TriggerServerEvent('aether:police:status', tostring(daten.status or '')) + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('leitstelle', function(daten, rueckgabe) + TriggerServerEvent('aether:police:leitstelle', tonumber(daten.quelle)) + rueckgabe({ ok = true }) +end) + +--- Einsatzmarkierungen auf der Karte auffrischen +RegisterNUICallback('einsatzBlips', function(daten, rueckgabe) + SetzeEinsatzBlips(daten.einsaetze or {}) + rueckgabe({ ok = true }) +end) + +--- Wegpunkt auf der Karte setzen (z. B. zu einem Einsatzort) +RegisterNUICallback('wegpunkt', function(daten, rueckgabe) + if daten.x and daten.y then + SetNewWaypoint(daten.x + 0.0, daten.y + 0.0) + ZeigeSpielmeldung('Wegpunkt gesetzt.') + end + rueckgabe({ ok = true }) +end) + +-- --------------------------------------------------------------------- +-- Aufräumen +-- --------------------------------------------------------------------- +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + SetNuiFocus(false, false) + LeereEinsatzBlips() +end) diff --git a/resources/[aether]/aether-police/fxmanifest.lua b/resources/[aether]/aether-police/fxmanifest.lua new file mode 100644 index 0000000..d8b274a --- /dev/null +++ b/resources/[aether]/aether-police/fxmanifest.lua @@ -0,0 +1,73 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +name 'aether-police' +author 'Jerrit Fritzsche' +description 'Polizei-Managementsystem — modulare Verwaltungssoftware für den Polizeidienst' +version '0.1.0' + +ui_page 'html/index.html' + +files { + 'html/index.html', + 'html/css/app.css', + 'html/js/app.js', + 'html/js/module/dashboard.js', + 'html/js/module/mdt.js', + 'html/js/module/einsatz.js', + 'html/js/module/anzeige.js', + 'html/js/module/gewahrsam.js', + 'html/js/module/beweis.js', + 'html/js/module/asservat.js', + 'html/js/module/bericht.js', + 'html/js/module/fahndung.js', + 'html/js/module/fahrzeug.js', + 'html/js/module/personal.js', + 'html/js/module/kommunikation.js', + 'html/js/module/statistik.js', + 'html/js/module/admin.js', +} + +shared_scripts { + '@aether-core/shared/config.lua', + '@aether-core/shared/utils.lua', + 'shared/config.lua', + 'shared/rollen.lua', +} + +client_scripts { + 'client/main.lua', +} + +-- Reihenfolge ist wichtig: Kern und Basisdienste vor den Fachmodulen. +server_scripts { + '@oxmysql/lib/MySQL.lua', + 'server/kern.lua', + 'server/rechte.lua', + 'server/protokoll.lua', + 'server/dienst.lua', + 'server/hinweise.lua', + -- --- Fachmodule (hier neue Module ergänzen) --- + 'server/module/dashboard.lua', + 'server/module/mdt.lua', + 'server/module/einsatz.lua', + 'server/module/anzeige.lua', + 'server/module/gewahrsam.lua', + 'server/module/beweis.lua', + 'server/module/asservat.lua', + 'server/module/bericht.lua', + 'server/module/fahndung.lua', + 'server/module/fahrzeug.lua', + 'server/module/personal.lua', + 'server/module/kommunikation.lua', + 'server/module/statistik.lua', + 'server/module/admin.lua', + -- --- Schnittstelle zuletzt --- + 'server/main.lua', +} + +dependencies { + 'aether-core', + 'oxmysql', +} diff --git a/resources/[aether]/aether-police/html/css/app.css b/resources/[aether]/aether-police/html/css/app.css new file mode 100644 index 0000000..c948684 --- /dev/null +++ b/resources/[aether]/aether-police/html/css/app.css @@ -0,0 +1,1079 @@ +/* ===================================================================== + aether-police — Oberfläche + Behördensoftware-Optik: ruhig, sachlich, hohe Informationsdichte. + Dunkelmodus ist Standard, helles Design über .thema-hell. +===================================================================== */ + +/* ---- Farbwelt ---- */ +.thema-dunkel { + --hg: #0b0e14; + --flaeche: #121722; + --flaeche-2: #1a2130; + --flaeche-3: #232c3d; + --rand: rgba(255,255,255,0.09); + --rand-stark: rgba(255,255,255,0.16); + --text: #e8edf5; + --text-leise: #8b97ad; + --akzent: #3b82f6; + --akzent-weich: rgba(59,130,246,0.14); + --gut: #34d399; + --warn: #f59e0b; + --kritisch: #ef4444; + --info: #8b5cf6; +} + +.thema-hell { + --hg: #eef1f6; + --flaeche: #ffffff; + --flaeche-2: #f5f7fa; + --flaeche-3: #e8ecf3; + --rand: rgba(15,23,42,0.10); + --rand-stark: rgba(15,23,42,0.20); + --text: #0f172a; + --text-leise: #5a6478; + --akzent: #2563eb; + --akzent-weich: rgba(37,99,235,0.10); + --gut: #059669; + --warn: #d97706; + --kritisch: #dc2626; + --info: #7c3aed; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; height: 100%; overflow: hidden; + background: transparent; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: var(--text); + font-size: 14px; +} + +button, input, select, textarea { font-family: inherit; color: inherit; } +[hidden] { display: none !important; } + +/* ===================================================================== + Anmeldung +===================================================================== */ +.anmeldung { + position: fixed; inset: 0; z-index: 20; + display: grid; place-items: center; + background: rgba(6,9,14,0.55); backdrop-filter: blur(6px); +} +.anmeldung-karte { + width: 380px; padding: 34px 30px; text-align: center; + background: var(--flaeche); border: 1px solid var(--rand); + border-radius: 18px; box-shadow: 0 40px 80px -30px rgba(0,0,0,0.7); +} +.wappen { + width: 64px; height: 64px; margin: 0 auto 14px; border-radius: 16px; + display: grid; place-items: center; font-size: 30px; + background: linear-gradient(150deg, var(--akzent), #1e40af); color: #fff; +} +.an-behoerde { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em; + color: var(--text-leise); font-weight: 700; +} +.anmeldung-karte h1 { font-size: 22px; margin: 6px 0 20px; font-weight: 800; } +.an-zeile { + display: flex; justify-content: space-between; padding: 9px 0; + border-bottom: 1px solid var(--rand); font-size: 13px; +} +.an-zeile span { color: var(--text-leise); } +.an-fuss { + margin: 18px 0 0; font-size: 11px; line-height: 1.5; color: var(--text-leise); +} + +/* ===================================================================== + Tablet-Grundgerüst +===================================================================== */ +.tablet { + position: fixed; inset: 0; z-index: 10; + display: grid; grid-template-columns: 232px 1fr; + background: var(--hg); +} + +/* ---- Seitenleiste ---- */ +.seitenleiste { + display: flex; flex-direction: column; + background: var(--flaeche); border-right: 1px solid var(--rand); +} +.marke { + display: flex; align-items: center; gap: 11px; + padding: 18px 18px; border-bottom: 1px solid var(--rand); +} +.marke-wappen { + width: 36px; height: 36px; border-radius: 10px; flex: none; + display: grid; place-items: center; font-size: 18px; + background: linear-gradient(150deg, var(--akzent), #1e40af); color: #fff; +} +.marke-text b { display: block; font-size: 15px; font-weight: 800; letter-spacing: 0.02em; } +.marke-text small { display: block; font-size: 10.5px; color: var(--text-leise); } + +.navigation { flex: 1; padding: 10px 9px; overflow-y: auto; } +.navigation button { + width: 100%; display: flex; align-items: center; gap: 11px; + padding: 10px 12px; margin-bottom: 2px; + border: none; background: none; border-radius: 9px; + color: var(--text-leise); font-size: 13.5px; font-weight: 600; + cursor: pointer; text-align: left; transition: .12s; +} +.navigation button:hover { background: var(--flaeche-2); color: var(--text); } +.navigation button.aktiv { background: var(--akzent-weich); color: var(--akzent); font-weight: 700; } +.navigation button .nav-symbol { width: 18px; text-align: center; font-size: 15px; } +.navigation button .nav-zaehler { + margin-left: auto; background: var(--kritisch); color: #fff; + font-size: 10px; font-weight: 800; padding: 1px 6px; border-radius: 999px; +} + +.seitenleiste-fuss { + display: flex; gap: 6px; padding: 12px; border-top: 1px solid var(--rand); +} + +/* ---- Hauptbereich ---- */ +.hauptbereich { display: grid; grid-template-rows: auto 1fr; min-width: 0; } + +.kopfleiste { + display: flex; align-items: center; gap: 14px; + padding: 12px 18px; border-bottom: 1px solid var(--rand); + background: var(--flaeche); +} +.suche-feld { + flex: 1; max-width: 620px; position: relative; + display: flex; align-items: center; +} +.suche-symbol { + position: absolute; left: 12px; color: var(--text-leise); font-size: 16px; +} +.suche-feld input { + width: 100%; padding: 10px 14px 10px 34px; + background: var(--flaeche-2); border: 1px solid var(--rand); + border-radius: 10px; font-size: 13px; outline: none; +} +.suche-feld input:focus { border-color: var(--akzent); background: var(--hg); } + +.kopf-rechts { margin-left: auto; display: flex; align-items: center; gap: 9px; } + +/* Dienststatus */ +.status-waehler { position: relative; } +.status-knopf { + display: flex; align-items: center; gap: 8px; + padding: 8px 13px; border-radius: 9px; cursor: pointer; + background: var(--flaeche-2); border: 1px solid var(--rand); + font-size: 12.5px; font-weight: 700; +} +.status-punkt { + width: 9px; height: 9px; border-radius: 50%; background: var(--gut); flex: none; +} +.status-liste { + position: absolute; top: calc(100% + 6px); right: 0; z-index: 5; + min-width: 180px; padding: 6px; + background: var(--flaeche-2); border: 1px solid var(--rand-stark); + border-radius: 11px; box-shadow: 0 20px 40px -16px rgba(0,0,0,0.6); +} +.status-liste button { + width: 100%; display: flex; align-items: center; gap: 9px; + padding: 9px 10px; border: none; background: none; border-radius: 7px; + font-size: 12.5px; font-weight: 600; cursor: pointer; text-align: left; +} +.status-liste button:hover { background: var(--flaeche-3); } + +.beamter-chip { + display: flex; align-items: center; gap: 11px; + padding: 6px 12px 6px 13px; border-radius: 10px; + background: var(--flaeche-2); border: 1px solid var(--rand); +} +.beamter-text b { display: block; font-size: 12.5px; font-weight: 700; line-height: 1.3; } +.beamter-text small { display: block; font-size: 10.5px; color: var(--text-leise); } +.beamter-nummer { + font-size: 11px; font-weight: 800; color: var(--akzent); + background: var(--akzent-weich); padding: 3px 8px; border-radius: 6px; + font-variant-numeric: tabular-nums; +} + +/* ---- Inhaltsbereich ---- */ +.inhalt { overflow-y: auto; padding: 20px 22px; } +.inhalt::-webkit-scrollbar, .navigation::-webkit-scrollbar, +.schublade-inhalt::-webkit-scrollbar { width: 8px; } +.inhalt::-webkit-scrollbar-thumb, .navigation::-webkit-scrollbar-thumb, +.schublade-inhalt::-webkit-scrollbar-thumb { + background: var(--rand-stark); border-radius: 4px; +} + +/* ===================================================================== + Wiederverwendbare Bausteine +===================================================================== */ +.seiten-kopf { + display: flex; align-items: flex-end; justify-content: space-between; + gap: 14px; margin-bottom: 18px; +} +.seiten-kopf h2 { margin: 0; font-size: 20px; font-weight: 800; letter-spacing: -0.01em; } +.seiten-kopf p { margin: 3px 0 0; font-size: 12.5px; color: var(--text-leise); } + +.karte { + background: var(--flaeche); border: 1px solid var(--rand); + border-radius: 14px; padding: 16px; +} +.karte-kopf { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 12px; +} +.karte-titel { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.07em; + color: var(--text-leise); font-weight: 800; +} + +.raster { display: grid; gap: 14px; } +.raster.k2 { grid-template-columns: repeat(2, 1fr); } +.raster.k3 { grid-template-columns: repeat(3, 1fr); } +.raster.k4 { grid-template-columns: repeat(4, 1fr); } +.raster.k6 { grid-template-columns: repeat(6, 1fr); } +@media (max-width: 1400px) { .raster.k6 { grid-template-columns: repeat(3, 1fr); } } +@media (max-width: 1100px) { + .raster.k4, .raster.k3 { grid-template-columns: repeat(2, 1fr); } + .tablet { grid-template-columns: 64px 1fr; } + .marke-text, .navigation button span:not(.nav-symbol) { display: none; } +} + +/* Kennzahl */ +.kennzahl { background: var(--flaeche); border: 1px solid var(--rand); border-radius: 12px; padding: 14px 16px; } +.kennzahl .kz-titel { + font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.06em; + color: var(--text-leise); font-weight: 800; +} +.kennzahl .kz-wert { + font-size: 26px; font-weight: 800; margin-top: 4px; font-variant-numeric: tabular-nums; +} +.kennzahl .kz-zusatz { font-size: 11.5px; color: var(--text-leise); margin-top: 2px; } +.kennzahl.hoch .kz-wert { color: var(--kritisch); } +.kennzahl.warn .kz-wert { color: var(--warn); } +.kennzahl.gut .kz-wert { color: var(--gut); } + +/* Listen */ +.liste { display: flex; flex-direction: column; } +.liste-zeile { + display: flex; align-items: center; gap: 12px; + padding: 10px 2px; border-bottom: 1px solid var(--rand); +} +.liste-zeile:last-child { border-bottom: none; } +.liste-zeile .lz-haupt { flex: 1; min-width: 0; } +.liste-zeile .lz-titel { font-size: 13px; font-weight: 700; } +.liste-zeile .lz-unter { font-size: 11.5px; color: var(--text-leise); margin-top: 1px; } +.liste-leer { + padding: 22px; text-align: center; font-size: 12.5px; color: var(--text-leise); +} + +/* Marken / Etiketten */ +.marker { + font-size: 10px; font-weight: 800; padding: 3px 8px; border-radius: 999px; + display: inline-block; white-space: nowrap; +} +.marker.gut { background: rgba(52,211,153,0.15); color: var(--gut); } +.marker.warn { background: rgba(245,158,11,0.15); color: var(--warn); } +.marker.kritisch { background: rgba(239,68,68,0.15); color: var(--kritisch); } +.marker.info { background: rgba(139,92,246,0.15); color: var(--info); } +.marker.neutral { background: var(--flaeche-3); color: var(--text-leise); } + +/* Prioritätsstreifen */ +.prio { width: 3px; align-self: stretch; border-radius: 2px; flex: none; } +.prio.p1 { background: var(--kritisch); } +.prio.p2 { background: var(--warn); } +.prio.p3 { background: var(--akzent); } +.prio.p4 { background: var(--text-leise); } + +/* Knöpfe */ +.knopf { + padding: 10px 16px; border-radius: 9px; border: 1px solid transparent; + background: var(--akzent); color: #fff; + font-size: 13px; font-weight: 700; cursor: pointer; transition: .12s; +} +.knopf:hover { filter: brightness(1.08); } +.knopf:active { transform: scale(0.98); } +.knopf:disabled { opacity: .45; cursor: not-allowed; } +.knopf.leise { background: var(--flaeche-2); color: var(--text); border-color: var(--rand); } +.knopf.leise:hover { background: var(--flaeche-3); filter: none; } +.knopf.gefahr { background: var(--kritisch); } +.knopf.gross { width: 100%; padding: 13px; font-size: 14px; margin-top: 18px; } +.knopf.klein { padding: 7px 10px; font-size: 12px; } +.knopf.symbol { padding: 8px 11px; font-size: 15px; position: relative; } + +.zaehler { + position: absolute; top: 2px; right: 2px; + background: var(--kritisch); color: #fff; + font-size: 9px; font-weight: 800; min-width: 15px; height: 15px; + border-radius: 999px; display: grid; place-items: center; padding: 0 3px; +} + +/* Warnhinweise */ +.warnung { + display: flex; align-items: center; gap: 11px; + padding: 11px 14px; border-radius: 10px; margin-bottom: 8px; + border: 1px solid var(--rand); background: var(--flaeche); + font-size: 12.5px; cursor: pointer; +} +.warnung .w-symbol { font-size: 16px; } +.warnung.hoch { border-color: rgba(239,68,68,0.4); background: rgba(239,68,68,0.07); } +.warnung.normal { border-color: rgba(245,158,11,0.35); background: rgba(245,158,11,0.06); } +.warnung.niedrig { border-color: var(--rand); } + +/* ===================================================================== + Mitteilungs-Schublade +===================================================================== */ +.schublade { + position: absolute; top: 0; right: 0; bottom: 0; width: 360px; z-index: 15; + background: var(--flaeche); border-left: 1px solid var(--rand-stark); + display: grid; grid-template-rows: auto 1fr; + box-shadow: -20px 0 50px -20px rgba(0,0,0,0.5); + animation: schubladeRein .18s ease; +} +@keyframes schubladeRein { from { transform: translateX(24px); opacity: 0; } } +.schublade-kopf { + display: flex; align-items: center; justify-content: space-between; + padding: 15px 16px; border-bottom: 1px solid var(--rand); +} +.schublade-inhalt { overflow-y: auto; padding: 10px 12px; } +.hinweis-eintrag { + padding: 11px 12px; border-radius: 10px; margin-bottom: 7px; + background: var(--flaeche-2); border: 1px solid var(--rand); cursor: pointer; +} +.hinweis-eintrag.ungelesen { border-left: 3px solid var(--akzent); } +.hinweis-eintrag .he-kopf { + display: flex; align-items: center; justify-content: space-between; gap: 8px; +} +.hinweis-eintrag .he-titel { font-size: 12.5px; font-weight: 700; } +.hinweis-eintrag .he-text { font-size: 11.5px; color: var(--text-leise); margin-top: 3px; line-height: 1.45; } +.hinweis-eintrag .he-zeit { font-size: 10px; color: var(--text-leise); } + +/* ===================================================================== + Fenster (Dialoge) +===================================================================== */ +.fenster-ebene { position: absolute; inset: 0; z-index: 18; pointer-events: none; } +.fenster-ebene > * { pointer-events: auto; } +.fenster-hg { + position: absolute; inset: 0; background: rgba(4,7,12,0.6); backdrop-filter: blur(3px); + display: grid; place-items: center; padding: 30px; +} +.fenster { + width: min(720px, 92%); max-height: 86vh; display: grid; grid-template-rows: auto 1fr auto; + background: var(--flaeche); border: 1px solid var(--rand-stark); + border-radius: 15px; box-shadow: 0 40px 90px -30px rgba(0,0,0,0.8); + animation: fensterRein .16s ease; +} +@keyframes fensterRein { from { opacity: 0; transform: translateY(10px) scale(.99); } } +.fenster-kopf { + display: flex; align-items: center; justify-content: space-between; + padding: 15px 18px; border-bottom: 1px solid var(--rand); +} +.fenster-kopf h3 { margin: 0; font-size: 15.5px; font-weight: 800; } +.fenster-koerper { padding: 18px; overflow-y: auto; } +.fenster-fuss { + display: flex; gap: 9px; justify-content: flex-end; + padding: 13px 18px; border-top: 1px solid var(--rand); +} + +/* Formularelemente */ +.feld { margin-bottom: 14px; } +.feld label { + display: block; font-size: 11px; font-weight: 800; color: var(--text-leise); + text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; +} +.feld input, .feld select, .feld textarea { + width: 100%; padding: 10px 12px; border-radius: 9px; + background: var(--flaeche-2); border: 1px solid var(--rand); + font-size: 13px; outline: none; +} +.feld input:focus, .feld select:focus, .feld textarea:focus { border-color: var(--akzent); } +.feld textarea { min-height: 90px; resize: vertical; line-height: 1.5; } +.feld-hinweis { font-size: 11px; color: var(--text-leise); margin-top: 5px; line-height: 1.45; } + +/* ===================================================================== + Kurzmeldungen +===================================================================== */ +.meldungen { + position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%); + z-index: 25; display: flex; flex-direction: column; gap: 8px; align-items: center; + pointer-events: none; +} +.meldung { + padding: 11px 20px; border-radius: 10px; font-size: 13px; font-weight: 600; + background: var(--flaeche-3); border: 1px solid var(--rand-stark); + box-shadow: 0 16px 34px -14px rgba(0,0,0,0.6); + animation: meldungRein .2s ease; +} +@keyframes meldungRein { from { opacity: 0; transform: translateY(10px); } } +.meldung.gut { border-color: var(--gut); } +.meldung.fehler { border-color: var(--kritisch); } + +/* Ladeanzeige */ +.laden { + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 12px; padding: 60px; color: var(--text-leise); font-size: 13px; +} +.spinner { + width: 26px; height: 26px; border-radius: 50%; + border: 2.5px solid var(--rand-stark); border-top-color: var(--akzent); + animation: dreh .7s linear infinite; +} +@keyframes dreh { to { transform: rotate(360deg); } } + +/* Grundsatz-Hinweis (erscheint an Entscheidungspunkten) */ +.grundsatz { + display: flex; gap: 10px; padding: 11px 13px; border-radius: 9px; + background: var(--akzent-weich); border: 1px solid rgba(59,130,246,0.25); + font-size: 11.5px; line-height: 1.5; color: var(--text-leise); margin-top: 12px; +} +.grundsatz b { color: var(--text); } + +/* ===================================================================== + MDT +===================================================================== */ +.mdt-reiter { display: flex; gap: 5px; margin-bottom: 14px; } +.mdt-reiter button { + padding: 9px 16px; border-radius: 9px; cursor: pointer; + background: var(--flaeche); border: 1px solid var(--rand); + font-size: 13px; font-weight: 700; color: var(--text-leise); transition: .12s; +} +.mdt-reiter button:hover { color: var(--text); } +.mdt-reiter button.aktiv { + background: var(--akzent-weich); border-color: var(--akzent); color: var(--akzent); +} + +.mdt-suchleiste { display: flex; gap: 8px; } +.mdt-suchleiste input[type="search"] { + flex: 1; padding: 10px 13px; border-radius: 9px; + background: var(--flaeche); border: 1px solid var(--rand); + font-size: 13px; outline: none; +} +.mdt-suchleiste input:focus { border-color: var(--akzent); } +.mdt-suchleiste select { + padding: 10px 12px; border-radius: 9px; + background: var(--flaeche); border: 1px solid var(--rand); font-size: 12.5px; +} + +/* Anklickbare Treffer */ +.liste-zeile.treffer { cursor: pointer; border-radius: 8px; padding-inline: 8px; transition: .1s; } +.liste-zeile.treffer:hover { background: var(--flaeche-2); } +.treffer-text { cursor: pointer; } +.treffer-text:hover { color: var(--akzent); } + +/* Aktenkopf */ +.akte-kopf { + display: flex; gap: 18px; align-items: flex-start; + padding-bottom: 16px; border-bottom: 1px solid var(--rand); +} +.akte-bild { + width: 108px; height: 132px; flex: none; border-radius: 10px; + background: var(--flaeche-2); border: 1px solid var(--rand); + display: grid; place-items: center; overflow: hidden; +} +.akte-bild img { width: 100%; height: 100%; object-fit: cover; } +.akte-bild .kein-bild { + font-size: 10.5px; color: var(--text-leise); text-align: center; padding: 8px; +} +.akte-kopf-text { flex: 1; min-width: 0; } +.akte-kopf-text h3 { margin: 0 0 12px; font-size: 20px; font-weight: 800; } +.akte-zeilen { + display: grid; grid-template-columns: auto 1fr auto 1fr; gap: 6px 14px; + font-size: 12.5px; align-items: baseline; +} +.akte-zeilen span { color: var(--text-leise); } +.akte-zeilen b { font-weight: 700; } +.akte-kopf-aktionen { display: flex; flex-direction: column; gap: 6px; flex: none; } + +.akte-inhalt { font-size: 13px; line-height: 1.65; white-space: pre-wrap; } + +/* Kennzeichen-Darstellung */ +.kennzeichen { + font-family: "Arial Narrow", Arial, sans-serif; font-weight: 800; font-size: 13px; + letter-spacing: 0.05em; padding: 5px 9px; border-radius: 5px; + background: #f3efe4; color: #1a2a5a; border: 2px solid #c9c4b4; flex: none; +} +.kennzeichen-gross { + font-family: "Arial Narrow", Arial, sans-serif; font-weight: 800; font-size: 22px; + letter-spacing: 0.06em; background: #f3efe4; color: #1a2a5a; border: 3px solid #c9c4b4; + height: 66px; width: 132px; +} +.farbtupfer { + display: inline-block; width: 11px; height: 11px; border-radius: 3px; + border: 1px solid var(--rand-stark); vertical-align: -1px; +} + +/* ===================================================================== + Einsätze & Dispatch +===================================================================== */ +.dispatch { display: grid; grid-template-columns: 1fr 340px; gap: 14px; align-items: start; } +@media (max-width: 1200px) { .dispatch { grid-template-columns: 1fr; } } +.dispatch-spalte { min-width: 0; } + +.einsatz-karte { + display: flex; align-items: center; gap: 12px; + padding: 11px 12px; margin-bottom: 7px; border-radius: 10px; + background: var(--flaeche-2); border: 1px solid var(--rand); + cursor: pointer; transition: .12s; +} +.einsatz-karte:hover { border-color: var(--akzent); transform: translateX(2px); } +.einsatz-karte.offen { border-left: 3px solid var(--kritisch); } +.einsatz-karte .ek-haupt { flex: 1; min-width: 0; } +.einsatz-karte .ek-titel { + font-size: 13.5px; font-weight: 700; + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; +} +.einsatz-karte .ek-unter { + font-size: 11.5px; color: var(--text-leise); margin-top: 3px; + display: flex; align-items: center; gap: 7px; flex-wrap: wrap; +} +.einsatz-karte .ek-kategorie { + font-size: 10px; font-weight: 800; padding: 2px 7px; border-radius: 999px; + background: color-mix(in srgb, var(--kf) 18%, transparent); color: var(--kf); +} +.einsatz-karte .ek-rechts { + display: flex; flex-direction: column; align-items: flex-end; gap: 5px; flex: none; +} +.einsatz-karte .ek-einheiten { + font-size: 11px; color: var(--text-leise); font-weight: 700; +} + +/* ===================================================================== + Strafenkatalog & Anzeigen +===================================================================== */ +.anzeige-raster { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: start; } +@media (max-width: 1200px) { .anzeige-raster { grid-template-columns: 1fr; } } + +.katalog-suche { + padding: 7px 11px; border-radius: 8px; font-size: 12.5px; outline: none; + background: var(--flaeche-2); border: 1px solid var(--rand); max-width: 220px; +} +.katalog-suche:focus { border-color: var(--akzent); } + +.katalog-liste { max-height: 520px; overflow-y: auto; } +.katalog-gruppe { margin-bottom: 14px; } +.katalog-gruppe:last-child { margin-bottom: 0; } +.katalog-titel { + font-size: 10.5px; font-weight: 800; text-transform: uppercase; + letter-spacing: 0.06em; color: var(--text-leise); + padding: 5px 0 7px; border-bottom: 1px solid var(--rand); margin-bottom: 6px; +} + +.delikt { + display: flex; align-items: flex-start; gap: 10px; + padding: 8px 9px; border-radius: 8px; cursor: pointer; transition: .1s; + border: 1px solid transparent; +} +.delikt:hover { background: var(--flaeche-2); } +.delikt.gewaehlt { background: var(--akzent-weich); border-color: var(--akzent); } +.delikt input { margin-top: 2px; accent-color: var(--akzent); flex: none; } +.delikt-name { font-size: 12.5px; font-weight: 600; } +.delikt-unter { font-size: 11px; color: var(--text-leise); margin-top: 2px; } + +/* Vorschlag des Katalogs — bewusst als Zitat, nicht als Ergebnis */ +.vorschlag-block { + border: 1px dashed var(--rand-stark); border-radius: 11px; + padding: 13px 14px; margin: 4px 0 14px; background: var(--flaeche-2); +} +.vorschlag-kopf { display: flex; align-items: center; justify-content: space-between; } +.vorschlag-werte { display: flex; gap: 26px; margin-top: 10px; } +.vorschlag-werte span { + display: block; font-size: 10.5px; color: var(--text-leise); + text-transform: uppercase; letter-spacing: 0.05em; font-weight: 800; +} +.vorschlag-werte b { font-size: 17px; font-weight: 800; } +.vorschlag-hinweise { + margin-top: 11px; padding-top: 10px; border-top: 1px solid var(--rand); + font-size: 11.5px; color: var(--text-leise); line-height: 1.6; +} + +.entscheidung-block { + border: 1px solid var(--akzent); border-radius: 11px; + padding: 14px; background: var(--akzent-weich); +} + +.abweichung { + border: 1px solid var(--warn); border-radius: 9px; + background: rgba(245,158,11,0.08); padding: 11px 13px; margin-top: 12px; +} +.abweichung-kopf { font-size: 12.5px; font-weight: 800; color: var(--warn); } + +.person-waehler { display: flex; gap: 8px; } +.person-waehler input { flex: 1; } + +.anzeige-zeile { align-items: flex-start; } + +/* Folgen einer Bestätigung */ +.folgen { + border: 1px solid var(--rand); border-radius: 10px; + padding: 12px 14px; background: var(--flaeche-2); +} +.folgen-titel { + font-size: 11px; font-weight: 800; text-transform: uppercase; + letter-spacing: 0.05em; color: var(--text-leise); margin-bottom: 10px; +} +.folge { + display: flex; gap: 11px; align-items: flex-start; + font-size: 12.5px; line-height: 1.55; padding: 6px 0; +} +.folge-symbol { font-size: 15px; flex: none; } + +/* ===================================================================== + Gewahrsam +===================================================================== */ +.zellen { + display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; +} +.zelle { + padding: 12px 13px; border-radius: 10px; text-align: center; + border: 1px solid var(--rand); background: var(--flaeche-2); +} +.zelle.frei { border-style: dashed; opacity: .65; } +.zelle.belegt { border-color: var(--warn); background: rgba(245,158,11,0.07); } +.zelle.abgelaufen { border-color: var(--kritisch); background: rgba(239,68,68,0.09); } +.zelle-name { + font-size: 10.5px; font-weight: 800; text-transform: uppercase; + letter-spacing: 0.06em; color: var(--text-leise); +} +.zelle-status { font-size: 12.5px; color: var(--text-leise); margin-top: 8px; } +.zelle-insasse { font-size: 13px; font-weight: 700; margin-top: 7px; } +.zelle-rest { + font-size: 12px; font-weight: 800; margin-top: 3px; + font-variant-numeric: tabular-nums; color: var(--warn); +} +.zelle.abgelaufen .zelle-rest { color: var(--kritisch); } + +.gewahrsam-zeile { align-items: flex-start; } +.gewahrsam-zeile.abgelaufen { background: rgba(239,68,68,0.05); border-radius: 8px; } +.gewahrsam-rechts { + display: flex; flex-direction: column; align-items: flex-end; gap: 7px; flex: none; +} +.rest-anzeige { + font-size: 15px; font-weight: 800; font-variant-numeric: tabular-nums; + color: var(--warn); +} +.rest-anzeige.abgelaufen { color: var(--kritisch); } + +/* ===================================================================== + Beweise & Asservate +===================================================================== */ +.beweis-symbol { + font-size: 20px; width: 32px; text-align: center; flex: none; +} + +/* Verwahrkette */ +.kette { position: relative; padding-left: 4px; } +.kette-schritt { display: flex; gap: 14px; position: relative; padding-bottom: 16px; } +.kette-schritt:last-child { padding-bottom: 0; } +/* Verbindungslinie zwischen den Schritten */ +.kette-schritt:not(:last-child)::before { + content: ""; position: absolute; left: 13px; top: 28px; bottom: 0; + width: 2px; background: var(--rand-stark); +} +.kette-punkt { + width: 28px; height: 28px; border-radius: 50%; flex: none; + display: grid; place-items: center; font-size: 11px; font-weight: 800; + background: var(--akzent-weich); color: var(--akzent); + border: 1px solid var(--akzent); position: relative; z-index: 1; +} +.kette-inhalt { padding-top: 3px; } +.kette-aktion { font-size: 13px; font-weight: 700; } +.kette-meta { font-size: 11.5px; color: var(--text-leise); margin-top: 2px; } +.kette-notiz { + font-size: 12px; margin-top: 5px; padding: 7px 10px; border-radius: 7px; + background: var(--flaeche-2); border: 1px solid var(--rand); line-height: 1.5; +} + +/* ===================================================================== + Berichte +===================================================================== */ +.unterschriften { + display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; +} +.unterschrift { + padding: 12px 14px; border-radius: 10px; + background: var(--flaeche-2); border: 1px solid var(--rand); + /* Angedeutete Unterschriftenlinie */ + border-bottom: 2px solid var(--akzent); +} +.unterschrift .us-name { + font-size: 14px; font-weight: 700; font-style: italic; + padding-bottom: 6px; +} +.unterschrift .us-meta { font-size: 11px; color: var(--text-leise); } +.unterschrift .us-zeit { + font-size: 10.5px; color: var(--text-leise); margin-top: 3px; + font-variant-numeric: tabular-nums; +} + +/* ===================================================================== + Fahndungen +===================================================================== */ +.fahndungen { display: flex; flex-direction: column; gap: 9px; } + +.fahndung-karte { + display: flex; align-items: center; gap: 14px; + padding: 12px; border-radius: 11px; cursor: pointer; transition: .12s; + background: var(--flaeche-2); border: 1px solid var(--rand); + border-left: 4px solid var(--text-leise); +} +.fahndung-karte:hover { border-color: var(--akzent); transform: translateX(2px); } +.fahndung-karte.gefahr-bewaffnet { border-left-color: var(--warn); } +.fahndung-karte.gefahr-gefaehrlich { border-left-color: var(--kritisch); } + +.fahndung-karte .fk-bild { + width: 56px; height: 68px; flex: none; border-radius: 8px; overflow: hidden; + background: var(--flaeche-3); display: grid; place-items: center; +} +.fahndung-karte .fk-bild img { width: 100%; height: 100%; object-fit: cover; } +.fahndung-karte .fk-symbol { font-size: 26px; opacity: .6; } + +.fahndung-karte .fk-haupt { flex: 1; min-width: 0; } +.fahndung-karte .fk-titel { font-size: 14px; font-weight: 700; } +.fahndung-karte .fk-grund { + font-size: 12.5px; color: var(--text); margin-top: 3px; line-height: 1.45; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +} +.fahndung-karte .fk-meta { font-size: 11px; color: var(--text-leise); margin-top: 5px; } + +.fahndung-karte .fk-rechts { + display: flex; flex-direction: column; align-items: flex-end; gap: 5px; flex: none; +} +.fahndung-karte .fk-prio { font-size: 10.5px; color: var(--text-leise); font-weight: 700; } + +.fahndung-bild { + max-width: 100%; max-height: 420px; border-radius: 10px; + border: 1px solid var(--rand); display: block; +} + +/* ===================================================================== + Fuhrpark +===================================================================== */ +.fz-zeile { align-items: flex-start; } + +.fz-werte { + display: grid; grid-template-columns: auto 1fr auto 1fr; + gap: 6px 9px; align-items: center; margin-top: 7px; max-width: 460px; +} +.fz-wert { + font-size: 11px; font-weight: 700; color: var(--text-leise); + font-variant-numeric: tabular-nums; white-space: nowrap; +} +.fz-balken { + height: 6px; border-radius: 4px; background: var(--flaeche-3); overflow: hidden; +} +.fz-balken > i { display: block; height: 100%; border-radius: 4px; transition: width .3s; } + +.fz-anzeige { margin-top: 4px; } +.fz-anzeige-zeile { + display: flex; justify-content: space-between; align-items: baseline; + font-size: 12.5px; margin-bottom: 5px; +} +.fz-anzeige-zeile span { color: var(--text-leise); } +.fz-anzeige-zeile b { font-size: 15px; font-variant-numeric: tabular-nums; } + +/* ===================================================================== + Personalverwaltung +===================================================================== */ +.pv-verteilung { display: flex; flex-direction: column; gap: 7px; margin-top: 4px; } +.pv-verteilung-zeile { + display: grid; grid-template-columns: 150px 1fr 34px; + gap: 10px; align-items: center; +} +.pv-vt-name { font-size: 12.5px; color: var(--text-leise); } +.pv-vt-balken { + height: 7px; border-radius: 4px; background: var(--flaeche-3); overflow: hidden; +} +.pv-vt-balken > i { + display: block; height: 100%; border-radius: 4px; + background: var(--akzent); transition: width .3s; +} +.pv-vt-zahl { + font-size: 12.5px; font-weight: 800; text-align: right; + font-variant-numeric: tabular-nums; +} + +/* Dienstpunkt und Dienstnummer in der Liste */ +.pv-punkt { + width: 8px; height: 8px; border-radius: 50%; flex: none; + background: var(--flaeche-3); border: 1px solid var(--rand); +} +.pv-punkt.an { background: var(--gut); border-color: var(--gut); } +.pv-nummer { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11.5px; font-weight: 700; color: var(--text-leise); + min-width: 84px; flex: none; +} +.pv-dienstdauer { + font-size: 12px; font-weight: 700; color: var(--text-leise); + font-variant-numeric: tabular-nums; white-space: nowrap; +} +.pv-notiz { + margin-top: 12px; font-size: 12.5px; line-height: 1.5; + color: var(--text-leise); border-left: 2px solid var(--rand); padding-left: 10px; +} + +/* Rechtematrix */ +.pv-rechte { display: flex; flex-direction: column; gap: 16px; margin-top: 6px; } +.pv-rechte-gruppe { display: flex; flex-direction: column; gap: 4px; } +.pv-rechte-titel { + font-size: 11px; font-weight: 800; text-transform: uppercase; + letter-spacing: 0.05em; color: var(--text-leise); margin-bottom: 3px; +} +.pv-recht { + display: grid; grid-template-columns: 1fr auto auto; + gap: 10px; align-items: center; + padding: 8px 10px; border-radius: 8px; background: var(--flaeche-2); + border: 1px solid transparent; +} +.pv-recht.aktiv { border-color: var(--rand); } +.pv-recht-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.pv-recht-name { font-size: 12.5px; font-weight: 600; } +.pv-recht-id { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 10px; color: var(--text-leise); +} +.pv-recht-schalter { display: flex; gap: 3px; flex: none; } +.pv-schalter { + padding: 5px 9px; border-radius: 7px; cursor: pointer; + background: var(--flaeche-3); border: 1px solid var(--rand); + color: var(--text-leise); font-size: 11px; font-weight: 800; transition: .12s; +} +.pv-schalter:hover:not(:disabled) { color: var(--text); border-color: var(--akzent); } +.pv-schalter.gewaehlt { + background: var(--akzent); border-color: var(--akzent); color: #fff; +} +.pv-schalter:disabled { opacity: .35; cursor: not-allowed; } + +/* Einstellungsdialog */ +.pv-trefferliste { max-height: 210px; overflow-y: auto; margin-bottom: 14px; } +.pv-trefferliste .liste-zeile.gewaehlt { + border-color: var(--akzent); background: var(--flaeche-3); +} + +/* ===================================================================== + Interne Kommunikation +===================================================================== */ +.komm-zaehler { + display: inline-block; margin-left: 6px; padding: 1px 6px; border-radius: 999px; + background: var(--kritisch); color: #fff; + font-size: 10px; font-weight: 800; vertical-align: middle; +} + +/* Posteingang */ +.komm-zeile.ungelesen .lz-titel { font-weight: 800; } +.komm-punkt { + width: 7px; height: 7px; border-radius: 50%; flex: none; background: transparent; +} +.komm-punkt.neu { background: var(--akzent); } +.komm-zeit { + font-size: 11px; color: var(--text-leise); white-space: nowrap; flex: none; + font-variant-numeric: tabular-nums; +} + +.komm-kopf { + display: flex; justify-content: space-between; align-items: baseline; + gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--rand); +} +.komm-kopf-unter { + display: block; font-size: 11px; color: var(--text-leise); margin-top: 2px; +} +.komm-text { + margin-top: 12px; font-size: 13px; line-height: 1.6; + white-space: normal; word-break: break-word; +} + +/* Empfängerauswahl */ +.komm-empf-arten { display: flex; gap: 5px; margin-bottom: 10px; } +.komm-empfliste { max-height: 230px; overflow-y: auto; margin-top: 8px; } +.komm-empfliste .liste-zeile.gewaehlt { + border-color: var(--akzent); background: var(--flaeche-3); +} + +/* Aushänge */ +.komm-aushaenge { display: flex; flex-direction: column; gap: 12px; } +.komm-aushang.angeheftet { border-color: var(--warn); } +.komm-aushang-marker { display: flex; gap: 5px; align-items: center; } +.komm-aushang-fuss { + display: flex; justify-content: space-between; align-items: flex-end; + gap: 12px; margin-top: 14px; padding-top: 11px; border-top: 1px solid var(--rand); + flex-wrap: wrap; +} +.komm-aushang-fuss .feld-hinweis { margin: 0; } +.komm-aushang-aktionen { + display: flex; gap: 6px; align-items: center; flex-wrap: wrap; +} + +.komm-haken { + width: auto !important; margin-right: 7px; vertical-align: -1px; +} +.feld label:has(.komm-haken) { + text-transform: none; letter-spacing: 0; font-size: 12.5px; + color: var(--text); cursor: pointer; +} + +/* ===================================================================== + Statistik +===================================================================== */ +.st-leiste { + display: flex; justify-content: space-between; align-items: center; + gap: 12px; flex-wrap: wrap; margin-bottom: 16px; +} +.st-leiste .mdt-reiter { margin: 0; } +.st-zeitraum { display: flex; gap: 4px; } + +.st-unter { + display: block; font-size: 10.5px; color: var(--text-leise); margin-top: 3px; +} + +/* Zwei Karten nebeneinander */ +.st-spalten { + display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 14px; +} + +/* Wertepaare */ +.st-werte { + display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 4px 18px; margin-top: 4px; +} +.st-werte > div { + display: flex; justify-content: space-between; align-items: baseline; + gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--rand); +} +.st-werte span { font-size: 12.5px; color: var(--text-leise); } +.st-werte b { font-size: 14px; font-variant-numeric: tabular-nums; white-space: nowrap; } + +.st-fuss { + margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--rand); + font-size: 11.5px; line-height: 1.5; color: var(--text-leise); +} +.st-fuss b { color: var(--text); } + +/* Waagerechte Verteilung */ +.st-verteilung { display: flex; flex-direction: column; gap: 7px; margin-top: 4px; } +.st-vt-zeile { + display: grid; grid-template-columns: 160px 1fr 52px 46px; + gap: 10px; align-items: center; +} +.st-vt-name { + font-size: 12.5px; color: var(--text-leise); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.st-vt-balken { + height: 7px; border-radius: 4px; background: var(--flaeche-3); overflow: hidden; +} +.st-vt-balken > i { + display: block; height: 100%; border-radius: 4px; + background: var(--akzent); transition: width .3s; +} +.st-vt-zahl { + font-size: 12.5px; font-weight: 800; text-align: right; + font-variant-numeric: tabular-nums; +} +.st-vt-anteil { + font-size: 11px; color: var(--text-leise); text-align: right; + font-variant-numeric: tabular-nums; +} + +/* Tagesverlauf */ +.st-legende { + display: flex; gap: 16px; align-items: center; flex-wrap: wrap; + font-size: 11.5px; color: var(--text-leise); margin-bottom: 10px; +} +.st-legende .feld-hinweis { margin: 0 0 0 auto; } +.st-punkt { + display: inline-block; width: 8px; height: 8px; border-radius: 2px; + margin-right: 5px; vertical-align: -1px; +} +.st-punkt.einsatz { background: var(--akzent); } +.st-punkt.anzeige { background: var(--warn); } + +.st-verlauf { + display: flex; align-items: flex-end; gap: 3px; + height: 150px; overflow-x: auto; padding-bottom: 2px; +} +.st-tag { + display: flex; flex-direction: column; align-items: center; gap: 4px; + flex: 1 0 18px; height: 100%; +} +.st-saeulen { + display: flex; align-items: flex-end; gap: 2px; + width: 100%; height: 100%; justify-content: center; +} +.st-saeulen > i { + display: block; width: 6px; min-height: 2px; border-radius: 2px 2px 0 0; + transition: height .3s; +} +.st-saeulen > i.einsatz { background: var(--akzent); } +.st-saeulen > i.anzeige { background: var(--warn); } +.st-tag-text { + font-size: 9.5px; color: var(--text-leise); font-variant-numeric: tabular-nums; +} + +/* Besetzung nach Tageszeit */ +.st-stunden { + display: flex; align-items: flex-end; gap: 4px; height: 170px; margin-top: 6px; +} +.st-stunde { + display: flex; flex-direction: column; align-items: center; gap: 5px; + flex: 1; height: 100%; +} +.st-stunde-balken { + display: flex; align-items: flex-end; width: 100%; height: 100%; + background: var(--flaeche-2); border-radius: 5px 5px 0 0; overflow: hidden; +} +.st-stunde-balken > i { + display: block; width: 100%; min-height: 2px; + background: var(--akzent); transition: height .3s; +} +.st-stunde-text { + font-size: 10px; color: var(--text-leise); font-variant-numeric: tabular-nums; +} + +.st-ungenutzt { + display: flex; flex-wrap: wrap; gap: 5px; margin-top: 4px; +} + +/* ===================================================================== + Administration +===================================================================== */ +.adm-icon { font-size: 17px; width: 24px; text-align: center; flex: none; } + +.adm-modul.aus, .adm-delikt.aus { opacity: .5; } +.adm-modul.aus .lz-titel { text-decoration: line-through; } + +.adm-vorschlag { + font-size: 12px; font-weight: 700; color: var(--text-leise); + font-variant-numeric: tabular-nums; white-space: nowrap; + min-width: 72px; text-align: right; flex: none; +} + +/* Zwei Felder nebeneinander im Formular */ +.adm-formular { + display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0 14px; +} + +.adm-protokoll { align-items: flex-start; } +.adm-grund { + margin-top: 4px; font-size: 11.5px; font-style: italic; color: var(--text-leise); + border-left: 2px solid var(--rand); padding-left: 8px; +} + +/* ===================================================================== + Zweitschreibweisen der Bausteine + Personal, Statistik und Administration nutzen diese Varianten. + Gleiche Optik, nur andere Klassennamen — hier zusammengeführt, + damit beide Schreibweisen sicher greifen. +===================================================================== */ +.raster-2 { grid-template-columns: repeat(2, 1fr); } +.raster-3 { grid-template-columns: repeat(3, 1fr); } +.raster-4 { grid-template-columns: repeat(4, 1fr); } +@media (max-width: 1100px) { + .raster-3, .raster-4 { grid-template-columns: repeat(2, 1fr); } +} + +/* Kennzahl mit Wert zuerst, Beschriftung darunter */ +.kennzahl .kz-zahl { + display: block; font-size: 24px; font-weight: 800; + font-variant-numeric: tabular-nums; line-height: 1.15; +} +.kennzahl .kz-text { + display: block; font-size: 10.5px; text-transform: uppercase; + letter-spacing: 0.06em; color: var(--text-leise); font-weight: 800; margin-top: 5px; +} diff --git a/resources/[aether]/aether-police/html/index.html b/resources/[aether]/aether-police/html/index.html new file mode 100644 index 0000000..ca11b0e --- /dev/null +++ b/resources/[aether]/aether-police/html/index.html @@ -0,0 +1,128 @@ + + + + + + +LSPD — Dienstsystem + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/[aether]/aether-police/html/js/app.js b/resources/[aether]/aether-police/html/js/app.js new file mode 100644 index 0000000..c39e106 --- /dev/null +++ b/resources/[aether]/aether-police/html/js/app.js @@ -0,0 +1,536 @@ +/* ===================================================================== + aether-police — Anwendungskern der Oberfläche + + Stellt bereit: + * NUI-Brücke mit Aufruf/Antwort-Zuordnung + * Modul-Registry (Ansichten registrieren sich selbst) + * Fenster-, Meldungs- und Mitteilungssystem + * Navigation und Grundzustand + + Fachansichten liegen in js/module/*.js und registrieren sich über + `App.registriereAnsicht(...)`. Der Kern kennt keine Fachlogik. +===================================================================== */ +"use strict"; + +const App = (() => { + + /* ---- Zustand ---------------------------------------------------- */ + const zustand = { + imDienst: false, + beamter: null, + module: [], + aktivesModul: null, + modulZiel: null, + dienststatus: [], + abteilungen: [], + beamteImDienst: [], + hinweise: [], + }; + + /* Registrierte Ansichten: [modulId] = { rendern, aktualisieren? } */ + const ansichten = {}; + + /* Offene Aufrufe: [ruf] = { aufloesen, ablehnen } */ + const offeneAufrufe = {}; + let rufZaehler = 0; + + const IST_FIVEM = (typeof GetParentResourceName === "function"); + const RESSOURCE = IST_FIVEM ? GetParentResourceName() : "aether-police"; + + /* ---- Hilfsfunktionen -------------------------------------------- */ + const $ = (id) => document.getElementById(id); + + /** Schützt Text vor HTML-Interpretation */ + function sicher(text) { + const d = document.createElement("div"); + d.textContent = text == null ? "" : String(text); + return d.innerHTML; + } + + /** Formatiert einen Zeitstempel der Datenbank als Datum + Uhrzeit */ + function zeit(wert) { + if (!wert) return "—"; + const text = String(wert).replace("T", " "); + return text.slice(8, 10) + "." + text.slice(5, 7) + ". " + text.slice(11, 16); + } + + /** Formatiert eine Dauer in Sekunden als „2 Std 14 Min" */ + function dauer(sekunden) { + const s = Math.max(0, Math.floor(sekunden || 0)); + const std = Math.floor(s / 3600); + const min = Math.floor((s % 3600) / 60); + return (std > 0 ? std + " Std " : "") + min + " Min"; + } + + /** Formatiert einen Betrag als Euro */ + function euro(betrag) { + return (Math.round(betrag || 0)).toLocaleString("de-DE") + " €"; + } + + /* ---- NUI-Brücke -------------------------------------------------- */ + + /** Schickt eine Nachricht an den Client */ + function anClient(pfad, daten) { + if (!IST_FIVEM) return Promise.resolve(); + return fetch(`https://${RESSOURCE}/${pfad}`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=UTF-8" }, + body: JSON.stringify(daten || {}), + }).catch(() => {}); + } + + /** + * Ruft eine Modul-Aktion auf dem Server auf. + * Liefert ein Promise, das mit dem Ergebnis aufgelöst wird. + */ + function aufruf(modul, aktion, daten) { + if (!IST_FIVEM) return Promise.reject(new Error("Keine Serververbindung")); + + return new Promise((aufloesen, ablehnen) => { + const ruf = ++rufZaehler; + offeneAufrufe[ruf] = { aufloesen, ablehnen }; + + // Sicherheitsnetz, damit ein Aufruf nicht ewig hängen bleibt + setTimeout(() => { + if (offeneAufrufe[ruf]) { + delete offeneAufrufe[ruf]; + ablehnen(new Error("Zeitüberschreitung")); + } + }, 15000); + + anClient("aufruf", { modul, aktion, daten: daten || {}, ruf }); + }); + } + + /* ---- Meldungen --------------------------------------------------- */ + function meldung(text, art) { + const el = document.createElement("div"); + el.className = "meldung" + (art ? " " + art : ""); + el.textContent = text; + $("meldungen").appendChild(el); + setTimeout(() => el.remove(), 3200); + } + + /* ---- Fenster (Dialoge) ------------------------------------------ */ + + /** + * Öffnet ein Dialogfenster. + * @param {object} o { titel, inhalt, knoepfe: [{text, art?, klick}] } + */ + function fenster(o) { + const ebene = $("fenster-ebene"); + const hg = document.createElement("div"); + hg.className = "fenster-hg"; + + const knoepfe = (o.knoepfe || []).map((k, i) => + `` + ).join(""); + + hg.innerHTML = + `
+
+

${sicher(o.titel || "")}

+ +
+
${o.inhalt || ""}
+ ${knoepfe ? `
${knoepfe}
` : ""} +
`; + + ebene.appendChild(hg); + + const schliessen = () => hg.remove(); + hg.querySelector("[data-zu]").addEventListener("click", schliessen); + hg.addEventListener("click", (e) => { if (e.target === hg) schliessen(); }); + + hg.querySelectorAll("[data-k]").forEach((b) => { + b.addEventListener("click", () => { + const eintrag = o.knoepfe[+b.dataset.k]; + // Der Rückruf entscheidet selbst, ob das Fenster zugeht + if (eintrag.klick) eintrag.klick(hg, schliessen); + else schliessen(); + }); + }); + + if (o.nachOeffnen) o.nachOeffnen(hg); + return { element: hg, schliessen }; + } + + /** + * Fenster mit Bestätigungspflicht. + * Für jede Maßnahme, die ein Beamter selbst verantworten muss. + */ + function bestaetigen(o) { + return fenster({ + titel: o.titel, + inhalt: `${o.inhalt || ""} +
+ ℹ +
Diese Entscheidung triffst du. Das System hat den Vorgang nur + vorbereitet — ausgeführt wird er erst mit deiner Bestätigung.
+
`, + knoepfe: [ + { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() }, + { text: o.bestaetigenText || "Bestätigen", art: o.gefahr ? "gefahr" : "", + klick: (el, zu) => { if (o.bestaetigt) o.bestaetigt(el, zu); } }, + ], + nachOeffnen: o.nachOeffnen, + }); + } + + /* ---- Navigation -------------------------------------------------- */ + + function zeichneNavigation() { + const nav = $("navigation"); + nav.innerHTML = zustand.module.map((m) => + `` + ).join(""); + + nav.querySelectorAll("[data-modul]").forEach((b) => { + b.addEventListener("click", () => oeffneModul(b.dataset.modul)); + }); + } + + /** + * Öffnet ein Modul und lässt seine Ansicht zeichnen. + * @param {string} modulId Modul + * @param {object} ziel Optionaler Sprungbefehl, z. B. { person: 42 }. + * Die Ansicht liest ihn über App.zustand.modulZiel und räumt ihn auf. + */ + async function oeffneModul(modulId, ziel) { + zustand.aktivesModul = modulId; + zustand.modulZiel = ziel || null; + zeichneNavigation(); + + const inhalt = $("inhalt"); + inhalt.innerHTML = `
Lade …
`; + + const ansicht = ansichten[modulId]; + if (!ansicht) { + inhalt.innerHTML = + `

Noch nicht verfügbar

+

Für diesen Bereich ist noch keine Ansicht hinterlegt.

`; + return; + } + + try { + await ansicht.rendern(inhalt); + } catch (fehler) { + inhalt.innerHTML = + `

Fehler

+

${sicher(fehler.message || "Der Bereich konnte nicht geladen werden.")}

`; + } + } + + /* ---- Dienststatus ------------------------------------------------ */ + + function zeichneStatus() { + const aktueller = zustand.dienststatus.find( + (s) => s.id === (zustand.beamter && zustand.beamter.status) + ) || zustand.dienststatus[0]; + + if (aktueller) { + $("status-text").textContent = aktueller.name; + $("status-punkt").style.background = aktueller.farbe; + } + + $("status-liste").innerHTML = zustand.dienststatus.map((s) => + `` + ).join(""); + + $("status-liste").querySelectorAll("[data-status]").forEach((b) => { + b.addEventListener("click", () => { + const neu = b.dataset.status; + if (zustand.beamter) zustand.beamter.status = neu; + anClient("status", { status: neu }); + $("status-liste").hidden = true; + zeichneStatus(); + }); + }); + } + + /* ---- Mitteilungen ------------------------------------------------ */ + + function zeichneHinweise() { + const ungelesen = zustand.hinweise.filter((h) => !h.gelesen).length; + const zaehler = $("hinweise-zaehler"); + zaehler.hidden = ungelesen === 0; + zaehler.textContent = ungelesen > 99 ? "99+" : ungelesen; + + const symbole = { + einsatz: "🚨", fahndung: "🔍", gewahrsam: "🔒", + bericht: "📄", akte: "🗂", beweis: "🔬", system: "ℹ", + }; + + $("hinweise-inhalt").innerHTML = zustand.hinweise.length + ? zustand.hinweise.map((h) => + `
+
+ ${symbole[h.kategorie] || "•"} ${sicher(h.titel)} + ${zeit(h.erstellt_am)} +
+ ${h.text ? `
${sicher(h.text)}
` : ""} +
` + ).join("") + : `
Keine Mitteilungen.
`; + + $("hinweise-inhalt").querySelectorAll("[data-hinweis]").forEach((el) => { + el.addEventListener("click", () => { + const id = +el.dataset.hinweis; + if (!id) return; + const treffer = zustand.hinweise.find((h) => h.id === id); + if (treffer) treffer.gelesen = 1; + aufruf("dashboard", "hinweisGelesen", { id }).catch(() => {}); + zeichneHinweise(); + if (treffer) springeZuHinweis(treffer); + }); + }); + } + + /** + * Öffnet das Modul, zu dem ein Hinweis gehört. + * Nur Ziele, die eine Ansicht auch auswertet, werden mitgegeben — + * alles andere öffnet schlicht das passende Modul. + */ + function springeZuHinweis(hinweis) { + if (!hinweis.zielTyp) return; + + // zielTyp → [Modul, Schlüssel des Sprungziels oder null] + const wege = { + akte: ["mdt", "akte"], + nachricht: ["kommunikation", "nachricht"], + aushang: ["kommunikation", "aushang"], + einsatz: ["einsatz", null], + fahndung: ["fahndung", null], + gewahrsam: ["gewahrsam", null], + bericht: ["bericht", null], + beweis: ["beweis", null], + }; + + const weg = wege[hinweis.zielTyp]; + if (!weg) return; + + // Nur öffnen, wenn das Modul für diesen Beamten überhaupt sichtbar ist + if (!zustand.module.some((m) => m.id === weg[0])) return; + + const ziel = weg[1] && hinweis.zielId ? { [weg[1]]: +hinweis.zielId } : null; + oeffneModul(weg[0], ziel); + $("hinweise-schublade").hidden = true; + } + + /** Nimmt einen neu eingetroffenen Hinweis auf */ + function neuerHinweis(hinweis) { + zustand.hinweise.unshift(Object.assign({ erstellt_am: new Date().toISOString() }, hinweis)); + if (zustand.hinweise.length > 50) zustand.hinweise.pop(); + zeichneHinweise(); + meldung(hinweis.titel, hinweis.prioritaet === "kritisch" ? "fehler" : ""); + + // Offene Ansicht auffrischen, falls sie das betrifft + const ansicht = ansichten[zustand.aktivesModul]; + if (ansicht && ansicht.aktualisieren) ansicht.aktualisieren(); + } + + /* ---- Aufbau bei Öffnen ------------------------------------------ */ + + function zeigeTablet(daten) { + zustand.imDienst = true; + zustand.beamter = daten.beamter; + zustand.module = daten.module || []; + zustand.dienststatus = daten.status || []; + zustand.abteilungen = daten.abteilungen || []; + + $("anmeldung").hidden = true; + $("tablet").hidden = false; + + $("tb-kuerzel").textContent = daten.kuerzel || "POL"; + $("tb-behoerde").textContent = daten.behoerde || "Dienstsystem"; + $("tb-name").textContent = daten.beamter.name; + $("tb-rolle").textContent = daten.beamter.rolleName + + (daten.beamter.leitstelle ? " · Leitstelle" : ""); + $("tb-nummer").textContent = daten.beamter.dienstnummer; + + zeichneNavigation(); + zeichneStatus(); + + // Erstes Modul öffnen + if (zustand.module.length) oeffneModul(zustand.module[0].id); + } + + function zeigeAnmeldung(daten) { + zustand.imDienst = false; + $("tablet").hidden = true; + $("anmeldung").hidden = false; + + $("an-behoerde").textContent = daten.behoerde || ""; + $("an-name").textContent = daten.name || "—"; + $("an-nummer").textContent = daten.dienstnummer || "—"; + $("an-rolle").textContent = daten.rolleName || "—"; + $("an-abteilung").textContent = daten.abteilung || "—"; + } + + /* ---- Nachrichten vom Client ------------------------------------- */ + + window.addEventListener("message", (ereignis) => { + const n = ereignis.data || {}; + + switch (n.aktion) { + case "oeffnen": + document.body.style.display = ""; + zeigeTablet(n); + break; + + case "anmeldung": + document.body.style.display = ""; + zeigeAnmeldung(n); + break; + + case "antwort": { + const eintrag = offeneAufrufe[n.ruf]; + if (!eintrag) break; + delete offeneAufrufe[n.ruf]; + if (n.fehler) eintrag.ablehnen(new Error(n.fehler)); + else eintrag.aufloesen(n.ergebnis); + break; + } + + case "dienstliste": + zustand.beamteImDienst = n.beamte || []; + { + const ansicht = ansichten[zustand.aktivesModul]; + if (ansicht && ansicht.aktualisieren) ansicht.aktualisieren(); + } + break; + + case "hinweis": + neuerHinweis(n.hinweis || {}); + break; + + case "fehler": + meldung(n.text || "Aktion nicht möglich", "fehler"); + break; + + case "schliessen": + document.body.style.display = "none"; + break; + } + }); + + /* ---- Bedienelemente der Shell ----------------------------------- */ + + function verdrahteShell() { + $("tablet-schliessen").addEventListener("click", () => anClient("schliessen", {})); + $("an-abbrechen").addEventListener("click", () => anClient("schliessen", {})); + $("an-antreten").addEventListener("click", () => anClient("dienstAntreten", {})); + + $("dienst-beenden").addEventListener("click", () => { + bestaetigen({ + titel: "Dienst beenden", + inhalt: `

+ Deine Dienstzeit wird protokolliert. Laufende Vorgänge bleiben bestehen und + können von anderen Beamten weitergeführt werden.

`, + bestaetigenText: "Dienst beenden", + bestaetigt: (_, zu) => { anClient("dienstBeenden", {}); zu(); }, + }); + }); + + $("status-knopf").addEventListener("click", () => { + $("status-liste").hidden = !$("status-liste").hidden; + }); + + $("hinweise-knopf").addEventListener("click", () => { + const schublade = $("hinweise-schublade"); + schublade.hidden = !schublade.hidden; + if (!schublade.hidden) zeichneHinweise(); + }); + $("hinweise-zu").addEventListener("click", () => { $("hinweise-schublade").hidden = true; }); + + $("thema-wechsel").addEventListener("click", () => { + document.body.classList.toggle("thema-dunkel"); + document.body.classList.toggle("thema-hell"); + }); + + // Klick ausserhalb schliesst die Statusliste + document.addEventListener("click", (e) => { + if (!$("status-waehler").contains(e.target)) $("status-liste").hidden = true; + }); + + // ESC schliesst Fenster, sonst das Tablet + document.addEventListener("keydown", (e) => { + if (e.key !== "Escape") return; + const offeneFenster = $("fenster-ebene").lastElementChild; + if (offeneFenster) { offeneFenster.remove(); return; } + if (!$("hinweise-schublade").hidden) { $("hinweise-schublade").hidden = true; return; } + anClient("schliessen", {}); + }); + + // Globale Suche (wird von den Fachmodulen bedient) + $("global-suche").addEventListener("keydown", (e) => { + if (e.key === "Enter") { + const begriff = e.target.value.trim(); + if (begriff.length >= 2) globaleSuche(begriff); + } + }); + } + + /** Globale Suche über alle Module, die sich dafür anmelden */ + const suchAnbieter = []; + function registriereSuche(funktion) { suchAnbieter.push(funktion); } + + async function globaleSuche(begriff) { + if (!suchAnbieter.length) { + meldung("Es ist noch kein durchsuchbarer Bereich verfügbar."); + return; + } + + const treffer = []; + for (const anbieter of suchAnbieter) { + try { + const ergebnis = await anbieter(begriff); + if (Array.isArray(ergebnis)) treffer.push(...ergebnis); + } catch (e) { /* einzelne Anbieter dürfen scheitern */ } + } + + fenster({ + titel: `Suche: „${begriff}"`, + inhalt: treffer.length + ? `
${treffer.map((t) => + `
+ ${sicher(t.bereich)} +
+
${sicher(t.titel)}
+
${sicher(t.unter || "")}
+
+
`).join("")}
` + : `
Keine Treffer.
`, + }); + } + + /* ---- Öffentliche Schnittstelle ---------------------------------- */ + return { + /** Registriert eine Modulansicht */ + registriereAnsicht(modulId, ansicht) { ansichten[modulId] = ansicht; }, + /** Registriert einen Anbieter für die globale Suche */ + registriereSuche, + zustand, aufruf, meldung, fenster, bestaetigen, + sicher, zeit, dauer, euro, $, + anClient, oeffneModul, + start() { + verdrahteShell(); + if (IST_FIVEM) document.body.style.display = "none"; + else { + // Eigenständige Browser-Ansicht: Anmeldebildschirm zeigen + zeigeAnmeldung({ + behoerde: "Los Santos Police Department", name: "Vorschau", + dienstnummer: "LSPD-0000", rolleName: "Officer", abteilung: "streife", + }); + } + }, + }; +})(); + +App.start(); diff --git a/resources/[aether]/aether-police/html/js/module/admin.js b/resources/[aether]/aether-police/html/js/module/admin.js new file mode 100644 index 0000000..3685b5c --- /dev/null +++ b/resources/[aether]/aether-police/html/js/module/admin.js @@ -0,0 +1,495 @@ +/* ===================================================================== + aether-police / Ansicht: Administration + + Module an- und abschalten, Strafenkatalog pflegen, Protokoll lesen. + Die Reiter richten sich nach den Rechten des Beamten — wer nur das + Protokoll lesen darf, sieht auch nur dieses. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, euro, aufruf, meldung, fenster, bestaetigen } = App; + + const z = { + bereiche: null, // welche Reiter erlaubt sind + reiter: null, + daten: null, + katalogSuche: "", + katalogKategorie: "", + katalogStillgelegt: false, + protokollModul: "", + protokollNummer: "", + }; + + /* ---- Reiter ---------------------------------------------------------- */ + + function reiterleiste() { + const moeglich = [ + { id: "module", text: "Module", erlaubt: z.bereiche.module }, + { id: "katalog", text: "Strafenkatalog", erlaubt: z.bereiche.katalog }, + { id: "protokoll", text: "Protokoll", erlaubt: z.bereiche.protokoll }, + ].filter((e) => e.erlaubt); + + return `
+ ${moeglich.map((e) => ` + `).join("")} +
`; + } + + function verdrahteReiter(behaelter) { + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.reiter = el.dataset.reiter; + rendern(behaelter); + }); + }); + } + + /* ---- Module ----------------------------------------------------------- */ + + function moduleAnsicht(behaelter) { + const d = z.daten; + const aktiv = d.module.filter((m) => m.aktiv).length; + + behaelter.innerHTML = ` +
+
+

Module

+

Jeder Baustein lässt sich einzeln abschalten. Abgeschaltete Module + verschwinden aus der Seitenleiste — ihre Daten bleiben unangetastet.

+
+
+ + ${reiterleiste()} + +
+
${aktiv}/${d.module.length} + Module aktiv
+
${d.beamteImDienst} + Beamte im Dienst
+
+ ${d.module.reduce((s, m) => s + m.aktionen, 0)} + registrierte Aktionen
+
+ +
+
Registrierte Module
+
+ ${d.module.map(modulZeile).join("")} +
+
+ +
+
+ Konfiguration + Steht in config.lua — Änderung erfordert Neustart +
+
+ ${d.konfiguration.map((k) => + `
${sicher(k.name)}${sicher(k.wert)}
`).join("")} +
+
+ `; + + verdrahteModule(behaelter); + } + + function modulZeile(m) { + return `
+ ${sicher(m.icon)} +
+
${sicher(m.name)}
+
+ ${sicher(m.beschreibung || "")} + ${m.rechtName ? ` · Recht: ${sicher(m.rechtName)}` + : m.recht ? ` · Recht: ${sicher(String(m.recht))}` : " · für alle sichtbar"} +
+
+ ${m.aktionen} Aktionen + ${m.unverzichtbar + ? ` + unverzichtbar` + : ``} +
`; + } + + function verdrahteModule(behaelter) { + verdrahteReiter(behaelter); + + behaelter.querySelectorAll("[data-schalten]").forEach((el) => { + el.addEventListener("click", () => { + const id = el.dataset.schalten; + const einschalten = el.dataset.wert === "1"; + const modul = z.daten.module.find((m) => m.id === id); + + bestaetigen({ + titel: einschalten ? "Modul einschalten" : "Modul abschalten", + gefahr: !einschalten, + inhalt: `

${sicher(modul.name)} wird + ${einschalten ? "wieder in der Seitenleiste erscheinen" + : "für alle Beamten aus der Seitenleiste verschwinden"}.

+ ${einschalten ? "" : `

Vorhandene Daten bleiben erhalten + und sind nach dem Wiedereinschalten unverändert da. Beamte, die das Tablet + gerade offen haben, sehen die Änderung beim nächsten Öffnen.

`} +
+
`, + bestaetigenText: einschalten ? "Einschalten" : "Abschalten", + bestaetigt: async (dialog, zu) => { + try { + await aufruf("admin", "modulSchalten", { + id, aktiv: einschalten, + grund: dialog.querySelector("[name=grund]").value.trim(), + }); + zu(); + meldung(einschalten ? "Modul eingeschaltet." : "Modul abgeschaltet.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + }); + } + + /* ---- Strafenkatalog ---------------------------------------------------- */ + + function katalogAnsicht(behaelter) { + const d = z.daten; + + // Delikte nach Kategorie gruppieren + const gruppen = {}; + d.delikte.forEach((delikt) => { + (gruppen[delikt.kategorie] = gruppen[delikt.kategorie] || []).push(delikt); + }); + + behaelter.innerHTML = ` +
+
+

Strafenkatalog

+

Die Werte hier sind Vorschläge. Sie werden nie automatisch verhängt — + ein Beamter kann jederzeit begründet davon abweichen.

+
+ +
+ + ${reiterleiste()} + +
+ + + +
+ + ${Object.keys(gruppen).length + ? Object.keys(gruppen).map((kategorie) => ` +
+
+ ${sicher(kategorie)} + ${gruppen[kategorie].length} Delikte +
+
${gruppen[kategorie].map(deliktZeile).join("")}
+
`).join("") + : `
Kein Delikt gefunden.
`} + `; + + verdrahteKatalog(behaelter); + } + + function deliktZeile(delikt) { + return `
+ ${sicher(delikt.kennung)} +
+
${sicher(delikt.bezeichnung)}
+
+ ${sicher(delikt.paragraph || "ohne Paragraf")} + ${delikt.hinweis ? " · " + sicher(delikt.hinweis) : ""} +
+
+ + ${delikt.haft || 0} Min + + ${euro(delikt.geld || 0)} + ${delikt.aktiv ? "" : `stillgelegt`} + + +
`; + } + + function verdrahteKatalog(behaelter) { + verdrahteReiter(behaelter); + + const suchfeld = behaelter.querySelector("[data-suche]"); + let warten = null; + suchfeld.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(() => { + z.katalogSuche = suchfeld.value.trim(); + rendern(behaelter); + }, 260); + }); + + behaelter.querySelector("[data-kategorie]").addEventListener("change", (e) => { + z.katalogKategorie = e.target.value; + rendern(behaelter); + }); + + behaelter.querySelector("[data-stillgelegt]").addEventListener("click", () => { + z.katalogStillgelegt = !z.katalogStillgelegt; + rendern(behaelter); + }); + + behaelter.querySelector("[data-neu]").addEventListener("click", () => + deliktFenster(behaelter, null)); + + behaelter.querySelectorAll("[data-bearbeiten]").forEach((el) => { + el.addEventListener("click", () => { + const delikt = z.daten.delikte.find((d) => d.id === +el.dataset.bearbeiten); + deliktFenster(behaelter, delikt); + }); + }); + + behaelter.querySelectorAll("[data-stilllegen]").forEach((el) => { + el.addEventListener("click", () => { + const delikt = z.daten.delikte.find((d) => d.id === +el.dataset.stilllegen); + const freigeben = el.dataset.wert === "1"; + + bestaetigen({ + titel: freigeben ? "Delikt freigeben" : "Delikt stilllegen", + gefahr: !freigeben, + inhalt: `

${sicher(delikt.kennung)} — ${sicher(delikt.bezeichnung)}

+

${freigeben + ? "Das Delikt steht wieder zur Auswahl." + : "Das Delikt lässt sich für neue Anzeigen nicht mehr auswählen."}

+

Gelöscht wird nichts — bestehende Anzeigen behalten + ihren Eintrag und bleiben nachvollziehbar.

+
+
`, + bestaetigenText: freigeben ? "Freigeben" : "Stilllegen", + bestaetigt: async (dialog, zu) => { + try { + await aufruf("admin", "katalogSchalten", { + id: delikt.id, aktiv: freigeben, + grund: dialog.querySelector("[name=grund]").value.trim(), + }); + zu(); + meldung(freigeben ? "Delikt freigegeben." : "Delikt stillgelegt.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + }); + } + + function deliktFenster(behaelter, delikt) { + const neu = !delikt; + delikt = delikt || { kennung: "", kategorie: "", bezeichnung: "", paragraph: "", + haft: 0, geld: 0, hinweis: "", sortierung: 0 }; + + fenster({ + titel: neu ? "Delikt anlegen" : "Delikt bearbeiten", + inhalt: ` +
+
+
+
+ + + ${z.daten.kategorien.map((k) => + `
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+ ℹ +
Diese Werte sind Vorschläge. Das System verhängt sie nie — + es rechnet sie nur zusammen und legt sie dem Beamten vor.
+
`, + knoepfe: [ + { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() }, + { text: neu ? "Anlegen" : "Speichern", klick: async (el, zu) => { + const feld = (name) => el.querySelector(`[name=${name}]`).value.trim(); + try { + await aufruf("admin", "katalogSpeichern", { + id: neu ? null : delikt.id, + kennung: feld("kennung"), + kategorie: feld("kategorie"), + bezeichnung: feld("bezeichnung"), + paragraph: feld("paragraph"), + haft: +feld("haft"), + geld: +feld("geld"), + hinweis: feld("hinweis"), + sortierung: +feld("sortierung"), + }); + zu(); + meldung(neu ? "Delikt angelegt." : "Delikt gespeichert.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + } }, + ], + }); + } + + /* ---- Protokoll ---------------------------------------------------------- */ + + function protokollAnsicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Protokoll

+

Jede Entscheidung im System steht hier mit Beamtem, Zeit und Begründung. + Einträge lassen sich weder ändern noch entfernen.

+
+ ${d.gesamt.toLocaleString("de-DE")} Einträge gesamt +
+ + ${reiterleiste()} + +
+ + + +
+ +
+ ${d.eintraege.length + ? d.eintraege.map(protokollZeile).join("") + : `
${d.unbekannteNummer + ? "Diese Dienstnummer gibt es nicht." + : "Kein Eintrag gefunden."}
`} +
+ `; + + verdrahteProtokoll(behaelter); + } + + function protokollZeile(e) { + // Details als kurze Schlüssel-Wert-Kette + const details = e.details && typeof e.details === "object" + ? Object.keys(e.details).map((k) => + `${sicher(k)}: ${sicher(String(e.details[k]))}`).join(" · ") + : ""; + + return `
+ ${sicher(e.modul)} +
+
${sicher(e.aktion)}
+
+ ${sicher(e.name)}${e.dienstnummer ? " (" + sicher(e.dienstnummer) + ")" : ""} + ${e.ziel_typ ? ` · ${sicher(e.ziel_typ)} ${sicher(e.ziel_id || "")}` : ""} + ${details ? ` · ${details}` : ""} +
+ ${e.grund ? `
„${sicher(e.grund)}"
` : ""} +
+ ${zeit(e.am || e.erstellt_am)} +
`; + } + + function verdrahteProtokoll(behaelter) { + verdrahteReiter(behaelter); + + behaelter.querySelector("[data-modul]").addEventListener("change", (e) => { + z.protokollModul = e.target.value; + rendern(behaelter); + }); + + const nummer = behaelter.querySelector("[data-nummer]"); + let warten = null; + nummer.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(() => { + z.protokollNummer = nummer.value.trim(); + rendern(behaelter); + }, 320); + }); + + behaelter.querySelector("[data-zuruecksetzen]").addEventListener("click", () => { + z.protokollModul = ""; + z.protokollNummer = ""; + rendern(behaelter); + }); + } + + /* ---- Laden -------------------------------------------------------------- */ + + async function rendern(behaelter) { + try { + // Erlaubte Bereiche einmal holen und den Startreiter danach wählen + if (!z.bereiche) { + z.bereiche = await aufruf("admin", "bereiche", {}); + z.reiter = z.bereiche.module ? "module" + : z.bereiche.katalog ? "katalog" + : z.bereiche.protokoll ? "protokoll" : null; + } + + if (!z.reiter) { + behaelter.innerHTML = `

Administration

+

Für keinen Bereich der Administration hast du eine Berechtigung.

`; + return; + } + + if (z.reiter === "katalog") { + z.daten = await aufruf("admin", "katalog", { + begriff: z.katalogSuche, kategorie: z.katalogKategorie, + stillgelegt: z.katalogStillgelegt, + }); + katalogAnsicht(behaelter); + } else if (z.reiter === "protokoll") { + z.daten = await aufruf("admin", "protokoll", { + modul: z.protokollModul, dienstnummer: z.protokollNummer, limit: 200, + }); + protokollAnsicht(behaelter); + } else { + z.daten = await aufruf("admin", "module", {}); + moduleAnsicht(behaelter); + } + } catch (f) { + behaelter.innerHTML = `

Administration

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("admin", { rendern }); +})(); diff --git a/resources/[aether]/aether-police/html/js/module/anzeige.js b/resources/[aether]/aether-police/html/js/module/anzeige.js new file mode 100644 index 0000000..b008a44 --- /dev/null +++ b/resources/[aether]/aether-police/html/js/module/anzeige.js @@ -0,0 +1,553 @@ +/* ===================================================================== + aether-police / Ansicht: Strafenkatalog & Anzeigen + + Der Katalog liefert einen VORSCHLAG. Die Felder darunter gehören dem + Beamten — er kann sie jederzeit ändern. Weicht er ab, wird das + sichtbar gemacht und eine Begründung verlangt. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, euro, aufruf, meldung, fenster, bestaetigen, $ } = App; + + const z = { + reiter: "erfassen", // erfassen | vorgaenge | katalog + katalog: [], + rechte: {}, + auswahl: new Set(), // gewählte Delikt-Kennungen + vorschlag: null, // { delikte, haft, geld, hinweise } + beschuldigter: null, // { id, name } + anzeigen: [], + filterStatus: "", + }; + + /* ---- Bausteine --------------------------------------------------- */ + + function reiterLeiste() { + const reiter = [ + { id: "erfassen", name: "Anzeige erfassen", symbol: "✎" }, + { id: "vorgaenge", name: "Vorgänge", symbol: "📋" }, + ]; + if (z.rechte.bearbeiten) reiter.push({ id: "katalog", name: "Katalog pflegen", symbol: "⚙" }); + + return `
${reiter.map((r) => + ``).join("")}
`; + } + + /** Zeigt Minuten als „1 Std 30 Min" */ + function haftText(minuten) { + const m = Math.max(0, Math.floor(minuten || 0)); + if (m === 0) return "keine Haft"; + if (m < 60) return m + " Min"; + return Math.floor(m / 60) + " Std " + (m % 60 ? (m % 60) + " Min" : ""); + } + + /* ===================================================================== + ANZEIGE ERFASSEN + ===================================================================== */ + + function katalogAuswahl() { + if (!z.katalog.length) return `
Der Katalog ist leer.
`; + + return z.katalog.map((gruppe) => ` +
+
${sicher(gruppe.kategorie)}
+ ${gruppe.delikte.map((d) => ` + `).join("")} +
`).join(""); + } + + function erfassenAnsicht(behaelter) { + const v = z.vorschlag; + + behaelter.innerHTML = ` +
+
+

Anzeige erfassen

+

Der Katalog schlägt vor — entschieden wird von dir.

+
+
+ + ${reiterLeiste()} + +
+
+
+ Strafenkatalog + +
+
${katalogAuswahl()}
+
+ +
+
Vorgang
+ +
+ +
+ + +
+ ${z.beschuldigter + ? `
Ausgewählt: ${sicher(z.beschuldigter.name)}
` + : `
Ohne Beschuldigten kann keine Anzeige erfasst werden.
`} +
+ + ${v ? ` +
+
+ Vorschlag des Katalogs + ${v.delikte.length} Delikt${v.delikte.length === 1 ? "" : "e"} +
+
+
Haft${haftText(v.haft)}
+
Geldstrafe${euro(v.geld)}
+
+ ${v.hinweise.length ? ` +
+ ${v.hinweise.map((h) => `
ℹ ${sicher(h)}
`).join("")} +
` : ""} +
+ +
+
Deine Entscheidung
+
+
+ + +
+
+ + +
+
+ + + +
+ ℹ +
Der Katalog ist ein Nachschlagewerk. Beide Werte lassen sich frei + anpassen — gespeichert werden Vorschlag und Entscheidung getrennt, damit + nachvollziehbar bleibt, wer was festgelegt hat.
+
+ + +
+ Es wird noch nichts eingezogen und niemand kommt in Haft. +
+
` + : `
Wähle links mindestens ein Delikt aus.
`} +
+
`; + + verdrahteErfassen(behaelter); + } + + function verdrahteErfassen(behaelter) { + // Delikt an-/abwählen + behaelter.querySelectorAll("[data-delikt]").forEach((k) => + k.addEventListener("change", async () => { + const kennung = k.dataset.delikt; + if (k.checked) z.auswahl.add(kennung); else z.auswahl.delete(kennung); + await berechneVorschlag(); + erfassenAnsicht(behaelter); + })); + + // Katalogsuche (nur Anzeige filtern) + const suche = $("an-katalogsuche"); + if (suche) suche.addEventListener("input", () => { + const begriff = suche.value.trim().toLowerCase(); + behaelter.querySelectorAll(".delikt").forEach((el) => { + const text = el.textContent.toLowerCase(); + el.style.display = !begriff || text.includes(begriff) ? "" : "none"; + }); + behaelter.querySelectorAll(".katalog-gruppe").forEach((g) => { + const sichtbar = [...g.querySelectorAll(".delikt")].some((e) => e.style.display !== "none"); + g.style.display = sichtbar ? "" : "none"; + }); + }); + + // Person suchen + const personSuche = () => { + const feld = $("an-person"); + const begriff = feld.value.trim(); + if (begriff.length < 2) { meldung("Bitte mindestens zwei Zeichen.", "fehler"); return; } + personWaehlen(behaelter, begriff); + }; + const personKnopf = $("an-personsuche"); + if (personKnopf) personKnopf.addEventListener("click", personSuche); + const personFeld = $("an-person"); + if (personFeld) personFeld.addEventListener("keydown", (e) => { if (e.key === "Enter") personSuche(); }); + + // Abweichung sichtbar machen + const pruefeAbweichung = () => { + if (!z.vorschlag) return; + const haft = +($("an-haft") || {}).value || 0; + const geld = +($("an-geld") || {}).value || 0; + const abweichung = haft !== z.vorschlag.haft || geld !== z.vorschlag.geld; + const block = $("an-abweichung"); + if (block) block.hidden = !abweichung; + }; + ["an-haft", "an-geld"].forEach((id) => { + const el = $(id); + if (el) el.addEventListener("input", pruefeAbweichung); + }); + + // Erfassen + const speichern = $("an-speichern"); + if (speichern) speichern.addEventListener("click", async () => { + if (!z.beschuldigter) { meldung("Bitte eine Person auswählen.", "fehler"); return; } + + const haft = +($("an-haft") || {}).value || 0; + const geld = +($("an-geld") || {}).value || 0; + const abweichung = haft !== z.vorschlag.haft || geld !== z.vorschlag.geld; + const grund = ($("an-grund") || {}).value || ""; + + if (abweichung && !z.rechte.abweichen) { + meldung("Dir fehlt die Berechtigung, vom Vorschlag abzuweichen.", "fehler"); + return; + } + if (abweichung && grund.trim().length < 5) { + meldung("Bitte begründe die Abweichung.", "fehler"); + return; + } + + try { + await aufruf("anzeige", "erstellen", { + beschuldigter: z.beschuldigter.id, + delikte: [...z.auswahl], + haft, geld, begruendung: grund, + }); + meldung("Als Entwurf erfasst — Bestätigung steht noch aus.", "gut"); + z.auswahl.clear(); z.vorschlag = null; z.beschuldigter = null; + z.reiter = "vorgaenge"; + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }); + } + + /** Holt den Vorschlag zur aktuellen Auswahl vom Server */ + async function berechneVorschlag() { + if (!z.auswahl.size) { z.vorschlag = null; return; } + try { + z.vorschlag = await aufruf("anzeige", "vorschlag", { delikte: [...z.auswahl] }); + } catch (f) { meldung(f.message, "fehler"); z.vorschlag = null; } + } + + /** Personensuche über das MDT-Modul */ + async function personWaehlen(behaelter, begriff) { + try { + const d = await aufruf("mdt", "personSuchen", { begriff }); + const treffer = d.treffer || []; + + fenster({ + titel: "Person auswählen", + inhalt: treffer.length + ? `
${treffer.map((p) => + `
+
+
${sicher(p.firstname + " " + p.lastname)}
+
geb. ${sicher(String(p.dob || "").slice(0, 10))}
+
+
`).join("")}
` + : `
Keine Treffer.
`, + nachOeffnen: (el) => { + el.querySelectorAll("[data-wahl]").forEach((zeile) => + zeile.addEventListener("click", () => { + z.beschuldigter = { id: +zeile.dataset.wahl, name: zeile.dataset.name }; + el.remove(); + erfassenAnsicht(behaelter); + })); + }, + }); + } catch (f) { meldung(f.message, "fehler"); } + } + + /* ===================================================================== + VORGÄNGE + ===================================================================== */ + + function vorgaengeAnsicht(behaelter) { + const statusArt = { entwurf: "warn", bestaetigt: "info", verworfen: "neutral" }; + const statusText = { entwurf: "Entwurf", bestaetigt: "Bestätigt", verworfen: "Verworfen" }; + + behaelter.innerHTML = ` +
+

Vorgänge

Erfasste Anzeigen und ihr Bearbeitungsstand

+ +
+ + ${reiterLeiste()} + +
+ ${z.anzeigen.length ? `
${z.anzeigen.map((a) => ` +
+
+
+ ${sicher((a.b_vorname || "") + " " + (a.b_nachname || ""))} + ${a.abweichung ? 'abweichend' : ""} +
+
+ ${sicher((a.delikte || []).map((d) => d.name).join(", ") || "—")} +
+
+ ${haftText(a.haft_final)} · ${euro(a.geld_final)} + ${a.abweichung ? ` (Vorschlag: ${haftText(a.haft_empfohlen)} · ${euro(a.geld_empfohlen)})` : ""} + · erfasst von ${sicher((a.e_vorname || "") + " " + (a.e_nachname || ""))} · ${zeit(a.erstellt_am)} +
+ ${a.begruendung ? `
„${sicher(a.begruendung)}"
` : ""} +
+
+ ${sicher(statusText[a.status] || a.status)} + ${a.status === "entwurf" && z.rechte.bestaetigen + ? `` : ""} + ${a.status === "entwurf" + ? `` : ""} +
+
`).join("")}
` + : `
Keine Vorgänge.
`} +
`; + + $("an-filter").addEventListener("change", (e) => { + z.filterStatus = e.target.value; + rendern(behaelter); + }); + + behaelter.querySelectorAll("[data-bestaetigen]").forEach((b) => + b.addEventListener("click", () => bestaetigenDialog(behaelter, +b.dataset.bestaetigen))); + + behaelter.querySelectorAll("[data-verwerfen]").forEach((b) => + b.addEventListener("click", () => { + bestaetigen({ + titel: "Entwurf verwerfen", + gefahr: true, + inhalt: `
+
`, + bestaetigenText: "Verwerfen", + bestaetigt: async (el, zu) => { + try { + await aufruf("anzeige", "verwerfen", { + id: +b.dataset.verwerfen, + grund: el.querySelector("#av-grund").value.trim(), + }); + zu(); meldung("Entwurf verworfen."); rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + })); + } + + /** Bestätigungsdialog — benennt genau, was ausgeführt wird */ + function bestaetigenDialog(behaelter, anzeigeId) { + const a = z.anzeigen.find((x) => x.id === anzeigeId); + if (!a) return; + + bestaetigen({ + titel: "Anzeige bestätigen", + inhalt: ` +

+ ${sicher((a.b_vorname || "") + " " + (a.b_nachname || ""))}
+ ${sicher((a.delikte || []).map((d) => d.name).join(", "))} +

+ +
+
Mit deiner Bestätigung geschieht Folgendes:
+
+ 💶 +
Die Geldstrafe von ${euro(a.geld_final)} wird eingezogen und + der Staatskasse zugeführt.
+
+
+ 🔒 +
${a.haft_final > 0 + ? `Die festgelegte Haftdauer von ${haftText(a.haft_final)} wird + vermerkt — eine Einweisung in Gewahrsam erfolgt dadurch nicht. + Das ist ein eigener Schritt.` + : "Es ist keine Haft vorgesehen."}
+
+
+ 📄 +
Der Vorgang wird mit deinem Namen als bestätigend festgehalten.
+
+
+ + ${a.abweichung ? ` +
+
⚠ Abweichung vom Katalogvorschlag
+
+ Vorschlag war ${haftText(a.haft_empfohlen)} · ${euro(a.geld_empfohlen)}. + ${a.begruendung ? `Begründung: „${sicher(a.begruendung)}"` : "Keine Begründung hinterlegt."} +
+
` : ""}`, + bestaetigenText: "Bestätigen", + bestaetigt: async (_, zu) => { + try { + const ergebnis = await aufruf("anzeige", "bestaetigen", { id: anzeigeId }); + zu(); + meldung(ergebnis.eingezogen > 0 + ? "Bestätigt · " + euro(ergebnis.eingezogen) + " eingezogen." + : "Bestätigt.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ===================================================================== + KATALOGPFLEGE + ===================================================================== */ + + function katalogAnsicht(behaelter) { + behaelter.innerHTML = ` +
+

Strafenkatalog pflegen

+

Die Werte hier sind Empfehlungen — sie binden niemanden.

+ +
+ + ${reiterLeiste()} + +
+ ${z.katalog.map((gruppe) => ` +
+
${sicher(gruppe.kategorie)}
+
${gruppe.delikte.map((d) => ` +
+ ${sicher(d.kennung)} +
+
${sicher(d.bezeichnung)}
+
${d.paragraph ? sicher(d.paragraph) + " · " : ""} + ${haftText(d.haft)} · ${euro(d.geld)} + ${d.hinweis ? " · " + sicher(d.hinweis) : ""}
+
+ + +
`).join("")}
+
`).join("")} +
`; + + $("kat-neu").addEventListener("click", () => katalogDialog(behaelter, null)); + + behaelter.querySelectorAll("[data-kat-bearbeiten]").forEach((b) => + b.addEventListener("click", () => { + const kennung = b.dataset.katBearbeiten; + let eintrag = null; + z.katalog.forEach((g) => g.delikte.forEach((d) => { if (d.kennung === kennung) eintrag = d; })); + katalogDialog(behaelter, eintrag); + })); + + behaelter.querySelectorAll("[data-kat-weg]").forEach((b) => + b.addEventListener("click", () => { + bestaetigen({ + titel: "Eintrag deaktivieren", + inhalt: `

+ Der Eintrag verschwindet aus der Auswahl, bleibt aber in bestehenden Anzeigen + nachvollziehbar. Gelöscht wird nichts.

`, + bestaetigenText: "Deaktivieren", + bestaetigt: async (_, zu) => { + try { + await aufruf("anzeige", "katalogDeaktivieren", { kennung: b.dataset.katWeg }); + zu(); meldung("Eintrag deaktiviert."); rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + })); + } + + function katalogDialog(behaelter, eintrag) { + const e = eintrag || {}; + fenster({ + titel: eintrag ? "Eintrag ändern" : "Neuer Katalogeintrag", + inhalt: ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Speichern", klick: async (el, zu) => { + try { + await aufruf("anzeige", "katalogSpeichern", { + kennung: el.querySelector("#kd-kennung").value.trim(), + kategorie: el.querySelector("#kd-kategorie").value.trim(), + bezeichnung: el.querySelector("#kd-bezeichnung").value.trim(), + paragraph: el.querySelector("#kd-paragraph").value.trim(), + haft: +el.querySelector("#kd-haft").value, + geld: +el.querySelector("#kd-geld").value, + hinweis: el.querySelector("#kd-hinweis").value.trim(), + }); + zu(); meldung("Gespeichert.", "gut"); rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + } + + /* ---- Laden -------------------------------------------------------- */ + + async function rendern(behaelter) { + try { + const k = await aufruf("anzeige", "katalog", {}); + z.katalog = k.katalog; + z.rechte = k.rechte; + + if (z.reiter === "vorgaenge") { + const v = await aufruf("anzeige", "liste", { status: z.filterStatus }); + z.anzeigen = v.anzeigen; + z.rechte = Object.assign({}, z.rechte, v.rechte); + vorgaengeAnsicht(behaelter); + } else if (z.reiter === "katalog") { + katalogAnsicht(behaelter); + } else { + erfassenAnsicht(behaelter); + } + + behaelter.querySelectorAll("[data-reiter]").forEach((b) => + b.addEventListener("click", () => { z.reiter = b.dataset.reiter; rendern(behaelter); })); + + } catch (f) { + behaelter.innerHTML = `

Anzeigen

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("anzeige", { rendern }); +})(); diff --git a/resources/[aether]/aether-police/html/js/module/asservat.js b/resources/[aether]/aether-police/html/js/module/asservat.js new file mode 100644 index 0000000..4c43f96 --- /dev/null +++ b/resources/[aether]/aether-police/html/js/module/asservat.js @@ -0,0 +1,394 @@ +/* ===================================================================== + aether-police / Ansicht: Asservatenkammer + + Jedes Objekt trägt eine lückenlose Verwahrkette: wer hat es + eingelagert, ausgegeben, zurückgenommen oder vernichtet. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $ } = App; + + const z = { + daten: null, + offen: null, + filterKategorie: "", + filterStatus: "", + suche: "", + }; + + const katSymbol = { + waffe: "🔫", drogen: "💊", bargeld: "💵", schmuck: "💍", + datentraeger: "💾", dokument: "📄", sonstiges: "📦", + }; + + const statusArt = { + eingelagert: "gut", ausgegeben: "warn", + vernichtet: "kritisch", zurueckgegeben: "neutral", + }; + const statusText = { + eingelagert: "Eingelagert", ausgegeben: "Ausgegeben", + vernichtet: "Vernichtet", zurueckgegeben: "Zurückgegeben", + }; + + function katName(id) { + const treffer = (z.daten.kategorien || []).find((k) => k.id === id); + return treffer ? treffer.name : id; + } + + /* ---- Übersicht ---------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Asservatenkammer

+

Jeder Schritt wird mit Namen festgehalten — die Verwahrkette bleibt lückenlos.

+
+ ${d.rechte.einlagern ? `` : ""} +
+ +
+
Eingelagert
+
${d.bestand.eingelagert || 0}
+
Ausgegeben
+
${d.bestand.ausgegeben || 0}
+
Zurückgegeben
+
${d.bestand.zurueckgegeben || 0}
+
Vernichtet
+
${d.bestand.vernichtet || 0}
+
+ +
+ + + +
+ +
+ ${d.asservate.length ? `
${d.asservate.map((a) => ` +
+ ${katSymbol[a.kategorie] || "📦"} +
+
${sicher(a.bezeichnung)} + ${a.menge ? `${sicher(a.menge)}` : ""}
+
+ ${sicher(a.kennung)} · ${sicher(katName(a.kategorie))} · ${sicher(a.lagerort)} + ${a.aktenzeichen ? " · " + sicher(a.aktenzeichen) : ""} +
+
+ verantwortlich: ${sicher(a.dienstnummer || "")} + ${sicher((a.firstname || "") + " " + (a.lastname || ""))} · ${zeit(a.eingelagert_am)} +
+
+ ${sicher(statusText[a.status] || a.status)} +
`).join("")}
` + : `
Keine Asservate gefunden.
`} +
`; + + const neu = $("as-neu"); + if (neu) neu.addEventListener("click", () => einlagernDialog(behaelter)); + + const suchfeld = $("as-suche"); + suchfeld.addEventListener("keydown", (e) => { + if (e.key === "Enter") { z.suche = suchfeld.value.trim(); rendern(behaelter); } + }); + $("as-kategorie").addEventListener("change", (e) => { + z.filterKategorie = e.target.value; rendern(behaelter); + }); + $("as-status").addEventListener("change", (e) => { + z.filterStatus = e.target.value; rendern(behaelter); + }); + + behaelter.querySelectorAll("[data-asservat]").forEach((el) => + el.addEventListener("click", () => oeffne(behaelter, +el.dataset.asservat))); + } + + /* ---- Einzelansicht ------------------------------------------------ */ + + function einzelAnsicht(behaelter, d) { + const a = d.asservat; + + behaelter.innerHTML = ` +
+
+

${katSymbol[a.kategorie] || "📦"} ${sicher(a.bezeichnung)}

+

${sicher(a.kennung)} · ${sicher(katName(a.kategorie))}

+
+
+ + ${a.status === "eingelagert" && d.rechte.ausgeben + ? `` : ""} + ${a.status === "ausgegeben" && d.rechte.ausgeben + ? `` : ""} + ${a.status === "eingelagert" + ? `` : ""} + ${a.status !== "vernichtet" && d.rechte.vernichten + ? `` : ""} +
+
+ +
+
+
+ Objekt + ${sicher(statusText[a.status] || a.status)} +
+
+ Kennung${sicher(a.kennung)} + Kategorie${sicher(katName(a.kategorie))} + Menge${sicher(a.menge || "—")} + Lagerort${sicher(a.lagerort)} + Verantwortlich${sicher(a.dienstnummer || "")} + ${sicher((a.firstname || "") + " " + (a.lastname || ""))} + Eingelagert${zeit(a.eingelagert_am)} +
+
+ +
+
Zuordnung
+
+ ${a.aktenzeichen ? `
+ Akte +
${sicher(a.akte_titel || "")}
+
${sicher(a.aktenzeichen)}
` : ""} + ${a.beweis_kennung ? `
+ Beweis +
${sicher(a.beweis_bezeichnung || "")}
+
${sicher(a.beweis_kennung)}
` : ""} + ${!a.aktenzeichen && !a.beweis_kennung + ? `
Keine Zuordnung hinterlegt.
` : ""} +
+
+ +
+
+ Verwahrkette + ${d.historie.length} Schritte +
+
${d.historie.map((h, i) => ` +
+
${i + 1}
+
+
${sicher(h.aktion)}
+
+ ${sicher(h.dienstnummer || "")} + ${sicher((h.firstname || "") + " " + (h.lastname || ""))} · ${zeit(h.am)} +
+ ${h.notiz ? `
${sicher(h.notiz)}
` : ""} +
+
`).join("")}
+
+
`; + + $("as-zurueck").addEventListener("click", () => { z.offen = null; rendern(behaelter); }); + + const ausgeben = $("as-ausgeben"); + if (ausgeben) ausgeben.addEventListener("click", () => { + bestaetigen({ + titel: "Asservat ausgeben", + inhalt: ` +

+ ${sicher(a.kennung)} — ${sicher(a.bezeichnung)}

+
+
+
+
`, + bestaetigenText: "Ausgeben", + bestaetigt: async (el, zu) => { + try { + await aufruf("asservat", "ausgeben", { + id: a.id, + empfaenger: el.querySelector("#aa-empf").value.trim(), + grund: el.querySelector("#aa-grund").value.trim(), + }); + zu(); meldung("Ausgegeben.", "gut"); oeffne(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + + const zurueckgeben = $("as-zurueckgeben"); + if (zurueckgeben) zurueckgeben.addEventListener("click", () => { + fenster({ + titel: "Zurücknehmen", + inhalt: ` +
+
+
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Zurücknehmen", klick: async (el, zu) => { + try { + await aufruf("asservat", "zurueckgeben", { + id: a.id, + lagerort: el.querySelector("#az-ort").value.trim(), + notiz: el.querySelector("#az-notiz").value.trim(), + }); + zu(); meldung("Zurückgenommen.", "gut"); oeffne(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + }); + + const umlagern = $("as-umlagern"); + if (umlagern) umlagern.addEventListener("click", () => { + fenster({ + titel: "Umlagern", + inhalt: `
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Umlagern", klick: async (el, zu) => { + try { + await aufruf("asservat", "umlagern", { + id: a.id, lagerort: el.querySelector("#au-ort").value.trim(), + }); + zu(); meldung("Umgelagert.", "gut"); oeffne(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + }); + + const vernichten = $("as-vernichten"); + if (vernichten) vernichten.addEventListener("click", () => { + bestaetigen({ + titel: "Asservat vernichten", + gefahr: true, + inhalt: ` +
+
Endgültige Maßnahme
+
+ 🔥 +
${sicher(a.kennung)} — ${sicher(a.bezeichnung)} wird als vernichtet + gekennzeichnet.
+
+
+ 📄 +
Der Datensatz bleibt bestehen — vernichtet wird der Gegenstand, + nicht seine Dokumentation. Alle Beamten werden benachrichtigt.
+
+
+
+
+
+
`, + bestaetigenText: "Vernichtung bestätigen", + bestaetigt: async (el, zu) => { + try { + await aufruf("asservat", "vernichten", { + id: a.id, + zeuge: el.querySelector("#av-zeuge").value.trim(), + grund: el.querySelector("#av-grund").value.trim(), + }); + zu(); meldung("Vernichtung dokumentiert."); oeffne(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + } + + /* ---- Einlagern ---------------------------------------------------- */ + + function einlagernDialog(behaelter) { + fenster({ + titel: "Asservat einlagern", + inhalt: ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ℹ +
Du wirst als verantwortlich eingetragen. Jede weitere Bewegung + des Objekts wird in der Verwahrkette festgehalten.
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Einlagern", klick: async (el, zu) => { + try { + const ergebnis = await aufruf("asservat", "einlagern", { + kategorie: el.querySelector("#an-kat").value, + bezeichnung: el.querySelector("#an-bez").value.trim(), + menge: el.querySelector("#an-menge").value.trim(), + lagerort: el.querySelector("#an-ort").value.trim(), + akte: +el.querySelector("#an-akte").value || null, + beweis: +el.querySelector("#an-beweis").value || null, + notiz: el.querySelector("#an-notiz").value.trim(), + }); + zu(); meldung("Eingelagert als " + ergebnis.kennung + ".", "gut"); + oeffne(behaelter, ergebnis.id); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + } + + /* ---- Laden -------------------------------------------------------- */ + + async function oeffne(behaelter, asservatId) { + try { + const d = await aufruf("asservat", "oeffnen", { id: asservatId }); + z.offen = asservatId; + // Kategorien für die Benennung mitführen + if (!z.daten) z.daten = { kategorien: [] }; + einzelAnsicht(behaelter, d); + } catch (f) { meldung(f.message, "fehler"); } + } + + async function rendern(behaelter) { + try { + z.daten = await aufruf("asservat", "liste", { + kategorie: z.filterKategorie, status: z.filterStatus, begriff: z.suche, + }); + if (z.offen) await oeffne(behaelter, z.offen); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Asservate

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("asservat", { rendern }); + + App.registriereSuche(async (begriff) => { + try { + const d = await aufruf("asservat", "liste", { begriff }); + return (d.asservate || []).slice(0, 5).map((a) => ({ + bereich: "Asservat", titel: a.bezeichnung, + unter: a.kennung + " · " + a.lagerort, + })); + } catch (e) { return []; } + }); +})(); diff --git a/resources/[aether]/aether-police/html/js/module/bericht.js b/resources/[aether]/aether-police/html/js/module/bericht.js new file mode 100644 index 0000000..6ee494b --- /dev/null +++ b/resources/[aether]/aether-police/html/js/module/bericht.js @@ -0,0 +1,602 @@ +/* ===================================================================== + aether-police / Ansicht: Berichtswesen + + Drei Stufen: Entwurf → Eingereicht → Freigegeben. Keine davon wird + automatisch erreicht. Unterschriften sind verbindlich. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $ } = App; + + const z = { + daten: null, + offen: null, + filterStatus: "", + nurEigene: false, + suche: "", + }; + + const statusArt = { entwurf: "warn", eingereicht: "info", freigegeben: "gut" }; + const statusText = { entwurf: "Entwurf", eingereicht: "Eingereicht", freigegeben: "Freigegeben" }; + + /* ---- Übersicht ---------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Berichte

+

Entwurf → Eingereicht → Freigegeben. Jede Stufe verlangt eine Entscheidung.

+
+ ${d.rechte.erstellen ? `` : ""} +
+ +
+
+
Meine offenen Entwürfe
+
${d.eigeneOffen}
+
warten auf Ausarbeitung
+
+
+
Zur Prüfung eingereicht
+
${d.zurPruefung}
+
${d.rechte.freigeben ? "warten auf deine Freigabe" : "in Bearbeitung"}
+
+
+ +
+ + + +
+ +
+ ${d.berichte.length ? `
${d.berichte.map((b) => ` +
+ +
+
${sicher(b.titel)} + ${b.eigener ? 'eigener' : ""}
+
+ ${sicher(b.nummer)} + ${b.einsatz_nummer ? " · Einsatz " + sicher(b.einsatz_nummer) : ""} + · ${sicher(b.dienstnummer || "")} ${sicher((b.firstname || "") + " " + (b.lastname || ""))} + · ${zeit(b.erstellt_am)} +
+
+
+ ${sicher(statusText[b.status] || b.status)} + ${b.unterschriften.length + ? `✍ ${b.unterschriften.length}` : ""} +
+
`).join("")}
` + : `
Keine Berichte gefunden.
`} +
`; + + const neu = $("br-neu"); + if (neu) neu.addEventListener("click", () => anlegenDialog(behaelter)); + + const suchfeld = $("br-suche"); + suchfeld.addEventListener("keydown", (e) => { + if (e.key === "Enter") { z.suche = suchfeld.value.trim(); rendern(behaelter); } + }); + $("br-status").addEventListener("change", (e) => { + z.filterStatus = e.target.value; rendern(behaelter); + }); + $("br-eigene").addEventListener("click", () => { + z.nurEigene = !z.nurEigene; rendern(behaelter); + }); + + behaelter.querySelectorAll("[data-bericht]").forEach((el) => + el.addEventListener("click", () => oeffne(behaelter, +el.dataset.bericht))); + } + + /* ---- Einzelansicht ------------------------------------------------ */ + + function einzelAnsicht(behaelter, d) { + const b = d.bericht; + const bearbeitbar = d.rechte.bearbeiten; + + behaelter.innerHTML = ` +
+
+

${sicher(b.titel)}

+

${sicher(b.nummer)} + ${b.einsatz_nummer ? " · Einsatz " + sicher(b.einsatz_nummer) : ""} + ${b.aktenzeichen ? " · Akte " + sicher(b.aktenzeichen) : ""}

+
+
+ + ${bearbeitbar ? `` : ""} + ${!d.habeUnterschrieben && b.status !== "freigegeben" + ? `` : ""} + ${b.status === "entwurf" && bearbeitbar + ? `` : ""} + ${b.status === "eingereicht" && d.rechte.freigeben + ? ` + ` : ""} +
+
+ + ${b.status === "freigegeben" ? ` +
+ 🔒 + Dieser Bericht ist freigegeben und damit inhaltlich gesperrt — + auch für den Verfasser. +
` : ""} + +
+
+
+ Sachverhalt + ${sicher(statusText[b.status] || b.status)} +
+
+ Verfasser${sicher(b.dienstnummer || "")} + ${sicher((b.firstname || "") + " " + (b.lastname || ""))} + Angelegt${zeit(b.erstellt_am)} +
+
${b.beschreibung + ? sicher(b.beschreibung).replace(/\n/g, "
") + : "Noch keine Beschreibung erfasst."}
+ +
Getroffene Maßnahmen
+
${b.massnahmen + ? sicher(b.massnahmen).replace(/\n/g, "
") + : "Keine Maßnahmen dokumentiert."}
+
+ +
+
+ Beteiligte + ${bearbeitbar ? `` : ""} +
+ ${b.beteiligte.length ? `
${b.beteiligte.map((p, i) => ` +
+ + ${p.typ === "beamter" ? (p.dienstnummer || "Beamter") : "Person"} +
+
${sicher(p.name)}
+ ${p.rolle ? `
${sicher(p.rolle)}
` : ""} +
+ ${bearbeitbar && p.charakterId !== b.erstellt_von + ? `` : ""} +
`).join("")}
` + : `
Keine Beteiligten erfasst.
`} +
+ +
+
+ Anlagen & Beweise + ${bearbeitbar ? `` : ""} +
+ ${b.anlagen.length ? `
${b.anlagen.map((a, i) => ` +
+ + ${a.typ === "beweis" ? sicher(a.kennung) : "Anlage"} +
+
${sicher(a.titel)}
+ ${a.adresse ? `
${sicher(a.adresse)}
` : ""} +
+ ${a.adresse + ? `Öffnen` : ""} + ${bearbeitbar ? `` : ""} +
`).join("")}
` + : `
Keine Anlagen.
`} +
+ +
+
+ Unterschriften + ${b.unterschriften.length} +
+ ${b.unterschriften.length ? `
${b.unterschriften.map((u) => ` +
+
${sicher(u.name)}
+
${sicher(u.dienstnummer || "")} · ${sicher(u.rolle || "")}
+
${zeit(u.am)}${u.freigabe ? " · Freigabe" : ""}
+
`).join("")}
` + : `
Noch nicht unterschrieben.
`} +
+ ℹ +
Eine Unterschrift ist verbindlich und wird nicht zurückgenommen. + Sie bestätigt, dass der Inhalt aus deiner Sicht zutrifft.
+
+
+
`; + + verdrahteEinzel(behaelter, d); + } + + function verdrahteEinzel(behaelter, d) { + const b = d.bericht; + + $("br-zurueck").addEventListener("click", () => { z.offen = null; rendern(behaelter); }); + + const bearbeiten = $("br-bearbeiten"); + if (bearbeiten) bearbeiten.addEventListener("click", () => { + const unterschrieben = b.unterschriften.length > 0; + fenster({ + titel: "Bericht bearbeiten", + inhalt: ` +
+
+
+
+
+
+ ${unterschrieben ? ` +
+
⚠ Der Bericht wurde bereits unterschrieben
+
+ + +
+
` : ""}`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Speichern", klick: async (el, zu) => { + try { + await aufruf("bericht", "speichern", { + id: b.id, + titel: el.querySelector("#be-titel").value.trim(), + beschreibung: el.querySelector("#be-text").value, + massnahmen: el.querySelector("#be-mass").value, + grund: (el.querySelector("#be-grund") || {}).value || "", + }); + zu(); meldung("Gespeichert.", "gut"); oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + }); + + const beteiligt = $("br-beteiligt"); + if (beteiligt) beteiligt.addEventListener("click", () => beteiligtDialog(behaelter, b.id)); + + behaelter.querySelectorAll("[data-bet-weg]").forEach((el) => + el.addEventListener("click", async () => { + try { + await aufruf("bericht", "beteiligtEntfernen", { id: b.id, index: +el.dataset.betWeg }); + oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + })); + + const anlage = $("br-anlage"); + if (anlage) anlage.addEventListener("click", () => anlageDialog(behaelter, b.id)); + + behaelter.querySelectorAll("[data-anl-weg]").forEach((el) => + el.addEventListener("click", async () => { + try { + await aufruf("bericht", "anlageEntfernen", { id: b.id, index: +el.dataset.anlWeg }); + oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + })); + + const unterschreiben = $("br-unterschreiben"); + if (unterschreiben) unterschreiben.addEventListener("click", () => { + bestaetigen({ + titel: "Bericht unterschreiben", + inhalt: `
+
+ ✍ +
Du bestätigst, dass der Inhalt von ${sicher(b.nummer)} aus deiner + Sicht zutrifft. Die Unterschrift ist verbindlich und kann nicht + zurückgenommen werden.
+
+
`, + bestaetigenText: "Unterschreiben", + bestaetigt: async (_, zu) => { + try { + await aufruf("bericht", "unterschreiben", { id: b.id }); + zu(); meldung("Unterschrieben.", "gut"); oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + + const einreichen = $("br-einreichen"); + if (einreichen) einreichen.addEventListener("click", () => { + bestaetigen({ + titel: "Bericht einreichen", + inhalt: `

+ ${sicher(b.nummer)} geht zur Prüfung an die Führung. Bis zur Freigabe + kannst du ihn weiterhin ändern — jede Änderung nach dem Unterschreiben wird + allerdings vermerkt.

`, + bestaetigenText: "Einreichen", + bestaetigt: async (_, zu) => { + try { + await aufruf("bericht", "einreichen", { id: b.id }); + zu(); meldung("Eingereicht.", "gut"); oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + + const freigeben = $("br-freigeben"); + if (freigeben) freigeben.addEventListener("click", () => { + bestaetigen({ + titel: "Bericht freigeben", + inhalt: ` +
+
Mit der Freigabe geschieht Folgendes:
+
+ 🔒 +
Der Bericht wird inhaltlich gesperrt — auch der Verfasser kann + ihn danach nicht mehr ändern.
+
+
+ ✍ +
Du unterschreibst den Bericht damit zugleich als freigebender Beamter.
+
+
+
+
`, + bestaetigenText: "Freigeben", + bestaetigt: async (el, zu) => { + try { + await aufruf("bericht", "freigeben", { + id: b.id, vermerk: el.querySelector("#bf-vermerk").value.trim(), + }); + zu(); meldung("Freigegeben.", "gut"); oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + + const zurueckweisen = $("br-zurueckweisen"); + if (zurueckweisen) zurueckweisen.addEventListener("click", () => { + fenster({ + titel: "Zur Nachbesserung zurückgeben", + inhalt: `
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Zurückweisen", art: "gefahr", klick: async (el, zu) => { + try { + await aufruf("bericht", "zurueckweisen", { + id: b.id, grund: el.querySelector("#bz-grund").value.trim(), + }); + zu(); meldung("Zurückgewiesen."); oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + }); + } + + /* ---- Dialoge ------------------------------------------------------ */ + + function anlegenDialog(behaelter) { + aufruf("bericht", "bezuege", {}).then((bez) => { + fenster({ + titel: "Bericht anlegen", + inhalt: ` +
+
+
+
+
+
+
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Anlegen", klick: async (el, zu) => { + try { + const ergebnis = await aufruf("bericht", "erstellen", { + titel: el.querySelector("#bn-titel").value.trim(), + einsatz: +el.querySelector("#bn-einsatz").value || null, + beschreibung: el.querySelector("#bn-text").value, + massnahmen: el.querySelector("#bn-mass").value, + }); + zu(); meldung("Bericht " + ergebnis.nummer + " angelegt.", "gut"); + oeffne(behaelter, ergebnis.id); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + }).catch((f) => meldung(f.message, "fehler")); + } + + function beteiligtDialog(behaelter, berichtId) { + fenster({ + titel: "Beteiligten hinzufügen", + inhalt: ` +
+
+
+ +
+ + +
+
Oder unten einen Namen frei eintragen.
+
+
+
+
+
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Hinzufügen", klick: async (el, zu) => { + try { + await aufruf("bericht", "beteiligtHinzu", { + id: berichtId, + typ: el.querySelector("#bb-typ").value, + name: el.querySelector("#bb-name").value.trim(), + rolle: el.querySelector("#bb-rolle").value.trim(), + charakterId: el.dataset.gewaehlt ? +el.dataset.gewaehlt : null, + }); + zu(); oeffne(behaelter, berichtId); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + nachOeffnen: (el) => { + const suchen = async () => { + const begriff = el.querySelector("#bb-suche").value.trim(); + if (begriff.length < 2) { meldung("Bitte mindestens zwei Zeichen.", "fehler"); return; } + try { + const d = await aufruf("mdt", "personSuchen", { begriff }); + const ziel = el.querySelector("#bb-treffer"); + ziel.innerHTML = (d.treffer || []).length + ? `
${d.treffer.slice(0, 6).map((p) => + `
+
${sicher(p.firstname + " " + p.lastname)}
+
geb. ${sicher(String(p.dob || "").slice(0, 10))}
+
`).join("")}
` + : `
Keine Treffer.
`; + + ziel.querySelectorAll("[data-wahl]").forEach((zeile) => + zeile.addEventListener("click", () => { + el.dataset.gewaehlt = zeile.dataset.wahl; + el.querySelector("#bb-name").value = zeile.dataset.name; + ziel.innerHTML = `
Ausgewählt: ${sicher(zeile.dataset.name)}
`; + })); + } catch (f) { meldung(f.message, "fehler"); } + }; + el.querySelector("#bb-suchen").addEventListener("click", suchen); + el.querySelector("#bb-suche").addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); suchen(); } + }); + }, + }); + } + + function anlageDialog(behaelter, berichtId) { + fenster({ + titel: "Anlage hinzufügen", + inhalt: ` +
+
+ +
+
+
+
+
+
+ + `, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Hinzufügen", klick: async (el, zu) => { + const typ = el.querySelector("#ba-typ").value; + try { + await aufruf("bericht", "anlageHinzu", { + id: berichtId, typ, + titel: (el.querySelector("#ba-titel") || {}).value || "", + adresse: (el.querySelector("#ba-adresse") || {}).value || "", + beweisId: el.dataset.beweis ? +el.dataset.beweis : null, + }); + zu(); oeffne(behaelter, berichtId); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + nachOeffnen: (el) => { + el.querySelector("#ba-typ").addEventListener("change", (e) => { + const istBeweis = e.target.value === "beweis"; + el.querySelector("#ba-datei").hidden = istBeweis; + el.querySelector("#ba-beweis").hidden = !istBeweis; + }); + + const suchen = async () => { + const begriff = el.querySelector("#ba-suche").value.trim(); + if (begriff.length < 2) { meldung("Bitte mindestens zwei Zeichen.", "fehler"); return; } + try { + const d = await aufruf("bericht", "beweisSuche", { begriff }); + const ziel = el.querySelector("#ba-treffer"); + ziel.innerHTML = (d.beweise || []).length + ? `
${d.beweise.map((b) => + `
+ ${sicher(b.kennung)} +
${sicher(b.bezeichnung)}
+
${sicher(b.fundort || "")}
+
`).join("")}
` + : `
Keine Treffer.
`; + + ziel.querySelectorAll("[data-wahl]").forEach((zeile) => + zeile.addEventListener("click", () => { + el.dataset.beweis = zeile.dataset.wahl; + ziel.innerHTML = `
Ausgewählt.
`; + })); + } catch (f) { meldung(f.message, "fehler"); } + }; + el.querySelector("#ba-suchen").addEventListener("click", suchen); + }, + }); + } + + /* ---- Laden -------------------------------------------------------- */ + + async function oeffne(behaelter, berichtId) { + try { + const d = await aufruf("bericht", "oeffnen", { id: berichtId }); + z.offen = berichtId; + einzelAnsicht(behaelter, d); + } catch (f) { meldung(f.message, "fehler"); } + } + + async function rendern(behaelter) { + try { + z.daten = await aufruf("bericht", "liste", { + status: z.filterStatus, nurEigene: z.nurEigene, begriff: z.suche, + }); + if (z.offen) await oeffne(behaelter, z.offen); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Berichte

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("bericht", { + rendern, + aktualisieren: () => { + if (App.zustand.aktivesModul === "bericht") rendern($("inhalt")); + }, + }); + + App.registriereSuche(async (begriff) => { + try { + const d = await aufruf("bericht", "liste", { begriff }); + return (d.berichte || []).slice(0, 5).map((b) => ({ + bereich: "Bericht", titel: b.titel, + unter: b.nummer + " · " + (statusText[b.status] || b.status), + })); + } catch (e) { return []; } + }); +})(); diff --git a/resources/[aether]/aether-police/html/js/module/beweis.js b/resources/[aether]/aether-police/html/js/module/beweis.js new file mode 100644 index 0000000..99d622d --- /dev/null +++ b/resources/[aether]/aether-police/html/js/module/beweis.js @@ -0,0 +1,368 @@ +/* ===================================================================== + aether-police / Ansicht: Beweisverwaltung + + Es gibt keine automatische Spurenerkennung — jeder Beweis wird hier + von einem Beamten erfasst und beschrieben. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $ } = App; + + const z = { + daten: null, + offen: null, // geöffneter Beweis + filterArt: "", + suche: "", + }; + + const artSymbol = { + foto: "📷", video: "🎬", fingerabdruck: "🖐", dna: "🧬", + huelse: "🔩", dokument: "📄", aussage: "🗣", gegenstand: "📦", + }; + + function artName(id) { + const treffer = (z.daten.arten || []).find((a) => a.id === id); + return treffer ? treffer.name : id; + } + + /* ---- Übersicht ---------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Beweismittel

+

Jeder Beweis wird von einem Beamten erfasst — es gibt keine automatische Spurenerkennung.

+
+ ${d.rechte.erfassen ? `` : ""} +
+ +
+ + +
+ +
+ ${d.beweise.length ? `
${d.beweise.map((b) => ` +
+ ${artSymbol[b.art] || "•"} +
+
${sicher(b.bezeichnung)}
+
+ ${sicher(b.kennung)} · ${sicher(artName(b.art))} + ${b.fundort ? " · Fundort: " + sicher(b.fundort) : ""} + ${b.aktenzeichen ? " · " + sicher(b.aktenzeichen) : ""} +
+
+ erfasst von ${sicher(b.dienstnummer || "")} + ${sicher((b.firstname || "") + " " + (b.lastname || ""))} · ${zeit(b.erfasst_am)} +
+
+ ${b.datei ? 'Anlage' : ""} +
`).join("")}
` + : `
Keine Beweismittel gefunden.
`} +
`; + + const neu = $("bw-neu"); + if (neu) neu.addEventListener("click", () => erfassenDialog(behaelter)); + + const suchfeld = $("bw-suche"); + suchfeld.addEventListener("keydown", (e) => { + if (e.key === "Enter") { z.suche = suchfeld.value.trim(); rendern(behaelter); } + }); + $("bw-art").addEventListener("change", (e) => { + z.filterArt = e.target.value; rendern(behaelter); + }); + + behaelter.querySelectorAll("[data-beweis]").forEach((el) => + el.addEventListener("click", () => oeffne(behaelter, +el.dataset.beweis))); + } + + /* ---- Einzelansicht ------------------------------------------------ */ + + function einzelAnsicht(behaelter, d) { + const b = d.beweis; + + behaelter.innerHTML = ` +
+
+

${artSymbol[b.art] || "•"} ${sicher(b.bezeichnung)}

+

${sicher(b.kennung)} · ${sicher(artName(b.art))}

+
+
+ + + ${d.rechte.loeschen ? `` : ""} +
+
+ +
+
+
Beweismittel
+
+ Kennung${sicher(b.kennung)} + Art${sicher(artName(b.art))} + Fundort${sicher(b.fundort || "—")} + Aufgefunden${zeit(b.gefunden_am)} + Erfasst von${sicher(b.dienstnummer || "")} + ${sicher((b.firstname || "") + " " + (b.lastname || ""))} + Erfasst am${zeit(b.erfasst_am)} +
+ ${b.beschreibung + ? `
${sicher(b.beschreibung).replace(/\n/g, "
")}
` + : ""} + ${b.datei ? `` : ""} +
+ +
+
+ Verknüpfte Akten + +
+ ${d.akten.length ? `
${d.akten.map((a) => ` +
+ ${sicher(a.typ)} +
+
${sicher(a.titel)}
+
${sicher(a.aktenzeichen)}
+
+ ${sicher(a.status)} +
`).join("")}
` + : `
Noch keiner Akte zugeordnet.
`} +
+ +
+
Zugehörige Asservate
+ ${d.asservate.length ? `
${d.asservate.map((a) => ` +
+ ${sicher(a.kennung)} +
+
${sicher(a.bezeichnung)}
+
${sicher(a.lagerort)}
+
+ ${sicher(a.status)} +
`).join("")}
` + : `
Keine Asservate hinterlegt.
`} +
+ +
+
Vorgangshistorie
+ ${d.historie.length ? `
${d.historie.map((h) => ` +
+
+
${sicher(h.aktion)}
+
${sicher(h.dienstnummer || "")} + ${sicher((h.firstname || "") + " " + (h.lastname || ""))} · ${zeit(h.am)} + ${h.grund ? " · " + sicher(h.grund) : ""}
+
+
`).join("")}
` + : `
Keine Einträge.
`} +
+
`; + + $("bw-zurueck").addEventListener("click", () => { z.offen = null; rendern(behaelter); }); + + $("bw-bearbeiten").addEventListener("click", () => { + fenster({ + titel: "Beweis ändern", + inhalt: ` +
+
+
+
+
+
+
+
+
+ +
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Speichern", klick: async (el, zu) => { + try { + await aufruf("beweis", "bearbeiten", { + id: b.id, + bezeichnung: el.querySelector("#be-bez").value.trim(), + fundort: el.querySelector("#be-ort").value.trim(), + beschreibung: el.querySelector("#be-text").value, + datei: el.querySelector("#be-datei").value.trim(), + grund: el.querySelector("#be-grund").value.trim(), + }); + zu(); meldung("Gespeichert.", "gut"); oeffne(behaelter, b.id); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + }); + + const entfernen = $("bw-entfernen"); + if (entfernen) entfernen.addEventListener("click", () => { + bestaetigen({ + titel: "Beweismittel entfernen", + gefahr: true, + inhalt: ` +
+
+ ⚠ +
Der Beweis ${sicher(b.kennung)} wird aus dem Bestand entfernt. + Der Vorgang bleibt im Protokoll sichtbar und alle Beamten werden benachrichtigt.
+
+
+
+ + +
`, + bestaetigenText: "Entfernen", + bestaetigt: async (el, zu) => { + try { + await aufruf("beweis", "entfernen", { + id: b.id, grund: el.querySelector("#bl-grund").value.trim(), + }); + zu(); meldung("Beweis entfernt."); z.offen = null; rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + + $("bw-akte").addEventListener("click", () => akteVerknuepfenDialog(behaelter, b.id)); + } + + /* ---- Dialoge ------------------------------------------------------ */ + + function erfassenDialog(behaelter) { + const arten = (z.daten.arten || []); + + fenster({ + titel: "Beweis erfassen", + inhalt: ` +
+
+
+
+
+
+
+
+
+
+
+
+
+ ℹ +
Der Fundort ist Pflicht — er gehört zur Beweiskette. Das System + erkennt keine Spuren; alles hier ist deine Dokumentation.
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Erfassen", klick: async (el, zu) => { + try { + const ergebnis = await aufruf("beweis", "erfassen", { + art: el.querySelector("#bn-art").value, + bezeichnung: el.querySelector("#bn-bez").value.trim(), + beschreibung: el.querySelector("#bn-text").value, + fundort: el.querySelector("#bn-ort").value.trim(), + datei: el.querySelector("#bn-datei").value.trim(), + }); + zu(); meldung("Beweis " + ergebnis.kennung + " erfasst.", "gut"); + oeffne(behaelter, ergebnis.id); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + } + + function akteVerknuepfenDialog(behaelter, beweisId) { + fenster({ + titel: "Mit Akte verknüpfen", + inhalt: ` +
+ +
+ + +
+
+
`, + nachOeffnen: (el) => { + const suchen = async () => { + const begriff = el.querySelector("#bv-suche").value.trim(); + if (begriff.length < 2) { meldung("Bitte mindestens zwei Zeichen.", "fehler"); return; } + try { + const d = await aufruf("beweis", "aktenSuche", { begriff }); + const ziel = el.querySelector("#bv-treffer"); + ziel.innerHTML = (d.akten || []).length + ? `
${d.akten.map((a) => + `
+ ${sicher(a.typ)} +
${sicher(a.titel)}
+
${sicher(a.aktenzeichen)}
+
`).join("")}
` + : `
Keine Treffer.
`; + + ziel.querySelectorAll("[data-wahl]").forEach((zeile) => + zeile.addEventListener("click", async () => { + try { + await aufruf("beweis", "akteVerknuepfen", { + beweisId, akteId: +zeile.dataset.wahl, + }); + el.remove(); meldung("Verknüpft.", "gut"); oeffne(behaelter, beweisId); + } catch (f) { meldung(f.message, "fehler"); } + })); + } catch (f) { meldung(f.message, "fehler"); } + }; + el.querySelector("#bv-suchen").addEventListener("click", suchen); + el.querySelector("#bv-suche").addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); suchen(); } + }); + }, + }); + } + + /* ---- Laden -------------------------------------------------------- */ + + async function oeffne(behaelter, beweisId) { + try { + const d = await aufruf("beweis", "oeffnen", { id: beweisId }); + z.offen = beweisId; + einzelAnsicht(behaelter, d); + } catch (f) { meldung(f.message, "fehler"); } + } + + async function rendern(behaelter) { + try { + z.daten = await aufruf("beweis", "liste", { art: z.filterArt, begriff: z.suche }); + if (z.offen) await oeffne(behaelter, z.offen); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Beweise

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("beweis", { rendern }); + + /* Globale Suche bedienen */ + App.registriereSuche(async (begriff) => { + try { + const d = await aufruf("beweis", "liste", { begriff }); + return (d.beweise || []).slice(0, 5).map((b) => ({ + bereich: "Beweis", titel: b.bezeichnung, + unter: b.kennung + " · " + (b.fundort || ""), + })); + } catch (e) { return []; } + }); +})(); diff --git a/resources/[aether]/aether-police/html/js/module/dashboard.js b/resources/[aether]/aether-police/html/js/module/dashboard.js new file mode 100644 index 0000000..085fe00 --- /dev/null +++ b/resources/[aether]/aether-police/html/js/module/dashboard.js @@ -0,0 +1,246 @@ +/* ===================================================================== + aether-police / Ansicht: Dashboard + + Zeigt die Lage der Dienststelle. Sämtliche Zahlen kommen bei jedem + Aufruf frisch vom Server — nichts ist fest hinterlegt. + Die Ansicht bewertet nichts und löst keine Maßnahme aus. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, dauer, aufruf, meldung, anClient } = App; + + /** Zwischenspeicher der zuletzt geladenen Daten (für Auffrischungen) */ + let letzteDaten = null; + + /* ---- Bausteine --------------------------------------------------- */ + + function kennzahl(titel, wert, zusatz, art) { + return `
+
${sicher(titel)}
+
${sicher(wert)}
+ ${zusatz ? `
${sicher(zusatz)}
` : ""} +
`; + } + + /** Übersetzt eine Prioritätszahl in eine Streifenklasse */ + const prioKlasse = (p) => "p" + Math.min(4, Math.max(1, p || 3)); + + function warnungen(liste) { + if (!liste || !liste.length) return ""; + const symbol = { hoch: "⚠", normal: "ℹ", niedrig: "•" }; + return liste.map((w) => + `
+ ${symbol[w.prioritaet] || "•"} + ${sicher(w.text)} +
` + ).join(""); + } + + function beamtenListe(beamte) { + if (!beamte.length) return `
Niemand im Dienst.
`; + + const statusFarbe = { + verfuegbar: "gut", einsatz: "warn", pause: "neutral", nicht: "kritisch", + }; + + return `
${beamte.map((b) => + `
+ ${sicher(b.dienstnummer)} +
+
${sicher(b.name)}${b.leitstelle ? ' Leitstelle' : ""}
+
${sicher(b.rolleName)} · ${sicher(b.abteilung)}
+
+ ${dauer((Date.now() / 1000) - b.seit)} +
`).join("")}
`; + } + + function einsatzListe(einsaetze) { + if (!einsaetze.length) return `
Keine offenen Einsätze.
`; + + return `
${einsaetze.map((e) => + `
+ +
+
${sicher(e.titel)}
+
${sicher(e.nummer)} · ${sicher(e.ort || "Ort unbekannt")} · ${zeit(e.erstellt_am)}
+
+ ${sicher(e.status)} + ${e.pos_x ? `` : ""} +
`).join("")}
`; + } + + function gewahrsamListe(eintraege) { + if (!eintraege.length) return `
Niemand in Gewahrsam.
`; + + return `
${eintraege.map((g) => { + const rest = Number(g.rest) || 0; + const abgelaufen = rest <= 0; + return `
+ +
+
${sicher(g.firstname + " " + g.lastname)}
+
${sicher(g.grund || "—")}${g.zelle ? " · Zelle " + sicher(g.zelle) : ""}
+
+ + ${abgelaufen ? "Frist abgelaufen" : rest + " Min"} + +
`; + }).join("")}
`; + } + + function fahndungsListe(fahndungen) { + if (!fahndungen.length) return `
Keine aktiven Fahndungen.
`; + + const gefahrKlasse = { gefaehrlich: "kritisch", bewaffnet: "warn", normal: "neutral" }; + + return `
${fahndungen.map((f) => + `
+ +
+
${sicher(f.bezeichnung)}
+
${sicher(f.grund)}
+
+ ${sicher(f.gefahr)} +
`).join("")}
`; + } + + function mitteilungsListe(mitteilungen) { + if (!mitteilungen.length) return `
Keine Mitteilungen.
`; + + return `
${mitteilungen.map((m) => + `
+
+
${sicher(m.betreff || "Mitteilung")}
+
${sicher((m.firstname || "") + " " + (m.lastname || ""))} · ${zeit(m.gesendet)}
+
+
`).join("")}
`; + } + + /* ---- Aufbau ------------------------------------------------------ */ + + function zeichne(behaelter, d) { + const k = d.kennzahlen; + + behaelter.innerHTML = ` +
+
+

Übersicht

+

Lage der Dienststelle — Stand ${new Date().toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })}

+
+ +
+ + ${warnungen(d.warnungen)} + +
+ ${kennzahl("Im Dienst", k.imDienst, `${k.verfuegbar} verfügbar`, k.imDienst > 0 ? "gut" : "")} + ${kennzahl("Offene Einsätze", k.einsaetzeOffen, null, k.einsaetzeOffen > 0 ? "warn" : "")} + ${kennzahl("Anzeigen (Entwurf)", k.anzeigenOffen, "warten auf Bestätigung")} + ${kennzahl("Fahndungen", k.fahndungen)} + ${kennzahl("In Gewahrsam", k.gefangene, + k.haftAbgelaufen > 0 ? `${k.haftAbgelaufen} Frist abgelaufen` : null, + k.haftAbgelaufen > 0 ? "hoch" : "")} + ${kennzahl("Dienstfahrzeuge", `${k.fahrzeugeEinsatz}/${k.fahrzeugeGesamt}`, "im Einsatz")} +
+ +
+
+
+ Offene Einsätze + ${d.einsaetze.length} +
+ ${einsatzListe(d.einsaetze)} +
+ +
+
+ Beamte im Dienst + ${d.beamte.length} +
+ ${beamtenListe(d.beamte)} +
+ +
+
+ Gewahrsam + ${d.gewahrsam.length} +
+ ${gewahrsamListe(d.gewahrsam)} +
+ ℹ +
Fristen dienen nur der Erinnerung. Eine Entlassung erfolgt + ausschließlich manuell durch einen Beamten.
+
+
+ +
+
+ Aktive Fahndungen + ${d.fahndungen.length} +
+ ${fahndungsListe(d.fahndungen)} +
+ +
+
+ Meine offenen Berichte + ${d.berichte.length} +
+ ${d.berichte.length + ? `
${d.berichte.map((b) => + `
+
+
${sicher(b.titel)}
+
${sicher(b.nummer)} · ${zeit(b.erstellt_am)}
+
+ Entwurf +
`).join("")}
` + : `
Keine offenen Berichte.
`} +
+ +
+
Neueste Mitteilungen
+ ${mitteilungsListe(d.mitteilungen)} +
+
+ `; + + // Aktualisieren + App.$("db-aktualisieren").addEventListener("click", () => laden(behaelter)); + + // Route zu einem Einsatzort setzen + behaelter.querySelectorAll("[data-weg]").forEach((b) => { + b.addEventListener("click", () => { + const [x, y] = b.dataset.weg.split(",").map(Number); + anClient("wegpunkt", { x, y }); + }); + }); + } + + /* ---- Laden ------------------------------------------------------- */ + + async function laden(behaelter) { + try { + const daten = await aufruf("dashboard", "laden", {}); + letzteDaten = daten; + App.zustand.hinweise = daten.hinweise || []; + zeichne(behaelter, daten); + } catch (fehler) { + behaelter.innerHTML = + `

Übersicht

+

Die Daten konnten nicht geladen werden: ${sicher(fehler.message)}

`; + } + } + + /* ---- Registrierung ----------------------------------------------- */ + + App.registriereAnsicht("dashboard", { + rendern: (behaelter) => laden(behaelter), + // Wird aufgerufen, wenn sich im Hintergrund etwas geändert hat + aktualisieren: () => { + const behaelter = App.$("inhalt"); + if (App.zustand.aktivesModul === "dashboard" && behaelter) laden(behaelter); + }, + }); +})(); diff --git a/resources/[aether]/aether-police/html/js/module/einsatz.js b/resources/[aether]/aether-police/html/js/module/einsatz.js new file mode 100644 index 0000000..7236834 --- /dev/null +++ b/resources/[aether]/aether-police/html/js/module/einsatz.js @@ -0,0 +1,483 @@ +/* ===================================================================== + aether-police / Ansicht: Einsätze & Dispatch + + Zwei Spalten: links das Einsatzaufkommen, rechts die verfügbaren + Einheiten. Zugewiesen wird nur auf ausdrückliche Anweisung der + Leitstelle — und auch dann entscheidet die Einheit selbst, ob sie + beitritt. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $, anClient } = App; + + const z = { + daten: null, + offenerEinsatz: null, + nurOffene: true, + }; + + /* ---- Bausteine --------------------------------------------------- */ + + const statusText = { + offen: "Offen", zugewiesen: "Zugewiesen", + vorort: "Vor Ort", abgeschlossen: "Abgeschlossen", + }; + const statusArt = { + offen: "kritisch", zugewiesen: "warn", vorort: "info", abgeschlossen: "neutral", + }; + const quelleText = { + manuell: "manuell erfasst", notruf: "Notruf", panik: "Panikknopf", extern: "extern", + }; + + /** Farbe einer Kategorie aus der Serverkonfiguration */ + function kategorieFarbe(id) { + const treffer = (z.daten.kategorien || []).find((k) => k.id === id); + return treffer ? treffer.farbe : "#8a93a5"; + } + function kategorieName(id) { + const treffer = (z.daten.kategorien || []).find((k) => k.id === id); + return treffer ? treffer.name : id; + } + + function einsatzKarte(e) { + return ` +
+ +
+
+ ${sicher(e.titel)} + ${e.ichDabei > 0 ? 'Du bist dabei' : ""} +
+
+ + ${sicher(kategorieName(e.kategorie))} + + ${sicher(e.nummer)} · ${sicher(e.ort || "Ort unbekannt")} · ${zeit(e.erstellt_am)} + ${e.quelle !== "manuell" ? " · " + sicher(quelleText[e.quelle] || e.quelle) : ""} +
+
+
+ ${sicher(statusText[e.status] || e.status)} + ${e.einheiten || 0} 🚓 +
+
`; + } + + function einheitenListe(beamte) { + if (!beamte.length) return `
Niemand im Dienst.
`; + + const farbe = { verfuegbar: "gut", einsatz: "warn", pause: "neutral", nicht: "kritisch" }; + const text = { verfuegbar: "Verfügbar", einsatz: "Im Einsatz", pause: "Pause", nicht: "Nicht verfügbar" }; + + return `
${beamte.map((b) => + `
+ ${sicher(b.dienstnummer)} +
+
${sicher(b.name)} + ${b.leitstelle ? 'Leitstelle' : ""}
+
${sicher(text[b.status] || b.status)} · ${sicher(b.abteilung)}
+
+
`).join("")}
`; + } + + /* ---- Übersicht --------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + const offene = d.einsaetze.filter((e) => e.status === "offen"); + const laufende = d.einsaetze.filter((e) => e.status !== "offen" && e.status !== "abgeschlossen"); + const erledigt = d.einsaetze.filter((e) => e.status === "abgeschlossen"); + + behaelter.innerHTML = ` +
+
+

Einsätze & Dispatch

+

${d.istLeitstelle + ? "Du führst die Leitstelle — du entscheidest über die Zuweisung." + : "Einsätze werden nicht automatisch verteilt. Tritt bei, wenn du übernimmst."}

+
+
+ + +
+
+ +
+
+
+
+ Offen — keine Einheit zugeordnet + ${offene.length} +
+ ${offene.length ? offene.map(einsatzKarte).join("") + : `
Keine offenen Einsätze.
`} +
+ +
+
+ In Bearbeitung + ${laufende.length} +
+ ${laufende.length ? laufende.map(einsatzKarte).join("") + : `
Keine laufenden Einsätze.
`} +
+ + ${!z.nurOffene ? ` +
+
+ Abgeschlossen + ${erledigt.length} +
+ ${erledigt.length ? erledigt.map(einsatzKarte).join("") + : `
Nichts abgeschlossen.
`} +
` : ""} +
+ +
+
+
+ Einheiten im Dienst + ${d.beamteImDienst.length} +
+ ${einheitenListe(d.beamteImDienst)} +
+ ℹ +
Eine Zuweisung ist eine Aufforderung, kein Befehl an das System. + Die Einheit entscheidet selbst, ob sie den Einsatz übernimmt.
+
+
+
+
`; + + $("e-neu").addEventListener("click", () => einsatzAnlegen(behaelter)); + $("e-filter").addEventListener("click", () => { + z.nurOffene = !z.nurOffene; + rendern(behaelter); + }); + + behaelter.querySelectorAll("[data-einsatz]").forEach((el) => + el.addEventListener("click", () => oeffneEinsatz(behaelter, +el.dataset.einsatz))); + } + + /* ---- Einzelansicht ------------------------------------------------ */ + + function einsatzAnsicht(behaelter, d) { + const e = d.einsatz; + const aktiv = e.status !== "abgeschlossen"; + const beteiligt = (e.beamte || []).filter((b) => !b.verlassen); + + behaelter.innerHTML = ` +
+
+

${sicher(e.titel)}

+

${sicher(e.nummer)} · ${sicher(kategorieName(e.kategorie))} · + ${sicher(quelleText[e.quelle] || e.quelle)} · ${zeit(e.erstellt_am)}

+
+ +
+ +
+
+
+
+ Lage + ${sicher(statusText[e.status] || e.status)} +
+
+ Priorität${sicher(e.prioritaet)} ${e.prioritaet === 1 ? "(höchste)" : ""} + Ort${sicher(e.ort || "unbekannt")} + Gemeldet von${e.erstellt_von + ? sicher((e.ersteller_vorname || "") + " " + (e.ersteller_nachname || "")) + : sicher(quelleText[e.quelle] || "extern")} +
+ ${e.beschreibung ? `
${sicher(e.beschreibung).replace(/\n/g, "
")}
` : ""} + +
+ ${e.pos_x ? `` : ""} + ${aktiv && !e.ichDabei ? `` : ""} + ${aktiv && e.ichDabei ? `` : ""} + ${aktiv && e.ichDabei ? `` : ""} + ${aktiv && d.rechte.schliessen ? `` : ""} +
+
+ +
+
+ Einsatzverlauf + ${aktiv ? `` : ""} +
+ ${(e.notizen || []).length ? `
${e.notizen.map((n) => + `
+ ${zeit(n.am).slice(-5)} +
+
${sicher(n.text)}
+
${sicher(n.dienstnummer || "")} + ${sicher((n.firstname || "") + " " + (n.lastname || ""))}
+
+
`).join("")}
` + : `
Noch keine Einträge.
`} +
+
+ +
+
+
+ Beteiligte Einheiten + ${beteiligt.length} +
+ ${beteiligt.length ? `
${beteiligt.map((b) => + `
+ ${sicher(b.dienstnummer || "—")} +
+
${sicher((b.firstname || "") + " " + (b.lastname || ""))}
+
seit ${zeit(b.beigetreten)}
+
+
`).join("")}
` + : `
Keine Einheit vor Ort.
`} +
+ + ${aktiv && d.rechte.zuweisen ? ` +
+
Einheit anfordern
+
${d.beamteImDienst.map((b) => + `
+ ${sicher(b.dienstnummer)} +
+
${sicher(b.name)}
+
${sicher(b.status)}
+
+ +
`).join("")}
+
+ ℹ +
Die Einheit erhält eine Anfrage mit Wegpunkt. Ob sie übernimmt, + entscheidet sie selbst.
+
+
` : ""} +
+
`; + + /* ---- Aktionen ---- */ + $("e-zurueck").addEventListener("click", () => { z.offenerEinsatz = null; rendern(behaelter); }); + + const route = $("e-route"); + if (route) route.addEventListener("click", () => + anClient("wegpunkt", { x: e.pos_x, y: e.pos_y })); + + const beitreten = $("e-beitreten"); + if (beitreten) beitreten.addEventListener("click", async () => { + try { + await aufruf("einsatz", "beitreten", { id: e.id }); + meldung("Du hast den Einsatz übernommen.", "gut"); + oeffneEinsatz(behaelter, e.id); + } catch (f) { meldung(f.message, "fehler"); } + }); + + const verlassen = $("e-verlassen"); + if (verlassen) verlassen.addEventListener("click", async () => { + try { + await aufruf("einsatz", "verlassen", { id: e.id }); + meldung("Du hast den Einsatz verlassen."); + oeffneEinsatz(behaelter, e.id); + } catch (f) { meldung(f.message, "fehler"); } + }); + + const vorort = $("e-vorort"); + if (vorort) vorort.addEventListener("click", async () => { + try { + await aufruf("einsatz", "status", { id: e.id, status: "vorort" }); + oeffneEinsatz(behaelter, e.id); + } catch (f) { meldung(f.message, "fehler"); } + }); + + const notiz = $("e-notiz"); + if (notiz) notiz.addEventListener("click", () => { + fenster({ + titel: "Notiz zum Einsatz", + inhalt: `
+ +
Der Verlauf hilft später beim Bericht.
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Eintragen", klick: async (el, zu) => { + const text = el.querySelector("#en-text").value.trim(); + if (text.length < 2) { meldung("Bitte einen Text eingeben.", "fehler"); return; } + try { + await aufruf("einsatz", "notiz", { id: e.id, text }); + zu(); oeffneEinsatz(behaelter, e.id); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + }); + + behaelter.querySelectorAll("[data-zuweisen]").forEach((b) => + b.addEventListener("click", async () => { + try { + await aufruf("einsatz", "zuweisen", { id: e.id, quelle: +b.dataset.zuweisen }); + meldung("Anfrage an die Einheit gesendet.", "gut"); + } catch (f) { meldung(f.message, "fehler"); } + })); + + const schliessen = $("e-schliessen"); + if (schliessen) schliessen.addEventListener("click", () => { + bestaetigen({ + titel: "Einsatz abschließen", + inhalt: ` +

+ ${sicher(e.nummer)} — ${sicher(e.titel)}
+ Alle Beteiligungen werden beendet. Es wird festgehalten, dass du den Einsatz + abgeschlossen hast. +

+
+
+ ${d.rechte.bericht ? ` + ` : ""}`, + bestaetigenText: "Einsatz abschließen", + bestaetigt: async (el, zu) => { + const kasten = el.querySelector("#es-bericht"); + try { + const ergebnis = await aufruf("einsatz", "abschliessen", { + id: e.id, + grund: el.querySelector("#es-grund").value.trim(), + berichtAnlegen: kasten ? kasten.checked : false, + }); + zu(); + meldung(ergebnis.berichtNummer + ? "Einsatz abgeschlossen · Entwurf " + ergebnis.berichtNummer + " angelegt." + : "Einsatz abgeschlossen.", "gut"); + z.offenerEinsatz = null; + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + } + + async function oeffneEinsatz(behaelter, einsatzId) { + try { + const d = await aufruf("einsatz", "oeffnen", { id: einsatzId }); + z.offenerEinsatz = einsatzId; + // Kategorien für die Anzeige mitführen + if (!z.daten) z.daten = { kategorien: [] }; + d.kategorien = z.daten.kategorien; + einsatzAnsicht(behaelter, d); + } catch (f) { meldung(f.message, "fehler"); } + } + + /* ---- Anlegen ------------------------------------------------------ */ + + function einsatzAnlegen(behaelter) { + const kategorien = (z.daten && z.daten.kategorien) || []; + + fenster({ + titel: "Einsatz anlegen", + inhalt: ` +
+
+
+
+
+
+
+
+
+
+ `, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Einsatz anlegen", klick: async (el, zu) => { + try { + const ergebnis = await aufruf("einsatz", "erstellen", { + titel: el.querySelector("#ne-titel").value.trim(), + kategorie: el.querySelector("#ne-kategorie").value, + prioritaet: +el.querySelector("#ne-prio").value, + ort: el.querySelector("#ne-ort").value.trim(), + beschreibung: el.querySelector("#ne-text").value, + eigenePosition: el.querySelector("#ne-hier").checked, + }); + zu(); + meldung("Einsatz " + ergebnis.nummer + " angelegt.", "gut"); + oeffneEinsatz(behaelter, ergebnis.id); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + } + + /* ---- Laden -------------------------------------------------------- */ + + async function rendern(behaelter) { + try { + z.daten = await aufruf("einsatz", "liste", { nurOffene: z.nurOffene }); + + // Markierungen im Spiel setzen + anClient("einsatzBlips", { einsaetze: z.daten.einsaetze }); + + if (z.offenerEinsatz) await oeffneEinsatz(behaelter, z.offenerEinsatz); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = + `

Einsätze

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + /* ---- Nachrichten vom Client -------------------------------------- */ + + window.addEventListener("message", (ereignis) => { + const n = ereignis.data || {}; + + // Eine Einheit wurde angefordert — der Beamte entscheidet selbst + if (n.aktion === "einsatzZuweisung") { + const d = n.daten || {}; + bestaetigen({ + titel: "Einsatz übernehmen?", + inhalt: `

+ ${sicher(d.von)} bittet dich um Übernahme:

+ ${sicher(d.nummer)} — ${sicher(d.titel)}
+ ${d.ort ? "Ort: " + sicher(d.ort) : "Ort unbekannt"}

+ Ein Wegpunkt wurde bereits gesetzt.

`, + bestaetigenText: "Übernehmen", + bestaetigt: async (_, zu) => { + try { + await aufruf("einsatz", "beitreten", { id: d.id }); + zu(); meldung("Einsatz übernommen.", "gut"); + if (App.zustand.aktivesModul === "einsatz") rendern($("inhalt")); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + // Im Hintergrund hat sich etwas geändert + if (n.aktion === "einsatzAenderung" && App.zustand.aktivesModul === "einsatz") { + rendern($("inhalt")); + } + }); + + /* ---- Registrierung ------------------------------------------------ */ + + App.registriereAnsicht("einsatz", { + rendern, + aktualisieren: () => { + if (App.zustand.aktivesModul === "einsatz") rendern($("inhalt")); + }, + }); +})(); diff --git a/resources/[aether]/aether-police/html/js/module/fahndung.js b/resources/[aether]/aether-police/html/js/module/fahndung.js new file mode 100644 index 0000000..aad294e --- /dev/null +++ b/resources/[aether]/aether-police/html/js/module/fahndung.js @@ -0,0 +1,458 @@ +/* ===================================================================== + aether-police / Ansicht: Fahndungen + + Fahndungen werden von Beamten ausgeschrieben und von Beamten + aufgehoben. Nichts davon geschieht von selbst. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $, anClient } = App; + + const z = { + daten: null, + offen: null, + filterTyp: "", + filterGefahr: "", + archiv: false, + suche: "", + }; + + const gefahrArt = { normal: "neutral", bewaffnet: "warn", gefaehrlich: "kritisch" }; + const gefahrText = { normal: "Normal", bewaffnet: "Bewaffnet", gefaehrlich: "Gefährlich" }; + const prioText = { 1: "Sofort", 2: "Hoch", 3: "Normal", 4: "Niedrig" }; + + /* ---- Übersicht ---------------------------------------------------- */ + + function fahndungKarte(f) { + return ` +
+
+ ${f.bild ? `` + : `${f.typ === "person" ? "👤" : "🚗"}`} +
+
+
${sicher(f.bezeichnung)}
+
${sicher(f.grund)}
+
+ ${sicher(f.dienstnummer || "")} ${sicher((f.firstname || "") + " " + (f.lastname || ""))} + · ${zeit(f.erstellt_am)} + ${!f.aktiv && f.aufgehoben_am + ? ` · aufgehoben ${zeit(f.aufgehoben_am)} durch + ${sicher((f.a_vorname || "") + " " + (f.a_nachname || ""))}` : ""} +
+
+
+ ${sicher(gefahrText[f.gefahr] || f.gefahr)} + Prio ${sicher(prioText[f.prioritaet] || f.prioritaet)} +
+
`; + } + + function uebersicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Fahndungen

+

Werden von Beamten ausgeschrieben — und nur von Beamten wieder aufgehoben.

+
+ ${d.rechte.erstellen ? `` : ""} +
+ +
+
Normal
+
${d.bestand.normal || 0}
+
Bewaffnet
+
${d.bestand.bewaffnet || 0}
+
Gefährlich
+
${d.bestand.gefaehrlich || 0}
+
+ +
+ + + + +
+ +
+ ${d.fahndungen.length + ? `
${d.fahndungen.map(fahndungKarte).join("")}
` + : `
${z.archiv ? "Keine aufgehobenen Fahndungen." : "Keine aktiven Fahndungen."}
`} +
`; + + const neu = $("fa-neu"); + if (neu) neu.addEventListener("click", () => ausschreibenDialog(behaelter)); + + const suchfeld = $("fa-suche"); + suchfeld.addEventListener("keydown", (e) => { + if (e.key === "Enter") { z.suche = suchfeld.value.trim(); rendern(behaelter); } + }); + $("fa-typ").addEventListener("change", (e) => { z.filterTyp = e.target.value; rendern(behaelter); }); + $("fa-gefahr").addEventListener("change", (e) => { z.filterGefahr = e.target.value; rendern(behaelter); }); + $("fa-archiv").addEventListener("click", () => { z.archiv = !z.archiv; rendern(behaelter); }); + + behaelter.querySelectorAll("[data-fahndung]").forEach((el) => + el.addEventListener("click", () => oeffne(behaelter, +el.dataset.fahndung))); + } + + /* ---- Einzelansicht ------------------------------------------------ */ + + function einzelAnsicht(behaelter, d) { + const f = d.fahndung; + const ziel = f.ziel; + const aktiv = f.aktiv === 1; + + behaelter.innerHTML = ` +
+
+

${f.typ === "person" ? "👤" : "🚗"} ${sicher(f.bezeichnung)}

+

Fahndung #${f.id} · ${sicher(gefahrText[f.gefahr] || f.gefahr)} + · Priorität ${sicher(prioText[f.prioritaet] || f.prioritaet)}

+
+
+ + ${aktiv && d.rechte.bearbeiten + ? `` : ""} + ${aktiv && d.rechte.aufheben + ? `` : ""} +
+
+ + ${!aktiv ? ` +
+ ✓ + Diese Fahndung wurde am ${zeit(f.aufgehoben_am)} von + ${sicher((f.a_vorname || "") + " " + (f.a_nachname || ""))} aufgehoben. +
` + : f.gefahr !== "normal" ? ` +
+ ⚠ + Gefahrenstufe ${sicher(gefahrText[f.gefahr])}. + ${f.gefahr === "gefaehrlich" + ? "Zugriff nur mit ausreichender Verstärkung." + : "Es ist mit Bewaffnung zu rechnen."} +
` : ""} + +
+
+
Fahndungsgrund
+
${sicher(f.grund).replace(/\n/g, "
")}
+ + ${f.hinweise ? ` +
Hinweise für die Streife
+
${sicher(f.hinweise).replace(/\n/g, "
")}
+ ` : ""} + +
+ Ausgeschrieben${sicher(f.dienstnummer || "")} + ${sicher((f.firstname || "") + " " + (f.lastname || ""))} + Am${zeit(f.erstellt_am)} +
+
+ +
+
+ ${f.typ === "person" ? "Gesuchte Person" : "Gesuchtes Fahrzeug"} + ${ziel && f.typ === "person" + ? `` : ""} + ${ziel && f.typ === "fahrzeug" + ? `` : ""} +
+ + ${!ziel ? `
Das Ziel ist nicht mehr im System auffindbar.
` + : f.typ === "person" ? ` +
+
+ ${ziel.lichtbild ? `` + : `Kein Lichtbild`} +
+
+ Name${sicher(ziel.firstname + " " + ziel.lastname)} + Geboren${sicher(String(ziel.dob || "").slice(0, 10))} + Warnhinweis${sicher(ziel.warnhinweis || "—")} +
+
` + : `
+ Kennzeichen${sicher(ziel.plate)} + Modell${sicher(ziel.name || ziel.model)} + Farbe${ziel.farbe + ? ` ${sicher(ziel.farbe)}` + : "—"} + Halter${sicher((ziel.firstname || "unbekannt") + " " + (ziel.lastname || ""))} + Status${sicher(ziel.state === "impound" ? "sichergestellt" + : ziel.state === "out" ? "unterwegs" : "eingeparkt")} +
`} +
+ + ${f.bild ? ` +
+
Fahndungsbild
+ Fahndungsbild +
` : ""} + +
+
Vorgangshistorie
+ ${d.historie.length ? `
${d.historie.map((h) => ` +
+
+
${sicher(h.aktion)}
+
${sicher(h.dienstnummer || "")} + ${sicher((h.firstname || "") + " " + (h.lastname || ""))} · ${zeit(h.am)} + ${h.grund ? " · " + sicher(h.grund) : ""}
+
+
`).join("")}
` + : `
Keine Einträge.
`} +
+
`; + + $("fa-zurueck").addEventListener("click", () => { z.offen = null; rendern(behaelter); }); + + // Sprünge ins MDT — die Akte wird dort direkt geöffnet + behaelter.querySelectorAll("[data-person]").forEach((el) => + el.addEventListener("click", () => + App.oeffneModul("mdt", { person: el.dataset.person }))); + behaelter.querySelectorAll("[data-fahrzeug]").forEach((el) => + el.addEventListener("click", () => + App.oeffneModul("mdt", { fahrzeug: el.dataset.fahrzeug }))); + + const bearbeiten = $("fa-bearbeiten"); + if (bearbeiten) bearbeiten.addEventListener("click", () => bearbeitenDialog(behaelter, f)); + + const aufheben = $("fa-aufheben"); + if (aufheben) aufheben.addEventListener("click", () => { + bestaetigen({ + titel: "Fahndung aufheben", + inhalt: ` +

+ ${sicher(f.bezeichnung)}
${sicher(f.grund)} +

+
+
+ 📄 +
Die Fahndung wandert ins Archiv und bleibt dort nachvollziehbar. + Es wird festgehalten, dass du sie aufgehoben hast.
+
+
+
+ + +
`, + bestaetigenText: "Fahndung aufheben", + bestaetigt: async (el, zu) => { + try { + await aufruf("fahndung", "aufheben", { + id: f.id, grund: el.querySelector("#fh-grund").value.trim(), + }); + zu(); meldung("Fahndung aufgehoben.", "gut"); z.offen = null; rendern(behaelter); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }, + }); + }); + } + + /* ---- Dialoge ------------------------------------------------------ */ + + function ausschreibenDialog(behaelter) { + let gewaehlt = null; + + fenster({ + titel: "Fahndung ausschreiben", + inhalt: ` +
+
+ +
+ +
+ + +
+
Noch kein Ziel ausgewählt.
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+ ℹ +
Die Gefahrenstufe bestimmt, wie dringlich alle Beamten benachrichtigt werden. + Bei gefährlich geht ein kritischer Hinweis an alle im Dienst.
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Ausschreiben", klick: async (el, zu) => { + if (!gewaehlt) { meldung("Bitte ein Ziel auswählen.", "fehler"); return; } + try { + const ergebnis = await aufruf("fahndung", "erstellen", { + typ: el.querySelector("#fn-typ").value, + zielId: gewaehlt, + gefahr: el.querySelector("#fn-gefahr").value, + prioritaet: +el.querySelector("#fn-prio").value, + grund: el.querySelector("#fn-grund").value.trim(), + hinweise: el.querySelector("#fn-hinweise").value.trim(), + bild: el.querySelector("#fn-bild").value.trim(), + }); + zu(); meldung("Fahndung ausgeschrieben: " + ergebnis.bezeichnung, "gut"); + oeffne(behaelter, ergebnis.id); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }}, + ], + nachOeffnen: (el) => { + const suchen = async () => { + const begriff = el.querySelector("#fn-suche").value.trim(); + if (begriff.length < 2) { meldung("Bitte mindestens zwei Zeichen.", "fehler"); return; } + + try { + const d = await aufruf("fahndung", "zielSuche", { + typ: el.querySelector("#fn-typ").value, begriff, + }); + const anzeige = el.querySelector("#fn-gewaehlt"); + anzeige.innerHTML = (d.treffer || []).length + ? `
${d.treffer.slice(0, 6).map((t) => + `
+
${sicher(t.titel)}
+
${sicher(t.unter)}
+
`).join("")}
` + : `
Keine Treffer.
`; + + anzeige.querySelectorAll("[data-wahl]").forEach((zeile) => + zeile.addEventListener("click", () => { + gewaehlt = zeile.dataset.wahl; + anzeige.innerHTML = `Ausgewählt: ${sicher(zeile.dataset.titel)}`; + })); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }; + + el.querySelector("#fn-suchen").addEventListener("click", suchen); + el.querySelector("#fn-suche").addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); suchen(); } + }); + // Bei Wechsel der Art die Auswahl verwerfen + el.querySelector("#fn-typ").addEventListener("change", () => { + gewaehlt = null; + el.querySelector("#fn-gewaehlt").textContent = "Noch kein Ziel ausgewählt."; + }); + }, + }); + } + + function bearbeitenDialog(behaelter, f) { + fenster({ + titel: "Fahndung bearbeiten", + inhalt: ` +
+
+
+
+
+
+
+
+
+
+
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Speichern", klick: async (el, zu) => { + try { + await aufruf("fahndung", "bearbeiten", { + id: f.id, + gefahr: el.querySelector("#fb-gefahr").value, + prioritaet: +el.querySelector("#fb-prio").value, + grund: el.querySelector("#fb-grund").value.trim(), + hinweise: el.querySelector("#fb-hinweise").value.trim(), + bild: el.querySelector("#fb-bild").value.trim(), + }); + zu(); meldung("Gespeichert.", "gut"); oeffne(behaelter, f.id); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }}, + ], + }); + } + + /* ---- Laden -------------------------------------------------------- */ + + async function oeffne(behaelter, fahndungId) { + try { + const d = await aufruf("fahndung", "oeffnen", { id: fahndungId }); + z.offen = fahndungId; + einzelAnsicht(behaelter, d); + } catch (f) { meldung(f.message, "fehler"); } + } + + async function rendern(behaelter) { + try { + z.daten = await aufruf("fahndung", "liste", { + typ: z.filterTyp, gefahr: z.filterGefahr, archiv: z.archiv, begriff: z.suche, + }); + if (z.offen) await oeffne(behaelter, z.offen); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Fahndungen

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("fahndung", { + rendern, + aktualisieren: () => { + if (App.zustand.aktivesModul === "fahndung") rendern($("inhalt")); + }, + }); + + App.registriereSuche(async (begriff) => { + try { + const d = await aufruf("fahndung", "liste", { begriff }); + return (d.fahndungen || []).slice(0, 5).map((f) => ({ + bereich: "Fahndung", titel: f.bezeichnung, + unter: f.grund + " · " + (gefahrText[f.gefahr] || f.gefahr), + })); + } catch (e) { return []; } + }); +})(); diff --git a/resources/[aether]/aether-police/html/js/module/fahrzeug.js b/resources/[aether]/aether-police/html/js/module/fahrzeug.js new file mode 100644 index 0000000..390c1a5 --- /dev/null +++ b/resources/[aether]/aether-police/html/js/module/fahrzeug.js @@ -0,0 +1,451 @@ +/* ===================================================================== + aether-police / Ansicht: Fuhrpark + + Zeigt Standort, Tankstand, Zustand und letzten Fahrer — das sind + gemeldete Tatsachen. Zuweisung, Status und Wartung setzt immer ein + Beamter; es gibt keine automatische Fahrzeugvergabe. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $, anClient } = App; + + const z = { + daten: null, + offen: null, + filterAbteilung: "", + filterStatus: "", + filterWartung: "", + suche: "", + }; + + const statusArt = { + verfuegbar: "gut", im_einsatz: "warn", + werkstatt: "kritisch", ausser_dienst: "neutral", + }; + const wartungArt = { ok: "gut", faellig: "warn", werkstatt: "kritisch" }; + + /* ---- Bausteine --------------------------------------------------- */ + + /** Balken für Tank oder Zustand */ + function balken(anteil, art) { + const wert = Math.max(0, Math.min(100, anteil)); + const farbe = wert < 20 ? "var(--kritisch)" : wert < 50 ? "var(--warn)" : "var(--gut)"; + return `
+ +
`; + } + + function statusName(id) { + return (z.daten.statusliste && z.daten.statusliste[id]) || id; + } + function wartungName(id) { + return (z.daten.wartungsliste && z.daten.wartungsliste[id]) || id; + } + function abteilungName(id) { + const treffer = (z.daten.abteilungen || []).find((a) => a.id === id); + return treffer ? treffer.name : id; + } + + /* ---- Übersicht ---------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Fuhrpark

+

Standort, Tank und Zustand werden gemeldet — Zuweisung und Status setzt ein Beamter.

+
+ ${d.rechte.wartung ? `` : ""} +
+ +
+
Verfügbar
+
${d.bestand.verfuegbar || 0}
+
Im Einsatz
+
${d.bestand.im_einsatz || 0}
+
Werkstatt
+
${d.bestand.werkstatt || 0}
+
Wartung offen
+
${d.wartungFaellig}
+
+ +
+ + + +
+ +
+ ${d.fahrzeuge.length ? `
${d.fahrzeuge.map((f) => ` +
+ ${sicher(f.kennzeichen)} +
+
${sicher(f.bezeichnung)} + ${f.wartung !== "ok" + ? `${sicher(wartungName(f.wartung))}` : ""} +
+
+ ${sicher(f.modell)} · ${sicher(abteilungName(f.abteilung))} + ${f.z_nummer ? " · zugewiesen: " + sicher(f.z_nummer) : ""} +
+
+ ⛽ ${Math.round(f.tank)} %${balken(f.tank, "Tank")} + 🔧 ${Math.round(f.zustand / 10)} %${balken(f.zustand / 10, "Zustand")} +
+
+
+ ${sicher(statusName(f.status))} + ${f.pos_x ? `` : ""} +
+
`).join("")}
` + : `
Kein Fahrzeug im Fuhrpark.
`} +
`; + + const neu = $("fz-neu"); + if (neu) neu.addEventListener("click", () => anlegenDialog(behaelter)); + + const suchfeld = $("fz-suche"); + suchfeld.addEventListener("keydown", (e) => { + if (e.key === "Enter") { z.suche = suchfeld.value.trim(); rendern(behaelter); } + }); + $("fz-abteilung").addEventListener("change", (e) => { + z.filterAbteilung = e.target.value; rendern(behaelter); + }); + $("fz-status").addEventListener("change", (e) => { + z.filterStatus = e.target.value; rendern(behaelter); + }); + + // Standort auf der Karte markieren, ohne die Zeile zu öffnen + behaelter.querySelectorAll("[data-weg]").forEach((b) => + b.addEventListener("click", (e) => { + e.stopPropagation(); + const [x, y] = b.dataset.weg.split(",").map(Number); + anClient("wegpunkt", { x, y }); + })); + + behaelter.querySelectorAll("[data-fahrzeug]").forEach((el) => + el.addEventListener("click", () => oeffne(behaelter, +el.dataset.fahrzeug))); + } + + /* ---- Einzelansicht ------------------------------------------------ */ + + function einzelAnsicht(behaelter, d) { + const f = d.fahrzeug; + + behaelter.innerHTML = ` +
+
+

${sicher(f.bezeichnung)}

+

${sicher(f.kennzeichen)} · ${sicher(f.modell)} · ${sicher(abteilungName(f.abteilung))}

+
+
+ + ${f.pos_x ? `` : ""} + ${d.rechte.zuweisen ? `` : ""} + ${d.rechte.wartung ? `` : ""} + ${d.rechte.zuweisen ? `` : ""} +
+
+ + ${f.wartung !== "ok" ? ` +
+ 🔧 + ${sicher(wartungName(f.wartung))} — das Fahrzeug sollte vor dem + nächsten Einsatz geprüft werden. +
` : ""} + +
+
+
+ Zustand + ${sicher(statusName(f.status))} +
+ +
+
+ ⛽ Tankstand + ${Math.round(f.tank)} % +
+ ${balken(f.tank, "Tank")} +
+ 🔧 Karosserie + ${Math.round(f.zustand / 10)} % +
+ ${balken(f.zustand / 10, "Zustand")} +
+ +
+ Wartung${sicher(wartungName(f.wartung))} + Zugewiesen an${f.z_nummer + ? sicher(f.z_nummer + " " + (f.z_vorname || "") + " " + (f.z_nachname || "")) + : "— frei —"} + Letzter Fahrer${f.l_nummer + ? sicher(f.l_nummer + " " + (f.l_vorname || "") + " " + (f.l_nachname || "")) + : "— nicht erfasst —"} + Standort${f.pos_x + ? `${Math.round(f.pos_x)}, ${Math.round(f.pos_y)}` + : "— nicht gemeldet —"} +
+ +
+ ℹ +
Tank, Zustand, Standort und letzter Fahrer werden vom Fahrzeug + gemeldet. Status, Zuweisung und Wartung setzt ausschließlich ein Beamter.
+
+
+ +
+
+ Verwaltung + ${d.rechte.wartung && !f.zugewiesen_an + ? `` : ""} +
+ ${f.zugewiesen_an ? ` +
+
+ ${sicher(f.z_nummer || "")} +
+
${sicher((f.z_vorname || "") + " " + (f.z_nachname || ""))}
+
Fahrzeug ist dieser Person zugewiesen
+
+ ${d.rechte.zuweisen + ? `` : ""} +
+
` + : `
Das Fahrzeug ist keiner Person zugewiesen.
`} +
+ +
+
Vorgangshistorie
+ ${d.historie.length ? `
${d.historie.map((h) => ` +
+
+
${sicher(h.aktion)}
+
${sicher(h.dienstnummer || "")} + ${sicher((h.firstname || "") + " " + (h.lastname || ""))} · ${zeit(h.am)} + ${h.grund ? " · " + sicher(h.grund) : ""}
+
+
`).join("")}
` + : `
Keine Einträge.
`} +
+
`; + + verdrahteEinzel(behaelter, d); + } + + function verdrahteEinzel(behaelter, d) { + const f = d.fahrzeug; + + $("fz-zurueck").addEventListener("click", () => { z.offen = null; rendern(behaelter); }); + + const standort = $("fz-standort"); + if (standort) standort.addEventListener("click", () => + anClient("wegpunkt", { x: f.pos_x, y: f.pos_y })); + + const zuweisen = $("fz-zuweisen"); + if (zuweisen) zuweisen.addEventListener("click", () => { + const beamte = d.beamteImDienst || []; + fenster({ + titel: "Fahrzeug zuweisen", + inhalt: ` +

+ ${sicher(f.bezeichnung)} (${sicher(f.kennzeichen)})

+ ${beamte.length ? `
${beamte.map((b) => + `
+ ${sicher(b.dienstnummer)} +
${sicher(b.name)}
+
${sicher(b.rolleName)} · ${sicher(b.abteilung)}
+
`).join("")}
` + : `
Niemand im Dienst.
`} +
+
`, + nachOeffnen: (el) => { + el.querySelectorAll("[data-wahl]").forEach((zeile) => + zeile.addEventListener("click", async () => { + try { + await aufruf("fahrzeug", "zuweisen", { + id: f.id, charakterId: +zeile.dataset.wahl, + grund: el.querySelector("#fzz-grund").value.trim(), + }); + el.remove(); meldung("Zugewiesen.", "gut"); oeffne(behaelter, f.id); + } catch (fehler) { meldung(fehler.message, "fehler"); } + })); + }, + }); + }); + + const freigeben = $("fz-freigeben"); + if (freigeben) freigeben.addEventListener("click", async () => { + try { + await aufruf("fahrzeug", "zuweisen", { id: f.id, freigeben: true }); + meldung("Zuweisung aufgehoben."); oeffne(behaelter, f.id); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }); + + const statusSetzen = $("fz-status-setzen"); + if (statusSetzen) statusSetzen.addEventListener("click", () => { + fenster({ + titel: "Status setzen", + inhalt: ` +
+
+
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Speichern", klick: async (el, zu) => { + try { + await aufruf("fahrzeug", "status", { + id: f.id, status: el.querySelector("#fzs-status").value, + grund: el.querySelector("#fzs-grund").value.trim(), + }); + zu(); meldung("Status gesetzt.", "gut"); oeffne(behaelter, f.id); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }}, + ], + }); + }); + + const wartung = $("fz-wartung"); + if (wartung) wartung.addEventListener("click", () => { + fenster({ + titel: "Wartungsstatus", + inhalt: ` +
+
+
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Speichern", klick: async (el, zu) => { + try { + await aufruf("fahrzeug", "wartung", { + id: f.id, wartung: el.querySelector("#fzw-wartung").value, + vermerk: el.querySelector("#fzw-vermerk").value.trim(), + }); + zu(); meldung("Wartungsstatus gesetzt.", "gut"); oeffne(behaelter, f.id); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }}, + ], + }); + }); + + const entfernen = $("fz-entfernen"); + if (entfernen) entfernen.addEventListener("click", () => { + bestaetigen({ + titel: "Fahrzeug ausmustern", + gefahr: true, + inhalt: ` +

+ ${sicher(f.bezeichnung)} (${sicher(f.kennzeichen)}) wird aus dem Fuhrpark + entfernt. Der Vorgang bleibt im Protokoll sichtbar.

+
+
`, + bestaetigenText: "Ausmustern", + bestaetigt: async (el, zu) => { + try { + await aufruf("fahrzeug", "entfernen", { + id: f.id, grund: el.querySelector("#fze-grund").value.trim(), + }); + zu(); meldung("Ausgemustert."); z.offen = null; rendern(behaelter); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }, + }); + }); + } + + /* ---- Anlegen ------------------------------------------------------ */ + + function anlegenDialog(behaelter) { + fenster({ + titel: "Fahrzeug aufnehmen", + inhalt: ` +
+
+
+
+
+
+
+
+
+
+
+ Das Kennzeichen muss mit dem im Spiel übereinstimmen — nur dann können + Tank, Zustand und Standort gemeldet werden. +
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Aufnehmen", klick: async (el, zu) => { + try { + const ergebnis = await aufruf("fahrzeug", "anlegen", { + kennzeichen: el.querySelector("#fn-kz").value.trim(), + modell: el.querySelector("#fn-modell").value.trim(), + bezeichnung: el.querySelector("#fn-bez").value.trim(), + abteilung: el.querySelector("#fn-abteilung").value, + }); + zu(); meldung("Fahrzeug aufgenommen.", "gut"); + oeffne(behaelter, ergebnis.id); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }}, + ], + }); + } + + /* ---- Laden -------------------------------------------------------- */ + + async function oeffne(behaelter, fahrzeugId) { + try { + const d = await aufruf("fahrzeug", "oeffnen", { id: fahrzeugId }); + z.offen = fahrzeugId; + einzelAnsicht(behaelter, d); + } catch (f) { meldung(f.message, "fehler"); } + } + + async function rendern(behaelter) { + try { + z.daten = await aufruf("fahrzeug", "liste", { + abteilung: z.filterAbteilung, status: z.filterStatus, + wartung: z.filterWartung, begriff: z.suche, + }); + if (z.offen) await oeffne(behaelter, z.offen); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Fuhrpark

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("fahrzeug", { rendern }); + + App.registriereSuche(async (begriff) => { + try { + const d = await aufruf("fahrzeug", "liste", { begriff }); + return (d.fahrzeuge || []).slice(0, 5).map((f) => ({ + bereich: "Dienstfahrzeug", titel: f.bezeichnung, + unter: f.kennzeichen + " · " + f.modell, + })); + } catch (e) { return []; } + }); +})(); diff --git a/resources/[aether]/aether-police/html/js/module/gewahrsam.js b/resources/[aether]/aether-police/html/js/module/gewahrsam.js new file mode 100644 index 0000000..2febf7d --- /dev/null +++ b/resources/[aether]/aether-police/html/js/module/gewahrsam.js @@ -0,0 +1,432 @@ +/* ===================================================================== + aether-police / Ansicht: Gewahrsamsverwaltung + + Zeigt Zellenbelegung und laufende Fristen. Abgelaufene Fristen werden + deutlich hervorgehoben — aber niemand wird dadurch entlassen. Die + Freilassung ist immer eine bewusste Handlung eines Beamten. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $ } = App; + + const z = { + daten: null, + reiter: "aktuell", // aktuell | verlauf + }; + + /* Laufender Ticker, damit die Restzeiten mitlaufen */ + let ticker = null; + + /* ---- Bausteine --------------------------------------------------- */ + + /** Sekunden als „12:34" oder „abgelaufen" */ + function restText(sekunden) { + if (sekunden <= 0) return "abgelaufen"; + const m = Math.floor(sekunden / 60); + const s = Math.floor(sekunden % 60); + return m + ":" + String(s).padStart(2, "0") + " Min"; + } + + function reiterLeiste() { + return `
+ + +
`; + } + + function zellenUebersicht(zellen) { + return `
${zellen.map((zelle) => ` +
+
${sicher(zelle.name)}
+ ${zelle.frei + ? `
frei
` + : `
${sicher(zelle.insasse.name)}
+
+ ${restText(zelle.insasse.rest)} +
`} +
`).join("")}
`; + } + + function bestandListe(offene) { + if (!offene.length) return `
Niemand in Gewahrsam.
`; + + return `
${offene.map((g) => ` +
+ +
+
+ ${sicher(g.firstname + " " + g.lastname)} + ${g.zelle ? `${sicher(g.zelle)}` : ""} + ${g.online ? "" : 'offline'} +
+
${sicher(g.grund)}
+
+ ${g.haft_minuten} Min festgelegt · eingewiesen von + ${sicher(g.e_dienstnummer || "")} ${sicher((g.e_vorname || "") + " " + (g.e_nachname || ""))} + · ${zeit(g.beginn)} +
+
+
+ + ${restText(g.rest_sekunden)} + +
+ ${z.daten.rechte.entlassen + ? `` : ""} + ${z.daten.rechte.einweisen + ? `` : ""} + ${z.daten.rechte.entlassen + ? `` : ""} +
+
+
`).join("")}
`; + } + + /* ---- Aktuelle Ansicht -------------------------------------------- */ + + function aktuellAnsicht(behaelter) { + const d = z.daten; + const abgelaufen = d.offene.filter((g) => g.abgelaufen).length; + + behaelter.innerHTML = ` +
+
+

Gewahrsam

+

Fristen laufen mit — entlassen wird ausschließlich durch einen Beamten.

+
+ ${d.rechte.einweisen + ? `` : ""} +
+ + ${reiterLeiste()} + + ${abgelaufen > 0 ? ` +
+ ⏰ + ${abgelaufen} Haftzeit${abgelaufen === 1 ? "" : "en"} abgelaufen. + Die Person${abgelaufen === 1 ? " bleibt" : "en bleiben"} im Gewahrsam, bis ein Beamter + über die Entlassung entscheidet. +
` : ""} + +
+
+
+ Zellenbelegung + ${d.zellen.filter((c) => !c.frei).length} / ${d.zellen.length} +
+ ${zellenUebersicht(d.zellen)} +
+ +
+
+ Aktueller Bestand + ${d.offene.length} +
+ ${bestandListe(d.offene)} +
+ ℹ +
Das System erinnert an ablaufende Fristen — es entlässt niemanden. + Auch eine längst abgelaufene Haftzeit bleibt offen stehen, bis jemand entscheidet.
+
+
+
`; + + verdrahteAktuell(behaelter); + starteTicker(behaelter); + } + + function verdrahteAktuell(behaelter) { + const neu = $("gw-neu"); + if (neu) neu.addEventListener("click", () => einweisenDialog(behaelter)); + + behaelter.querySelectorAll("[data-entlassen]").forEach((b) => + b.addEventListener("click", () => entlassenDialog(behaelter, +b.dataset.entlassen))); + + behaelter.querySelectorAll("[data-frist]").forEach((b) => + b.addEventListener("click", () => fristDialog(behaelter, +b.dataset.frist))); + + behaelter.querySelectorAll("[data-zelle]").forEach((b) => + b.addEventListener("click", () => zelleDialog(behaelter, +b.dataset.zelle))); + } + + /** Lässt die Restzeiten sekündlich weiterlaufen, ohne neu zu laden */ + function starteTicker(behaelter) { + clearInterval(ticker); + ticker = setInterval(() => { + const felder = behaelter.querySelectorAll("[data-rest]"); + if (!felder.length) { clearInterval(ticker); return; } + + felder.forEach((el) => { + let rest = +el.dataset.rest - 1; + el.dataset.rest = rest; + el.textContent = restText(rest); + if (rest <= 0) el.classList.add("abgelaufen"); + }); + }, 1000); + } + + /* ---- Einweisen ---------------------------------------------------- */ + + function einweisenDialog(behaelter) { + const freieZellen = z.daten.zellen.filter((c) => c.frei); + let gewaehlt = null; // { id, name } + + const f = fenster({ + titel: "In Gewahrsam nehmen", + inhalt: ` +
+ +
+ + +
+
Noch niemand ausgewählt.
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ ℹ +
Die Haftdauer legst du fest. Ein Katalogvorschlag aus einer Anzeige + ist nur eine Empfehlung und wird hier nicht automatisch übernommen. + ${z.daten.verbringen + ? "

Die Person wird in die gewählte Zelle verbracht." + : "

Die Person wird nicht automatisch verbracht — das geschieht im Rollenspiel."}
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "In Gewahrsam nehmen", klick: async (el, zu) => { + if (!gewaehlt) { meldung("Bitte eine Person auswählen.", "fehler"); return; } + try { + await aufruf("gewahrsam", "einweisen", { + charakterId: gewaehlt.id, + dauer: +el.querySelector("#gw-dauer").value, + zelle: el.querySelector("#gw-zelle").value || null, + grund: el.querySelector("#gw-grund").value.trim(), + }); + zu(); meldung("In Gewahrsam genommen.", "gut"); rendern(behaelter); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }}, + ], + nachOeffnen: (el) => { + const suchen = async () => { + const begriff = el.querySelector("#gw-person").value.trim(); + if (begriff.length < 2) { meldung("Bitte mindestens zwei Zeichen.", "fehler"); return; } + + try { + const d = await aufruf("mdt", "personSuchen", { begriff }); + const treffer = d.treffer || []; + if (!treffer.length) { meldung("Keine Treffer."); return; } + + // Ergebnisse direkt unter dem Feld anbieten + const anzeige = el.querySelector("#gw-gewaehlt"); + anzeige.innerHTML = `
${treffer.slice(0, 6).map((p) => + `
+
${sicher(p.firstname + " " + p.lastname)}
+
geb. ${sicher(String(p.dob || "").slice(0, 10))}
+
`).join("")}
`; + + anzeige.querySelectorAll("[data-wahl]").forEach((zeile) => + zeile.addEventListener("click", () => { + gewaehlt = { id: +zeile.dataset.wahl, name: zeile.dataset.name }; + anzeige.innerHTML = `Ausgewählt: ${sicher(gewaehlt.name)}`; + })); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }; + + el.querySelector("#gw-suchen").addEventListener("click", suchen); + el.querySelector("#gw-person").addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); suchen(); } + }); + }, + }); + return f; + } + + /* ---- Entlassen ---------------------------------------------------- */ + + function entlassenDialog(behaelter, gewahrsamId) { + const g = z.daten.offene.find((x) => x.id === gewahrsamId); + if (!g) return; + + const vorzeitig = g.rest_sekunden > 0; + const restMinuten = Math.ceil(g.rest_sekunden / 60); + + bestaetigen({ + titel: vorzeitig ? "Vorzeitig entlassen" : "Entlassen", + gefahr: vorzeitig, + inhalt: ` +

+ ${sicher(g.firstname + " " + g.lastname)}
+ ${sicher(g.grund)} +

+ + ${vorzeitig ? ` +
+
⚠ Die Frist läuft noch ${restMinuten} Minuten
+
+ Eine vorzeitige Entlassung ist möglich — sie verlangt aber eine Begründung + und wird als solche protokolliert. +
+
` + : `
+
+ ⏰ +
Die festgelegte Haftzeit ist abgelaufen. Das System hat lediglich + daran erinnert — die Entlassung führst du aus.
+
+
`} + +
+ + +
`, + bestaetigenText: "Entlassung ausführen", + bestaetigt: async (el, zu) => { + try { + await aufruf("gewahrsam", "entlassen", { + id: gewahrsamId, + grund: el.querySelector("#ge-grund").value.trim(), + }); + zu(); meldung("Entlassung durchgeführt.", "gut"); rendern(behaelter); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }, + }); + } + + /* ---- Frist anpassen ----------------------------------------------- */ + + function fristDialog(behaelter, gewahrsamId) { + fenster({ + titel: "Haftzeit anpassen", + inhalt: ` +
+ + +
Beispiel: -10 verkürzt um zehn Minuten, + 15 verlängert um fünfzehn.
+
+
+ + +
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Frist ändern", klick: async (el, zu) => { + try { + await aufruf("gewahrsam", "fristAendern", { + id: gewahrsamId, + minuten: +el.querySelector("#gf-minuten").value, + grund: el.querySelector("#gf-grund").value.trim(), + }); + zu(); meldung("Frist angepasst.", "gut"); rendern(behaelter); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }}, + ], + }); + } + + /* ---- Zelle wechseln ----------------------------------------------- */ + + function zelleDialog(behaelter, gewahrsamId) { + const frei = z.daten.zellen.filter((c) => c.frei); + if (!frei.length) { meldung("Keine freie Zelle verfügbar.", "fehler"); return; } + + fenster({ + titel: "Zelle wechseln", + inhalt: `
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Verlegen", klick: async (el, zu) => { + try { + await aufruf("gewahrsam", "zelleWechseln", { + id: gewahrsamId, zelle: el.querySelector("#gz-zelle").value, + }); + zu(); meldung("Verlegt.", "gut"); rendern(behaelter); + } catch (fehler) { meldung(fehler.message, "fehler"); } + }}, + ], + }); + } + + /* ---- Verlauf ------------------------------------------------------ */ + + function verlaufAnsicht(behaelter) { + const v = z.daten.verlauf; + + behaelter.innerHTML = ` +
+

Gewahrsam — Verlauf

+

Abgeschlossene Vorgänge, nachvollziehbar mit entscheidendem Beamten

+
+ + ${reiterLeiste()} + +
+ ${v.length ? `
${v.map((g) => ` +
+
+
${sicher(g.firstname + " " + g.lastname)}
+
${sicher(g.grund)}
+
+ ${g.haft_minuten} Min · ${zeit(g.beginn)} bis ${zeit(g.entlassen_am)} + · entlassen durch ${sicher((g.e_vorname || "") + " " + (g.e_nachname || ""))} + ${g.entlass_grund ? ` · „${sicher(g.entlass_grund)}"` : ""} +
+
+
`).join("")}
` + : `
Keine abgeschlossenen Vorgänge.
`} +
`; + + behaelter.querySelectorAll("[data-reiter]").forEach((b) => + b.addEventListener("click", () => { z.reiter = b.dataset.reiter; rendern(behaelter); })); + } + + /* ---- Laden -------------------------------------------------------- */ + + async function rendern(behaelter) { + try { + z.daten = await aufruf("gewahrsam", "liste", {}); + + if (z.reiter === "verlauf") verlaufAnsicht(behaelter); + else aktuellAnsicht(behaelter); + + behaelter.querySelectorAll("[data-reiter]").forEach((b) => + b.addEventListener("click", () => { z.reiter = b.dataset.reiter; rendern(behaelter); })); + + } catch (fehler) { + behaelter.innerHTML = `

Gewahrsam

+

Konnte nicht geladen werden: ${sicher(fehler.message)}

`; + } + } + + App.registriereAnsicht("gewahrsam", { + rendern, + aktualisieren: () => { + if (App.zustand.aktivesModul === "gewahrsam") rendern($("inhalt")); + }, + }); +})(); diff --git a/resources/[aether]/aether-police/html/js/module/kommunikation.js b/resources/[aether]/aether-police/html/js/module/kommunikation.js new file mode 100644 index 0000000..142bf0d --- /dev/null +++ b/resources/[aether]/aether-police/html/js/module/kommunikation.js @@ -0,0 +1,568 @@ +/* ===================================================================== + aether-police / Ansicht: Interne Kommunikation + + Posteingang und Aushänge. Der Posteingang merkt sich, was du gelesen + hast — das ist Anzeige. Eine Kenntnisnahme ist dagegen eine Erklärung + und wird nur durch deinen ausdrücklichen Klick gesetzt. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, aufruf, meldung, fenster, bestaetigen } = App; + + const z = { + reiter: "eingang", // eingang | gesendet | aushang | archiv + daten: null, + aushangDaten: null, + offeneNachricht: null, + offenerAushang: null, + }; + + // Farbgebung der Aushangkategorien + const kategorieArt = { + anweisung: "info", information: "neutral", warnung: "kritisch", + }; + const kategorieSymbol = { + anweisung: "📋", information: "ℹ", warnung: "⚠", + }; + + /* ---- Gerüst -------------------------------------------------------- */ + + function reiterleiste(d) { + const eintraege = [ + { id: "eingang", text: "Posteingang", zaehler: d.ungelesen }, + { id: "gesendet", text: "Gesendet" }, + { id: "aushang", text: "Aushänge", zaehler: d.offeneKenntnis }, + { id: "archiv", text: "Archiv" }, + ]; + + return `
+ ${eintraege.map((e) => ` + `).join("")} +
`; + } + + /* ---- Posteingang --------------------------------------------------- */ + + function postliste(behaelter) { + const d = z.daten; + const gesendet = z.reiter === "gesendet"; + + behaelter.innerHTML = ` +
+
+

Kommunikation

+

Nachrichten und Aushänge der Behörde. Zugestellt wird automatisch — + gelesen, beantwortet und zur Kenntnis genommen wird von Hand.

+
+ ${d.rechte.senden + ? `` : ""} +
+ + ${reiterleiste(d)} + +
+ ${(d.nachrichten || []).length + ? d.nachrichten.map((n) => postzeile(n, gesendet)).join("") + : `
${gesendet + ? "Du hast noch nichts gesendet." + : "Keine Nachrichten."}
`} +
+ `; + + verdrahtePost(behaelter); + } + + function postzeile(n, gesendet) { + const typMarker = n.empf_typ === "rundmeldung" + ? `Rundmeldung` + : n.empf_typ === "abteilung" + ? `Abteilung` : ""; + + return `
+ +
+
${sicher(n.betreff || "(kein Betreff)")}
+
+ ${gesendet ? "an " + sicher(n.empfaengerName) + : "von " + sicher(n.absenderName) + + (n.dienstnummer ? " · " + sicher(n.dienstnummer) : "")} + — ${sicher(n.vorschau)} +
+
+ ${typMarker} + ${zeit(n.gesendet)} +
`; + } + + function verdrahtePost(behaelter) { + verdrahteReiter(behaelter); + + behaelter.querySelectorAll("[data-nachricht]").forEach((el) => { + el.addEventListener("click", () => nachrichtOeffnen(behaelter, +el.dataset.nachricht)); + }); + + const neu = behaelter.querySelector("[data-neu]"); + if (neu) neu.addEventListener("click", () => verfassen(behaelter)); + } + + function verdrahteReiter(behaelter) { + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.reiter = el.dataset.reiter; + rendern(behaelter); + }); + }); + } + + /* ---- Nachricht lesen ------------------------------------------------ */ + + async function nachrichtOeffnen(behaelter, id) { + try { + const n = await aufruf("kommunikation", "nachrichtOeffnen", { id }); + + fenster({ + titel: n.betreff || "(kein Betreff)", + inhalt: ` +
+
+ ${sicher(n.absenderName)} + + ${sicher(n.dienstnummer || "")} ${n.rolleName ? "· " + sicher(n.rolleName) : ""} + +
+ ${zeit(n.gesendet)} +
+ ${n.empfaengerName + ? `

Gerichtet an: ${sicher(n.empfaengerName)}

` : ""} +
${sicher(n.text).replace(/\n/g, "
")}
`, + knoepfe: n.antwortAn + ? [{ text: "Antworten", klick: (_, zu) => { + zu(); + verfassen(behaelter, { + empfTyp: "person", empfaenger: n.antwortAn, + empfaengerName: n.absenderName, + betreff: n.betreff && n.betreff.startsWith("Re:") + ? n.betreff : "Re: " + (n.betreff || ""), + }); + } }, + { text: "Schließen", art: "leise", klick: (_, zu) => zu() }] + : [{ text: "Schließen", art: "leise", klick: (_, zu) => zu() }], + }); + + // Der Lesestatus hat sich geändert — Liste auffrischen + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + } + + /* ---- Nachricht verfassen -------------------------------------------- */ + + async function verfassen(behaelter, vorgabe) { + vorgabe = vorgabe || {}; + + let empfaengerDaten; + try { + empfaengerDaten = await aufruf("kommunikation", "empfaenger", {}); + } catch (f) { return meldung(f.message, "fehler"); } + + let gewaehlt = vorgabe.empfaenger + ? { typ: vorgabe.empfTyp, id: vorgabe.empfaenger, name: vorgabe.empfaengerName } + : null; + + fenster({ + titel: "Nachricht verfassen", + inhalt: ` +
+
+ + + ${empfaengerDaten.rundmeldung + ? `` : ""} +
+
+
+
+
+
+
`, + knoepfe: [ + { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() }, + { text: "Senden", klick: async (el, zu) => { + if (!gewaehlt) return meldung("Bitte einen Empfänger wählen.", "fehler"); + try { + await aufruf("kommunikation", "senden", { + empfTyp: gewaehlt.typ, + empfaenger: gewaehlt.id, + betreff: el.querySelector("[name=betreff]").value.trim(), + text: el.querySelector("[name=text]").value.trim(), + }); + zu(); + meldung("Nachricht gesendet.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + } }, + ], + nachOeffnen: (hg) => { + const bereich = hg.querySelector("[data-empfbereich]"); + + /** Zeichnet den Empfängerbereich passend zur gewählten Art */ + function zeichne(art) { + hg.querySelectorAll("[data-art]").forEach((b) => + b.classList.toggle("gewaehlt", b.dataset.art === art)); + + if (art === "rundmeldung") { + gewaehlt = { typ: "rundmeldung", id: "alle", name: "Alle Einheiten" }; + bereich.innerHTML = `
+ 📣 +
Diese Nachricht erreicht jeden Beamten der Behörde.
+
`; + return; + } + + if (art === "abteilung") { + gewaehlt = null; + bereich.innerHTML = ``; + bereich.querySelector("[name=abt]").addEventListener("change", (e) => { + gewaehlt = e.target.value + ? { typ: "abteilung", id: e.target.value, name: e.target.value } : null; + }); + return; + } + + // Einzelner Beamter: Liste mit Suchfeld + bereich.innerHTML = ` + +
`; + + const liste = bereich.querySelector("[data-liste]"); + const suchfeld = bereich.querySelector("[name=suche]"); + + function zeichneListe(begriff) { + const gefiltert = empfaengerDaten.beamte.filter((b) => { + if (!begriff) return true; + const text = (b.firstname + " " + b.lastname + " " + b.dienstnummer).toLowerCase(); + return text.includes(begriff.toLowerCase()); + }).slice(0, 40); + + liste.innerHTML = gefiltert.length + ? gefiltert.map((b) => ` +
+ + ${sicher(b.dienstnummer)} +
+
${sicher(b.firstname)} ${sicher(b.lastname)}
+
${sicher(b.rolleName)}
+
+
`).join("") + : `
Kein Beamter gefunden.
`; + + liste.querySelectorAll("[data-beamter]").forEach((el) => { + el.addEventListener("click", () => { + const b = empfaengerDaten.beamte.find((x) => x.character === +el.dataset.beamter); + gewaehlt = { typ: "person", id: b.character, + name: b.firstname + " " + b.lastname }; + liste.querySelectorAll("[data-beamter]") + .forEach((zeile) => zeile.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + }); + }); + } + + zeichneListe(gewaehlt && gewaehlt.name ? gewaehlt.name : ""); + suchfeld.addEventListener("input", () => zeichneListe(suchfeld.value.trim())); + } + + hg.querySelectorAll("[data-art]").forEach((b) => + b.addEventListener("click", () => zeichne(b.dataset.art))); + + zeichne(gewaehlt ? gewaehlt.typ : "person"); + }, + }); + } + + /* ---- Aushänge -------------------------------------------------------- */ + + function aushangListe(behaelter) { + const d = z.aushangDaten; + + behaelter.innerHTML = ` +
+
+

${d.archiv ? "Archivierte Aushänge" : "Aushänge"}

+

Dienstanweisungen und Mitteilungen der Leitung. Eine Kenntnisnahme + bestätigst ausschließlich du selbst.

+
+ ${d.rechte.schreiben && !d.archiv + ? `` : ""} +
+ + ${reiterleiste(z.daten || { ungelesen: 0, offeneKenntnis: 0 })} + +
+ ${(d.aushaenge || []).length + ? d.aushaenge.map((a) => aushangKarte(a, d)).join("") + : `
${d.archiv + ? "Das Archiv ist leer." : "Zurzeit hängt nichts aus."}
`} +
+ `; + + verdrahteAushaenge(behaelter); + } + + function aushangKarte(a, d) { + const art = kategorieArt[a.kategorie] || "neutral"; + const offen = a.betroffen - a.kenntnisse; + + return `
+
+ + ${kategorieSymbol[a.kategorie] || ""} ${sicher(a.titel)} + +
+ ${a.angeheftet ? `angeheftet` : ""} + ${sicher(a.kategorieName)} + ${a.abteilungName + ? `${sicher(a.abteilungName)}` : ""} +
+
+ +
${sicher(a.text).replace(/\n/g, "
")}
+ +
+ + ${sicher(a.autor)}${a.dienstnummer ? " (" + sicher(a.dienstnummer) + ")" : ""} + · ${zeit(a.erstellt_am)} + ${a.frist ? ` · Kenntnisnahme bis ${zeit(a.frist)}` : ""} + +
+ ${a.kenntnispflicht ? (a.bestaetigt + ? `✓ zur Kenntnis genommen` + : (a.aktiv === 1 + ? `` : "")) : ""} + ${a.kenntnispflicht && d.rechte.schreiben + ? `` : ""} + ${d.rechte.schreiben && a.aktiv === 1 + ? `` : ""} + ${d.rechte.archivieren && a.aktiv === 1 + ? `` : ""} +
+
+
`; + } + + function verdrahteAushaenge(behaelter) { + verdrahteReiter(behaelter); + + const neu = behaelter.querySelector("[data-neu-aushang]"); + if (neu) neu.addEventListener("click", () => aushangVerfassen(behaelter)); + + behaelter.querySelectorAll("[data-kenntnis]").forEach((el) => { + el.addEventListener("click", () => kenntnisBestaetigen(behaelter, +el.dataset.kenntnis)); + }); + + behaelter.querySelectorAll("[data-stand]").forEach((el) => { + el.addEventListener("click", () => kenntnisStand(+el.dataset.stand)); + }); + + behaelter.querySelectorAll("[data-anheften]").forEach((el) => { + el.addEventListener("click", async () => { + try { + await aufruf("kommunikation", "anheften", { + id: +el.dataset.anheften, angeheftet: el.dataset.wert === "1", + }); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }); + }); + + behaelter.querySelectorAll("[data-archiv]").forEach((el) => { + el.addEventListener("click", () => aushangArchivieren(behaelter, +el.dataset.archiv)); + }); + } + + /* ---- Kenntnisnahme ---------------------------------------------------- */ + + function kenntnisBestaetigen(behaelter, id) { + // Bewusst über den Bestätigungsdialog: das ist eine Erklärung, kein Klick + bestaetigen({ + titel: "Kenntnisnahme bestätigen", + inhalt: `

Du erklärst damit, den Aushang gelesen und verstanden zu haben. + Die Bestätigung wird mit deinem Namen und der Uhrzeit vermerkt.

`, + bestaetigenText: "Zur Kenntnis genommen", + bestaetigt: async (_, zu) => { + try { + await aufruf("kommunikation", "kenntnisnahme", { id }); + zu(); + meldung("Kenntnisnahme vermerkt.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + async function kenntnisStand(id) { + try { + const d = await aufruf("kommunikation", "kenntnisStand", { id }); + + fenster({ + titel: "Stand der Kenntnisnahmen", + inhalt: ` +

${sicher(d.titel)}

+

${d.gesamt - d.offen} von ${d.gesamt} Beamten haben bestätigt. + ${d.frist ? "Frist: " + zeit(d.frist) + "." : ""} + Das System erinnert nur — es bestätigt für niemanden.

+
+ ${d.beamte.map((b) => ` +
+ ${sicher(b.dienstnummer)} +
${sicher(b.name)}
+ ${b.bestaetigt_am + ? `${zeit(b.bestaetigt_am)}` + : `offen`} +
`).join("")} +
`, + }); + } catch (f) { meldung(f.message, "fehler"); } + } + + /* ---- Aushang verfassen und archivieren -------------------------------- */ + + function aushangVerfassen(behaelter) { + const d = z.aushangDaten; + + bestaetigen({ + titel: "Aushang verfassen", + inhalt: ` +
+
+
+
+
+
+
+
+
+ +
+
+ +
Beamte bestätigen selbst. Das System erinnert nur + an offene Bestätigungen und setzt nie eine.
+
+ `, + bestaetigenText: "Veröffentlichen", + bestaetigt: async (el, zu) => { + const kenntnispflicht = el.querySelector("[name=kenntnispflicht]").checked; + const frist = +el.querySelector("[name=frist]").value; + try { + await aufruf("kommunikation", "aushangErstellen", { + titel: el.querySelector("[name=titel]").value.trim(), + text: el.querySelector("[name=text]").value.trim(), + kategorie: el.querySelector("[name=kategorie]").value, + abteilung: el.querySelector("[name=abteilung]").value, + angeheftet: el.querySelector("[name=angeheftet]").checked, + kenntnispflicht, + fristMinuten: kenntnispflicht && frist > 0 ? frist : null, + }); + zu(); + meldung("Aushang veröffentlicht.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + // Das Fristfeld ist nur sinnvoll, wenn eine Kenntnisnahme verlangt wird + const haken = hg.querySelector("[name=kenntnispflicht]"); + const fristfeld = hg.querySelector("[data-fristfeld]"); + haken.addEventListener("change", () => { + fristfeld.style.display = haken.checked ? "" : "none"; + }); + }, + }); + } + + function aushangArchivieren(behaelter, id) { + bestaetigen({ + titel: "Aushang archivieren", + gefahr: true, + inhalt: `

Der Aushang verschwindet vom Brett und wandert ins Archiv. + Bereits bestätigte Kenntnisnahmen bleiben erhalten.

+
+
`, + bestaetigenText: "Archivieren", + bestaetigt: async (el, zu) => { + try { + await aufruf("kommunikation", "aushangArchivieren", { + id, grund: el.querySelector("[name=grund]").value.trim(), + }); + zu(); + meldung("Aushang archiviert.", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Laden ------------------------------------------------------------- */ + + async function rendern(behaelter) { + try { + // Die Zähler der Reiterleiste kommen immer aus der Übersicht + z.daten = await aufruf("kommunikation", "uebersicht", { + ordner: z.reiter === "gesendet" ? "gesendet" : "eingang", + }); + + // Sprungziel aus einem Hinweis, z. B. „Neuer Aushang" + const ziel = App.zustand.modulZiel; + if (ziel && ziel.aushang) { + App.zustand.modulZiel = null; + z.reiter = "aushang"; + } else if (ziel && ziel.nachricht) { + App.zustand.modulZiel = null; + z.reiter = "eingang"; + postliste(behaelter); + return nachrichtOeffnen(behaelter, ziel.nachricht); + } + + if (z.reiter === "aushang" || z.reiter === "archiv") { + z.aushangDaten = await aufruf("kommunikation", "aushaenge", { + archiv: z.reiter === "archiv", + }); + aushangListe(behaelter); + } else { + postliste(behaelter); + } + } catch (f) { + behaelter.innerHTML = `

Kommunikation

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("kommunikation", { rendern }); +})(); diff --git a/resources/[aether]/aether-police/html/js/module/mdt.js b/resources/[aether]/aether-police/html/js/module/mdt.js new file mode 100644 index 0000000..2ea9240 --- /dev/null +++ b/resources/[aether]/aether-police/html/js/module/mdt.js @@ -0,0 +1,892 @@ +/* ===================================================================== + aether-police / Ansicht: MDT + + Drei Reiter: Personen, Fahrzeuge, Akten. + Die Ansicht zeigt und dokumentiert — sie bewertet nichts und löst + keine Maßnahme aus. Jede Änderung verlangt eine Bestätigung. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, aufruf, meldung, fenster, bestaetigen, $, anClient } = App; + + /* Zustand der Ansicht */ + const z = { + reiter: "personen", // personen | fahrzeuge | akten + suche: "", + treffer: [], + offenePerson: null, // geladene Personenakte + offenesFahrzeug: null, + offeneAkte: null, + akteFilter: { typ: "", status: "" }, + stammdaten: null, // Aktenarten, Beteiligtenrollen + }; + + /* ---- kleine Bausteine ------------------------------------------- */ + + const gefahrText = ["keine", "gering", "erhöht", "hoch"]; + + /** Marke für die Gefahrenstufe einer Person */ + function gefahrMarke(stufe) { + const s = Number(stufe) || 0; + if (s === 0) return ""; + const art = s >= 3 ? "kritisch" : s === 2 ? "warn" : "neutral"; + return `Gefahr: ${gefahrText[s]}`; + } + + function reiterLeiste() { + const reiter = [ + { id: "personen", name: "Personen", symbol: "👤" }, + { id: "fahrzeuge", name: "Fahrzeuge", symbol: "🚗" }, + { id: "akten", name: "Akten", symbol: "🗂" }, + ]; + return `
${reiter.map((r) => + ``).join("")}
`; + } + + /* ===================================================================== + PERSONEN + ===================================================================== */ + + function personenTrefferListe() { + if (!z.treffer.length) { + return `
${z.suche ? "Keine Treffer." : "Suchbegriff eingeben — Name, Geburtsdatum oder Telefonnummer."}
`; + } + + return `
${z.treffer.map((p) => + `
+
+
+ ${sicher(p.firstname + " " + p.lastname)} + ${p.fahndung > 0 ? 'Fahndung' : ""} + ${gefahrMarke(p.gefahrenstufe)} +
+
+ geb. ${sicher(String(p.dob || "").slice(0, 10))} + ${p.telefon ? " · ☎ " + sicher(p.telefon) : ""} + ${p.job ? " · " + sicher(p.job) : ""} +
+
+ +
`).join("")}
`; + } + + /** Zeichnet eine vollständige Personenakte */ + function personenAkte(d) { + const p = d.person; + + const lizenzName = { + perso: "Personalausweis", fs: "Führerschein", ws: "Waffenschein", + gk: "Gesundheitskarte", vk: "Visitenkarte", + }; + + return ` +
+
${p.lichtbild + ? `Lichtbild` + : `Kein Lichtbild`}
+
+

${sicher(p.firstname + " " + p.lastname)}

+
+ Geboren${sicher(String(p.dob || "").slice(0, 10))} + Geschlecht${sicher(p.gender || "—")} + Herkunft${sicher(p.nationality || "—")} + Beruf${sicher(p.job || "—")} + Telefon${sicher(d.telefon || "—")} + Fingerabdruck${sicher(p.fingerabdruck || "nicht erfasst")} + DNA${sicher(p.dna || "nicht erfasst")} +
+
+
+ + + + +
+
+ + ${p.warnhinweis ? ` +
+ ⚠ + Warnhinweis: ${sicher(p.warnhinweis)} — Gefahrenstufe ${gefahrText[Number(p.gefahrenstufe) || 0]} +
` : ""} + + ${d.fahndung ? ` +
+ 🔍 + Zur Fahndung ausgeschrieben: ${sicher(d.fahndung.grund)} + (${sicher(d.fahndung.gefahr)}) +
` : ""} + + ${d.gewahrsam ? ` +
+ 🔒 + In Gewahrsam — ${sicher(d.gewahrsam.grund || "ohne Grundangabe")}; + Frist ${Number(d.gewahrsam.rest) <= 0 ? "abgelaufen" : "noch " + d.gewahrsam.rest + " Minuten"}. + Eine Entlassung entscheidet ein Beamter. +
` : ""} + +
+
+
Dokumente & Lizenzen
+ ${d.lizenzen.length ? `
${d.lizenzen.map((l) => + `
+
+
${sicher(lizenzName[l.typ] || l.typ)}
+
+ ${l.nummer ? sicher(l.nummer) : "—"} + ${l.klassen ? " · Klassen " + sicher(l.klassen.join(", ")) : ""} + ${l.gueltigBis ? " · gültig bis " + sicher(l.gueltigBis) : ""} +
+
+ + ${l.entzogen ? "entzogen" : l.gueltig ? "gültig" : "abgelaufen"} + +
`).join("")}
` + : `
Keine Dokumente hinterlegt.
`} +
+ +
+
Bekannte Fahrzeuge
+ ${d.fahrzeuge.length ? `
${d.fahrzeuge.map((f) => + `
+
+
${sicher(f.name || f.model)} + ${f.fahndung > 0 ? 'Fahndung' : ""}
+
${sicher(f.plate)} · ${sicher(f.state === "impound" ? "sichergestellt" : f.state === "out" ? "unterwegs" : "eingeparkt")}
+
+
`).join("")}
` + : `
Keine Fahrzeuge zugeordnet.
`} +
+ +
+
+ Vorgänge & Anzeigen + ${d.anzeigen.length} +
+ ${d.anzeigen.length ? `
${d.anzeigen.map((a) => + `
+
+
${sicher((a.delikte || []).map((x) => x.name).join(", ") || "Anzeige")}
+
${zeit(a.erstellt_am)} + ${a.status === "bestaetigt" ? ` · ${a.haft_final} Min · ${App.euro(a.geld_final)}` : ""}
+
+ + ${sicher(a.status)} + +
`).join("")}
` + : `
Keine Vorgänge erfasst.
`} +
+ +
+
Verknüpfte Akten
+ ${d.akten.length ? `
${d.akten.map((a) => + `
+
+
${sicher(a.titel)}
+
${sicher(a.aktenzeichen)} · ${sicher(a.typ)} + ${a.rolle ? " · " + sicher(a.rolle) : ""}
+
+ ${sicher(a.status)} +
`).join("")}
` + : `
Keine Akten verknüpft.
`} +
+ +
+
Bekannte Kontakte
+ ${d.kontakte.length ? `
${d.kontakte.map((k) => + `
+
+
${sicher(k.name)}
+
☎ ${sicher(k.number)}
+
+ ${k.charakterId ? 'bekannt' : ""} +
`).join("")}
` + : `
Keine Kontakte bekannt.
`} +
+ +
+
+ Notizen + ${d.notizen.length} +
+ ${d.notizen.length ? `
${d.notizen.map((n) => + `
+
+
${sicher(n.text)}
+
${sicher((n.firstname || "") + " " + (n.lastname || ""))} · ${zeit(n.am)}
+
+ +
`).join("")}
` + : `
Keine Notizen.
`} +
+
+ `; + } + + /* ---- Aktionen an der Personenakte -------------------------------- */ + + function verdrahtePersonenakte(behaelter, charakterId) { + // Notiz hinzufügen + const notizKnopf = $("p-notiz"); + if (notizKnopf) notizKnopf.addEventListener("click", () => { + fenster({ + titel: "Notiz hinzufügen", + inhalt: `
+ + +
Notizen sind für alle Beamten sichtbar und werden protokolliert.
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Speichern", art: "", klick: async (el, zu) => { + const text = el.querySelector("#nz-text").value.trim(); + if (text.length < 3) { meldung("Bitte einen Text eingeben.", "fehler"); return; } + try { + await aufruf("mdt", "personNotiz", { charakterId, text }); + zu(); meldung("Notiz gespeichert.", "gut"); + oeffnePerson(behaelter, charakterId); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + }); + + // Notiz entfernen + behaelter.querySelectorAll("[data-notiz-weg]").forEach((b) => { + b.addEventListener("click", async (e) => { + e.stopPropagation(); + try { + await aufruf("mdt", "personNotizLoeschen", { id: +b.dataset.notizWeg }); + oeffnePerson(behaelter, charakterId); + } catch (f) { meldung(f.message, "fehler"); } + }); + }); + + // Warnhinweis setzen + const warnKnopf = $("p-warn"); + if (warnKnopf) warnKnopf.addEventListener("click", () => { + const p = z.offenePerson.person; + bestaetigen({ + titel: "Warnhinweis festlegen", + inhalt: ` +
+ + +
+
+ + +
+
+ + +
`, + bestaetigenText: "Warnhinweis setzen", + bestaetigt: async (el, zu) => { + try { + await aufruf("mdt", "personWarnhinweis", { + charakterId, + hinweis: el.querySelector("#wh-text").value.trim(), + stufe: +el.querySelector("#wh-stufe").value, + grund: el.querySelector("#wh-grund").value.trim(), + }); + zu(); meldung("Warnhinweis aktualisiert.", "gut"); + oeffnePerson(behaelter, charakterId); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + + // Erkennungsdienstliche Daten + const edKnopf = $("p-ed"); + if (edKnopf) edKnopf.addEventListener("click", () => { + const p = z.offenePerson.person; + fenster({ + titel: "Erkennungsdienstliche Daten", + inhalt: ` +
+
+
+
+
+ +
Die Aufnahme selbst erfolgt im Rollenspiel — hier wird nur dokumentiert.
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Speichern", klick: async (el, zu) => { + try { + await aufruf("mdt", "personStammdaten", { + charakterId, + fingerabdruck: el.querySelector("#ed-finger").value.trim(), + dna: el.querySelector("#ed-dna").value.trim(), + lichtbild: el.querySelector("#ed-bild").value.trim(), + }); + zu(); meldung("Daten gespeichert.", "gut"); + oeffnePerson(behaelter, charakterId); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + }); + + // Akte direkt zu dieser Person anlegen + const akteKnopf = $("p-akte-neu"); + if (akteKnopf) akteKnopf.addEventListener("click", () => + akteAnlegenDialog(behaelter, { typ: "person", id: charakterId })); + + // Sprünge in andere Akten + behaelter.querySelectorAll("[data-akte]").forEach((el) => + el.addEventListener("click", () => { z.reiter = "akten"; oeffneAkte(behaelter, +el.dataset.akte); })); + behaelter.querySelectorAll("[data-fahrzeug]").forEach((el) => + el.addEventListener("click", () => { z.reiter = "fahrzeuge"; oeffneFahrzeug(behaelter, el.dataset.fahrzeug); })); + behaelter.querySelectorAll("[data-person]").forEach((el) => + el.addEventListener("click", () => oeffnePerson(behaelter, +el.dataset.person))); + } + + async function oeffnePerson(behaelter, charakterId) { + try { + const daten = await aufruf("mdt", "personAkte", { charakterId }); + z.offenePerson = daten; + z.reiter = "personen"; + zeichne(behaelter, personenAkte(daten), () => verdrahtePersonenakte(behaelter, charakterId), true); + } catch (f) { meldung(f.message, "fehler"); } + } + + /* ===================================================================== + FAHRZEUGE + ===================================================================== */ + + function fahrzeugTrefferListe() { + if (!z.treffer.length) { + return `
${z.suche ? "Keine Treffer." : "Kennzeichen, Modell oder Halter eingeben."}
`; + } + + return `
${z.treffer.map((f) => + `
+ ${sicher(f.plate)} +
+
${sicher(f.name || f.model)} + ${f.fahndung > 0 ? 'Fahndung' : ""} + ${f.state === "impound" ? 'sichergestellt' : ""} +
+
Halter: ${sicher((f.firstname || "unbekannt") + " " + (f.lastname || ""))}
+
+ +
`).join("")}
`; + } + + function fahrzeugAkte(d) { + const f = d.fahrzeug; + const zustandText = { in: "eingeparkt", out: "unterwegs", impound: "sichergestellt" }; + + return ` +
+
${sicher(f.plate)}
+
+

${sicher(f.name || f.model)}

+
+ Modell${sicher(f.model)} + Klasse${sicher(f.klasse || "—")} + Farbe${f.farbe ? ` ${sicher(f.farbe)}` : "—"} + Status${sicher(zustandText[f.state] || f.state)} + Standort${sicher(f.garage || "—")} + Kilometerstand${sicher(f.mileage || 0)} km +
+
+
+ ${f.owner ? `` : ""} + +
+
+ + ${d.fahndung ? ` +
+ 🔍 + Fahrzeug zur Fahndung ausgeschrieben: ${sicher(d.fahndung.grund)} +
` : ""} + +
+
+
Halter
+ ${f.owner ? `
+
+
+
${sicher((f.firstname || "") + " " + (f.lastname || ""))}
+
geb. ${sicher(String(f.dob || "").slice(0, 10))}
+
+ +
` + : `
Kein Halter eingetragen.
`} +
+ +
+
Verknüpfte Akten
+ ${d.akten.length ? `
${d.akten.map((a) => + `
+
+
${sicher(a.titel)}
+
${sicher(a.aktenzeichen)} · ${sicher(a.typ)}
+
+
`).join("")}
` + : `
Keine Akten verknüpft.
`} +
+ +
+
Historie
+ ${d.historie.length ? `
${d.historie.map((h) => + `
+
+
${sicher(h.aktion)}
+
${sicher((h.firstname || "") + " " + (h.lastname || ""))} + ${h.dienstnummer ? " · " + sicher(h.dienstnummer) : ""} · ${zeit(h.am)} + ${h.grund ? " · " + sicher(h.grund) : ""}
+
+
`).join("")}
` + : `
Keine dokumentierten Vorgänge.
`} +
+
+ `; + } + + async function oeffneFahrzeug(behaelter, kennzeichen) { + try { + const daten = await aufruf("mdt", "fahrzeugAkte", { kennzeichen }); + z.offenesFahrzeug = daten; + z.reiter = "fahrzeuge"; + zeichne(behaelter, fahrzeugAkte(daten), () => { + behaelter.querySelectorAll("[data-person]").forEach((el) => + el.addEventListener("click", () => oeffnePerson(behaelter, +el.dataset.person))); + behaelter.querySelectorAll("[data-akte]").forEach((el) => + el.addEventListener("click", () => oeffneAkte(behaelter, +el.dataset.akte))); + const neu = $("v-akte-neu"); + if (neu) neu.addEventListener("click", () => + akteAnlegenDialog(behaelter, { typ: "fahrzeug", id: daten.fahrzeug.plate })); + }, true); + } catch (f) { meldung(f.message, "fehler"); } + } + + /* ===================================================================== + AKTEN + ===================================================================== */ + + function aktenListe(akten) { + if (!akten.length) return `
Keine Akten gefunden.
`; + + const statusArt = { offen: "warn", pruefung: "info", geschlossen: "neutral" }; + + return `
${akten.map((a) => + `
+ ${sicher(a.typ)} +
+
${sicher(a.titel)}
+
${sicher(a.aktenzeichen)} · + ${sicher((a.firstname || "") + " " + (a.lastname || ""))} · ${zeit(a.erstellt_am)} + ${a.version > 1 ? ` · Version ${a.version}` : ""}
+
+ ${sicher(a.status)} +
`).join("")}
`; + } + + function akteAnsicht(d) { + const a = d.akte; + const statusArt = { offen: "warn", pruefung: "info", geschlossen: "neutral" }; + + return ` +
+
+

${sicher(a.titel)}

+
+ Aktenzeichen${sicher(a.aktenzeichen)} + Art${sicher(a.typ)} + Angelegt von${sicher((a.firstname || "") + " " + (a.lastname || ""))} + Angelegt am${zeit(a.erstellt_am)} + Version${sicher(a.version)} +
+
+
+ ${sicher(a.status)} + ${d.darfBearbeiten ? `` : ""} + ${d.darfBearbeiten ? `` : ""} + ${d.darfSchliessen && a.status !== "geschlossen" + ? `` : ""} +
+
+ +
+
+
Sachverhalt
+
${a.inhalt ? sicher(a.inhalt).replace(/\n/g, "
") : "Kein Inhalt hinterlegt."}
+
+ +
+
+ Verknüpfungen + ${d.verknuepfungen.length} +
+ ${d.verknuepfungen.length ? `
${d.verknuepfungen.map((v) => + `
+ ${sicher(v.typ)} +
+
+ ${sicher(v.bezeichnung || v.ziel_id)} +
+ ${v.rolle ? `
${sicher(v.rolle)}
` : ""} +
+ ${d.darfBearbeiten ? `` : ""} +
`).join("")}
` + : `
Nichts verknüpft.
`} +
+ +
+
Änderungshistorie
+ ${d.versionen.length ? `
${d.versionen.map((v) => + `
+ v${sicher(v.version)} +
+
${sicher(v.grund || "ohne Angabe")}
+
${sicher((v.firstname || "") + " " + (v.lastname || ""))} · ${zeit(v.geaendert_am)}
+
+
`).join("")}
` + : `
Keine Änderungen.
`} +
+
+ `; + } + + async function oeffneAkte(behaelter, akteId) { + try { + const daten = await aufruf("mdt", "akteOeffnen", { id: akteId }); + z.offeneAkte = daten; + z.reiter = "akten"; + zeichne(behaelter, akteAnsicht(daten), () => verdrahteAkte(behaelter, daten), true); + } catch (f) { meldung(f.message, "fehler"); } + } + + function verdrahteAkte(behaelter, d) { + const a = d.akte; + + const bearbeiten = $("a-bearbeiten"); + if (bearbeiten) bearbeiten.addEventListener("click", () => { + fenster({ + titel: "Akte bearbeiten", + inhalt: ` +
+
+
+
+
+ +
Jede Änderung erzeugt eine neue Version. Die alte bleibt nachvollziehbar.
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Speichern", klick: async (el, zu) => { + try { + await aufruf("mdt", "akteBearbeiten", { + id: a.id, + titel: el.querySelector("#ab-titel").value.trim(), + inhalt: el.querySelector("#ab-inhalt").value, + grund: el.querySelector("#ab-grund").value.trim(), + }); + zu(); meldung("Akte gespeichert.", "gut"); + oeffneAkte(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + }); + + const verknuepfen = $("a-verknuepfen"); + if (verknuepfen) verknuepfen.addEventListener("click", () => { + const rollen = (z.stammdaten && z.stammdaten.rollen) || []; + fenster({ + titel: "Verknüpfung hinzufügen", + inhalt: ` +
+
+
+
+
+
`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Verknüpfen", klick: async (el, zu) => { + try { + await aufruf("mdt", "akteVerknuepfen", { + akteId: a.id, + typ: el.querySelector("#vk-typ").value, + zielId: el.querySelector("#vk-ziel").value.trim(), + rolle: el.querySelector("#vk-rolle").value || null, + }); + zu(); meldung("Verknüpfung angelegt.", "gut"); + oeffneAkte(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + }); + + behaelter.querySelectorAll("[data-vk-weg]").forEach((b) => + b.addEventListener("click", async () => { + try { + await aufruf("mdt", "akteVerknuepfungLoesen", { id: +b.dataset.vkWeg }); + oeffneAkte(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + })); + + const schliessen = $("a-schliessen"); + if (schliessen) schliessen.addEventListener("click", () => { + bestaetigen({ + titel: "Akte schließen", + gefahr: true, + inhalt: `

+ Die Akte ${sicher(a.aktenzeichen)} wird als abgeschlossen gekennzeichnet. + Es wird festgehalten, dass du diese Entscheidung getroffen hast.

+
`, + bestaetigenText: "Akte schließen", + bestaetigt: async (el, zu) => { + try { + await aufruf("mdt", "akteStatus", { + id: a.id, status: "geschlossen", + grund: el.querySelector("#as-grund").value.trim(), + }); + zu(); meldung("Akte geschlossen.", "gut"); + oeffneAkte(behaelter, a.id); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + }); + + behaelter.querySelectorAll("[data-person]").forEach((el) => + el.addEventListener("click", () => oeffnePerson(behaelter, +el.dataset.person))); + behaelter.querySelectorAll("[data-fahrzeug]").forEach((el) => + el.addEventListener("click", () => oeffneFahrzeug(behaelter, el.dataset.fahrzeug))); + } + + /** Dialog zum Anlegen einer Akte, optional gleich verknüpft */ + function akteAnlegenDialog(behaelter, verknuepfung) { + const arten = (z.stammdaten && z.stammdaten.aktenarten) || []; + const rollen = (z.stammdaten && z.stammdaten.rollen) || []; + + fenster({ + titel: "Akte anlegen", + inhalt: ` +
+
+
+
+
+
+ ${verknuepfung ? ` +
+
` : ""}`, + knoepfe: [ + { text: "Abbrechen", klick: (_, zu) => zu() }, + { text: "Akte anlegen", klick: async (el, zu) => { + try { + const ergebnis = await aufruf("mdt", "akteErstellen", { + typ: el.querySelector("#an-typ").value, + titel: el.querySelector("#an-titel").value.trim(), + inhalt: el.querySelector("#an-inhalt").value, + verknuepfeTyp: verknuepfung && verknuepfung.typ, + verknuepfeId: verknuepfung && verknuepfung.id, + verknuepfeRolle: verknuepfung ? (el.querySelector("#an-rolle") || {}).value : null, + }); + zu(); + meldung("Akte " + ergebnis.aktenzeichen + " angelegt.", "gut"); + oeffneAkte(behaelter, ergebnis.id); + } catch (f) { meldung(f.message, "fehler"); } + }}, + ], + }); + } + + /* ===================================================================== + Gerüst + ===================================================================== */ + + /** + * Zeichnet den MDT-Rahmen mit Inhalt. + * @param {boolean} detail Wenn true, wird ein Zurück-Knopf gezeigt. + */ + function zeichne(behaelter, inhalt, nachher, detail) { + const suchfeld = { + personen: "Name, Geburtsdatum oder Telefonnummer …", + fahrzeuge: "Kennzeichen, Modell oder Halter …", + akten: "Aktenzeichen oder Titel …", + }[z.reiter]; + + behaelter.innerHTML = ` +
+

MDT

Personen, Fahrzeuge und Akten

+ ${z.reiter === "akten" + ? `` : ""} +
+ + ${reiterLeiste()} + + ${detail + ? `` + : `
+ + + ${z.reiter === "akten" ? ` + + ` : ""} +
`} + +
${inhalt}
+ `; + + // Reiter wechseln + behaelter.querySelectorAll("[data-reiter]").forEach((b) => + b.addEventListener("click", () => { + z.reiter = b.dataset.reiter; + z.suche = ""; z.treffer = []; + rendern(behaelter); + })); + + const zurueck = $("mdt-zurueck"); + if (zurueck) zurueck.addEventListener("click", () => rendern(behaelter)); + + const neueAkte = $("mdt-akte-neu"); + if (neueAkte) neueAkte.addEventListener("click", () => akteAnlegenDialog(behaelter, null)); + + // Suche + const suchfeldEl = $("mdt-suche"); + if (suchfeldEl) { + const suchen = () => { z.suche = suchfeldEl.value.trim(); ausfuehrenSuche(behaelter); }; + suchfeldEl.addEventListener("keydown", (e) => { if (e.key === "Enter") suchen(); }); + $("mdt-suchen").addEventListener("click", suchen); + } + + ["mdt-filter-typ", "mdt-filter-status"].forEach((id) => { + const el = $(id); + if (el) el.addEventListener("change", () => { + z.akteFilter.typ = ($("mdt-filter-typ") || {}).value || ""; + z.akteFilter.status = ($("mdt-filter-status") || {}).value || ""; + ausfuehrenSuche(behaelter); + }); + }); + + if (nachher) nachher(); + } + + /** Führt die Suche des aktiven Reiters aus */ + async function ausfuehrenSuche(behaelter) { + try { + if (z.reiter === "personen") { + if (!z.suche) { z.treffer = []; zeichne(behaelter, personenTrefferListe(), () => verdrahteTreffer(behaelter)); return; } + const d = await aufruf("mdt", "personSuchen", { begriff: z.suche }); + z.treffer = d.treffer; + zeichne(behaelter, personenTrefferListe(), () => verdrahteTreffer(behaelter)); + + } else if (z.reiter === "fahrzeuge") { + if (!z.suche) { z.treffer = []; zeichne(behaelter, fahrzeugTrefferListe(), () => verdrahteTreffer(behaelter)); return; } + const d = await aufruf("mdt", "fahrzeugSuchen", { begriff: z.suche }); + z.treffer = d.treffer; + zeichne(behaelter, fahrzeugTrefferListe(), () => verdrahteTreffer(behaelter)); + + } else { + const d = await aufruf("mdt", "aktenListe", { + begriff: z.suche, typ: z.akteFilter.typ, status: z.akteFilter.status, + }); + zeichne(behaelter, aktenListe(d.akten), () => verdrahteTreffer(behaelter)); + } + } catch (f) { meldung(f.message, "fehler"); } + } + + function verdrahteTreffer(behaelter) { + behaelter.querySelectorAll("[data-person]").forEach((el) => + el.addEventListener("click", () => oeffnePerson(behaelter, +el.dataset.person))); + behaelter.querySelectorAll("[data-fahrzeug]").forEach((el) => + el.addEventListener("click", () => oeffneFahrzeug(behaelter, el.dataset.fahrzeug))); + behaelter.querySelectorAll("[data-akte]").forEach((el) => + el.addEventListener("click", () => oeffneAkte(behaelter, +el.dataset.akte))); + } + + async function rendern(behaelter) { + // Stammdaten einmalig holen (Aktenarten, Beteiligtenrollen) + if (!z.stammdaten) { + try { z.stammdaten = await aufruf("mdt", "stammdaten", {}); } catch (f) { z.stammdaten = {}; } + } + + /* Sprungziel eines anderen Moduls auswerten, z. B. aus einer Fahndung. + Es gilt nur einmal und wird danach verworfen. */ + const ziel = App.zustand.modulZiel; + if (ziel) { + App.zustand.modulZiel = null; + if (ziel.person) { await oeffnePerson(behaelter, +ziel.person); return; } + if (ziel.fahrzeug) { await oeffneFahrzeug(behaelter, ziel.fahrzeug); return; } + if (ziel.akte) { await oeffneAkte(behaelter, +ziel.akte); return; } + } + + await ausfuehrenSuche(behaelter); + } + + /* ---- Registrierung ----------------------------------------------- */ + + App.registriereAnsicht("mdt", { rendern }); + + /* Globale Suche bedienen */ + App.registriereSuche(async (begriff) => { + const treffer = []; + try { + const personen = await aufruf("mdt", "personSuchen", { begriff }); + (personen.treffer || []).slice(0, 5).forEach((p) => treffer.push({ + bereich: "Person", titel: p.firstname + " " + p.lastname, + unter: "geb. " + String(p.dob || "").slice(0, 10), + })); + } catch (e) { /* Bereich überspringen */ } + + try { + const fahrzeuge = await aufruf("mdt", "fahrzeugSuchen", { begriff }); + (fahrzeuge.treffer || []).slice(0, 5).forEach((f) => treffer.push({ + bereich: "Fahrzeug", titel: f.plate + " · " + (f.name || f.model), + unter: "Halter: " + ((f.firstname || "unbekannt") + " " + (f.lastname || "")), + })); + } catch (e) { /* Bereich überspringen */ } + + try { + const akten = await aufruf("mdt", "aktenListe", { begriff }); + (akten.akten || []).slice(0, 5).forEach((a) => treffer.push({ + bereich: "Akte", titel: a.titel, unter: a.aktenzeichen + " · " + a.typ, + })); + } catch (e) { /* Bereich überspringen */ } + + return treffer; + }); +})(); diff --git a/resources/[aether]/aether-police/html/js/module/personal.js b/resources/[aether]/aether-police/html/js/module/personal.js new file mode 100644 index 0000000..c5b1c5f --- /dev/null +++ b/resources/[aether]/aether-police/html/js/module/personal.js @@ -0,0 +1,633 @@ +/* ===================================================================== + aether-police / Ansicht: Personalverwaltung + + Dienstgrade und Rechte werden hier vergeben — jede Änderung ist eine + bewusste Entscheidung eines Vorgesetzten. Das System befördert, + degradiert oder entlässt niemanden von selbst; es zeigt nur an, was + gilt, und woher ein Recht kommt. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, zeit, dauer, aufruf, meldung, fenster, bestaetigen } = App; + + const z = { + daten: null, + offen: null, // Charakter-ID der geöffneten Personalakte + suche: "", + filterRolle: "", + filterAbteilung: "", + ehemalige: false, + }; + + // Woher stammt ein Recht — Beschriftung und Farbe des Markers + const herkunftText = { + rolle: { text: "aus Rolle", art: "gut" }, + zusaetzlich: { text: "zusätzlich", art: "warn" }, + entzogen: { text: "entzogen", art: "kritisch" }, + keins: { text: "kein Recht", art: "neutral" }, + }; + + /* ---- Bausteine --------------------------------------------------- */ + + /** Name einer Abteilung anhand ihrer Kennung */ + function abteilungName(id) { + const treffer = ((z.daten && z.daten.abteilungen) || []).find((a) => a.id === id); + return treffer ? treffer.name : id; + } + + /** + * Auswahlfeld der Dienstgrade. + * @param {string} gewaehlt vorausgewählte Rolle + * @param {number|undefined} maxStufe nur Rollen unterhalb dieser Stufe anbieten + */ + function rollenFeld(gewaehlt, maxStufe) { + const optionen = ((z.daten && z.daten.rollen) || []) + // Schranke: nur Rollen unterhalb des eigenen Rangs sind wählbar + .filter((r) => maxStufe === undefined || r.stufe < maxStufe) + .map((r) => ``).join(""); + return ``; + } + + /** Auswahlfeld der Abteilungen */ + function abteilungsFeld(gewaehlt) { + const optionen = ((z.daten && z.daten.abteilungen) || []) + .map((a) => ``).join(""); + return ``; + } + + /* ---- Übersicht ---------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + const liste = d.personal || []; + + // Kennzahlen ausschließlich aus den gelieferten Daten + const gesamt = (d.verteilung || []).reduce((summe, v) => summe + v.anzahl, 0); + const imDienst = liste.filter((p) => p.imDienst).length; + const abweichend = liste.filter((p) => p.abweichungen > 0).length; + + // Verteilung nach Rollen, in der Rangfolge der Rollenliste + const verteilungZeilen = (d.rollen || []).map((r) => { + const eintrag = (d.verteilung || []).find((v) => v.rolle === r.id); + const anzahl = eintrag ? eintrag.anzahl : 0; + const anteil = gesamt > 0 ? Math.round((anzahl / gesamt) * 100) : 0; + return `
+ ${sicher(r.name)} +
+ ${anzahl} +
`; + }).join(""); + + const rollenOptionen = (d.rollen || []).map((r) => + ``).join(""); + const abteilungsOptionen = (d.abteilungen || []).map((a) => + ``).join(""); + + behaelter.innerHTML = ` +
+
+

Personal

+

Dienstgrade und Rechte vergibt ein Vorgesetzter — das System schlägt nichts vor + und befördert niemanden automatisch.

+
+ ${d.rechte.einstellen + ? `` : ""} +
+ +
+
${gesamt} + im Bestand
+
${imDienst} + gerade im Dienst
+
${liste.length} + in dieser Ansicht
+
${abweichend} + mit Einzelrechten
+
+ +
+
Verteilung nach Dienstgrad
+
${verteilungZeilen || `
Kein Personal erfasst.
`}
+
+ +
+ + + + +
+ +
+ ${liste.length ? liste.map(zeile).join("") + : `
Kein Personal gefunden.
`} +
+ `; + + verdrahteUebersicht(behaelter); + } + + /** Eine Zeile der Personalliste */ + function zeile(p) { + const punkt = p.imDienst + ? `` + : ``; + + return `
+ ${punkt} + ${sicher(p.dienstnummer)} +
+
${sicher(p.firstname)} ${sicher(p.lastname)}
+
${sicher(p.rolleName)} · ${sicher(abteilungName(p.abteilung))} + · ${dauer(p.dienstzeit || 0)} Dienstzeit
+
+ ${p.abweichungen > 0 + ? ` + ${p.abweichungen} Einzelrecht${p.abweichungen === 1 ? "" : "e"}` : ""} + ${p.aktiv ? "" : `entlassen`} +
`; + } + + function verdrahteUebersicht(behaelter) { + const suchfeld = behaelter.querySelector("[data-suche]"); + if (suchfeld) { + let warten = null; + suchfeld.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(() => { + z.suche = suchfeld.value.trim(); + rendern(behaelter); + }, 260); + }); + } + + behaelter.querySelectorAll("[data-f]").forEach((feld) => { + feld.addEventListener("change", () => { + if (feld.dataset.f === "rolle") z.filterRolle = feld.value; + else z.filterAbteilung = feld.value; + rendern(behaelter); + }); + }); + + const umschalter = behaelter.querySelector("[data-ehemalige]"); + if (umschalter) umschalter.addEventListener("click", () => { + z.ehemalige = !z.ehemalige; + rendern(behaelter); + }); + + behaelter.querySelectorAll("[data-oeffne]").forEach((el) => { + el.addEventListener("click", () => oeffne(behaelter, +el.dataset.oeffne)); + }); + + const neu = behaelter.querySelector("[data-neu]"); + if (neu) neu.addEventListener("click", () => einstellungsFenster(behaelter)); + } + + /* ---- Personalakte -------------------------------------------------- */ + + function akte(behaelter, d) { + const p = d.personal; + + // Rechte-Matrix nach Gruppen + const rechteBloecke = (d.rechte || []).map((gruppe) => { + const zeilen = gruppe.eintraege.map((r) => { + const h = herkunftText[r.herkunft] || herkunftText.keins; + const aktiv = r.herkunft === "rolle" || r.herkunft === "zusaetzlich"; + + // Umschalten nur, wenn der Betrachter den Datensatz verwalten darf + const bedienbar = d.darfVerwalten && d.meineRechte.rechte; + const gesperrt = !bedienbar || (!r.vergebbar && !aktiv); + + return `
+
+ ${sicher(r.name)} + ${sicher(r.id)} +
+ ${h.text} +
+ ${["rolle", "zusaetzlich", "entzogen"].map((modus) => { + const gewaehlt = + (modus === "rolle" && (r.herkunft === "rolle" || r.herkunft === "keins")) || + modus === r.herkunft; + const beschriftung = + modus === "rolle" ? "Rolle" : modus === "zusaetzlich" ? "+" : "-"; + const titel = + modus === "rolle" ? "Es gilt das Rollenpaket" + : modus === "zusaetzlich" ? "Zusätzlich gewähren" + : "Einzeln entziehen"; + return ``; + }).join("")} +
+
`; + }).join(""); + + return `
+
${sicher(gruppe.gruppe)}
+ ${zeilen} +
`; + }).join(""); + + // Letzte Dienste + const dienste = (d.dienste || []).map((s) => + `
+ ${sicher(abteilungName(s.abteilung))} +
+
${zeit(s.beginn)}
+
${s.ende ? "bis " + zeit(s.ende) : "läuft noch"}
+
+ ${dauer(s.sekunden || 0)} +
`).join(""); + + behaelter.innerHTML = ` +
+
+

${sicher(p.name)}

+

${sicher(p.dienstnummer)} · ${sicher(p.rolleName)} + · ${sicher(abteilungName(p.abteilung))} + ${p.aktiv ? "" : " · entlassen"}

+
+ +
+ + ${!d.darfVerwalten ? ` +
+ 🔒 +
${sicher(d.sperrgrund || "Dieser Datensatz ist für dich schreibgeschützt.")} + Du siehst ihn nur.
+
` : ""} + +
+
${dauer(d.gesamtzeit || 0)} + Dienstzeit gesamt
+
${zeit(p.eingestellt)} + eingestellt am
+
${sicher(p.geburt)} + geboren
+
+ + ${d.darfVerwalten ? ` +
+
Maßnahmen
+
+ ${d.meineRechte.rolle ? ` + + + ` : ""} + ${d.meineRechte.einstellen && p.aktiv + ? `` : ""} +
+ ${p.notiz ? `

${sicher(p.notiz)}

` : ""} +
` : ""} + +
+
+ Rechte + „Rolle" folgt dem Dienstgrad — + und - + weichen bewusst davon ab. +
+
${rechteBloecke}
+
+ +
+
Letzte Dienste
+
${dienste || `
Noch kein Dienst erfasst.
`}
+
+ `; + + verdrahteAkte(behaelter, d); + } + + function verdrahteAkte(behaelter, d) { + const p = d.personal; + + behaelter.querySelector("[data-zurueck]").addEventListener("click", () => { + z.offen = null; + rendern(behaelter); + }); + + behaelter.querySelectorAll("[data-recht]").forEach((knopf) => { + knopf.addEventListener("click", () => + rechtUmschalten(behaelter, p, knopf.dataset.recht, knopf.dataset.modus)); + }); + + const rolle = behaelter.querySelector("[data-rolle]"); + if (rolle) rolle.addEventListener("click", () => rollenFenster(behaelter, d)); + + const abteilung = behaelter.querySelector("[data-abteilung]"); + if (abteilung) abteilung.addEventListener("click", () => abteilungsFenster(behaelter, d)); + + const notiz = behaelter.querySelector("[data-notiz]"); + if (notiz) notiz.addEventListener("click", () => notizFenster(behaelter, d)); + + const entlassen = behaelter.querySelector("[data-entlassen]"); + if (entlassen) entlassen.addEventListener("click", () => entlassungsFenster(behaelter, d)); + } + + /* ---- Rechte umschalten -------------------------------------------- */ + + async function rechtUmschalten(behaelter, person, recht, modus) { + // Ein einzelnes Recht ist eine kleine, aber bewusste Entscheidung + const beschreibung = modus === "rolle" + ? "Für dieses Recht gilt künftig wieder das Paket des Dienstgrads." + : modus === "zusaetzlich" + ? "Dieses Recht wird zusätzlich zum Dienstgrad gewährt." + : "Dieses Recht wird trotz Dienstgrad einzeln entzogen."; + + bestaetigen({ + titel: "Recht ändern", + inhalt: `

${sicher(person.name)} · ${sicher(recht)}

+

${beschreibung}

+
+
`, + bestaetigenText: "Recht setzen", + gefahr: modus === "entzogen", + bestaetigt: async (el, zu) => { + try { + await aufruf("personal", "rechtSetzen", { + charakterId: person.charakterId, recht, modus, + vermerk: el.querySelector("[name=vermerk]").value.trim(), + }); + zu(); + meldung("Recht geändert.", "gut"); + oeffne(behaelter, person.charakterId); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Dienstgrad, Abteilung, Notiz ---------------------------------- */ + + function rollenFenster(behaelter, d) { + const p = d.personal; + + bestaetigen({ + titel: "Dienstgrad ändern", + inhalt: ` +

Aktuell: ${sicher(p.rolleName)}

+
+ ${rollenFeld(p.rolle, d.meineStufe)} +
Rollen auf oder über deinem eigenen Rang + stehen nicht zur Wahl.
+
+
`, + bestaetigenText: "Dienstgrad setzen", + bestaetigt: async (el, zu) => { + const vermerk = el.querySelector("[name=vermerk]").value.trim(); + if (vermerk.length < 3) return meldung("Bitte einen Vermerk angeben.", "fehler"); + try { + await aufruf("personal", "rolleAendern", { + charakterId: p.charakterId, + rolle: el.querySelector("[name=rolle]").value, + vermerk, + }); + zu(); + meldung("Dienstgrad geändert.", "gut"); + oeffne(behaelter, p.charakterId); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + function abteilungsFenster(behaelter, d) { + const p = d.personal; + + bestaetigen({ + titel: "Abteilung ändern", + inhalt: `
+ ${abteilungsFeld(p.abteilung)}
`, + bestaetigenText: "Versetzen", + bestaetigt: async (el, zu) => { + try { + await aufruf("personal", "abteilungAendern", { + charakterId: p.charakterId, + abteilung: el.querySelector("[name=abteilung]").value, + }); + zu(); + meldung("Abteilung geändert.", "gut"); + oeffne(behaelter, p.charakterId); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + function notizFenster(behaelter, d) { + const p = d.personal; + + fenster({ + titel: "Personalnotiz", + inhalt: `
+
`, + knoepfe: [ + { text: "Abbrechen", art: "leise", klick: (_, zu) => zu() }, + { text: "Speichern", klick: async (el, zu) => { + try { + await aufruf("personal", "notiz", { + charakterId: p.charakterId, + notiz: el.querySelector("[name=notiz]").value.trim(), + }); + zu(); + meldung("Notiz gespeichert.", "gut"); + oeffne(behaelter, p.charakterId); + } catch (f) { meldung(f.message, "fehler"); } + } }, + ], + }); + } + + function entlassungsFenster(behaelter, d) { + const p = d.personal; + + bestaetigen({ + titel: "Beamten entlassen", + gefahr: true, + inhalt: ` +

${sicher(p.name)} (${sicher(p.dienstnummer)}) verliert sofort allen Zugriff + und wird aus einem laufenden Dienst abgemeldet.

+

Der Personaldatensatz bleibt erhalten und kann später + wieder aktiviert werden — gelöscht wird nichts.

+
+
`, + bestaetigenText: "Entlassen", + bestaetigt: async (el, zu) => { + const grund = el.querySelector("[name=grund]").value.trim(); + if (grund.length < 5) return meldung("Bitte eine Begründung angeben.", "fehler"); + try { + await aufruf("personal", "entlassen", { charakterId: p.charakterId, grund }); + zu(); + meldung("Beamter entlassen.", "gut"); + z.offen = null; + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + }); + } + + /* ---- Einstellung ---------------------------------------------------- */ + + async function einstellungsFenster(behaelter) { + let gewaehlt = null; // ausgewählte Person aus der Suche + + let vorschlag = ""; + try { + const antwort = await aufruf("personal", "nummerVorschlag", {}); + vorschlag = antwort.dienstnummer; + } catch (f) { /* Vorschlag ist optional */ } + + const dialog = bestaetigen({ + titel: "Beamten einstellen", + inhalt: ` +
+
+
+
Mindestens zwei Zeichen eingeben.
+
+ `, + bestaetigenText: "Einstellen", + bestaetigt: async (el, zu) => { + if (!gewaehlt) return meldung("Bitte zuerst eine Person auswählen.", "fehler"); + try { + const antwort = await aufruf("personal", "einstellen", { + charakterId: gewaehlt.id, + dienstnummer: el.querySelector("[name=dienstnummer]").value.trim(), + rolle: el.querySelector("[name=rolle]").value, + abteilung: el.querySelector("[name=abteilung]").value, + notiz: el.querySelector("[name=notiz]").value.trim(), + }); + zu(); + meldung("Eingestellt als " + antwort.dienstnummer + ".", "gut"); + rendern(behaelter); + } catch (f) { meldung(f.message, "fehler"); } + }, + nachOeffnen: (hg) => { + // Auswahlfelder erst hier einsetzen, sie brauchen z.daten + hg.querySelector("[data-rollenfeld]").innerHTML = + rollenFeld("anwaerter", z.daten.meineStufe); + hg.querySelector("[data-abteilungsfeld]").innerHTML = + abteilungsFeld("streife"); + + const suchfeld = hg.querySelector("[name=suche]"); + const trefferliste = hg.querySelector("[data-treffer]"); + const formular = hg.querySelector("[data-formular]"); + let warten = null; + + suchfeld.addEventListener("input", () => { + clearTimeout(warten); + warten = setTimeout(async () => { + const begriff = suchfeld.value.trim(); + if (begriff.length < 2) { + trefferliste.innerHTML = + `
Mindestens zwei Zeichen eingeben.
`; + return; + } + try { + const antwort = await aufruf("personal", "personSuche", { begriff }); + const treffer = antwort.treffer || []; + trefferliste.innerHTML = treffer.length + ? treffer.map((t) => ` +
+
+
${sicher(t.firstname)} ${sicher(t.lastname)}
+
geb. ${sicher(String(t.dob).slice(0, 10))}
+
+ ${t.dienstnummer && t.aktiv + ? `bereits im Dienst` + : t.dienstnummer + ? `ehemals ${sicher(t.dienstnummer)}` + : ""} +
`).join("") + : `
Keine Person gefunden.
`; + + trefferliste.querySelectorAll("[data-person]").forEach((el) => { + el.addEventListener("click", () => { + const person = treffer.find((t) => t.id === +el.dataset.person); + if (person && person.dienstnummer && person.aktiv) { + return meldung("Diese Person ist bereits im Personalbestand.", "fehler"); + } + gewaehlt = person; + trefferliste.querySelectorAll("[data-person]") + .forEach((zeile) => zeile.classList.remove("gewaehlt")); + el.classList.add("gewaehlt"); + formular.style.display = ""; + }); + }); + } catch (f) { meldung(f.message, "fehler"); } + }, 260); + }); + }, + }); + + return dialog; + } + + /* ---- Laden ---------------------------------------------------------- */ + + async function oeffne(behaelter, charakterId) { + try { + const d = await aufruf("personal", "oeffnen", { charakterId }); + z.offen = charakterId; + akte(behaelter, d); + } catch (f) { meldung(f.message, "fehler"); } + } + + async function rendern(behaelter) { + try { + z.daten = await aufruf("personal", "liste", { + begriff: z.suche, rolle: z.filterRolle, + abteilung: z.filterAbteilung, ehemalige: z.ehemalige, + }); + + // Sprungziel aus einem anderen Modul, z. B. „Personalakte öffnen" + const ziel = App.zustand.modulZiel; + if (ziel && ziel.person) { + App.zustand.modulZiel = null; + return oeffne(behaelter, ziel.person); + } + + if (z.offen) await oeffne(behaelter, z.offen); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Personal

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("personal", { rendern }); + + App.registriereSuche(async (begriff) => { + try { + const d = await aufruf("personal", "liste", { begriff }); + return (d.personal || []).slice(0, 5).map((p) => ({ + bereich: "Personal", + titel: p.firstname + " " + p.lastname, + unter: p.dienstnummer + " · " + p.rolleName, + })); + } catch (e) { return []; } + }); +})(); diff --git a/resources/[aether]/aether-police/html/js/module/statistik.js b/resources/[aether]/aether-police/html/js/module/statistik.js new file mode 100644 index 0000000..57db86e --- /dev/null +++ b/resources/[aether]/aether-police/html/js/module/statistik.js @@ -0,0 +1,373 @@ +/* ===================================================================== + aether-police / Ansicht: Statistik + + Zahlen über die Behörde, nicht über einzelne Beamte. Es gibt hier + bewusst keine Rangliste — jede Kennzahl beschreibt einen Vorgang, + keine Leistung. +===================================================================== */ +"use strict"; + +(() => { + const { sicher, aufruf, meldung, euro } = App; + + const z = { + reiter: "uebersicht", // uebersicht | abdeckung | katalog + tage: 30, + daten: null, + }; + + /* ---- Bausteine ------------------------------------------------------ */ + + /** Runde Zahl mit Tausenderpunkt */ + function zahl(wert) { + return Math.round(wert || 0).toLocaleString("de-DE"); + } + + /** Kennzahlkachel */ + function kachel(wert, text, unter) { + return `
+ ${wert} + ${sicher(text)} + ${unter ? `${sicher(unter)}` : ""} +
`; + } + + /** Waagerechtes Balkendiagramm aus einer Verteilung */ + function verteilung(daten, leer) { + if (!daten || !daten.eintraege.length) { + return `
${sicher(leer || "Keine Daten im Zeitraum.")}
`; + } + + return `
+ ${daten.eintraege.map((e) => ` +
+ ${sicher(e.name)} +
+ ${zahl(e.anzahl)} + ${e.anteil} % +
`).join("")} +
`; + } + + /** Säulendiagramm des Tagesverlaufs */ + function verlauf(zeilen) { + if (!zeilen || !zeilen.length) { + return `
Im Zeitraum wurde nichts erfasst.
`; + } + + // Höchstwert bestimmt die Skalierung beider Reihen + let hoechst = 1; + zeilen.forEach((t) => { + hoechst = Math.max(hoechst, +t.einsaetze || 0, +t.anzeigen || 0); + }); + + return ` +
+ Einsätze + Anzeigen + Höchstwert: ${hoechst} +
+
+ ${zeilen.map((t) => { + const einsaetze = +t.einsaetze || 0; + const anzeigen = +t.anzeigen || 0; + const tag = String(t.tag).slice(0, 10); + return `
+
+ + +
+ ${sicher(tag.slice(8))} +
`; + }).join("")} +
`; + } + + function reiterleiste() { + const eintraege = [ + { id: "uebersicht", text: "Überblick" }, + { id: "abdeckung", text: "Dienstabdeckung" }, + { id: "katalog", text: "Katalogauswertung" }, + ]; + + const zeitraeume = (z.daten && z.daten.zeitraeume) || [7, 30, 90]; + + return ` +
+
+ ${eintraege.map((e) => ` + `).join("")} +
+
+ ${zeitraeume.map((t) => ` + `).join("")} +
+
`; + } + + /* ---- Überblick -------------------------------------------------------- */ + + function uebersicht(behaelter) { + const d = z.daten; + const e = d.einsaetze, a = d.anzeigen, g = d.gewahrsam, v = d.vorgaenge, p = d.personal; + + behaelter.innerHTML = ` +
+
+

Statistik

+

Auswertung der letzten ${d.tage} Tage. Alle Zahlen beschreiben Vorgänge + der Behörde — es gibt hier keine Bewertung einzelner Beamter.

+
+
+ + ${reiterleiste()} + +
+ ${kachel(zahl(e.gesamt), "Einsätze", e.offen + " offen")} + ${kachel(zahl(a.gesamt), "Anzeigen", a.bestaetigt + " bestätigt")} + ${kachel(zahl(g.einweisungen), "Einweisungen", g.laufend + " laufend")} + ${kachel(zahl(p.dienstStunden), "Dienststunden", p.bestand + " Beamte im Bestand")} +
+ +
+
Verlauf
+ ${verlauf(d.verlauf)} +
+ +
+
+
Einsätze nach Kategorie
+ ${verteilung(e.nachKategorie)} +
+ Ein Einsatz bleibt im Schnitt + ${zahl(e.laufzeitMinuten)} Minuten offen. +
+
+ +
+
Einsätze nach Priorität
+ ${verteilung(e.nachPrioritaet)} +
+
+ +
+
+
Anzeigen
+
+
Bestätigt${zahl(a.bestaetigt)}
+
Noch Entwurf${zahl(a.entwurf)}
+
Ø festgelegte Haft${zahl(a.haftDurchschnitt)} Min
+
Ø festgelegte Geldstrafe${euro(a.geldDurchschnitt)}
+
+
+ In ${zahl(a.abweichungen)} von ${zahl(a.bestaetigt)} bestätigten Anzeigen + wich die Entscheidung vom Katalogvorschlag ab. Das ist eine Aussage über den + Katalog — nicht über die Beamten. +
+
+ +
+
Gewahrsam
+
+
Einweisungen${zahl(g.einweisungen)}
+
Laufend${zahl(g.laufend)}
+
Ø festgelegte Dauer${zahl(g.haftDurchschnitt)} Min
+
Vorzeitig entlassen${zahl(g.vorzeitig)}
+
+
+ Zwischen Fristablauf und tatsächlicher Entlassung lagen im Schnitt + ${zahl(g.verzugMinuten)} Minuten. Das System entlässt niemanden von + selbst — der Wert zeigt, wie schnell jemand erreichbar war. +
+
+
+ +
+
+
Vorgänge
+
+
Akten angelegt${zahl(v.akten)}
+
Akten offen${zahl(v.aktenOffen)}
+
Berichte${zahl(v.berichte)}
+
Berichte im Entwurf${zahl(v.berichteEntwurf)}
+
Fahndungen${zahl(v.fahndungen)}
+
Fahndungen aktiv${zahl(v.fahndungenAktiv)}
+
Beweise erfasst${zahl(v.beweise)}
+
Asservate eingelagert${zahl(v.asservate)}
+
+
+ +
+
Akten nach Art
+ ${verteilung(v.aktenNachTyp)} +
+
+ +
+
+ Personal nach Abteilung + ${p.imDienst} gerade im Dienst +
+ ${verteilung(p.nachAbteilung)} +
+ `; + + verdrahte(behaelter); + } + + /* ---- Dienstabdeckung --------------------------------------------------- */ + + function abdeckung(behaelter) { + const d = z.daten; + const hoechst = Math.max(d.hoechstwert, 0.1); + + // Wo ist die Besetzung am dünnsten? + const schwach = [...d.stunden].sort((a, b) => a.mittel - b.mittel).slice(0, 3); + + behaelter.innerHTML = ` +
+
+

Dienstabdeckung

+

Mittlere Besetzung je Tageszeit über ${d.tage} Tage — ein Planungswert. + Er sagt nichts darüber, wer Dienst hatte.

+
+
+ + ${reiterleiste()} + +
+ ${kachel(zahl(d.schichten), "Schichten im Zeitraum")} + ${kachel(d.hoechstwert.toFixed(2), "stärkste Stunde", "gleichzeitig im Dienst")} + ${kachel(schwach.map((s) => s.stunde + " Uhr").join(", "), "dünnste Stunden")} +
+ +
+
+ Besetzung nach Tageszeit + Durchschnittlich gleichzeitig im Dienst +
+
+ ${d.stunden.map((s) => ` +
+
+ +
+ ${String(s.stunde).padStart(2, "0")} +
`).join("")} +
+
+ +
+
Schichten nach Abteilung
+ ${verteilung(d.nachAbteilung)} +
+ `; + + verdrahte(behaelter); + } + + /* ---- Katalogauswertung -------------------------------------------------- */ + + function katalog(behaelter) { + const d = z.daten; + + behaelter.innerHTML = ` +
+
+

Katalogauswertung

+

Welche Delikte kommen vor und wie oft weicht die Entscheidung vom + Vorschlag ab. Eine hohe Abweichungsquote heißt: der Katalogwert passt + nicht — nicht, dass jemand falsch entschieden hat.

+
+
+ + ${reiterleiste()} + +
+ ${kachel(zahl(d.anzeigen), "ausgewertete Anzeigen", "letzte " + d.tage + " Tage")} + ${kachel(zahl(d.delikte.length), "verwendete Delikte")} + ${kachel(zahl(d.ungenutzt.length), "nie verwendet", "von " + d.katalogGesamt + " im Katalog")} +
+ +
+
Häufigste Delikte
+ ${d.delikte.length ? ` +
+ ${d.delikte.map((delikt) => ` +
+ ${sicher(delikt.kennung)} +
+
${sicher(delikt.name)}
+
${sicher(delikt.paragraph || "")}
+
+ ${zahl(delikt.anzahl)}× + 0 ? "info" : "neutral"}" + title="Anteil der Anzeigen mit diesem Delikt, in denen vom Vorschlag abgewichen wurde"> + ${delikt.abweichungsAnteil} % abweichend +
`).join("")} +
` + : `
Im Zeitraum wurde keine Anzeige bestätigt.
`} +
+ +
+
+ Im Zeitraum nie verwendet + Kandidaten zum Aufräumen des Katalogs +
+ ${d.ungenutzt.length ? ` +
+ ${d.ungenutzt.map((delikt) => ` + + ${sicher(delikt.kennung)} · ${sicher(delikt.bezeichnung)}`).join("")} +
` + : `
Jedes Delikt des Katalogs kam vor.
`} +
+ `; + + verdrahte(behaelter); + } + + /* ---- Verdrahtung --------------------------------------------------------- */ + + function verdrahte(behaelter) { + behaelter.querySelectorAll("[data-reiter]").forEach((el) => { + el.addEventListener("click", () => { + z.reiter = el.dataset.reiter; + rendern(behaelter); + }); + }); + + behaelter.querySelectorAll("[data-tage]").forEach((el) => { + el.addEventListener("click", () => { + z.tage = +el.dataset.tage; + rendern(behaelter); + }); + }); + } + + /* ---- Laden ---------------------------------------------------------------- */ + + async function rendern(behaelter) { + const aktionen = { + uebersicht: "uebersicht", abdeckung: "abdeckung", katalog: "katalog", + }; + + try { + z.daten = await aufruf("statistik", aktionen[z.reiter], { tage: z.tage }); + // Der Server bestimmt den gültigen Zeitraum + z.tage = z.daten.tage; + + if (z.reiter === "abdeckung") abdeckung(behaelter); + else if (z.reiter === "katalog") katalog(behaelter); + else uebersicht(behaelter); + } catch (f) { + behaelter.innerHTML = `

Statistik

+

Konnte nicht geladen werden: ${sicher(f.message)}

`; + } + } + + App.registriereAnsicht("statistik", { rendern }); +})(); diff --git a/resources/[aether]/aether-police/server/dienst.lua b/resources/[aether]/aether-police/server/dienst.lua new file mode 100644 index 0000000..b303019 --- /dev/null +++ b/resources/[aether]/aether-police/server/dienst.lua @@ -0,0 +1,243 @@ +-- ===================================================================== +-- aether-police — Dienstsystem +-- +-- Speichert ausschliesslich Statusinformationen. Es gibt keine +-- automatische Uniform und keine automatische Fahrzeugausgabe. +-- ===================================================================== + +Dienst = {} + +-- Beamte im Dienst: [quelle] = beamter +local imDienst = {} +-- Wer hält die Leitstelle? (Server-ID oder nil) +local leitstelle = nil + +local Core = exports['aether-core'] + +-- --------------------------------------------------------------------- +-- Zugriff +-- --------------------------------------------------------------------- + +--- Liefert die Beamtendaten eines Spielers im Dienst +--- @param quelle number Server-ID +--- @return table|nil +function Dienst.Hole(quelle) + return imDienst[quelle] +end + +--- Liefert alle Beamten im Dienst +--- @return table [quelle] = beamter +function Dienst.AlleImDienst() + return imDienst +end + +--- Sucht die Server-ID zu einer Charakter-ID +--- @param charakterId number Charakter +--- @return number|nil +function Dienst.QuelleVonCharakter(charakterId) + for quelle, beamter in pairs(imDienst) do + if beamter.charakterId == charakterId then return quelle end + end + return nil +end + +--- Wer hält die Leitstelle? +--- @return number|nil Server-ID +function Dienst.Leitstelle() + return leitstelle +end + +-- --------------------------------------------------------------------- +-- Personalakte laden +-- --------------------------------------------------------------------- + +--- Lädt den Personaldatensatz eines Charakters und baut daraus den Beamten +--- @param quelle number Server-ID +--- @param charakterId number Charakter +--- @return table|nil Beamter, string|nil Fehler +function Dienst.LadeBeamten(quelle, charakterId) + local zeilen = MySQL.query.await( + 'SELECT * FROM police_personal WHERE `character` = ? AND aktiv = 1', { charakterId }) + + if not zeilen or not zeilen[1] then + return nil, 'Du bist nicht im Personalbestand der Behörde.' + end + + local personal = zeilen[1] + local person = Core:HoleSpielerInfo(quelle) + + local rolle = Polizei.HoleRolle(personal.rolle) + + return { + quelle = quelle, + charakterId = charakterId, + name = ((person and person.vorname or '') .. ' ' .. (person and person.nachname or '')), + dienstnummer = personal.dienstnummer, + rolle = personal.rolle, + rolleName = rolle and rolle.name or personal.rolle, + stufe = Polizei.Stufe(personal.rolle), + abteilung = personal.abteilung, + rechte = Rechte.Berechne(personal), + status = 'verfuegbar', + seit = os.time(), + dienstId = nil, + } +end + +-- --------------------------------------------------------------------- +-- Dienst antreten und beenden +-- --------------------------------------------------------------------- + +--- Meldet einen Beamten zum Dienst an +--- @param quelle number Server-ID +--- @return table|nil Beamter, string|nil Fehler +function Dienst.Antreten(quelle) + if imDienst[quelle] then return imDienst[quelle] end + + local charakterId = Core:HoleCharakterId(quelle) + if not charakterId then return nil, 'Kein Charakter geladen.' end + + local beamter, fehler = Dienst.LadeBeamten(quelle, charakterId) + if not beamter then return nil, fehler end + + if not Rechte.Hat(beamter, 'dienst.antreten') then + return nil, 'Dir fehlt die Berechtigung, den Dienst anzutreten.' + end + + -- Dienstzeit protokollieren + beamter.dienstId = MySQL.insert.await( + 'INSERT INTO police_dienst (`character`, abteilung) VALUES (?, ?)', + { charakterId, beamter.abteilung }) + + imDienst[quelle] = beamter + + Protokoll.Schreibe(beamter, 'dienst', 'Dienst angetreten') + Kern.Loese('dienst.angetreten', quelle, beamter) + Dienst.Verteile() + + return beamter +end + +--- Meldet einen Beamten vom Dienst ab +--- @param quelle number Server-ID +function Dienst.Beenden(quelle) + local beamter = imDienst[quelle] + if not beamter then return end + + if beamter.dienstId then + MySQL.update('UPDATE police_dienst SET ende = NOW(), sekunden = ? WHERE id = ?', + { os.time() - beamter.seit, beamter.dienstId }) + end + + imDienst[quelle] = nil + + -- Leitstelle freigeben, wenn der Inhaber geht + if leitstelle == quelle then + leitstelle = nil + end + + Protokoll.Schreibe(beamter, 'dienst', 'Dienst beendet') + Kern.Loese('dienst.beendet', quelle, beamter) + Dienst.Verteile() +end + +--- Setzt den Status eines Beamten (verfügbar, Einsatz, Pause ...) +--- @param quelle number Server-ID +--- @param status string Statuskennung +--- @return boolean +function Dienst.SetzeStatus(quelle, status) + local beamter = imDienst[quelle] + if not beamter then return false end + + local gueltig = false + for _, eintrag in ipairs(Polizei.Config.Dienststatus) do + if eintrag.id == status then gueltig = true break end + end + if not gueltig then return false end + + beamter.status = status + Kern.Loese('dienst.status', quelle, beamter, status) + Dienst.Verteile() + return true +end + +--- Übernimmt oder überträgt die Leitstelle +--- @param quelle number Handelnder +--- @param zielQuelle number|nil Ziel (nil = selbst übernehmen) +--- @return boolean, string|nil +function Dienst.SetzeLeitstelle(quelle, zielQuelle) + local beamter = imDienst[quelle] + if not beamter then return false end + + if not Rechte.Hat(beamter, 'dienst.leitstelle') then + return false, 'Dir fehlt die Berechtigung für die Leitstelle.' + end + + if zielQuelle and zielQuelle ~= quelle then + -- Zuweisen an andere setzt voraus, dass man über ihnen steht + local ziel = imDienst[zielQuelle] + if not ziel then return false, 'Diese Person ist nicht im Dienst.' end + if not Rechte.StehtUeber(beamter, ziel.rolle) then + return false, 'Du kannst die Leitstelle nur an rangniedrigere Beamte übertragen.' + end + leitstelle = zielQuelle + Protokoll.Schreibe(beamter, 'dienst', 'Leitstelle übertragen', + { typ = 'person', id = ziel.charakterId }) + else + leitstelle = quelle + Protokoll.Schreibe(beamter, 'dienst', 'Leitstelle übernommen') + end + + Kern.Loese('dienst.leitstelle', leitstelle) + Dienst.Verteile() + return true +end + +-- --------------------------------------------------------------------- +-- Verteilung an die Clients +-- --------------------------------------------------------------------- + +--- Baut die Liste der Beamten im Dienst (für Dashboard und Karte) +--- @return table +function Dienst.BaueListe() + local liste = {} + for quelle, beamter in pairs(imDienst) do + local position = GetEntityCoords(GetPlayerPed(quelle)) + liste[#liste + 1] = { + quelle = quelle, + charakterId = beamter.charakterId, + name = beamter.name, + dienstnummer = beamter.dienstnummer, + rolle = beamter.rolle, + rolleName = beamter.rolleName, + abteilung = beamter.abteilung, + status = beamter.status, + leitstelle = leitstelle == quelle, + seit = beamter.seit, + position = { x = position.x, y = position.y, z = position.z }, + } + end + table.sort(liste, function(a, b) return a.dienstnummer < b.dienstnummer end) + return liste +end + +--- Schickt die Dienstliste an alle Beamten im Dienst +function Dienst.Verteile() + local liste = Dienst.BaueListe() + for quelle in pairs(imDienst) do + TriggerClientEvent('aether:police:dienstliste', quelle, liste) + end +end + +-- Positionen regelmäßig auffrischen (für die Einsatzkarte) +CreateThread(function() + while true do + Wait(5000) + if next(imDienst) then Dienst.Verteile() end + end +end) + +-- Beim Verlassen sauber abmelden +AddEventHandler('playerDropped', function() + Dienst.Beenden(source) +end) diff --git a/resources/[aether]/aether-police/server/hinweise.lua b/resources/[aether]/aether-police/server/hinweise.lua new file mode 100644 index 0000000..b97feb3 --- /dev/null +++ b/resources/[aether]/aether-police/server/hinweise.lua @@ -0,0 +1,219 @@ +-- ===================================================================== +-- aether-police — Benachrichtigungen und Fristenüberwachung +-- +-- WICHTIG: Dieses Modul ERINNERT ausschliesslich. Es entlässt niemanden, +-- schliesst nichts ab und ergreift keine Maßnahme. Jede Meldung endet +-- in einer Aufforderung an einen Beamten, selbst zu entscheiden. +-- ===================================================================== + +Hinweise = {} + +-- Bereits gemeldete Fristen, damit nicht im Minutentakt gewarnt wird: +-- [gewahrsamId] = { [stufe] = true } +local gemeldet = {} + +--- Erstellt einen Hinweis +--- @param ziel table { charakterId? } — ohne charakterId geht er an alle im Dienst +--- @param daten table { kategorie, prioritaet?, titel, text?, zielTyp?, zielId? } +function Hinweise.Erstelle(ziel, daten) + local id = MySQL.insert.await([[ + INSERT INTO police_hinweis + (`character`, kategorie, prioritaet, titel, text, ziel_typ, ziel_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + ]], { + ziel and ziel.charakterId or nil, + daten.kategorie or 'system', + daten.prioritaet or 'normal', + daten.titel, + daten.text, + daten.zielTyp, + daten.zielId and tostring(daten.zielId) or nil, + }) + + -- Betroffene Clients sofort informieren. + -- Die Feldnamen entsprechen bewusst denen aus Hinweise.Lade, damit ein + -- frisch gemeldeter Hinweis genauso anklickbar ist wie ein geladener. + local paket = { + id = id, + kategorie = daten.kategorie or 'system', + prioritaet = daten.prioritaet or 'normal', + titel = daten.titel, + text = daten.text, + zielTyp = daten.zielTyp, + zielId = daten.zielId, + } + + if ziel and ziel.charakterId then + local quelle = Dienst.QuelleVonCharakter(ziel.charakterId) + if quelle then + TriggerClientEvent('aether:police:hinweis', quelle, paket) + end + else + for quelle in pairs(Dienst.AlleImDienst()) do + TriggerClientEvent('aether:police:hinweis', quelle, paket) + end + end +end + +--- Lädt ungelesene Hinweise eines Beamten +--- @param charakterId number Charakter +--- @param limit number|nil Anzahl +--- @return table +function Hinweise.Lade(charakterId, limit) + local zeilen = MySQL.query.await([[ + SELECT * FROM police_hinweis + WHERE (`character` = ? OR `character` IS NULL) + ORDER BY id DESC LIMIT ? + ]], { charakterId, limit or 30 }) or {} + + -- Sprungziel auf die Schreibweise der Oberfläche bringen + for _, eintrag in ipairs(zeilen) do + eintrag.zielTyp = eintrag.ziel_typ + eintrag.zielId = eintrag.ziel_id + eintrag.ziel_typ, eintrag.ziel_id = nil, nil + end + + return zeilen +end + +--- Markiert einen Hinweis als gelesen +function Hinweise.MarkiereGelesen(charakterId, hinweisId) + MySQL.update('UPDATE police_hinweis SET gelesen = 1 WHERE id = ? AND (`character` = ? OR `character` IS NULL)', + { hinweisId, charakterId }) +end + +-- --------------------------------------------------------------------- +-- Fristenüberwachung +-- +-- Läuft im Hintergrund und erzeugt NUR Erinnerungen. Ein Gewahrsam +-- endet niemals von selbst — er bleibt offen, bis ein Beamter die +-- Entlassung ausführt. +-- --------------------------------------------------------------------- +CreateThread(function() + Wait(5000) + + while true do + Wait(Polizei.Config.Erinnerungen.pruefIntervall) + + -- --- Gewahrsam: auslaufende Fristen --- + local offene = MySQL.query.await([[ + SELECT g.id, g.character, g.faellig_am, g.grund, + TIMESTAMPDIFF(MINUTE, NOW(), g.faellig_am) AS rest, + c.firstname, c.lastname + FROM police_gewahrsam g + JOIN aether_characters c ON c.id = g.character + WHERE g.entlassen_am IS NULL + ]]) or {} + + for _, eintrag in ipairs(offene) do + local rest = tonumber(eintrag.rest) or 0 + gemeldet[eintrag.id] = gemeldet[eintrag.id] or {} + + for _, stufe in ipairs(Polizei.Config.Erinnerungen.gewahrsamStufen) do + -- Erinnern, sobald die Restzeit die Stufe erreicht oder unterschreitet + if rest <= stufe and not gemeldet[eintrag.id][stufe] then + gemeldet[eintrag.id][stufe] = true + + local name = eintrag.firstname .. ' ' .. eintrag.lastname + local titel, text, prio + + if stufe > 0 then + titel = ('Gewahrsam: noch %d Minuten'):format(stufe) + text = ('%s — Haftzeit endet bald. Entlassung muss manuell erfolgen.') + :format(name) + prio = 'normal' + else + titel = 'Haftzeit abgelaufen' + text = ('%s — die festgelegte Haftzeit ist abgelaufen. Bitte über die Entlassung entscheiden.') + :format(name) + prio = 'hoch' + end + + Hinweise.Erstelle(nil, { + kategorie = 'gewahrsam', prioritaet = prio, + titel = titel, text = text, + zielTyp = 'gewahrsam', zielId = eintrag.id, + }) + break -- pro Durchlauf nur eine Stufe melden + end + end + end + + -- --- Berichte, die zu lange im Entwurf liegen --- + local entwuerfe = MySQL.query.await([[ + SELECT b.id, b.nummer, b.titel, b.erstellt_von + FROM police_bericht b + WHERE b.status = 'entwurf' + AND b.erstellt_am < DATE_SUB(NOW(), INTERVAL ? MINUTE) + ]], { Polizei.Config.Erinnerungen.berichtOffenNach }) or {} + + for _, bericht in ipairs(entwuerfe) do + -- Nur einmal je Bericht erinnern + local schluessel = 'bericht_' .. bericht.id + if not gemeldet[schluessel] then + gemeldet[schluessel] = true + Hinweise.Erstelle({ charakterId = bericht.erstellt_von }, { + kategorie = 'bericht', prioritaet = 'normal', + titel = 'Offener Bericht', + text = ('%s „%s" liegt noch als Entwurf vor.'):format(bericht.nummer, bericht.titel), + zielTyp = 'bericht', zielId = bericht.id, + }) + end + end + + -- --- Aushänge mit Kenntnisnahmefrist --- + -- Das System erinnert nur; es bestätigt keine Kenntnisnahme selbst. + local fristen = MySQL.query.await([[ + SELECT a.id, a.titel, a.abteilung, + TIMESTAMPDIFF(MINUTE, NOW(), a.frist) AS rest + FROM police_aushang a + WHERE a.aktiv = 1 AND a.kenntnispflicht = 1 AND a.frist IS NOT NULL + AND a.frist > DATE_SUB(NOW(), INTERVAL 1 DAY) + ]]) or {} + + for _, aushang in ipairs(fristen) do + local rest = tonumber(aushang.rest) or 0 + local schluessel = 'aushang_' .. aushang.id + + -- Einmal vorwarnen, einmal beim Ablauf + local stufe = rest <= 0 and 'abgelaufen' + or (rest <= Polizei.Config.Erinnerungen.aushangVorwarnung and 'bald') + or nil + + if stufe then + gemeldet[schluessel] = gemeldet[schluessel] or {} + if not gemeldet[schluessel][stufe] then + gemeldet[schluessel][stufe] = true + + -- Nur Beamte ansprechen, die noch nicht bestätigt haben + for quelle, beamter in pairs(Dienst.AlleImDienst()) do + local passt = aushang.abteilung == nil + or aushang.abteilung == beamter.abteilung + if passt then + local schon = MySQL.scalar.await([[ + SELECT 1 FROM police_aushang_kenntnis + WHERE aushang = ? AND `character` = ? + ]], { aushang.id, beamter.charakterId }) + + if not schon then + Hinweise.Erstelle({ charakterId = beamter.charakterId }, { + kategorie = 'system', + prioritaet = stufe == 'abgelaufen' and 'hoch' or 'normal', + titel = stufe == 'abgelaufen' + and 'Kenntnisnahme überfällig' or 'Kenntnisnahme offen', + text = ('„%s" — bitte zur Kenntnis nehmen.'):format(aushang.titel), + zielTyp = 'aushang', zielId = aushang.id, + }) + end + end + end + end + end + end + end +end) + +--- Räumt gemeldete Fristen auf, wenn ein Gewahrsam abgeschlossen wurde +Kern.Abonniere('gewahrsam.entlassen', function(gewahrsamId) + gemeldet[gewahrsamId] = nil +end) diff --git a/resources/[aether]/aether-police/server/kern.lua b/resources/[aether]/aether-police/server/kern.lua new file mode 100644 index 0000000..fcc9b98 --- /dev/null +++ b/resources/[aether]/aether-police/server/kern.lua @@ -0,0 +1,227 @@ +-- ===================================================================== +-- aether-police — Kern +-- +-- Der Kern kennt keine Fachlogik. Er stellt bereit: +-- * eine Modul-Registry (Plugin-System) +-- * einen Ereignisbus zwischen den Modulen +-- * ein Handler-Register, an das die Oberfläche Anfragen schickt +-- +-- Fachmodule registrieren sich selbst und können jederzeit ergänzt +-- werden, ohne dass der Kern angefasst werden muss. +-- ===================================================================== + +Polizei = Polizei or {} +Kern = {} + +-- Registrierte Fachmodule: [id] = { id, name, icon, recht, reihenfolge, aktiv } +local module = {} +-- Handler, die die Oberfläche aufrufen darf: [modulId][aktion] = funktion +local handler = {} +-- Abonnenten des Ereignisbusses: [ereignis] = { funktion, ... } +local abonnenten = {} + +-- --------------------------------------------------------------------- +-- Modul-Registry +-- --------------------------------------------------------------------- + +--- Registriert ein Fachmodul. +--- @param beschreibung table { +--- id eindeutige Kennung +--- name Anzeigename in der Seitenleiste +--- icon Symbol +--- recht Recht, das zum Öffnen nötig ist (nil = für alle) +--- reihenfolge Sortierung in der Seitenleiste +--- aktiv Standardmäßig aktiv? +--- } +function Kern.RegistriereModul(beschreibung) + if type(beschreibung) ~= 'table' or not beschreibung.id then + Aether.Fehler('Modul ohne Kennung kann nicht registriert werden.') + return false + end + + if module[beschreibung.id] then + Aether.Fehler(('Modul "%s" ist bereits registriert.'):format(beschreibung.id)) + return false + end + + module[beschreibung.id] = { + id = beschreibung.id, + name = beschreibung.name or beschreibung.id, + icon = beschreibung.icon or '▪', + recht = beschreibung.recht, + reihenfolge = beschreibung.reihenfolge or 100, + aktiv = beschreibung.aktiv ~= false, + beschreibung = beschreibung.beschreibung, + } + + handler[beschreibung.id] = handler[beschreibung.id] or {} + Aether.Debug(('Polizei-Modul registriert: %s'):format(beschreibung.id)) + return true +end + +--- Registriert einen Handler, den die Oberfläche aufrufen kann. +--- @param modulId string Modul +--- @param aktion string Name der Aktion +--- @param recht string|nil Erforderliches Recht (nil = nur Modulrecht) +--- @param funktion function fun(quelle, beamter, daten): ergebnis +function Kern.RegistriereAktion(modulId, aktion, recht, funktion) + handler[modulId] = handler[modulId] or {} + handler[modulId][aktion] = { recht = recht, funktion = funktion } +end + +--- Prüft das Öffnungsrecht eines Moduls. +--- `recht` darf eine Kennung oder eine Liste sein — bei einer Liste +--- genügt EIN Recht daraus. Das brauchen Module, die mehrere Bereiche +--- mit je eigenem Recht unter einem Dach zusammenfassen. +--- @param beamter table Beamtendaten +--- @param recht string|table|nil Erforderliches Recht +--- @return boolean +function Kern.DarfOeffnen(beamter, recht) + if not recht then return true end + + if type(recht) == 'table' then + for _, einzeln in ipairs(recht) do + if Rechte.Hat(beamter, einzeln) then return true end + end + return false + end + + return Rechte.Hat(beamter, recht) +end + +--- Liefert alle Module, die ein Beamter sehen darf +--- @param beamter table Beamtendaten (mit Rechten) +--- @return table Liste +function Kern.SichtbareModule(beamter) + local liste = {} + for _, modul in pairs(module) do + if modul.aktiv and Kern.DarfOeffnen(beamter, modul.recht) then + liste[#liste + 1] = { + id = modul.id, name = modul.name, icon = modul.icon, + reihenfolge = modul.reihenfolge, beschreibung = modul.beschreibung, + } + end + end + table.sort(liste, function(a, b) return a.reihenfolge < b.reihenfolge end) + return liste +end + +--- Liefert alle registrierten Module, auch die abgeschalteten. +--- Nur für die Administration gedacht. +--- @return table Liste +function Kern.AlleModule() + local liste = {} + for _, modul in pairs(module) do + -- Wie viele Aktionen hängen an diesem Modul? + local aktionen = 0 + for _ in pairs(handler[modul.id] or {}) do aktionen = aktionen + 1 end + + liste[#liste + 1] = { + id = modul.id, name = modul.name, icon = modul.icon, + recht = modul.recht, reihenfolge = modul.reihenfolge, + beschreibung = modul.beschreibung, + aktiv = modul.aktiv, aktionen = aktionen, + } + end + table.sort(liste, function(a, b) return a.reihenfolge < b.reihenfolge end) + return liste +end + +--- Gibt es dieses Modul? +--- @param modulId string Modul +--- @return boolean +function Kern.KenntModul(modulId) + return module[modulId] ~= nil +end + +--- Schaltet ein Modul an oder aus (Administration) +--- @param modulId string Modul +--- @param aktiv boolean Neuer Zustand +function Kern.SetzeModulAktiv(modulId, aktiv) + if module[modulId] then + module[modulId].aktiv = aktiv and true or false + end +end + +--- Führt eine Aktion aus — inklusive Rechteprüfung. +--- Wird von der Oberfläche über ein einziges Netz-Event aufgerufen. +--- @param quelle number Server-ID +--- @param beamter table Beamtendaten +--- @param modulId string Modul +--- @param aktion string Aktion +--- @param daten table Nutzdaten +--- @return any Ergebnis, string|nil Fehler +function Kern.FuehreAus(quelle, beamter, modulId, aktion, daten) + local modul = module[modulId] + if not modul or not modul.aktiv then + return nil, 'Dieses Modul ist nicht verfügbar.' + end + + -- Recht auf das Modul selbst + if not Kern.DarfOeffnen(beamter, modul.recht) then + return nil, 'Dir fehlt die Berechtigung für diesen Bereich.' + end + + local eintrag = handler[modulId] and handler[modulId][aktion] + if not eintrag then + return nil, 'Unbekannte Aktion.' + end + + -- Zusätzliches Recht auf die einzelne Aktion + if eintrag.recht and not Rechte.Hat(beamter, eintrag.recht) then + return nil, 'Dir fehlt die Berechtigung für diese Aktion.' + end + + local erfolg, ergebnis, fehler = pcall(eintrag.funktion, quelle, beamter, daten or {}) + if not erfolg then + Aether.Fehler(('Fehler in %s/%s: %s'):format(modulId, aktion, tostring(ergebnis))) + return nil, 'Interner Fehler im Modul.' + end + + return ergebnis, fehler +end + +-- --------------------------------------------------------------------- +-- Ereignisbus zwischen den Modulen +-- --------------------------------------------------------------------- + +--- Abonniert ein internes Ereignis +--- @param ereignis string Name +--- @param funktion function Rückruf +function Kern.Abonniere(ereignis, funktion) + abonnenten[ereignis] = abonnenten[ereignis] or {} + table.insert(abonnenten[ereignis], funktion) +end + +--- Löst ein internes Ereignis aus. +--- Fehler eines Abonnenten dürfen die anderen nicht mitreißen. +--- @param ereignis string Name +--- @param ... any Nutzdaten +function Kern.Loese(ereignis, ...) + for _, funktion in ipairs(abonnenten[ereignis] or {}) do + local erfolg, fehler = pcall(funktion, ...) + if not erfolg then + Aether.Fehler(('Abonnent von "%s" schlug fehl: %s'):format(ereignis, tostring(fehler))) + end + end + + -- Zusätzlich nach außen geben, damit fremde Ressourcen andocken können + TriggerEvent('aether:police:' .. ereignis, ...) +end + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen für Fachmodule +-- --------------------------------------------------------------------- + +--- Erzeugt eine fortlaufende Kennung, z. B. AZ-2026-0042 +--- @param praefix string Vorsilbe +--- @param tabelle string Tabellenname +--- @param spalte string Spalte mit der Kennung +--- @return string +function Kern.ErzeugeKennung(praefix, tabelle, spalte) + local jahr = os.date('%Y') + local anzahl = MySQL.scalar.await( + ('SELECT COUNT(*) FROM %s WHERE %s LIKE ?'):format(tabelle, spalte), + { praefix .. '-' .. jahr .. '-%' }) or 0 + return ('%s-%s-%04d'):format(praefix, jahr, anzahl + 1) +end diff --git a/resources/[aether]/aether-police/server/main.lua b/resources/[aether]/aether-police/server/main.lua new file mode 100644 index 0000000..31c44fd --- /dev/null +++ b/resources/[aether]/aether-police/server/main.lua @@ -0,0 +1,195 @@ +-- ===================================================================== +-- aether-police — Server-Schnittstelle +-- +-- Ein einziges Netz-Event bedient alle Module. Jede Anfrage durchläuft +-- Dienstprüfung und Rechteprüfung, bevor sie ein Modul erreicht. +-- ===================================================================== + +local Core = exports['aether-core'] + +-- --------------------------------------------------------------------- +-- Tablet öffnen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:police:oeffnen', function() + local quelle = source + + local beamter = Dienst.Hole(quelle) + if not beamter then + -- Noch nicht im Dienst: Personalakte prüfen und Anmeldung anbieten + local charakterId = Core:HoleCharakterId(quelle) + if not charakterId then return end + + local geprueft, fehler = Dienst.LadeBeamten(quelle, charakterId) + if not geprueft then + TriggerClientEvent('aether:police:fehler', quelle, fehler) + return + end + + TriggerClientEvent('aether:police:anmeldung', quelle, { + name = geprueft.name, + dienstnummer = geprueft.dienstnummer, + rolleName = geprueft.rolleName, + abteilung = geprueft.abteilung, + behoerde = Polizei.Config.Behoerde, + }) + return + end + + TriggerClientEvent('aether:police:zeige', quelle, { + behoerde = Polizei.Config.Behoerde, + kuerzel = Polizei.Config.Kuerzel, + module = Kern.SichtbareModule(beamter), + beamter = { + name = beamter.name, dienstnummer = beamter.dienstnummer, + rolle = beamter.rolle, rolleName = beamter.rolleName, + abteilung = beamter.abteilung, status = beamter.status, + leitstelle = Dienst.Leitstelle() == quelle, + }, + status = Polizei.Config.Dienststatus, + abteilungen = Polizei.Config.Abteilungen, + }) +end) + +-- --------------------------------------------------------------------- +-- Dienst +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:police:dienstAntreten', function() + local quelle = source + local beamter, fehler = Dienst.Antreten(quelle) + + if not beamter then + TriggerClientEvent('aether:police:fehler', quelle, fehler) + return + end + + TriggerClientEvent('aether:police:zeige', quelle, { + behoerde = Polizei.Config.Behoerde, + kuerzel = Polizei.Config.Kuerzel, + module = Kern.SichtbareModule(beamter), + beamter = { + name = beamter.name, dienstnummer = beamter.dienstnummer, + rolle = beamter.rolle, rolleName = beamter.rolleName, + abteilung = beamter.abteilung, status = beamter.status, + leitstelle = Dienst.Leitstelle() == quelle, + }, + status = Polizei.Config.Dienststatus, + abteilungen = Polizei.Config.Abteilungen, + }) +end) + +RegisterNetEvent('aether:police:dienstBeenden', function() + local quelle = source + Dienst.Beenden(quelle) + TriggerClientEvent('aether:police:schliessen', quelle) +end) + +RegisterNetEvent('aether:police:status', function(status) + local quelle = source + if type(status) ~= 'string' then return end + Dienst.SetzeStatus(quelle, status) +end) + +RegisterNetEvent('aether:police:leitstelle', function(zielQuelle) + local quelle = source + local erfolg, fehler = Dienst.SetzeLeitstelle(quelle, + type(zielQuelle) == 'number' and zielQuelle or nil) + if not erfolg and fehler then + TriggerClientEvent('aether:police:fehler', quelle, fehler) + end +end) + +-- --------------------------------------------------------------------- +-- Zentraler Modul-Aufruf +-- +-- Die Oberfläche schickt { modul, aktion, daten, ruf }. Der `ruf` ist +-- eine laufende Nummer, damit die Antwort zugeordnet werden kann. +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:police:aufruf', function(modulId, aktion, daten, ruf) + local quelle = source + + if type(modulId) ~= 'string' or type(aktion) ~= 'string' then return end + + local beamter = Dienst.Hole(quelle) + if not beamter then + TriggerClientEvent('aether:police:antwort', quelle, ruf, nil, + 'Du bist nicht im Dienst.') + return + end + + local ergebnis, fehler = Kern.FuehreAus(quelle, beamter, modulId, aktion, daten) + TriggerClientEvent('aether:police:antwort', quelle, ruf, ergebnis, fehler) +end) + +-- --------------------------------------------------------------------- +-- Eingehende Notrufe aus anderen Modulen +-- +-- Sie erzeugen einen Einsatz mit Status „offen". Zugewiesen wird +-- ausschliesslich von der Leitstelle — nie automatisch. +-- --------------------------------------------------------------------- + +--- Notruf aus dem Handy (110) +AddEventHandler('aether:phone:notruf', function(quelle, fraktionId, nummer) + if fraktionId ~= 'lspd' and fraktionId ~= 'polizei' then return end + + local position = GetEntityCoords(GetPlayerPed(quelle)) + EinsatzEingang({ + titel = 'Notruf über Telefon', + kategorie = 'notruf', + prioritaet = 2, + ort = 'Position des Anrufers', + beschreibung = ('Eingehender Anruf von %s.'):format(nummer or 'unbekannt'), + position = { x = position.x, y = position.y, z = position.z }, + quelle = 'notruf', + }) +end) + +-- ===================================================================== +-- EXPORTS — Schnittstelle für externe Systeme +-- Damit lassen sich Notrufe und Hinweise von aussen einspeisen, ohne +-- dass am Kern etwas geändert werden muss. +-- ===================================================================== + +--- Meldet einen Notruf an die Polizei. +--- Er erscheint als offener Einsatz — zugewiesen wird er nie automatisch. +exports('MeldeNotruf', function(daten) + if type(daten) ~= 'table' or not daten.titel then return nil end + return EinsatzEingang(daten) +end) + +--- Erstellt einen Hinweis für alle Beamten im Dienst +exports('MeldeHinweis', function(daten) + if type(daten) ~= 'table' or not daten.titel then return false end + Hinweise.Erstelle(nil, daten) + return true +end) + +--- Ist ein Spieler als Polizeibeamter im Dienst? +exports('IstImDienst', function(quelle) + return Dienst.Hole(quelle) ~= nil +end) + +--- Liefert die Dienstdaten eines Beamten (ohne Rechtetabelle) +exports('HoleBeamten', function(quelle) + local beamter = Dienst.Hole(quelle) + if not beamter then return nil end + return { + charakterId = beamter.charakterId, + name = beamter.name, + dienstnummer = beamter.dienstnummer, + rolle = beamter.rolle, + rolleName = beamter.rolleName, + abteilung = beamter.abteilung, + status = beamter.status, + } +end) + +--- Erlaubt fremden Ressourcen, ein eigenes Modul zu registrieren +exports('RegistriereModul', function(beschreibung) + return Kern.RegistriereModul(beschreibung) +end) + +--- Erlaubt fremden Ressourcen, eine Aktion beizusteuern +exports('RegistriereAktion', function(modulId, aktion, recht, funktion) + Kern.RegistriereAktion(modulId, aktion, recht, funktion) + return true +end) diff --git a/resources/[aether]/aether-police/server/module/admin.lua b/resources/[aether]/aether-police/server/module/admin.lua new file mode 100644 index 0000000..44b82b2 --- /dev/null +++ b/resources/[aether]/aether-police/server/module/admin.lua @@ -0,0 +1,357 @@ +-- ===================================================================== +-- aether-police / Modul: Administration +-- +-- Drei getrennte Bereiche mit je eigenem Recht: +-- * Module (admin.module) — Bausteine an- und abschalten +-- * Katalog (katalog.bearbeiten) — Delikte und ihre Vorschlagswerte +-- * Protokoll (protokoll.lesen) — wer hat wann was getan +-- +-- Auch hier gilt: der Katalog liefert VORSCHLÄGE. Was hier eingetragen +-- wird, ist nie eine Strafe, sondern ein Richtwert, von dem ein Beamter +-- jederzeit begründet abweichen darf. +-- +-- Nichts wird gelöscht. Delikte werden stillgelegt, damit alte Anzeigen +-- nachvollziehbar bleiben. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'admin', name = 'Administration', icon = '⚙', + reihenfolge = 14, + -- Ein Recht aus dieser Liste genügt zum Öffnen; die einzelnen + -- Bereiche prüfen danach noch einmal getrennt. + recht = { 'admin.module', 'katalog.bearbeiten', 'protokoll.lesen' }, + beschreibung = 'Module, Strafenkatalog und Protokoll', +}) + +-- Module, die sich nicht abschalten lassen — sonst sperrt man sich aus +local UNVERZICHTBAR = { admin = true, dashboard = true } + +-- Schlüssel, unter dem die abgeschalteten Module gespeichert werden +local SCHLUESSEL_AUS = 'module_abgeschaltet' + +-- --------------------------------------------------------------------- +-- Einstellungen lesen und schreiben +-- --------------------------------------------------------------------- + +--- Liest einen Wert aus police_einstellung +--- @param schluessel string Kennung +--- @return any|nil +local function LeseEinstellung(schluessel) + local roh = MySQL.scalar.await( + 'SELECT wert FROM police_einstellung WHERE schluessel = ?', { schluessel }) + if not roh then return nil end + + local erfolg, wert = pcall(json.decode, roh) + return erfolg and wert or nil +end + +--- Schreibt einen Wert nach police_einstellung +--- @param schluessel string Kennung +--- @param wert any Beliebiger Wert +local function SchreibeEinstellung(schluessel, wert) + MySQL.query.await([[ + INSERT INTO police_einstellung (schluessel, wert) VALUES (?, ?) + ON DUPLICATE KEY UPDATE wert = VALUES(wert) + ]], { schluessel, json.encode(wert) }) +end + +-- --------------------------------------------------------------------- +-- Beim Start: gespeicherte Modulzustände wiederherstellen +-- Läuft erst, wenn alle Moduldateien geladen und registriert sind. +-- --------------------------------------------------------------------- +CreateThread(function() + Wait(0) + + local abgeschaltet = LeseEinstellung(SCHLUESSEL_AUS) + if type(abgeschaltet) ~= 'table' then return end + + for _, modulId in ipairs(abgeschaltet) do + if not UNVERZICHTBAR[modulId] and Kern.KenntModul(modulId) then + Kern.SetzeModulAktiv(modulId, false) + Aether.Debug(('Polizei-Modul abgeschaltet (gespeichert): %s'):format(modulId)) + end + end +end) + +-- ===================================================================== +-- MODULE +-- ===================================================================== + +--- Welche Bereiche darf dieser Beamte sehen? Steuert die Reiter der Oberfläche. +Kern.RegistriereAktion('admin', 'bereiche', nil, function(quelle, beamter) + return { + module = Rechte.Hat(beamter, 'admin.module'), + katalog = Rechte.Hat(beamter, 'katalog.bearbeiten'), + protokoll = Rechte.Hat(beamter, 'protokoll.lesen'), + } +end) + +Kern.RegistriereAktion('admin', 'module', 'admin.module', function(quelle, beamter) + local module = Kern.AlleModule() + + for _, modul in ipairs(module) do + modul.unverzichtbar = UNVERZICHTBAR[modul.id] == true + -- Welches Recht öffnet dieses Modul, und wie heißt es im Klartext? + modul.rechtName = nil + if modul.recht then + for _, gruppe in ipairs(Polizei.Rechte) do + for _, recht in ipairs(gruppe.eintraege) do + if recht.id == modul.recht then modul.rechtName = recht.name end + end + end + end + end + + -- Werte aus config.lua zur Ansicht — sie leben in der Datei, nicht in der DB + local konfiguration = { + { name = 'Behörde', wert = Polizei.Config.Behoerde }, + { name = 'Kürzel', wert = Polizei.Config.Kuerzel }, + { name = 'Terminals', wert = tostring(#Polizei.Config.Terminals) }, + { name = 'Abteilungen', wert = tostring(#Polizei.Config.Abteilungen) }, + { name = 'Rollen', wert = tostring(#Polizei.Rollen) }, + { name = 'Rechte', wert = tostring(#Polizei.AlleRechte()) }, + { name = 'Fristprüfung alle', wert = (Polizei.Config.Erinnerungen.pruefIntervall / 1000) + .. ' Sekunden' }, + } + + return { + module = module, + konfiguration = konfiguration, + beamteImDienst = (function() + local anzahl = 0 + for _ in pairs(Dienst.AlleImDienst()) do anzahl = anzahl + 1 end + return anzahl + end)(), + } +end) + +Kern.RegistriereAktion('admin', 'modulSchalten', 'admin.module', function(quelle, beamter, daten) + local modulId = tostring(daten.id or '') + local aktiv = daten.aktiv == true + + if not Kern.KenntModul(modulId) then return nil, 'Unbekanntes Modul.' end + if UNVERZICHTBAR[modulId] then + return nil, 'Dieses Modul lässt sich nicht abschalten.' + end + + Kern.SetzeModulAktiv(modulId, aktiv) + + -- Zustand merken, damit er einen Neustart übersteht + local abgeschaltet, name = {}, modulId + for _, modul in ipairs(Kern.AlleModule()) do + if not modul.aktiv then abgeschaltet[#abgeschaltet + 1] = modul.id end + if modul.id == modulId then name = modul.name end + end + SchreibeEinstellung(SCHLUESSEL_AUS, abgeschaltet) + + Protokoll.Schreibe(beamter, 'admin', + aktiv and 'Modul eingeschaltet' or 'Modul abgeschaltet', + { typ = 'modul', id = modulId }, nil, daten.grund) + + -- Alle im Dienst darüber informieren. Die Seitenleiste aktualisiert sich + -- beim nächsten Öffnen des Tablets — laufende Arbeit wird nicht unterbrochen. + Hinweise.Erstelle(nil, { + kategorie = 'system', prioritaet = 'niedrig', + titel = aktiv and 'Modul eingeschaltet' or 'Modul abgeschaltet', + text = ('„%s" — beim nächsten Öffnen des Tablets sichtbar.'):format(name), + }) + + Kern.Loese('admin.modulGeschaltet', modulId, aktiv) + + return { ok = true } +end) + +-- ===================================================================== +-- STRAFENKATALOG +-- +-- Die Werte hier sind Vorschläge. Sie werden nie automatisch verhängt. +-- ===================================================================== + +Kern.RegistriereAktion('admin', 'katalog', 'katalog.bearbeiten', function(quelle, beamter, daten) + local bedingungen, werte = { '1=1' }, {} + + if not daten.stillgelegt then + bedingungen[#bedingungen + 1] = 'aktiv = 1' + end + if daten.kategorie and daten.kategorie ~= '' then + bedingungen[#bedingungen + 1] = 'kategorie = ?' + werte[#werte + 1] = daten.kategorie + end + if type(daten.begriff) == 'string' and #daten.begriff >= 2 then + bedingungen[#bedingungen + 1] = + '(kennung LIKE ? OR bezeichnung LIKE ? OR paragraph LIKE ?)' + local muster = '%' .. daten.begriff .. '%' + werte[#werte + 1] = muster + werte[#werte + 1] = muster + werte[#werte + 1] = muster + end + + local delikte = MySQL.query.await(([[ + SELECT id, kennung, kategorie, bezeichnung, paragraph, haft, geld, + hinweis, aktiv, sortierung + FROM police_katalog + WHERE %s + ORDER BY kategorie, sortierung, kennung + LIMIT 500 + ]]):format(table.concat(bedingungen, ' AND ')), werte) or {} + + for _, delikt in ipairs(delikte) do + delikt.aktiv = delikt.aktiv == 1 + end + + local kategorien = MySQL.query.await( + 'SELECT DISTINCT kategorie FROM police_katalog ORDER BY kategorie') or {} + + return { + delikte = delikte, + kategorien = kategorien, + stillgelegt = daten.stillgelegt == true, + gesamt = MySQL.scalar.await('SELECT COUNT(*) FROM police_katalog WHERE aktiv = 1') or 0, + } +end) + +Kern.RegistriereAktion('admin', 'katalogSpeichern', 'katalog.bearbeiten', function(quelle, beamter, daten) + local kennung = type(daten.kennung) == 'string' + and daten.kennung:gsub('%s', ''):upper() or '' + local bezeichnung = type(daten.bezeichnung) == 'string' and daten.bezeichnung:sub(1, 160) or '' + local kategorie = type(daten.kategorie) == 'string' and daten.kategorie:sub(1, 48) or '' + + if #kennung < 2 then return nil, 'Bitte eine Kennung angeben.' end + if #bezeichnung < 3 then return nil, 'Bitte eine Bezeichnung angeben.' end + if #kategorie < 2 then return nil, 'Bitte eine Kategorie angeben.' end + + local haft = math.max(0, math.floor(tonumber(daten.haft) or 0)) + local geld = math.max(0, math.floor(tonumber(daten.geld) or 0)) + if haft > 100000 or geld > 10000000 then + return nil, 'Der Vorschlagswert ist unplausibel hoch.' + end + + local id = tonumber(daten.id) + local hinweis = type(daten.hinweis) == 'string' and daten.hinweis:sub(1, 255) or nil + local sortierung = math.floor(tonumber(daten.sortierung) or 0) + + -- Kennung darf nur einmal vergeben sein + local belegt = MySQL.scalar.await( + 'SELECT id FROM police_katalog WHERE kennung = ? AND (? IS NULL OR id <> ?)', + { kennung, id, id }) + if belegt then return nil, 'Diese Kennung ist bereits vergeben.' end + + if id then + local alt = MySQL.query.await( + 'SELECT kennung, bezeichnung, haft, geld FROM police_katalog WHERE id = ?', { id }) + if not alt or not alt[1] then return nil, 'Delikt nicht gefunden.' end + + MySQL.update.await([[ + UPDATE police_katalog + SET kennung = ?, kategorie = ?, bezeichnung = ?, paragraph = ?, + haft = ?, geld = ?, hinweis = ?, sortierung = ? + WHERE id = ? + ]], { kennung, kategorie, bezeichnung, daten.paragraph, haft, geld, + hinweis, sortierung, id }) + + Protokoll.Schreibe(beamter, 'admin', 'Delikt geändert', + { typ = 'katalog', id = id }, + { kennung = kennung, + haftAlt = alt[1].haft, haftNeu = haft, + geldAlt = alt[1].geld, geldNeu = geld }) + else + id = MySQL.insert.await([[ + INSERT INTO police_katalog + (kennung, kategorie, bezeichnung, paragraph, haft, geld, hinweis, sortierung) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ]], { kennung, kategorie, bezeichnung, daten.paragraph, haft, geld, + hinweis, sortierung }) + + Protokoll.Schreibe(beamter, 'admin', 'Delikt angelegt', + { typ = 'katalog', id = id }, + { kennung = kennung, haft = haft, geld = geld }) + end + + Kern.Loese('katalog.geaendert', id, beamter) + + return { id = id } +end) + +Kern.RegistriereAktion('admin', 'katalogSchalten', 'katalog.bearbeiten', function(quelle, beamter, daten) + local id = tonumber(daten.id) + if not id then return nil, 'Ungültige Kennung.' end + + local aktiv = daten.aktiv == true + + local delikt = MySQL.query.await( + 'SELECT kennung, bezeichnung FROM police_katalog WHERE id = ?', { id }) + if not delikt or not delikt[1] then return nil, 'Delikt nicht gefunden.' end + + -- Stilllegen statt löschen: bestehende Anzeigen bleiben nachvollziehbar + MySQL.update.await('UPDATE police_katalog SET aktiv = ? WHERE id = ?', + { aktiv and 1 or 0, id }) + + Protokoll.Schreibe(beamter, 'admin', + aktiv and 'Delikt wieder freigegeben' or 'Delikt stillgelegt', + { typ = 'katalog', id = id }, + { kennung = delikt[1].kennung }, daten.grund) + + return { ok = true } +end) + +-- ===================================================================== +-- PROTOKOLL +-- +-- Reine Auskunft. Einträge lassen sich weder ändern noch entfernen. +-- ===================================================================== + +Kern.RegistriereAktion('admin', 'protokoll', 'protokoll.lesen', function(quelle, beamter, daten) + local filter = { limit = math.min(tonumber(daten.limit) or 100, 500) } + + if daten.modul and daten.modul ~= '' then filter.modul = daten.modul end + + -- Nach Beamtem filtern: über die Dienstnummer, nicht über eine ID. + -- Eine unbekannte Nummer liefert eine leere Liste, keinen Fehler — + -- sonst bricht die Ansicht schon beim Tippen ab. + local unbekannteNummer = false + if type(daten.dienstnummer) == 'string' and #daten.dienstnummer >= 2 then + local charakterId = MySQL.scalar.await( + 'SELECT `character` FROM police_personal WHERE dienstnummer = ?', + { daten.dienstnummer:upper() }) + if charakterId then + filter.charakterId = charakterId + else + unbekannteNummer = true + end + end + + local eintraege = unbekannteNummer and {} or Protokoll.Lies(filter) + + for _, eintrag in ipairs(eintraege) do + eintrag.name = eintrag.firstname + and (eintrag.firstname .. ' ' .. eintrag.lastname) or 'System' + eintrag.firstname, eintrag.lastname = nil, nil + + -- Details lesbar machen + if eintrag.details then + local erfolg, wert = pcall(json.decode, eintrag.details) + eintrag.details = erfolg and wert or nil + end + end + + -- Welche Module tauchen im Protokoll überhaupt auf? + local module = MySQL.query.await( + 'SELECT DISTINCT modul FROM police_protokoll ORDER BY modul') or {} + + return { + eintraege = eintraege, + module = module, + unbekannteNummer = unbekannteNummer, + gesamt = MySQL.scalar.await('SELECT COUNT(*) FROM police_protokoll') or 0, + } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Liefert alle registrierten Module mit ihrem Zustand +--- @return table +exports('HoleModule', function() + return Kern.AlleModule() +end) diff --git a/resources/[aether]/aether-police/server/module/anzeige.lua b/resources/[aether]/aether-police/server/module/anzeige.lua new file mode 100644 index 0000000..fe51d96 --- /dev/null +++ b/resources/[aether]/aether-police/server/module/anzeige.lua @@ -0,0 +1,443 @@ +-- ===================================================================== +-- aether-police / Modul: Strafenkatalog & Anzeigen +-- +-- Der Katalog ist ein NACHSCHLAGEWERK. Er berechnet einen Vorschlag — +-- entschieden wird von einem Beamten. Deshalb speichert jede Anzeige +-- beides getrennt: +-- haft_empfohlen / geld_empfohlen = Vorschlag des Katalogs +-- haft_final / geld_final = Entscheidung des Beamten +-- Weicht der Beamte ab, ist eine Begründung Pflicht. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'anzeige', name = 'Anzeigen', icon = '⚖', + reihenfolge = 4, recht = 'anzeige.erstellen', + beschreibung = 'Strafenkatalog und Anzeigenerfassung', +}) + +-- --------------------------------------------------------------------- +-- Grundbestand des Katalogs +-- Wird nur angelegt, wenn die Tabelle leer ist. Danach pflegt ihn die +-- Administration — hier wird nichts mehr überschrieben. +-- --------------------------------------------------------------------- +local GRUNDKATALOG = { + -- kennung, kategorie, bezeichnung, paragraph, haft (Min), geld, hinweis + { 'v1', 'Verkehr', 'Überhöhte Geschwindigkeit', '§ 3 StVO', 0, 450, + 'Bei erheblicher Überschreitung Fahrverbot prüfen.' }, + { 'v2', 'Verkehr', 'Fahren ohne Fahrerlaubnis', '§ 21 StVG', 10, 1200, + 'Führerscheinstatus im MDT prüfen.' }, + { 'v3', 'Verkehr', 'Unerlaubtes Entfernen vom Unfallort', '§ 142 StGB', 30, 5000, nil }, + { 'v4', 'Verkehr', 'Trunkenheit im Verkehr', '§ 316 StGB', 20, 3500, + 'Bei Gefährdung § 315c prüfen.' }, + { 'v5', 'Verkehr', 'Verbotenes Kraftfahrzeugrennen', '§ 315d StGB', 45, 9000, nil }, + + { 'e1', 'Eigentum', 'Diebstahl (geringwertig)', '§ 242 StGB', 15, 800, nil }, + { 'e2', 'Eigentum', 'Einbruchdiebstahl', '§ 243 StGB', 45, 6000, nil }, + { 'e3', 'Eigentum', 'Sachbeschädigung', '§ 303 StGB', 10, 1500, nil }, + { 'e4', 'Eigentum', 'Hehlerei', '§ 259 StGB', 25, 4000, nil }, + + { 'g1', 'Gewalt', 'Körperverletzung', '§ 223 StGB', 45, 8000, nil }, + { 'g2', 'Gewalt', 'Gefährliche Körperverletzung', '§ 224 StGB', 90, 15000, nil }, + { 'g3', 'Gewalt', 'Raub', '§ 249 StGB', 120, 25000, nil }, + { 'g4', 'Gewalt', 'Widerstand gegen Vollstreckungsbeamte', '§ 113 StGB', 30, 4000, nil }, + { 'g5', 'Gewalt', 'Bedrohung', '§ 241 StGB', 20, 2500, nil }, + { 'g6', 'Gewalt', 'Freiheitsberaubung', '§ 239 StGB', 60, 9000, nil }, + + { 'w1', 'Waffen', 'Unerlaubter Waffenbesitz', '§ 52 WaffG', 60, 10000, + 'Waffenschein im MDT prüfen — ggf. Einziehung dokumentieren.' }, + { 'w2', 'Waffen', 'Führen einer Waffe ohne Erlaubnis', '§ 52 WaffG', 30, 5000, nil }, + + { 'b1', 'Betäubungsmittel', 'Besitz von Betäubungsmitteln', '§ 29 BtMG', 25, 3000, nil }, + { 'b2', 'Betäubungsmittel', 'Handel mit Betäubungsmitteln', '§ 29a BtMG', 110, 20000, nil }, + + { 's1', 'Sonstige', 'Hausfriedensbruch', '§ 123 StGB', 15, 1500, nil }, + { 's2', 'Sonstige', 'Beleidigung', '§ 185 StGB', 0, 800, nil }, + { 's3', 'Sonstige', 'Falsche Verdächtigung', '§ 164 StGB', 30, 4500, nil }, + { 's4', 'Sonstige', 'Urkundenfälschung', '§ 267 StGB', 40, 6000, nil }, +} + +CreateThread(function() + Wait(3000) + + local anzahl = MySQL.scalar.await('SELECT COUNT(*) FROM police_katalog') or 0 + if anzahl > 0 then return end + + for reihenfolge, eintrag in ipairs(GRUNDKATALOG) do + MySQL.insert.await([[ + INSERT INTO police_katalog + (kennung, kategorie, bezeichnung, paragraph, haft, geld, hinweis, sortierung) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ]], { eintrag[1], eintrag[2], eintrag[3], eintrag[4], + eintrag[5], eintrag[6], eintrag[7], reihenfolge }) + end + + Aether.Info(('Strafenkatalog mit %d Einträgen angelegt.'):format(#GRUNDKATALOG)) +end) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Lädt den aktiven Katalog, nach Kategorien gruppiert +--- @return table +local function LadeKatalog() + local eintraege = MySQL.query.await([[ + SELECT id, kennung, kategorie, bezeichnung, paragraph, haft, geld, hinweis + FROM police_katalog WHERE aktiv = 1 + ORDER BY sortierung ASC, id ASC + ]]) or {} + + local gruppen, reihenfolge = {}, {} + for _, eintrag in ipairs(eintraege) do + if not gruppen[eintrag.kategorie] then + gruppen[eintrag.kategorie] = {} + reihenfolge[#reihenfolge + 1] = eintrag.kategorie + end + table.insert(gruppen[eintrag.kategorie], eintrag) + end + + local liste = {} + for _, kategorie in ipairs(reihenfolge) do + liste[#liste + 1] = { kategorie = kategorie, delikte = gruppen[kategorie] } + end + return liste +end + +--- Rechnet den Vorschlag für eine Auswahl von Delikten aus. +--- Der Katalog ist die einzige Quelle — der Client schickt nur Kennungen. +--- @param kennungen table Liste von Katalog-Kennungen +--- @return table|nil { delikte, haft, geld }, string|nil Fehler +local function BerechneVorschlag(kennungen) + if type(kennungen) ~= 'table' or #kennungen == 0 then + return nil, 'Bitte mindestens ein Delikt auswählen.' + end + if #kennungen > 25 then + return nil, 'Zu viele Delikte auf einmal.' + end + + local delikte, haft, geld = {}, 0, 0 + + for _, kennung in ipairs(kennungen) do + local eintrag = MySQL.query.await( + 'SELECT kennung, bezeichnung, paragraph, haft, geld FROM police_katalog WHERE kennung = ? AND aktiv = 1', + { tostring(kennung) }) + + if not eintrag or not eintrag[1] then + return nil, ('Unbekanntes Delikt: %s'):format(tostring(kennung)) + end + + local delikt = eintrag[1] + delikte[#delikte + 1] = { + kennung = delikt.kennung, name = delikt.bezeichnung, + paragraph = delikt.paragraph, haft = delikt.haft, geld = delikt.geld, + } + haft = haft + (delikt.haft or 0) + geld = geld + (delikt.geld or 0) + end + + return { delikte = delikte, haft = haft, geld = geld } +end + +-- ===================================================================== +-- KATALOG ABRUFEN +-- ===================================================================== + +Kern.RegistriereAktion('anzeige', 'katalog', nil, function(quelle, beamter) + return { + katalog = LadeKatalog(), + rechte = { + bestaetigen = Rechte.Hat(beamter, 'anzeige.bestaetigen'), + abweichen = Rechte.Hat(beamter, 'anzeige.abweichen'), + bearbeiten = Rechte.Hat(beamter, 'katalog.bearbeiten'), + }, + } +end) + +--- Berechnet den Vorschlag zu einer Auswahl — reine Vorschau, speichert nichts +Kern.RegistriereAktion('anzeige', 'vorschlag', nil, function(quelle, beamter, daten) + local vorschlag, fehler = BerechneVorschlag(daten.delikte) + if not vorschlag then return nil, fehler end + + -- Zusätzliche Hinweise der gewählten Delikte mitliefern + local hinweise = {} + for _, kennung in ipairs(daten.delikte) do + local hinweis = MySQL.scalar.await( + 'SELECT hinweis FROM police_katalog WHERE kennung = ? AND aktiv = 1', { kennung }) + if hinweis and hinweis ~= '' then hinweise[#hinweise + 1] = hinweis end + end + + return { + delikte = vorschlag.delikte, + haft = vorschlag.haft, + geld = vorschlag.geld, + hinweise = hinweise, + } +end) + +-- ===================================================================== +-- ANZEIGE ERFASSEN +-- +-- Erzeugt einen ENTWURF. Nichts wird eingezogen, niemand kommt in Haft. +-- ===================================================================== + +Kern.RegistriereAktion('anzeige', 'erstellen', 'anzeige.erstellen', function(quelle, beamter, daten) + local beschuldigter = tonumber(daten.beschuldigter) + if not beschuldigter then return nil, 'Kein Beschuldigter ausgewählt.' end + + local person = MySQL.scalar.await( + 'SELECT id FROM aether_characters WHERE id = ? AND deleted = 0', { beschuldigter }) + if not person then return nil, 'Diese Person existiert nicht.' end + + local vorschlag, fehler = BerechneVorschlag(daten.delikte) + if not vorschlag then return nil, fehler end + + -- Werte des Beamten übernehmen; fehlen sie, gilt der Vorschlag + local haftFinal = daten.haft ~= nil and math.max(0, math.min(1440, math.floor(tonumber(daten.haft) or 0))) or vorschlag.haft + local geldFinal = daten.geld ~= nil and math.max(0, math.min(10000000, math.floor(tonumber(daten.geld) or 0))) or vorschlag.geld + + local weichtAb = (haftFinal ~= vorschlag.haft) or (geldFinal ~= vorschlag.geld) + local begruendung = type(daten.begruendung) == 'string' + and daten.begruendung:gsub('^%s+', ''):gsub('%s+$', '') or '' + + -- Abweichen ist ein eigenes Recht und verlangt eine Begründung + if weichtAb then + if not Rechte.Hat(beamter, 'anzeige.abweichen') then + return nil, 'Dir fehlt die Berechtigung, vom Katalogvorschlag abzuweichen.' + end + if #begruendung < 5 then + return nil, 'Bei Abweichung vom Vorschlag ist eine Begründung erforderlich.' + end + end + + local anzeigeId = MySQL.insert.await([[ + INSERT INTO police_anzeige + (akte, beschuldigter, delikte, haft_empfohlen, geld_empfohlen, + haft_final, geld_final, begruendung, status, erstellt_von) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'entwurf', ?) + ]], { + tonumber(daten.akte), beschuldigter, json.encode(vorschlag.delikte), + vorschlag.haft, vorschlag.geld, haftFinal, geldFinal, + begruendung ~= '' and begruendung or nil, beamter.charakterId, + }) + + Protokoll.Schreibe(beamter, 'anzeige', 'Anzeige als Entwurf erfasst', + { typ = 'person', id = beschuldigter }, + { anzeige = anzeigeId, haft = haftFinal, geld = geldFinal, abweichung = weichtAb }, + begruendung ~= '' and begruendung or nil) + + Kern.Loese('anzeige.erstellt', anzeigeId, beamter) + + return { id = anzeigeId, entwurf = true } +end) + +-- ===================================================================== +-- BESTÄTIGEN +-- +-- Erst hier wird die Entscheidung des Beamten ausgeführt: Das Bußgeld +-- wird eingezogen. Eine Haft entsteht dadurch NICHT — die Einweisung +-- in Gewahrsam ist ein eigener, bewusster Schritt. +-- ===================================================================== + +Kern.RegistriereAktion('anzeige', 'bestaetigen', 'anzeige.bestaetigen', function(quelle, beamter, daten) + local anzeigeId = tonumber(daten.id) + if not anzeigeId then return nil, 'Ungültige Kennung.' end + + local anzeige = MySQL.query.await('SELECT * FROM police_anzeige WHERE id = ?', { anzeigeId }) + if not anzeige or not anzeige[1] then return nil, 'Anzeige nicht gefunden.' end + + local eintrag = anzeige[1] + if eintrag.status ~= 'entwurf' then + return nil, 'Diese Anzeige wurde bereits abschließend bearbeitet.' + end + + -- Vier-Augen-Prinzip: Wer erfasst hat, darf nur mit dem Recht + -- „bestaetigen" auch selbst bestätigen — das ist hier gegeben, + -- wird aber protokolliert. + local selbstbestaetigung = eintrag.erstellt_von == beamter.charakterId + + -- Bußgeld einziehen (erst Bargeld, dann Konto) + local Core = exports['aether-core'] + local eingezogen = 0 + + if eintrag.geld_final > 0 then + local zielQuelle = Core:HoleQuelleNachCharakter(eintrag.beschuldigter) + local bezahlt = false + + if zielQuelle then + bezahlt = Core:EntferneGeld(zielQuelle, 'bargeld', eintrag.geld_final, 'Bußgeld') + if not bezahlt then + bezahlt = Core:EntferneGeld(zielQuelle, 'konto', eintrag.geld_final, 'Bußgeld') + end + else + -- Offline: direkt vom Konto, auch ins Minus (Forderung bleibt bestehen) + MySQL.update.await('UPDATE aether_characters SET bank = bank - ? WHERE id = ?', + { eintrag.geld_final, eintrag.beschuldigter }) + bezahlt = true + end + + if bezahlt then + eingezogen = eintrag.geld_final + MySQL.update('UPDATE aether_treasury SET betrag = betrag + ? WHERE id = 1', + { eintrag.geld_final }) + end + end + + MySQL.update.await([[ + UPDATE police_anzeige + SET status = 'bestaetigt', bestaetigt_von = ?, bestaetigt_am = NOW() + WHERE id = ? + ]], { beamter.charakterId, anzeigeId }) + + Protokoll.Schreibe(beamter, 'anzeige', 'Anzeige bestätigt', + { typ = 'person', id = eintrag.beschuldigter }, + { anzeige = anzeigeId, eingezogen = eingezogen, + selbstbestaetigung = selbstbestaetigung }) + + -- Erinnerung, falls eine Haft festgelegt wurde — aber NICHT ausgeführt + if eintrag.haft_final > 0 then + Hinweise.Erstelle({ charakterId = beamter.charakterId }, { + kategorie = 'gewahrsam', prioritaet = 'normal', + titel = 'Haftzeit festgelegt — Einweisung offen', + text = ('Die Anzeige sieht %d Minuten vor. Die Einweisung in Gewahrsam ist ein eigener Schritt.') + :format(eintrag.haft_final), + zielTyp = 'person', zielId = eintrag.beschuldigter, + }) + end + + Kern.Loese('anzeige.bestaetigt', anzeigeId, beamter) + + return { + ok = true, + eingezogen = eingezogen, + haft = eintrag.haft_final, + beschuldigter = eintrag.beschuldigter, + } +end) + +--- Entwurf verwerfen +Kern.RegistriereAktion('anzeige', 'verwerfen', 'anzeige.erstellen', function(quelle, beamter, daten) + local anzeigeId = tonumber(daten.id) + if not anzeigeId then return nil, 'Ungültige Kennung.' end + + local anzeige = MySQL.query.await( + 'SELECT erstellt_von, status, beschuldigter FROM police_anzeige WHERE id = ?', { anzeigeId }) + if not anzeige or not anzeige[1] then return nil, 'Anzeige nicht gefunden.' end + if anzeige[1].status ~= 'entwurf' then + return nil, 'Nur Entwürfe können verworfen werden.' + end + + -- Fremde Entwürfe darf nur verwerfen, wer bestätigen darf + if anzeige[1].erstellt_von ~= beamter.charakterId + and not Rechte.Hat(beamter, 'anzeige.bestaetigen') then + return nil, 'Du kannst nur eigene Entwürfe verwerfen.' + end + + local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or nil + + MySQL.update.await("UPDATE police_anzeige SET status = 'verworfen' WHERE id = ?", { anzeigeId }) + + Protokoll.Schreibe(beamter, 'anzeige', 'Anzeige verworfen', + { typ = 'person', id = anzeige[1].beschuldigter }, { anzeige = anzeigeId }, grund) + + return { ok = true } +end) + +-- ===================================================================== +-- ÜBERSICHT UND EINZELANSICHT +-- ===================================================================== + +Kern.RegistriereAktion('anzeige', 'liste', nil, function(quelle, beamter, daten) + local bedingungen, werte = { '1=1' }, {} + + if daten.status and daten.status ~= '' then + bedingungen[#bedingungen + 1] = 'a.status = ?' + werte[#werte + 1] = daten.status + end + if daten.nurEigene then + bedingungen[#bedingungen + 1] = 'a.erstellt_von = ?' + werte[#werte + 1] = beamter.charakterId + end + + local anzeigen = MySQL.query.await(([[ + SELECT a.id, a.beschuldigter, a.delikte, a.haft_empfohlen, a.geld_empfohlen, + a.haft_final, a.geld_final, a.begruendung, a.status, + a.erstellt_am, a.bestaetigt_am, + b.firstname AS b_vorname, b.lastname AS b_nachname, + e.firstname AS e_vorname, e.lastname AS e_nachname + FROM police_anzeige a + LEFT JOIN aether_characters b ON b.id = a.beschuldigter + LEFT JOIN aether_characters e ON e.id = a.erstellt_von + WHERE %s + ORDER BY + CASE WHEN a.status = 'entwurf' THEN 0 ELSE 1 END, + a.id DESC + LIMIT 60 + ]]):format(table.concat(bedingungen, ' AND ')), werte) or {} + + for _, anzeige in ipairs(anzeigen) do + local erfolg, liste = pcall(json.decode, anzeige.delikte) + anzeige.delikte = (erfolg and type(liste) == 'table') and liste or {} + -- Weicht die Entscheidung vom Vorschlag ab? + anzeige.abweichung = (anzeige.haft_final ~= anzeige.haft_empfohlen) + or (anzeige.geld_final ~= anzeige.geld_empfohlen) + end + + return { + anzeigen = anzeigen, + rechte = { + bestaetigen = Rechte.Hat(beamter, 'anzeige.bestaetigen'), + abweichen = Rechte.Hat(beamter, 'anzeige.abweichen'), + }, + } +end) + +-- ===================================================================== +-- KATALOGPFLEGE (Administration) +-- ===================================================================== + +Kern.RegistriereAktion('anzeige', 'katalogSpeichern', 'katalog.bearbeiten', function(quelle, beamter, daten) + local kennung = type(daten.kennung) == 'string' and daten.kennung:gsub('%s', '') or '' + local bezeichnung = type(daten.bezeichnung) == 'string' + and daten.bezeichnung:gsub('^%s+', ''):gsub('%s+$', '') or '' + + if #kennung < 1 or #kennung > 24 then return nil, 'Ungültige Kennung.' end + if #bezeichnung < 3 then return nil, 'Bitte eine Bezeichnung angeben.' end + + local haft = math.max(0, math.min(1440, math.floor(tonumber(daten.haft) or 0))) + local geld = math.max(0, math.min(10000000, math.floor(tonumber(daten.geld) or 0))) + + MySQL.query.await([[ + INSERT INTO police_katalog + (kennung, kategorie, bezeichnung, paragraph, haft, geld, hinweis, aktiv) + VALUES (?, ?, ?, ?, ?, ?, ?, 1) + ON DUPLICATE KEY UPDATE + kategorie = VALUES(kategorie), bezeichnung = VALUES(bezeichnung), + paragraph = VALUES(paragraph), haft = VALUES(haft), geld = VALUES(geld), + hinweis = VALUES(hinweis), aktiv = 1 + ]], { + kennung, + type(daten.kategorie) == 'string' and daten.kategorie:sub(1, 48) or 'Sonstige', + bezeichnung, + type(daten.paragraph) == 'string' and daten.paragraph:sub(1, 48) or nil, + haft, geld, + type(daten.hinweis) == 'string' and daten.hinweis:sub(1, 512) or nil, + }) + + Protokoll.Schreibe(beamter, 'anzeige', 'Katalogeintrag gespeichert', + { typ = 'katalog', id = kennung }, { haft = haft, geld = geld }) + + return { ok = true } +end) + +Kern.RegistriereAktion('anzeige', 'katalogDeaktivieren', 'katalog.bearbeiten', function(quelle, beamter, daten) + local kennung = tostring(daten.kennung or '') + if kennung == '' then return nil, 'Ungültige Kennung.' end + + -- Nicht löschen, damit alte Anzeigen nachvollziehbar bleiben + MySQL.update.await('UPDATE police_katalog SET aktiv = 0 WHERE kennung = ?', { kennung }) + + Protokoll.Schreibe(beamter, 'anzeige', 'Katalogeintrag deaktiviert', + { typ = 'katalog', id = kennung }) + + return { ok = true } +end) diff --git a/resources/[aether]/aether-police/server/module/asservat.lua b/resources/[aether]/aether-police/server/module/asservat.lua new file mode 100644 index 0000000..96c4d6b --- /dev/null +++ b/resources/[aether]/aether-police/server/module/asservat.lua @@ -0,0 +1,320 @@ +-- ===================================================================== +-- aether-police / Modul: Asservatenkammer +-- +-- Jedes Objekt hat eine Kennung, einen Lagerort, einen Verantwortlichen +-- und eine lückenlose Historie. Nichts wird automatisch eingelagert, +-- ausgegeben oder vernichtet — jeder Schritt ist eine Handlung eines +-- Beamten und wird mit Namen festgehalten. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'asservat', name = 'Asservate', icon = '📦', + reihenfolge = 7, recht = 'asservat.einlagern', + beschreibung = 'Asservatenkammer und Verwahrkette', +}) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +local function KategorieGueltig(kategorie) + for _, eintrag in ipairs(Polizei.Config.Asservatkategorien) do + if eintrag.id == kategorie then return true end + end + return false +end + +--- Schreibt einen Historieneintrag zu einem Asservat +--- @param asservatId number +--- @param aktion string +--- @param beamter table +--- @param notiz string|nil +local function Historie(asservatId, aktion, beamter, notiz) + MySQL.insert('INSERT INTO police_asservat_historie (asservat, aktion, von, notiz) VALUES (?, ?, ?, ?)', + { asservatId, aktion, beamter.charakterId, notiz }) +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('asservat', 'liste', nil, function(quelle, beamter, daten) + local bedingungen, werte = { '1=1' }, {} + + if daten.kategorie and daten.kategorie ~= '' then + bedingungen[#bedingungen + 1] = 'a.kategorie = ?' + werte[#werte + 1] = daten.kategorie + end + if daten.status and daten.status ~= '' then + bedingungen[#bedingungen + 1] = 'a.status = ?' + werte[#werte + 1] = daten.status + end + if type(daten.begriff) == 'string' and #daten.begriff >= 2 then + bedingungen[#bedingungen + 1] = + '(a.bezeichnung LIKE ? OR a.kennung LIKE ? OR a.lagerort LIKE ?)' + local muster = '%' .. daten.begriff .. '%' + werte[#werte + 1] = muster + werte[#werte + 1] = muster + werte[#werte + 1] = muster + end + + local asservate = MySQL.query.await(([[ + SELECT a.id, a.kennung, a.kategorie, a.bezeichnung, a.menge, a.lagerort, + a.status, a.eingelagert_am, a.akte, a.beweis, + c.firstname, c.lastname, p.dienstnummer, + k.aktenzeichen + FROM police_asservat a + LEFT JOIN aether_characters c ON c.id = a.verantwortlich + LEFT JOIN police_personal p ON p.`character` = a.verantwortlich + LEFT JOIN police_akte k ON k.id = a.akte + WHERE %s + ORDER BY + CASE a.status WHEN 'eingelagert' THEN 0 WHEN 'ausgegeben' THEN 1 ELSE 2 END, + a.id DESC + LIMIT 80 + ]]):format(table.concat(bedingungen, ' AND ')), werte) or {} + + -- Bestandszahlen für die Übersicht + local zahlen = MySQL.query.await([[ + SELECT status, COUNT(*) AS anzahl FROM police_asservat GROUP BY status + ]]) or {} + + local bestand = { eingelagert = 0, ausgegeben = 0, vernichtet = 0, zurueckgegeben = 0 } + for _, zeile in ipairs(zahlen) do + bestand[zeile.status] = zeile.anzahl + end + + return { + asservate = asservate, + kategorien = Polizei.Config.Asservatkategorien, + bestand = bestand, + rechte = { + einlagern = Rechte.Hat(beamter, 'asservat.einlagern'), + ausgeben = Rechte.Hat(beamter, 'asservat.ausgeben'), + vernichten = Rechte.Hat(beamter, 'asservat.vernichten'), + }, + } +end) + +Kern.RegistriereAktion('asservat', 'oeffnen', nil, function(quelle, beamter, daten) + local asservatId = tonumber(daten.id) + if not asservatId then return nil, 'Ungültige Kennung.' end + + local asservat = MySQL.query.await([[ + SELECT a.*, c.firstname, c.lastname, p.dienstnummer, + k.aktenzeichen, k.titel AS akte_titel, + b.kennung AS beweis_kennung, b.bezeichnung AS beweis_bezeichnung + FROM police_asservat a + LEFT JOIN aether_characters c ON c.id = a.verantwortlich + LEFT JOIN police_personal p ON p.`character` = a.verantwortlich + LEFT JOIN police_akte k ON k.id = a.akte + LEFT JOIN police_beweis b ON b.id = a.beweis + WHERE a.id = ? + ]], { asservatId }) + + if not asservat or not asservat[1] then return nil, 'Asservat nicht gefunden.' end + + -- Lückenlose Verwahrkette + local historie = MySQL.query.await([[ + SELECT h.aktion, h.am, h.notiz, c.firstname, c.lastname, p.dienstnummer + FROM police_asservat_historie h + LEFT JOIN aether_characters c ON c.id = h.von + LEFT JOIN police_personal p ON p.`character` = h.von + WHERE h.asservat = ? + ORDER BY h.id ASC + ]], { asservatId }) or {} + + return { + asservat = asservat[1], + historie = historie, + rechte = { + ausgeben = Rechte.Hat(beamter, 'asservat.ausgeben'), + vernichten = Rechte.Hat(beamter, 'asservat.vernichten'), + }, + } +end) + +-- ===================================================================== +-- EINLAGERN +-- ===================================================================== + +Kern.RegistriereAktion('asservat', 'einlagern', 'asservat.einlagern', function(quelle, beamter, daten) + if not KategorieGueltig(daten.kategorie) then return nil, 'Unbekannte Kategorie.' end + + local bezeichnung = type(daten.bezeichnung) == 'string' + and daten.bezeichnung:gsub('^%s+', ''):gsub('%s+$', '') or '' + if #bezeichnung < 3 or #bezeichnung > 160 then + return nil, 'Die Bezeichnung muss 3 bis 160 Zeichen lang sein.' + end + + local lagerort = type(daten.lagerort) == 'string' and daten.lagerort:sub(1, 64) or '' + if #lagerort < 2 then return nil, 'Bitte einen Lagerort angeben.' end + + local kennung = Kern.ErzeugeKennung('AS', 'police_asservat', 'kennung') + + local asservatId = MySQL.insert.await([[ + INSERT INTO police_asservat + (kennung, kategorie, bezeichnung, menge, lagerort, verantwortlich, akte, beweis, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'eingelagert') + ]], { + kennung, daten.kategorie, bezeichnung, + type(daten.menge) == 'string' and daten.menge:sub(1, 48) or nil, + lagerort, beamter.charakterId, + tonumber(daten.akte), tonumber(daten.beweis), + }) + + Historie(asservatId, 'Eingelagert', beamter, + type(daten.notiz) == 'string' and daten.notiz:sub(1, 255) or nil) + + Protokoll.Schreibe(beamter, 'asservat', 'Asservat eingelagert', + { typ = 'asservat', id = asservatId }, + { kennung = kennung, kategorie = daten.kategorie, lagerort = lagerort }) + + Kern.Loese('asservat.eingelagert', asservatId, beamter) + + return { id = asservatId, kennung = kennung } +end) + +-- ===================================================================== +-- AUSGEBEN +-- +-- Wer etwas herausgibt, übernimmt die Verantwortung dafür — deshalb +-- sind Empfänger und Grund Pflicht. +-- ===================================================================== + +Kern.RegistriereAktion('asservat', 'ausgeben', 'asservat.ausgeben', function(quelle, beamter, daten) + local asservatId = tonumber(daten.id) + if not asservatId then return nil, 'Ungültige Kennung.' end + + local asservat = MySQL.query.await( + 'SELECT kennung, bezeichnung, status FROM police_asservat WHERE id = ?', { asservatId }) + if not asservat or not asservat[1] then return nil, 'Asservat nicht gefunden.' end + + if asservat[1].status ~= 'eingelagert' then + return nil, 'Nur eingelagerte Asservate können ausgegeben werden.' + end + + local empfaenger = type(daten.empfaenger) == 'string' + and daten.empfaenger:gsub('^%s+', ''):gsub('%s+$', '') or '' + if #empfaenger < 2 then return nil, 'Bitte den Empfänger angeben.' end + + local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or '' + if #grund < 3 then return nil, 'Bitte einen Grund für die Ausgabe angeben.' end + + MySQL.update.await("UPDATE police_asservat SET status = 'ausgegeben' WHERE id = ?", { asservatId }) + + Historie(asservatId, 'Ausgegeben an ' .. empfaenger, beamter, grund) + + Protokoll.Schreibe(beamter, 'asservat', 'Asservat ausgegeben', + { typ = 'asservat', id = asservatId }, + { kennung = asservat[1].kennung, empfaenger = empfaenger }, grund) + + return { ok = true } +end) + +--- Rückgabe in die Kammer +Kern.RegistriereAktion('asservat', 'zurueckgeben', 'asservat.ausgeben', function(quelle, beamter, daten) + local asservatId = tonumber(daten.id) + if not asservatId then return nil, 'Ungültige Kennung.' end + + local asservat = MySQL.query.await( + 'SELECT kennung, status FROM police_asservat WHERE id = ?', { asservatId }) + if not asservat or not asservat[1] then return nil, 'Asservat nicht gefunden.' end + if asservat[1].status ~= 'ausgegeben' then + return nil, 'Nur ausgegebene Asservate können zurückgenommen werden.' + end + + local lagerort = type(daten.lagerort) == 'string' and daten.lagerort:sub(1, 64) or nil + + MySQL.update.await([[ + UPDATE police_asservat + SET status = 'eingelagert', lagerort = COALESCE(?, lagerort), verantwortlich = ? + WHERE id = ? + ]], { lagerort, beamter.charakterId, asservatId }) + + Historie(asservatId, 'Zurückgenommen', beamter, + type(daten.notiz) == 'string' and daten.notiz:sub(1, 255) or nil) + + Protokoll.Schreibe(beamter, 'asservat', 'Asservat zurückgenommen', + { typ = 'asservat', id = asservatId }, { kennung = asservat[1].kennung }) + + return { ok = true } +end) + +-- ===================================================================== +-- VERNICHTEN +-- +-- Endgültig und nur mit eigenem Recht. Der Datensatz bleibt bestehen — +-- vernichtet wird der Gegenstand, nicht seine Dokumentation. +-- ===================================================================== + +Kern.RegistriereAktion('asservat', 'vernichten', 'asservat.vernichten', function(quelle, beamter, daten) + local asservatId = tonumber(daten.id) + if not asservatId then return nil, 'Ungültige Kennung.' end + + local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or '' + if #grund < 5 then + return nil, 'Die Vernichtung erfordert eine ausführliche Begründung.' + end + + local zeuge = type(daten.zeuge) == 'string' and daten.zeuge:sub(1, 64) or '' + if #zeuge < 2 then + return nil, 'Bitte einen zweiten Beamten als Zeugen angeben.' + end + + local asservat = MySQL.query.await( + 'SELECT kennung, bezeichnung, status FROM police_asservat WHERE id = ?', { asservatId }) + if not asservat or not asservat[1] then return nil, 'Asservat nicht gefunden.' end + if asservat[1].status == 'vernichtet' then + return nil, 'Dieses Asservat wurde bereits vernichtet.' + end + + MySQL.update.await("UPDATE police_asservat SET status = 'vernichtet' WHERE id = ?", { asservatId }) + + Historie(asservatId, 'Vernichtet (Zeuge: ' .. zeuge .. ')', beamter, grund) + + Protokoll.Schreibe(beamter, 'asservat', 'Asservat vernichtet', + { typ = 'asservat', id = asservatId }, + { kennung = asservat[1].kennung, bezeichnung = asservat[1].bezeichnung, zeuge = zeuge }, + grund) + + Hinweise.Erstelle(nil, { + kategorie = 'beweis', prioritaet = 'normal', + titel = 'Asservat vernichtet', + text = ('%s (%s) durch %s, Zeuge: %s') + :format(asservat[1].kennung, asservat[1].bezeichnung, beamter.dienstnummer, zeuge), + zielTyp = 'asservat', zielId = asservatId, + }) + + return { ok = true } +end) + +-- ===================================================================== +-- LAGERORT ÄNDERN +-- ===================================================================== + +Kern.RegistriereAktion('asservat', 'umlagern', 'asservat.einlagern', function(quelle, beamter, daten) + local asservatId = tonumber(daten.id) + local lagerort = type(daten.lagerort) == 'string' and daten.lagerort:sub(1, 64) or '' + + if not asservatId then return nil, 'Ungültige Kennung.' end + if #lagerort < 2 then return nil, 'Bitte einen Lagerort angeben.' end + + local asservat = MySQL.query.await( + 'SELECT kennung, lagerort, status FROM police_asservat WHERE id = ?', { asservatId }) + if not asservat or not asservat[1] then return nil, 'Asservat nicht gefunden.' end + if asservat[1].status ~= 'eingelagert' then + return nil, 'Nur eingelagerte Asservate können umgelagert werden.' + end + + MySQL.update.await('UPDATE police_asservat SET lagerort = ? WHERE id = ?', + { lagerort, asservatId }) + + Historie(asservatId, ('Umgelagert: %s → %s'):format(asservat[1].lagerort, lagerort), beamter) + + Protokoll.Schreibe(beamter, 'asservat', 'Asservat umgelagert', + { typ = 'asservat', id = asservatId }, + { von = asservat[1].lagerort, nach = lagerort }) + + return { ok = true } +end) diff --git a/resources/[aether]/aether-police/server/module/bericht.lua b/resources/[aether]/aether-police/server/module/bericht.lua new file mode 100644 index 0000000..4eb8e93 --- /dev/null +++ b/resources/[aether]/aether-police/server/module/bericht.lua @@ -0,0 +1,582 @@ +-- ===================================================================== +-- aether-police / Modul: Berichtswesen +-- +-- Ein Bericht durchläuft drei Stufen: Entwurf → Eingereicht → Freigegeben. +-- Keine davon wird automatisch erreicht. Auch die Freigabe ist eine +-- bewusste Handlung eines berechtigten Beamten und wird mit Namen +-- festgehalten. Unterschriften sind verbindlich und nicht widerrufbar. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'bericht', name = 'Berichte', icon = '📄', + reihenfolge = 8, recht = 'bericht.erstellen', + beschreibung = 'Einsatzberichte verfassen und freigeben', +}) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Entschlüsselt ein JSON-Feld sicher zu einer Liste +--- @param roh string|nil +--- @return table +local function ZuListe(roh) + if not roh then return {} end + local erfolg, liste = pcall(json.decode, roh) + return (erfolg and type(liste) == 'table') and liste or {} +end + +--- Lädt einen Bericht samt aufbereiteter Felder +--- @param berichtId number +--- @return table|nil +local function LadeBericht(berichtId) + local zeilen = MySQL.query.await([[ + SELECT b.*, c.firstname, c.lastname, p.dienstnummer, + e.nummer AS einsatz_nummer, e.titel AS einsatz_titel, + a.aktenzeichen, a.titel AS akte_titel + FROM police_bericht b + LEFT JOIN aether_characters c ON c.id = b.erstellt_von + LEFT JOIN police_personal p ON p.`character` = b.erstellt_von + LEFT JOIN police_einsatz e ON e.id = b.einsatz + LEFT JOIN police_akte a ON a.id = b.akte + WHERE b.id = ? + ]], { berichtId }) + + if not zeilen or not zeilen[1] then return nil end + + local bericht = zeilen[1] + bericht.beteiligte = ZuListe(bericht.beteiligte) + bericht.anlagen = ZuListe(bericht.anlagen) + bericht.unterschriften = ZuListe(bericht.unterschriften) + + return bericht +end + +--- Darf der Beamte diesen Bericht inhaltlich ändern? +--- @param bericht table +--- @param beamter table +--- @return boolean +local function DarfBearbeiten(bericht, beamter) + -- Freigegebene Berichte sind gesperrt — auch für den Verfasser + if bericht.status == 'freigegeben' then return false end + if bericht.erstellt_von == beamter.charakterId then return true end + -- Fremde Berichte darf nur bearbeiten, wer auch freigeben darf + return Rechte.Hat(beamter, 'bericht.freigeben') +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('bericht', 'liste', nil, function(quelle, beamter, daten) + local bedingungen, werte = { '1=1' }, {} + + if daten.status and daten.status ~= '' then + bedingungen[#bedingungen + 1] = 'b.status = ?' + werte[#werte + 1] = daten.status + end + if daten.nurEigene then + bedingungen[#bedingungen + 1] = 'b.erstellt_von = ?' + werte[#werte + 1] = beamter.charakterId + end + if type(daten.begriff) == 'string' and #daten.begriff >= 2 then + bedingungen[#bedingungen + 1] = '(b.titel LIKE ? OR b.nummer LIKE ?)' + local muster = '%' .. daten.begriff .. '%' + werte[#werte + 1] = muster + werte[#werte + 1] = muster + end + + local berichte = MySQL.query.await(([[ + SELECT b.id, b.nummer, b.titel, b.status, b.erstellt_am, b.erstellt_von, + b.unterschriften, b.einsatz, b.akte, + c.firstname, c.lastname, p.dienstnummer, + e.nummer AS einsatz_nummer + FROM police_bericht b + LEFT JOIN aether_characters c ON c.id = b.erstellt_von + LEFT JOIN police_personal p ON p.`character` = b.erstellt_von + LEFT JOIN police_einsatz e ON e.id = b.einsatz + WHERE %s + ORDER BY + CASE b.status WHEN 'entwurf' THEN 0 WHEN 'eingereicht' THEN 1 ELSE 2 END, + b.id DESC + LIMIT 80 + ]]):format(table.concat(bedingungen, ' AND ')), werte) or {} + + for _, bericht in ipairs(berichte) do + bericht.unterschriften = ZuListe(bericht.unterschriften) + bericht.eigener = bericht.erstellt_von == beamter.charakterId + end + + -- Zahlen für die Übersicht + local offen = MySQL.scalar.await( + "SELECT COUNT(*) FROM police_bericht WHERE status = 'entwurf' AND erstellt_von = ?", + { beamter.charakterId }) or 0 + local zurPruefung = MySQL.scalar.await( + "SELECT COUNT(*) FROM police_bericht WHERE status = 'eingereicht'") or 0 + + return { + berichte = berichte, + eigeneOffen = offen, + zurPruefung = zurPruefung, + rechte = { + erstellen = Rechte.Hat(beamter, 'bericht.erstellen'), + freigeben = Rechte.Hat(beamter, 'bericht.freigeben'), + }, + } +end) + +Kern.RegistriereAktion('bericht', 'oeffnen', nil, function(quelle, beamter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local bericht = LadeBericht(berichtId) + if not bericht then return nil, 'Bericht nicht gefunden.' end + + -- Habe ich bereits unterschrieben? + local habeUnterschrieben = false + for _, unterschrift in ipairs(bericht.unterschriften) do + if unterschrift.charakterId == beamter.charakterId then + habeUnterschrieben = true + break + end + end + + return { + bericht = bericht, + habeUnterschrieben = habeUnterschrieben, + rechte = { + bearbeiten = DarfBearbeiten(bericht, beamter), + freigeben = Rechte.Hat(beamter, 'bericht.freigeben'), + }, + } +end) + +-- ===================================================================== +-- ANLEGEN +-- ===================================================================== + +Kern.RegistriereAktion('bericht', 'erstellen', 'bericht.erstellen', function(quelle, beamter, daten) + local titel = type(daten.titel) == 'string' + and daten.titel:gsub('^%s+', ''):gsub('%s+$', '') or '' + if #titel < 3 or #titel > 160 then + return nil, 'Der Titel muss 3 bis 160 Zeichen lang sein.' + end + + -- Bezüge prüfen, falls angegeben + local einsatzId = tonumber(daten.einsatz) + if einsatzId then + local vorhanden = MySQL.scalar.await('SELECT id FROM police_einsatz WHERE id = ?', { einsatzId }) + if not vorhanden then return nil, 'Der angegebene Einsatz existiert nicht.' end + end + + local akteId = tonumber(daten.akte) + if akteId then + local vorhanden = MySQL.scalar.await('SELECT id FROM police_akte WHERE id = ?', { akteId }) + if not vorhanden then return nil, 'Die angegebene Akte existiert nicht.' end + end + + local nummer = Kern.ErzeugeKennung('BER', 'police_bericht', 'nummer') + + -- Der Verfasser ist von Beginn an als beteiligter Beamter geführt + local beteiligte = { { + typ = 'beamter', charakterId = beamter.charakterId, + name = beamter.name, dienstnummer = beamter.dienstnummer, + rolle = 'Verfasser', + } } + + local berichtId = MySQL.insert.await([[ + INSERT INTO police_bericht + (nummer, einsatz, akte, titel, beschreibung, massnahmen, beteiligte, + anlagen, unterschriften, status, erstellt_von) + VALUES (?, ?, ?, ?, ?, ?, ?, '[]', '[]', 'entwurf', ?) + ]], { + nummer, einsatzId, akteId, titel, + type(daten.beschreibung) == 'string' and daten.beschreibung or nil, + type(daten.massnahmen) == 'string' and daten.massnahmen or nil, + json.encode(beteiligte), beamter.charakterId, + }) + + Protokoll.Schreibe(beamter, 'bericht', 'Bericht angelegt', + { typ = 'bericht', id = berichtId }, { nummer = nummer }) + + Kern.Loese('bericht.erstellt', berichtId, beamter) + + return { id = berichtId, nummer = nummer } +end) + +-- ===================================================================== +-- BEARBEITEN +-- +-- Freigegebene Berichte sind gesperrt — auch für den Verfasser. +-- ===================================================================== + +Kern.RegistriereAktion('bericht', 'speichern', 'bericht.erstellen', function(quelle, beamter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local bericht = LadeBericht(berichtId) + if not bericht then return nil, 'Bericht nicht gefunden.' end + + if not DarfBearbeiten(bericht, beamter) then + return nil, bericht.status == 'freigegeben' + and 'Freigegebene Berichte können nicht mehr geändert werden.' + or 'Du kannst nur eigene Berichte bearbeiten.' + end + + -- Liegen bereits Unterschriften vor, ist eine Änderung heikel + if #bericht.unterschriften > 0 then + local grund = type(daten.grund) == 'string' and daten.grund or '' + if #grund < 5 then + return nil, 'Der Bericht wurde bereits unterschrieben. Eine Änderung erfordert eine Begründung.' + end + end + + MySQL.update.await([[ + UPDATE police_bericht SET titel = ?, beschreibung = ?, massnahmen = ? WHERE id = ? + ]], { + type(daten.titel) == 'string' and daten.titel:sub(1, 160) or bericht.titel, + type(daten.beschreibung) == 'string' and daten.beschreibung or nil, + type(daten.massnahmen) == 'string' and daten.massnahmen or nil, + berichtId, + }) + + Protokoll.Schreibe(beamter, 'bericht', 'Bericht bearbeitet', + { typ = 'bericht', id = berichtId }, + { unterschriften = #bericht.unterschriften }, + type(daten.grund) == 'string' and daten.grund or nil) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Beteiligte +-- --------------------------------------------------------------------- + +Kern.RegistriereAktion('bericht', 'beteiligtHinzu', 'bericht.erstellen', function(quelle, beamter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local bericht = LadeBericht(berichtId) + if not bericht then return nil, 'Bericht nicht gefunden.' end + if not DarfBearbeiten(bericht, beamter) then return nil, 'Keine Berechtigung.' end + + local typ = (daten.typ == 'beamter') and 'beamter' or 'person' + local name = type(daten.name) == 'string' + and daten.name:gsub('^%s+', ''):gsub('%s+$', '') or '' + if #name < 2 then return nil, 'Bitte einen Namen angeben.' end + + local charakterId = tonumber(daten.charakterId) + + -- Doppelte Einträge vermeiden + for _, eintrag in ipairs(bericht.beteiligte) do + if charakterId and eintrag.charakterId == charakterId then + return nil, 'Diese Person ist bereits aufgeführt.' + end + end + + table.insert(bericht.beteiligte, { + typ = typ, charakterId = charakterId, name = name:sub(1, 64), + rolle = type(daten.rolle) == 'string' and daten.rolle:sub(1, 48) or nil, + dienstnummer = type(daten.dienstnummer) == 'string' and daten.dienstnummer:sub(1, 16) or nil, + }) + + MySQL.update.await('UPDATE police_bericht SET beteiligte = ? WHERE id = ?', + { json.encode(bericht.beteiligte), berichtId }) + + Protokoll.Schreibe(beamter, 'bericht', 'Beteiligten ergänzt', + { typ = 'bericht', id = berichtId }, { name = name, rolle = daten.rolle }) + + return { ok = true } +end) + +Kern.RegistriereAktion('bericht', 'beteiligtEntfernen', 'bericht.erstellen', function(quelle, beamter, daten) + local berichtId = tonumber(daten.id) + local index = tonumber(daten.index) + if not berichtId or not index then return nil, 'Unvollständige Angaben.' end + + local bericht = LadeBericht(berichtId) + if not bericht then return nil, 'Bericht nicht gefunden.' end + if not DarfBearbeiten(bericht, beamter) then return nil, 'Keine Berechtigung.' end + + local eintrag = bericht.beteiligte[index] + if not eintrag then return nil, 'Eintrag nicht gefunden.' end + + -- Der Verfasser bleibt immer aufgeführt + if eintrag.charakterId == bericht.erstellt_von then + return nil, 'Der Verfasser kann nicht entfernt werden.' + end + + table.remove(bericht.beteiligte, index) + + MySQL.update.await('UPDATE police_bericht SET beteiligte = ? WHERE id = ?', + { json.encode(bericht.beteiligte), berichtId }) + + Protokoll.Schreibe(beamter, 'bericht', 'Beteiligten entfernt', + { typ = 'bericht', id = berichtId }, { name = eintrag.name }) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Anlagen (Dateien und Beweisbezüge) +-- --------------------------------------------------------------------- + +Kern.RegistriereAktion('bericht', 'anlageHinzu', 'bericht.erstellen', function(quelle, beamter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local bericht = LadeBericht(berichtId) + if not bericht then return nil, 'Bericht nicht gefunden.' end + if not DarfBearbeiten(bericht, beamter) then return nil, 'Keine Berechtigung.' end + + local eintrag + + if daten.typ == 'beweis' then + local beweisId = tonumber(daten.beweisId) + if not beweisId then return nil, 'Kein Beweismittel gewählt.' end + + local beweis = MySQL.query.await( + 'SELECT kennung, bezeichnung, art FROM police_beweis WHERE id = ?', { beweisId }) + if not beweis or not beweis[1] then return nil, 'Beweismittel nicht gefunden.' end + + for _, vorhanden in ipairs(bericht.anlagen) do + if vorhanden.typ == 'beweis' and vorhanden.beweisId == beweisId then + return nil, 'Dieses Beweismittel ist bereits aufgeführt.' + end + end + + eintrag = { + typ = 'beweis', beweisId = beweisId, + kennung = beweis[1].kennung, titel = beweis[1].bezeichnung, art = beweis[1].art, + } + else + local titel = type(daten.titel) == 'string' + and daten.titel:gsub('^%s+', ''):gsub('%s+$', '') or '' + if #titel < 2 then return nil, 'Bitte eine Bezeichnung angeben.' end + + eintrag = { + typ = 'datei', titel = titel:sub(1, 120), + adresse = type(daten.adresse) == 'string' and daten.adresse:sub(1, 2000) or nil, + } + end + + table.insert(bericht.anlagen, eintrag) + + MySQL.update.await('UPDATE police_bericht SET anlagen = ? WHERE id = ?', + { json.encode(bericht.anlagen), berichtId }) + + Protokoll.Schreibe(beamter, 'bericht', 'Anlage ergänzt', + { typ = 'bericht', id = berichtId }, eintrag) + + return { ok = true } +end) + +Kern.RegistriereAktion('bericht', 'anlageEntfernen', 'bericht.erstellen', function(quelle, beamter, daten) + local berichtId = tonumber(daten.id) + local index = tonumber(daten.index) + if not berichtId or not index then return nil, 'Unvollständige Angaben.' end + + local bericht = LadeBericht(berichtId) + if not bericht then return nil, 'Bericht nicht gefunden.' end + if not DarfBearbeiten(bericht, beamter) then return nil, 'Keine Berechtigung.' end + + local eintrag = bericht.anlagen[index] + if not eintrag then return nil, 'Anlage nicht gefunden.' end + + table.remove(bericht.anlagen, index) + + MySQL.update.await('UPDATE police_bericht SET anlagen = ? WHERE id = ?', + { json.encode(bericht.anlagen), berichtId }) + + Protokoll.Schreibe(beamter, 'bericht', 'Anlage entfernt', + { typ = 'bericht', id = berichtId }, { titel = eintrag.titel }) + + return { ok = true } +end) + +-- ===================================================================== +-- UNTERSCHREIBEN +-- +-- Eine Unterschrift ist verbindlich und wird nicht zurückgenommen. +-- ===================================================================== + +Kern.RegistriereAktion('bericht', 'unterschreiben', nil, function(quelle, beamter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local bericht = LadeBericht(berichtId) + if not bericht then return nil, 'Bericht nicht gefunden.' end + + if bericht.status == 'freigegeben' then + return nil, 'Der Bericht ist bereits freigegeben.' + end + + for _, unterschrift in ipairs(bericht.unterschriften) do + if unterschrift.charakterId == beamter.charakterId then + return nil, 'Du hast bereits unterschrieben.' + end + end + + table.insert(bericht.unterschriften, { + charakterId = beamter.charakterId, + name = beamter.name, + dienstnummer = beamter.dienstnummer, + rolle = beamter.rolleName, + am = os.date('%Y-%m-%d %H:%M:%S'), + }) + + MySQL.update.await('UPDATE police_bericht SET unterschriften = ? WHERE id = ?', + { json.encode(bericht.unterschriften), berichtId }) + + Protokoll.Schreibe(beamter, 'bericht', 'Bericht unterschrieben', + { typ = 'bericht', id = berichtId }, { nummer = bericht.nummer }) + + return { ok = true } +end) + +-- ===================================================================== +-- EINREICHEN UND FREIGEBEN +-- ===================================================================== + +Kern.RegistriereAktion('bericht', 'einreichen', 'bericht.erstellen', function(quelle, beamter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local bericht = LadeBericht(berichtId) + if not bericht then return nil, 'Bericht nicht gefunden.' end + + if bericht.status ~= 'entwurf' then + return nil, 'Nur Entwürfe können eingereicht werden.' + end + if bericht.erstellt_von ~= beamter.charakterId + and not Rechte.Hat(beamter, 'bericht.freigeben') then + return nil, 'Nur der Verfasser kann seinen Bericht einreichen.' + end + if not bericht.beschreibung or #bericht.beschreibung < 20 then + return nil, 'Der Bericht enthält noch keine ausreichende Beschreibung.' + end + if #bericht.unterschriften == 0 then + return nil, 'Bitte unterschreibe den Bericht, bevor du ihn einreichst.' + end + + MySQL.update.await("UPDATE police_bericht SET status = 'eingereicht' WHERE id = ?", { berichtId }) + + Protokoll.Schreibe(beamter, 'bericht', 'Bericht eingereicht', + { typ = 'bericht', id = berichtId }, { nummer = bericht.nummer }) + + -- Die Führung darüber informieren, dass eine Prüfung ansteht + Hinweise.Erstelle(nil, { + kategorie = 'bericht', prioritaet = 'normal', + titel = 'Bericht zur Prüfung eingereicht', + text = ('%s „%s" von %s wartet auf Freigabe.') + :format(bericht.nummer, bericht.titel, beamter.dienstnummer), + zielTyp = 'bericht', zielId = berichtId, + }) + + return { ok = true } +end) + +Kern.RegistriereAktion('bericht', 'freigeben', 'bericht.freigeben', function(quelle, beamter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local bericht = LadeBericht(berichtId) + if not bericht then return nil, 'Bericht nicht gefunden.' end + + if bericht.status == 'freigegeben' then + return nil, 'Der Bericht ist bereits freigegeben.' + end + if bericht.status ~= 'eingereicht' then + return nil, 'Nur eingereichte Berichte können freigegeben werden.' + end + + -- Mit der Freigabe unterschreibt der Freigebende zugleich + local schonDrin = false + for _, unterschrift in ipairs(bericht.unterschriften) do + if unterschrift.charakterId == beamter.charakterId then schonDrin = true break end + end + if not schonDrin then + table.insert(bericht.unterschriften, { + charakterId = beamter.charakterId, name = beamter.name, + dienstnummer = beamter.dienstnummer, rolle = beamter.rolleName, + am = os.date('%Y-%m-%d %H:%M:%S'), freigabe = true, + }) + end + + MySQL.update.await( + "UPDATE police_bericht SET status = 'freigegeben', unterschriften = ? WHERE id = ?", + { json.encode(bericht.unterschriften), berichtId }) + + Protokoll.Schreibe(beamter, 'bericht', 'Bericht freigegeben', + { typ = 'bericht', id = berichtId }, { nummer = bericht.nummer }, daten.vermerk) + + Hinweise.Erstelle({ charakterId = bericht.erstellt_von }, { + kategorie = 'bericht', prioritaet = 'normal', + titel = 'Bericht freigegeben', + text = ('%s wurde von %s freigegeben.'):format(bericht.nummer, beamter.dienstnummer), + zielTyp = 'bericht', zielId = berichtId, + }) + + Kern.Loese('bericht.freigegeben', berichtId, beamter) + + return { ok = true } +end) + +--- Zurück in den Entwurf (Nachbesserung anfordern) +Kern.RegistriereAktion('bericht', 'zurueckweisen', 'bericht.freigeben', function(quelle, beamter, daten) + local berichtId = tonumber(daten.id) + if not berichtId then return nil, 'Ungültige Kennung.' end + + local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or '' + if #grund < 5 then return nil, 'Bitte gib an, was nachgebessert werden soll.' end + + local bericht = LadeBericht(berichtId) + if not bericht then return nil, 'Bericht nicht gefunden.' end + if bericht.status ~= 'eingereicht' then + return nil, 'Nur eingereichte Berichte können zurückgewiesen werden.' + end + + MySQL.update.await("UPDATE police_bericht SET status = 'entwurf' WHERE id = ?", { berichtId }) + + Protokoll.Schreibe(beamter, 'bericht', 'Bericht zurückgewiesen', + { typ = 'bericht', id = berichtId }, { nummer = bericht.nummer }, grund) + + Hinweise.Erstelle({ charakterId = bericht.erstellt_von }, { + kategorie = 'bericht', prioritaet = 'hoch', + titel = 'Bericht zur Nachbesserung', + text = ('%s wurde zurückgewiesen: %s'):format(bericht.nummer, grund), + zielTyp = 'bericht', zielId = berichtId, + }) + + return { ok = true } +end) + +-- ===================================================================== +-- HILFSABFRAGEN FÜR DIE OBERFLÄCHE +-- ===================================================================== + +--- Offene Einsätze und Akten zur Auswahl beim Anlegen +Kern.RegistriereAktion('bericht', 'bezuege', nil, function(quelle, beamter, daten) + local einsaetze = MySQL.query.await([[ + SELECT id, nummer, titel FROM police_einsatz + ORDER BY id DESC LIMIT 30 + ]]) or {} + + return { einsaetze = einsaetze } +end) + +--- Beweismittel zur Auswahl als Anlage +Kern.RegistriereAktion('bericht', 'beweisSuche', 'bericht.erstellen', function(quelle, beamter, daten) + local begriff = type(daten.begriff) == 'string' and daten.begriff or '' + if #begriff < 2 then return { beweise = {} } end + + local muster = '%' .. begriff .. '%' + local beweise = MySQL.query.await([[ + SELECT id, kennung, art, bezeichnung, fundort + FROM police_beweis + WHERE kennung LIKE ? OR bezeichnung LIKE ? + ORDER BY id DESC LIMIT 20 + ]], { muster, muster }) or {} + + return { beweise = beweise } +end) diff --git a/resources/[aether]/aether-police/server/module/beweis.lua b/resources/[aether]/aether-police/server/module/beweis.lua new file mode 100644 index 0000000..90cd52d --- /dev/null +++ b/resources/[aether]/aether-police/server/module/beweis.lua @@ -0,0 +1,305 @@ +-- ===================================================================== +-- aether-police / Modul: Beweisverwaltung +-- +-- Es gibt KEINE automatische Spurenerkennung. Jeder Beweis wird von +-- einem Beamten erfasst, beschrieben und zugeordnet. Das System +-- vergibt lediglich eine Kennung und hält die Verknüpfungen zusammen. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'beweis', name = 'Beweise', icon = '🔬', + reihenfolge = 6, recht = 'beweis.erfassen', + beschreibung = 'Beweismittel erfassen und zuordnen', +}) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Prüft, ob eine Beweisart bekannt ist +local function ArtGueltig(art) + for _, eintrag in ipairs(Polizei.Config.Beweisarten) do + if eintrag.id == art then return true end + end + return false +end + +--- Lädt die Verknüpfungen eines Beweises (welche Akten nutzen ihn?) +--- @param beweisId number +--- @return table +local function LadeVerknuepfungen(beweisId) + return MySQL.query.await([[ + SELECT v.id, a.id AS akte, a.aktenzeichen, a.titel, a.typ, a.status + FROM police_verknuepfung v + JOIN police_akte a ON a.id = v.akte + WHERE v.typ = 'beweis' AND v.ziel_id = ? + ORDER BY a.id DESC + ]], { tostring(beweisId) }) or {} +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('beweis', 'liste', nil, function(quelle, beamter, daten) + local bedingungen, werte = { '1=1' }, {} + + if daten.art and daten.art ~= '' then + bedingungen[#bedingungen + 1] = 'b.art = ?' + werte[#werte + 1] = daten.art + end + if type(daten.begriff) == 'string' and #daten.begriff >= 2 then + bedingungen[#bedingungen + 1] = + '(b.bezeichnung LIKE ? OR b.kennung LIKE ? OR b.fundort LIKE ?)' + local muster = '%' .. daten.begriff .. '%' + werte[#werte + 1] = muster + werte[#werte + 1] = muster + werte[#werte + 1] = muster + end + if daten.akte then + bedingungen[#bedingungen + 1] = 'b.akte = ?' + werte[#werte + 1] = tonumber(daten.akte) + end + + local beweise = MySQL.query.await(([[ + SELECT b.id, b.kennung, b.art, b.bezeichnung, b.beschreibung, b.fundort, + b.gefunden_am, b.datei, b.akte, b.erfasst_am, + c.firstname, c.lastname, p.dienstnummer, + a.aktenzeichen + FROM police_beweis b + LEFT JOIN aether_characters c ON c.id = b.erfasst_von + LEFT JOIN police_personal p ON p.`character` = b.erfasst_von + LEFT JOIN police_akte a ON a.id = b.akte + WHERE %s + ORDER BY b.id DESC LIMIT 80 + ]]):format(table.concat(bedingungen, ' AND ')), werte) or {} + + return { + beweise = beweise, + arten = Polizei.Config.Beweisarten, + rechte = { + erfassen = Rechte.Hat(beamter, 'beweis.erfassen'), + loeschen = Rechte.Hat(beamter, 'beweis.loeschen'), + akte = Rechte.Hat(beamter, 'akte.bearbeiten'), + }, + } +end) + +Kern.RegistriereAktion('beweis', 'oeffnen', nil, function(quelle, beamter, daten) + local beweisId = tonumber(daten.id) + if not beweisId then return nil, 'Ungültige Kennung.' end + + local beweis = MySQL.query.await([[ + SELECT b.*, c.firstname, c.lastname, p.dienstnummer, a.aktenzeichen, a.titel AS akte_titel + FROM police_beweis b + LEFT JOIN aether_characters c ON c.id = b.erfasst_von + LEFT JOIN police_personal p ON p.`character` = b.erfasst_von + LEFT JOIN police_akte a ON a.id = b.akte + WHERE b.id = ? + ]], { beweisId }) + + if not beweis or not beweis[1] then return nil, 'Beweis nicht gefunden.' end + + -- Asservate, die zu diesem Beweis gehören + local asservate = MySQL.query.await([[ + SELECT id, kennung, kategorie, bezeichnung, status, lagerort + FROM police_asservat WHERE beweis = ? + ]], { beweisId }) or {} + + return { + beweis = beweis[1], + akten = LadeVerknuepfungen(beweisId), + asservate = asservate, + historie = Protokoll.Lies({ zielTyp = 'beweis', zielId = beweisId, limit = 30 }), + rechte = { + loeschen = Rechte.Hat(beamter, 'beweis.loeschen'), + akte = Rechte.Hat(beamter, 'akte.bearbeiten'), + }, + } +end) + +-- ===================================================================== +-- ERFASSEN +-- ===================================================================== + +Kern.RegistriereAktion('beweis', 'erfassen', 'beweis.erfassen', function(quelle, beamter, daten) + if not ArtGueltig(daten.art) then return nil, 'Unbekannte Beweisart.' end + + local bezeichnung = type(daten.bezeichnung) == 'string' + and daten.bezeichnung:gsub('^%s+', ''):gsub('%s+$', '') or '' + if #bezeichnung < 3 or #bezeichnung > 160 then + return nil, 'Die Bezeichnung muss 3 bis 160 Zeichen lang sein.' + end + + local fundort = type(daten.fundort) == 'string' + and daten.fundort:gsub('^%s+', ''):gsub('%s+$', '') or '' + if #fundort < 2 then + return nil, 'Bitte einen Fundort angeben — er gehört zur Beweiskette.' + end + + -- Akte prüfen, falls angegeben + local akteId = tonumber(daten.akte) + if akteId then + local vorhanden = MySQL.scalar.await('SELECT id FROM police_akte WHERE id = ?', { akteId }) + if not vorhanden then return nil, 'Die angegebene Akte existiert nicht.' end + end + + local kennung = Kern.ErzeugeKennung('BM', 'police_beweis', 'kennung') + + local beweisId = MySQL.insert.await([[ + INSERT INTO police_beweis + (kennung, akte, art, bezeichnung, beschreibung, fundort, datei, erfasst_von) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ]], { + kennung, akteId, daten.art, bezeichnung, + type(daten.beschreibung) == 'string' and daten.beschreibung or nil, + fundort, + type(daten.datei) == 'string' and daten.datei:sub(1, 2000) or nil, + beamter.charakterId, + }) + + -- Ist eine Akte angegeben, gleich verknüpfen + if akteId then + MySQL.insert('INSERT INTO police_verknuepfung (akte, typ, ziel_id) VALUES (?, ?, ?)', + { akteId, 'beweis', tostring(beweisId) }) + end + + Protokoll.Schreibe(beamter, 'beweis', 'Beweis erfasst', + { typ = 'beweis', id = beweisId }, + { kennung = kennung, art = daten.art, fundort = fundort }) + + Kern.Loese('beweis.erfasst', beweisId, beamter) + + return { id = beweisId, kennung = kennung } +end) + +-- ===================================================================== +-- BEARBEITEN +-- ===================================================================== + +Kern.RegistriereAktion('beweis', 'bearbeiten', 'beweis.erfassen', function(quelle, beamter, daten) + local beweisId = tonumber(daten.id) + if not beweisId then return nil, 'Ungültige Kennung.' end + + local beweis = MySQL.query.await( + 'SELECT erfasst_von, kennung FROM police_beweis WHERE id = ?', { beweisId }) + if not beweis or not beweis[1] then return nil, 'Beweis nicht gefunden.' end + + -- Fremde Beweise darf nur ändern, wer auch löschen darf + if beweis[1].erfasst_von ~= beamter.charakterId + and not Rechte.Hat(beamter, 'beweis.loeschen') then + return nil, 'Du kannst nur eigene Beweise bearbeiten.' + end + + local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or '' + if #grund < 3 then + return nil, 'Änderungen an Beweismitteln erfordern einen Grund.' + end + + MySQL.update.await([[ + UPDATE police_beweis + SET bezeichnung = ?, beschreibung = ?, fundort = ?, datei = ? + WHERE id = ? + ]], { + type(daten.bezeichnung) == 'string' and daten.bezeichnung:sub(1, 160) or nil, + type(daten.beschreibung) == 'string' and daten.beschreibung or nil, + type(daten.fundort) == 'string' and daten.fundort:sub(1, 160) or nil, + type(daten.datei) == 'string' and daten.datei:sub(1, 2000) or nil, + beweisId, + }) + + Protokoll.Schreibe(beamter, 'beweis', 'Beweis geändert', + { typ = 'beweis', id = beweisId }, { kennung = beweis[1].kennung }, grund) + + return { ok = true } +end) + +-- ===================================================================== +-- ENTFERNEN +-- +-- Beweise verschwinden nicht still — das Entfernen verlangt ein +-- eigenes Recht und eine Begründung und bleibt im Protokoll sichtbar. +-- ===================================================================== + +Kern.RegistriereAktion('beweis', 'entfernen', 'beweis.loeschen', function(quelle, beamter, daten) + local beweisId = tonumber(daten.id) + if not beweisId then return nil, 'Ungültige Kennung.' end + + local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or '' + if #grund < 5 then + return nil, 'Das Entfernen eines Beweismittels erfordert eine ausführliche Begründung.' + end + + local beweis = MySQL.query.await( + 'SELECT kennung, bezeichnung, art FROM police_beweis WHERE id = ?', { beweisId }) + if not beweis or not beweis[1] then return nil, 'Beweis nicht gefunden.' end + + -- Verknüpfte Asservate müssen zuerst geklärt werden + local asservate = MySQL.scalar.await( + "SELECT COUNT(*) FROM police_asservat WHERE beweis = ? AND status = 'eingelagert'", + { beweisId }) or 0 + if asservate > 0 then + return nil, ('Zu diesem Beweis sind noch %d Asservate eingelagert. Bitte zuerst klären.') + :format(asservate) + end + + MySQL.update.await('DELETE FROM police_verknuepfung WHERE typ = ? AND ziel_id = ?', + { 'beweis', tostring(beweisId) }) + MySQL.update.await('DELETE FROM police_beweis WHERE id = ?', { beweisId }) + + Protokoll.Schreibe(beamter, 'beweis', 'Beweis entfernt', + { typ = 'beweis', id = beweisId }, + { kennung = beweis[1].kennung, bezeichnung = beweis[1].bezeichnung }, grund) + + Hinweise.Erstelle(nil, { + kategorie = 'beweis', prioritaet = 'hoch', + titel = 'Beweismittel entfernt', + text = ('%s (%s) wurde von %s entfernt: %s') + :format(beweis[1].kennung, beweis[1].bezeichnung, beamter.dienstnummer, grund), + }) + + return { ok = true } +end) + +-- ===================================================================== +-- MIT AKTE VERKNÜPFEN +-- ===================================================================== + +Kern.RegistriereAktion('beweis', 'akteVerknuepfen', 'akte.bearbeiten', function(quelle, beamter, daten) + local beweisId = tonumber(daten.beweisId) + local akteId = tonumber(daten.akteId) + if not beweisId or not akteId then return nil, 'Unvollständige Angaben.' end + + local akte = MySQL.query.await( + 'SELECT aktenzeichen FROM police_akte WHERE id = ?', { akteId }) + if not akte or not akte[1] then return nil, 'Akte nicht gefunden.' end + + local vorhanden = MySQL.scalar.await( + 'SELECT id FROM police_verknuepfung WHERE akte = ? AND typ = ? AND ziel_id = ?', + { akteId, 'beweis', tostring(beweisId) }) + if vorhanden then return nil, 'Diese Verknüpfung besteht bereits.' end + + MySQL.insert.await('INSERT INTO police_verknuepfung (akte, typ, ziel_id) VALUES (?, ?, ?)', + { akteId, 'beweis', tostring(beweisId) }) + + Protokoll.Schreibe(beamter, 'beweis', 'Mit Akte verknüpft', + { typ = 'beweis', id = beweisId }, { akte = akte[1].aktenzeichen }) + + return { ok = true } +end) + +--- Aktensuche für die Verknüpfungsauswahl +Kern.RegistriereAktion('beweis', 'aktenSuche', 'akte.lesen', function(quelle, beamter, daten) + local begriff = type(daten.begriff) == 'string' and daten.begriff or '' + if #begriff < 2 then return { akten = {} } end + + local muster = '%' .. begriff .. '%' + local akten = MySQL.query.await([[ + SELECT id, aktenzeichen, typ, titel, status + FROM police_akte + WHERE aktenzeichen LIKE ? OR titel LIKE ? + ORDER BY id DESC LIMIT 20 + ]], { muster, muster }) or {} + + return { akten = akten } +end) diff --git a/resources/[aether]/aether-police/server/module/dashboard.lua b/resources/[aether]/aether-police/server/module/dashboard.lua new file mode 100644 index 0000000..520ca68 --- /dev/null +++ b/resources/[aether]/aether-police/server/module/dashboard.lua @@ -0,0 +1,170 @@ +-- ===================================================================== +-- aether-police / Modul: Dashboard +-- +-- Zeigt eine Lageübersicht. Alle Zahlen werden bei jedem Aufruf frisch +-- aus der Datenbank bzw. dem Dienstsystem geholt — nichts ist fest +-- programmiert. Das Dashboard bewertet nichts, es zeigt nur an. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'dashboard', name = 'Übersicht', icon = '▤', + reihenfolge = 1, + beschreibung = 'Lageübersicht der Dienststelle', +}) + +--- Sammelt alle Kennzahlen für die Übersicht +Kern.RegistriereAktion('dashboard', 'laden', nil, function(quelle, beamter) + local charakterId = beamter.charakterId + + -- --- Beamte im Dienst --- + local dienstliste = Dienst.BaueListe() + local nachStatus = {} + for _, eintrag in ipairs(dienstliste) do + nachStatus[eintrag.status] = (nachStatus[eintrag.status] or 0) + 1 + end + + -- --- Offene Einsätze --- + local einsaetze = MySQL.query.await([[ + SELECT id, nummer, titel, kategorie, prioritaet, ort, status, erstellt_am, + pos_x, pos_y, pos_z + FROM police_einsatz + WHERE status <> 'abgeschlossen' + ORDER BY prioritaet ASC, id DESC + LIMIT 20 + ]]) or {} + + -- --- Offene Anzeigen (Entwürfe warten auf Bestätigung) --- + local anzeigenOffen = MySQL.scalar.await( + "SELECT COUNT(*) FROM police_anzeige WHERE status = 'entwurf'") or 0 + + -- --- Aktive Fahndungen --- + local fahndungen = MySQL.query.await([[ + SELECT id, typ, bezeichnung, grund, gefahr, prioritaet + FROM police_fahndung + WHERE aktiv = 1 + ORDER BY prioritaet ASC, id DESC + LIMIT 10 + ]]) or {} + + -- --- Gefangene und auslaufende Haftzeiten --- + local gewahrsam = MySQL.query.await([[ + SELECT g.id, g.character, g.grund, g.faellig_am, g.zelle, + TIMESTAMPDIFF(MINUTE, NOW(), g.faellig_am) AS rest, + c.firstname, c.lastname + FROM police_gewahrsam g + JOIN aether_characters c ON c.id = g.character + WHERE g.entlassen_am IS NULL + ORDER BY g.faellig_am ASC + ]]) or {} + + local abgelaufen = 0 + for _, eintrag in ipairs(gewahrsam) do + if (tonumber(eintrag.rest) or 0) <= 0 then abgelaufen = abgelaufen + 1 end + end + + -- --- Offene Berichte des eigenen Beamten --- + local eigeneBerichte = MySQL.query.await([[ + SELECT id, nummer, titel, status, erstellt_am + FROM police_bericht + WHERE erstellt_von = ? AND status = 'entwurf' + ORDER BY id DESC LIMIT 10 + ]], { charakterId }) or {} + + -- --- Akten, die auf Freigabe warten --- + local akteFreigabe = MySQL.scalar.await( + "SELECT COUNT(*) FROM police_akte WHERE status = 'pruefung'") or 0 + + -- --- Dienstfahrzeuge im Einsatz --- + local fahrzeuge = MySQL.query.await([[ + SELECT id, kennzeichen, bezeichnung, status, wartung, tank, zugewiesen_an + FROM police_fahrzeug + ORDER BY kennzeichen ASC + ]]) or {} + + local fahrzeugeImEinsatz = 0 + local wartungFaellig = 0 + for _, fahrzeug in ipairs(fahrzeuge) do + if fahrzeug.status ~= 'verfuegbar' then fahrzeugeImEinsatz = fahrzeugeImEinsatz + 1 end + if fahrzeug.wartung ~= 'ok' then wartungFaellig = wartungFaellig + 1 end + end + + -- --- Neueste Mitteilungen --- + local mitteilungen = MySQL.query.await([[ + SELECT n.id, n.betreff, n.text, n.gesendet, c.firstname, c.lastname + FROM police_nachricht n + LEFT JOIN aether_characters c ON c.id = n.absender + WHERE n.empf_typ = 'rundmeldung' + OR (n.empf_typ = 'person' AND n.empfaenger = ?) + OR (n.empf_typ = 'abteilung' AND n.empfaenger = ?) + ORDER BY n.id DESC LIMIT 5 + ]], { tostring(charakterId), beamter.abteilung }) or {} + + -- --- Warnungen zusammenstellen (reine Hinweise) --- + local warnungen = {} + if abgelaufen > 0 then + warnungen[#warnungen + 1] = { + prioritaet = 'hoch', + text = ('%d Haftzeit%s abgelaufen — Entlassung muss manuell entschieden werden.') + :format(abgelaufen, abgelaufen == 1 and '' or 'en'), + zielTyp = 'gewahrsam', + } + end + if anzeigenOffen > 0 then + warnungen[#warnungen + 1] = { + prioritaet = 'normal', + text = ('%d Anzeige%s im Entwurf — wartet auf Bestätigung.') + :format(anzeigenOffen, anzeigenOffen == 1 and '' or 'n'), + zielTyp = 'anzeigen', + } + end + if akteFreigabe > 0 then + warnungen[#warnungen + 1] = { + prioritaet = 'normal', + text = ('%d Akte%s wartet auf Freigabe.') + :format(akteFreigabe, akteFreigabe == 1 and '' or 'n'), + zielTyp = 'akten', + } + end + if wartungFaellig > 0 then + warnungen[#warnungen + 1] = { + prioritaet = 'niedrig', + text = ('%d Dienstfahrzeug%s benötigt Wartung.') + :format(wartungFaellig, wartungFaellig == 1 and '' or 'e'), + zielTyp = 'fahrzeuge', + } + end + + return { + kennzahlen = { + imDienst = #dienstliste, + verfuegbar = nachStatus.verfuegbar or 0, + imEinsatz = nachStatus.einsatz or 0, + pause = nachStatus.pause or 0, + einsaetzeOffen = #einsaetze, + anzeigenOffen = anzeigenOffen, + fahndungen = #fahndungen, + gefangene = #gewahrsam, + haftAbgelaufen = abgelaufen, + berichteOffen = #eigeneBerichte, + akteFreigabe = akteFreigabe, + fahrzeugeGesamt = #fahrzeuge, + fahrzeugeEinsatz= fahrzeugeImEinsatz, + }, + beamte = dienstliste, + einsaetze = einsaetze, + fahndungen = fahndungen, + gewahrsam = gewahrsam, + berichte = eigeneBerichte, + fahrzeuge = fahrzeuge, + mitteilungen = mitteilungen, + warnungen = warnungen, + hinweise = Hinweise.Lade(charakterId, 15), + } +end) + +--- Markiert einen Hinweis als gelesen +Kern.RegistriereAktion('dashboard', 'hinweisGelesen', nil, function(quelle, beamter, daten) + if type(daten.id) ~= 'number' then return nil, 'Ungültige Kennung.' end + Hinweise.MarkiereGelesen(beamter.charakterId, daten.id) + return { ok = true } +end) diff --git a/resources/[aether]/aether-police/server/module/einsatz.lua b/resources/[aether]/aether-police/server/module/einsatz.lua new file mode 100644 index 0000000..6d369ca --- /dev/null +++ b/resources/[aether]/aether-police/server/module/einsatz.lua @@ -0,0 +1,495 @@ +-- ===================================================================== +-- aether-police / Modul: Einsatzverwaltung & Dispatch +-- +-- Einsätze entstehen entweder durch einen Beamten oder durch einen +-- eingehenden Notruf. Sie werden NIE automatisch zugewiesen, +-- bearbeitet oder geschlossen — das entscheidet die Leitstelle +-- beziehungsweise der jeweilige Beamte. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'einsatz', name = 'Einsätze', icon = '🚨', + reihenfolge = 3, recht = 'einsatz.erstellen', + beschreibung = 'Dispatch und Einsatzverwaltung', +}) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Prüft, ob eine Einsatzkategorie bekannt ist +local function KategorieGueltig(kategorie) + for _, eintrag in ipairs(Polizei.Config.Einsatzkategorien) do + if eintrag.id == kategorie then return true end + end + return false +end + +--- Lädt einen Einsatz samt beteiligten Beamten und Notizen +--- @param einsatzId number +--- @return table|nil +local function LadeEinsatz(einsatzId) + local einsatz = MySQL.query.await([[ + SELECT e.*, c.firstname AS ersteller_vorname, c.lastname AS ersteller_nachname + FROM police_einsatz e + LEFT JOIN aether_characters c ON c.id = e.erstellt_von + WHERE e.id = ? + ]], { einsatzId }) + + if not einsatz or not einsatz[1] then return nil end + local eintrag = einsatz[1] + + eintrag.beamte = MySQL.query.await([[ + SELECT b.character, b.beigetreten, b.verlassen, + c.firstname, c.lastname, p.dienstnummer + FROM police_einsatz_beamter b + LEFT JOIN aether_characters c ON c.id = b.character + LEFT JOIN police_personal p ON p.`character` = b.character + WHERE b.einsatz = ? + ORDER BY b.id ASC + ]], { einsatzId }) or {} + + eintrag.notizen = MySQL.query.await([[ + SELECT n.id, n.text, n.am, c.firstname, c.lastname, p.dienstnummer + FROM police_einsatz_notiz n + LEFT JOIN aether_characters c ON c.id = n.von + LEFT JOIN police_personal p ON p.`character` = n.von + WHERE n.einsatz = ? + ORDER BY n.id ASC + ]], { einsatzId }) or {} + + return eintrag +end + +--- Informiert alle Beamten im Dienst über eine Änderung an den Einsätzen +local function VerteileEinsaetze() + for quelle in pairs(Dienst.AlleImDienst()) do + TriggerClientEvent('aether:police:einsatzAenderung', quelle) + end +end + +--- Zählt, wie viele Beamte einem Einsatz aktuell zugeordnet sind +local function AnzahlBeamte(einsatzId) + return MySQL.scalar.await( + 'SELECT COUNT(*) FROM police_einsatz_beamter WHERE einsatz = ? AND verlassen IS NULL', + { einsatzId }) or 0 +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('einsatz', 'liste', nil, function(quelle, beamter, daten) + local nurOffene = daten.nurOffene ~= false + + local einsaetze = MySQL.query.await(([[ + SELECT e.id, e.nummer, e.titel, e.kategorie, e.prioritaet, e.ort, + e.pos_x, e.pos_y, e.pos_z, e.status, e.quelle, e.erstellt_am, + e.beschreibung, + (SELECT COUNT(*) FROM police_einsatz_beamter b + WHERE b.einsatz = e.id AND b.verlassen IS NULL) AS einheiten, + (SELECT COUNT(*) FROM police_einsatz_beamter b2 + WHERE b2.einsatz = e.id AND b2.character = ? AND b2.verlassen IS NULL) AS ichDabei + FROM police_einsatz e + %s + ORDER BY + CASE WHEN e.status = 'offen' THEN 0 ELSE 1 END, + e.prioritaet ASC, e.id DESC + LIMIT 60 + ]]):format(nurOffene and "WHERE e.status <> 'abgeschlossen'" or ''), + { beamter.charakterId }) or {} + + return { + einsaetze = einsaetze, + kategorien = Polizei.Config.Einsatzkategorien, + istLeitstelle = Dienst.Leitstelle() == quelle, + beamteImDienst = Dienst.BaueListe(), + rechte = { + zuweisen = Rechte.Hat(beamter, 'einsatz.zuweisen'), + schliessen = Rechte.Hat(beamter, 'einsatz.schliessen'), + bericht = Rechte.Hat(beamter, 'bericht.erstellen'), + }, + } +end) + +Kern.RegistriereAktion('einsatz', 'oeffnen', nil, function(quelle, beamter, daten) + local einsatzId = tonumber(daten.id) + if not einsatzId then return nil, 'Ungültige Kennung.' end + + local einsatz = LadeEinsatz(einsatzId) + if not einsatz then return nil, 'Einsatz nicht gefunden.' end + + -- Bin ich selbst beteiligt? + einsatz.ichDabei = false + for _, eintrag in ipairs(einsatz.beamte) do + if eintrag.character == beamter.charakterId and not eintrag.verlassen then + einsatz.ichDabei = true + break + end + end + + return { + einsatz = einsatz, + istLeitstelle = Dienst.Leitstelle() == quelle, + beamteImDienst = Dienst.BaueListe(), + rechte = { + zuweisen = Rechte.Hat(beamter, 'einsatz.zuweisen'), + schliessen = Rechte.Hat(beamter, 'einsatz.schliessen'), + bericht = Rechte.Hat(beamter, 'bericht.erstellen'), + }, + } +end) + +-- ===================================================================== +-- ANLEGEN +-- ===================================================================== + +Kern.RegistriereAktion('einsatz', 'erstellen', 'einsatz.erstellen', function(quelle, beamter, daten) + local titel = type(daten.titel) == 'string' and daten.titel:gsub('^%s+', ''):gsub('%s+$', '') or '' + if #titel < 3 or #titel > 160 then + return nil, 'Der Titel muss 3 bis 160 Zeichen lang sein.' + end + + if not KategorieGueltig(daten.kategorie) then + return nil, 'Unbekannte Kategorie.' + end + + local prioritaet = math.max(1, math.min(4, tonumber(daten.prioritaet) or 3)) + local nummer = Kern.ErzeugeKennung('EIN', 'police_einsatz', 'nummer') + + -- Position: entweder mitgeschickt oder die des erstellenden Beamten + local position = daten.position + if daten.eigenePosition then + local koordinaten = GetEntityCoords(GetPlayerPed(quelle)) + position = { x = koordinaten.x, y = koordinaten.y, z = koordinaten.z } + end + + local einsatzId = MySQL.insert.await([[ + INSERT INTO police_einsatz + (nummer, titel, kategorie, prioritaet, ort, pos_x, pos_y, pos_z, + beschreibung, status, quelle, erstellt_von) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'offen', 'manuell', ?) + ]], { + nummer, titel, daten.kategorie, prioritaet, + type(daten.ort) == 'string' and daten.ort:sub(1, 160) or nil, + position and position.x, position and position.y, position and position.z, + type(daten.beschreibung) == 'string' and daten.beschreibung or nil, + beamter.charakterId, + }) + + Protokoll.Schreibe(beamter, 'einsatz', 'Einsatz angelegt', + { typ = 'einsatz', id = einsatzId }, { nummer = nummer, kategorie = daten.kategorie }) + + -- Alle Beamten im Dienst informieren — zugewiesen wird nichts + Hinweise.Erstelle(nil, { + kategorie = 'einsatz', + prioritaet = prioritaet == 1 and 'kritisch' or prioritaet == 2 and 'hoch' or 'normal', + titel = 'Neuer Einsatz: ' .. titel, + text = (daten.ort and ('Ort: ' .. daten.ort .. ' · ') or '') .. 'Noch keine Einheit zugeordnet.', + zielTyp = 'einsatz', zielId = einsatzId, + }) + + Kern.Loese('einsatz.erstellt', einsatzId, beamter) + VerteileEinsaetze() + + return { id = einsatzId, nummer = nummer } +end) + +-- ===================================================================== +-- BEITRETEN / VERLASSEN — immer freiwillig +-- ===================================================================== + +Kern.RegistriereAktion('einsatz', 'beitreten', nil, function(quelle, beamter, daten) + local einsatzId = tonumber(daten.id) + if not einsatzId then return nil, 'Ungültige Kennung.' end + + local einsatz = MySQL.query.await('SELECT status FROM police_einsatz WHERE id = ?', { einsatzId }) + if not einsatz or not einsatz[1] then return nil, 'Einsatz nicht gefunden.' end + if einsatz[1].status == 'abgeschlossen' then + return nil, 'Dieser Einsatz ist bereits abgeschlossen.' + end + + -- Bereits dabei? Dann nichts tun + local vorhanden = MySQL.scalar.await([[ + SELECT id FROM police_einsatz_beamter + WHERE einsatz = ? AND `character` = ? AND verlassen IS NULL + ]], { einsatzId, beamter.charakterId }) + if vorhanden then return { ok = true } end + + MySQL.insert.await('INSERT INTO police_einsatz_beamter (einsatz, `character`) VALUES (?, ?)', + { einsatzId, beamter.charakterId }) + + -- Sobald jemand dabei ist, gilt der Einsatz als zugewiesen + MySQL.update("UPDATE police_einsatz SET status = 'zugewiesen' WHERE id = ? AND status = 'offen'", + { einsatzId }) + + Protokoll.Schreibe(beamter, 'einsatz', 'Einsatz beigetreten', { typ = 'einsatz', id = einsatzId }) + Kern.Loese('einsatz.beigetreten', einsatzId, beamter) + VerteileEinsaetze() + + return { ok = true } +end) + +Kern.RegistriereAktion('einsatz', 'verlassen', nil, function(quelle, beamter, daten) + local einsatzId = tonumber(daten.id) + if not einsatzId then return nil, 'Ungültige Kennung.' end + + MySQL.update.await([[ + UPDATE police_einsatz_beamter SET verlassen = NOW() + WHERE einsatz = ? AND `character` = ? AND verlassen IS NULL + ]], { einsatzId, beamter.charakterId }) + + -- Verlässt der letzte Beamte den Einsatz, geht er zurück auf „offen" + if AnzahlBeamte(einsatzId) == 0 then + MySQL.update("UPDATE police_einsatz SET status = 'offen' WHERE id = ? AND status <> 'abgeschlossen'", + { einsatzId }) + + Hinweise.Erstelle(nil, { + kategorie = 'einsatz', prioritaet = 'normal', + titel = 'Einsatz ohne Einheit', + text = 'Der Einsatz hat keine zugeordnete Einheit mehr.', + zielTyp = 'einsatz', zielId = einsatzId, + }) + end + + Protokoll.Schreibe(beamter, 'einsatz', 'Einsatz verlassen', { typ = 'einsatz', id = einsatzId }) + VerteileEinsaetze() + + return { ok = true } +end) + +-- ===================================================================== +-- ZUWEISUNG DURCH DIE LEITSTELLE +-- +-- Die Leitstelle spricht eine Zuweisung AUS — sie zwingt niemanden. +-- Der angesprochene Beamte entscheidet selbst, ob er beitritt. +-- ===================================================================== + +Kern.RegistriereAktion('einsatz', 'zuweisen', 'einsatz.zuweisen', function(quelle, beamter, daten) + local einsatzId = tonumber(daten.id) + local zielQuelle = tonumber(daten.quelle) + if not einsatzId or not zielQuelle then return nil, 'Unvollständige Angaben.' end + + local ziel = Dienst.Hole(zielQuelle) + if not ziel then return nil, 'Diese Einheit ist nicht im Dienst.' end + + local einsatz = MySQL.query.await( + 'SELECT nummer, titel, ort, pos_x, pos_y, pos_z FROM police_einsatz WHERE id = ?', + { einsatzId }) + if not einsatz or not einsatz[1] then return nil, 'Einsatz nicht gefunden.' end + + -- Aufforderung an die Einheit — kein automatischer Beitritt + TriggerClientEvent('aether:police:einsatzZuweisung', zielQuelle, { + id = einsatzId, + nummer = einsatz[1].nummer, + titel = einsatz[1].titel, + ort = einsatz[1].ort, + von = beamter.name .. ' (' .. beamter.dienstnummer .. ')', + position = einsatz[1].pos_x and { + x = einsatz[1].pos_x, y = einsatz[1].pos_y, z = einsatz[1].pos_z, + } or nil, + }) + + Hinweise.Erstelle({ charakterId = ziel.charakterId }, { + kategorie = 'einsatz', prioritaet = 'hoch', + titel = 'Zuweisung: ' .. einsatz[1].titel, + text = ('%s bittet dich, den Einsatz zu übernehmen.'):format(beamter.dienstnummer), + zielTyp = 'einsatz', zielId = einsatzId, + }) + + Protokoll.Schreibe(beamter, 'einsatz', 'Einheit zugewiesen', + { typ = 'einsatz', id = einsatzId }, { einheit = ziel.dienstnummer }) + + return { ok = true } +end) + +-- ===================================================================== +-- STATUS UND NOTIZEN +-- ===================================================================== + +Kern.RegistriereAktion('einsatz', 'status', nil, function(quelle, beamter, daten) + local einsatzId = tonumber(daten.id) + local status = tostring(daten.status or '') + if not einsatzId then return nil, 'Ungültige Kennung.' end + + local erlaubt = { offen = true, zugewiesen = true, vorort = true } + if not erlaubt[status] then return nil, 'Unbekannter Status.' end + + MySQL.update.await("UPDATE police_einsatz SET status = ? WHERE id = ? AND status <> 'abgeschlossen'", + { status, einsatzId }) + + Protokoll.Schreibe(beamter, 'einsatz', 'Status geändert', + { typ = 'einsatz', id = einsatzId }, { status = status }) + VerteileEinsaetze() + + return { ok = true } +end) + +Kern.RegistriereAktion('einsatz', 'notiz', nil, function(quelle, beamter, daten) + local einsatzId = tonumber(daten.id) + local text = type(daten.text) == 'string' and daten.text:gsub('^%s+', ''):gsub('%s+$', '') or '' + + if not einsatzId then return nil, 'Ungültige Kennung.' end + if #text < 2 or #text > 1000 then return nil, 'Die Notiz muss 2 bis 1000 Zeichen lang sein.' end + + MySQL.insert.await('INSERT INTO police_einsatz_notiz (einsatz, text, von) VALUES (?, ?, ?)', + { einsatzId, text, beamter.charakterId }) + + Protokoll.Schreibe(beamter, 'einsatz', 'Notiz ergänzt', { typ = 'einsatz', id = einsatzId }) + + return { ok = true } +end) + +-- ===================================================================== +-- ABSCHLIESSEN +-- +-- Beim Abschluss wird KEIN Bericht automatisch erzeugt. Das System +-- bietet lediglich an, einen vorbereiteten Entwurf anzulegen. +-- ===================================================================== + +Kern.RegistriereAktion('einsatz', 'abschliessen', 'einsatz.schliessen', function(quelle, beamter, daten) + local einsatzId = tonumber(daten.id) + if not einsatzId then return nil, 'Ungültige Kennung.' end + + local einsatz = LadeEinsatz(einsatzId) + if not einsatz then return nil, 'Einsatz nicht gefunden.' end + if einsatz.status == 'abgeschlossen' then return nil, 'Der Einsatz ist bereits abgeschlossen.' end + + MySQL.update.await([[ + UPDATE police_einsatz + SET status = 'abgeschlossen', geschlossen_von = ?, geschlossen_am = NOW() + WHERE id = ? + ]], { beamter.charakterId, einsatzId }) + + -- Alle noch offenen Beteiligungen beenden + MySQL.update('UPDATE police_einsatz_beamter SET verlassen = NOW() WHERE einsatz = ? AND verlassen IS NULL', + { einsatzId }) + + Protokoll.Schreibe(beamter, 'einsatz', 'Einsatz abgeschlossen', + { typ = 'einsatz', id = einsatzId }, nil, daten.grund) + + local ergebnis = { ok = true } + + -- Nur auf ausdrücklichen Wunsch einen Berichtsentwurf vorbereiten + if daten.berichtAnlegen and Rechte.Hat(beamter, 'bericht.erstellen') then + local nummer = Kern.ErzeugeKennung('BER', 'police_bericht', 'nummer') + + -- Beteiligte aus dem Einsatz übernehmen + local beteiligte = {} + for _, eintrag in ipairs(einsatz.beamte) do + beteiligte[#beteiligte + 1] = { + dienstnummer = eintrag.dienstnummer, + name = (eintrag.firstname or '') .. ' ' .. (eintrag.lastname or ''), + } + end + + -- Notizen als Ausgangstext, damit nichts abgetippt werden muss + local zeilen = {} + for _, notiz in ipairs(einsatz.notizen) do + zeilen[#zeilen + 1] = ('[%s] %s'):format( + tostring(notiz.am):sub(12, 16), notiz.text) + end + + local berichtId = MySQL.insert.await([[ + INSERT INTO police_bericht + (nummer, einsatz, titel, beschreibung, beteiligte, status, erstellt_von) + VALUES (?, ?, ?, ?, ?, 'entwurf', ?) + ]], { + nummer, einsatzId, einsatz.titel, + (einsatz.beschreibung and (einsatz.beschreibung .. '\n\n') or '') .. + (#zeilen > 0 and ('Einsatzverlauf:\n' .. table.concat(zeilen, '\n')) or ''), + json.encode(beteiligte), beamter.charakterId, + }) + + Hinweise.Erstelle({ charakterId = beamter.charakterId }, { + kategorie = 'bericht', prioritaet = 'normal', + titel = 'Berichtsentwurf angelegt', + text = ('%s wurde als Entwurf vorbereitet und wartet auf deine Ausarbeitung.'):format(nummer), + zielTyp = 'bericht', zielId = berichtId, + }) + + Protokoll.Schreibe(beamter, 'einsatz', 'Berichtsentwurf angelegt', + { typ = 'bericht', id = berichtId }, { einsatz = einsatzId }) + + ergebnis.berichtId = berichtId + ergebnis.berichtNummer = nummer + end + + Kern.Loese('einsatz.abgeschlossen', einsatzId, beamter) + VerteileEinsaetze() + + return ergebnis +end) + +-- ===================================================================== +-- EINGEHENDE MELDUNGEN (Dispatch) +-- ===================================================================== + +--- Legt einen eingehenden Einsatz an, ohne ihn zuzuweisen +--- @param daten table { titel, kategorie, prioritaet, ort, beschreibung, position, quelle } +--- @return number Einsatz-ID +function EinsatzEingang(daten) + local nummer = Kern.ErzeugeKennung('EIN', 'police_einsatz', 'nummer') + local prioritaet = math.max(1, math.min(4, tonumber(daten.prioritaet) or 2)) + + local einsatzId = MySQL.insert.await([[ + INSERT INTO police_einsatz + (nummer, titel, kategorie, prioritaet, ort, pos_x, pos_y, pos_z, + beschreibung, status, quelle) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'offen', ?) + ]], { + nummer, daten.titel, KategorieGueltig(daten.kategorie) and daten.kategorie or 'notruf', + prioritaet, daten.ort, + daten.position and daten.position.x, daten.position and daten.position.y, + daten.position and daten.position.z, + daten.beschreibung, daten.quelle or 'notruf', + }) + + Hinweise.Erstelle(nil, { + kategorie = 'einsatz', + prioritaet = prioritaet == 1 and 'kritisch' or 'hoch', + titel = daten.titel, + text = (daten.ort and ('Ort: ' .. daten.ort .. ' · ') or '') .. + 'Eingegangen — Zuweisung durch die Leitstelle.', + zielTyp = 'einsatz', zielId = einsatzId, + }) + + Kern.Loese('einsatz.eingegangen', einsatzId, daten) + VerteileEinsaetze() + + return einsatzId +end + +--- Panikknopf eines Beamten (10-13) +RegisterNetEvent('aether:police:panik', function() + local quelle = source + local beamter = Dienst.Hole(quelle) + if not beamter then return end + + local koordinaten = GetEntityCoords(GetPlayerPed(quelle)) + + local einsatzId = EinsatzEingang({ + titel = '10-13 — Beamter in Not', + kategorie = 'panik', + prioritaet = 1, + ort = 'Position der Einheit', + beschreibung = ('Panikknopf ausgelöst von %s (%s).') + :format(beamter.name, beamter.dienstnummer), + position = { x = koordinaten.x, y = koordinaten.y, z = koordinaten.z }, + quelle = 'panik', + }) + + -- Alle Beamten im Dienst mit Ton und Wegpunkt alarmieren + for andereQuelle in pairs(Dienst.AlleImDienst()) do + if andereQuelle ~= quelle then + TriggerClientEvent('aether:police:panikAlarm', andereQuelle, { + name = beamter.name, dienstnummer = beamter.dienstnummer, + position = { x = koordinaten.x, y = koordinaten.y, z = koordinaten.z }, + einsatzId = einsatzId, + }) + end + end + + Protokoll.Schreibe(beamter, 'einsatz', 'Panikknopf ausgelöst', + { typ = 'einsatz', id = einsatzId }) +end) diff --git a/resources/[aether]/aether-police/server/module/fahndung.lua b/resources/[aether]/aether-police/server/module/fahndung.lua new file mode 100644 index 0000000..36dfc13 --- /dev/null +++ b/resources/[aether]/aether-police/server/module/fahndung.lua @@ -0,0 +1,399 @@ +-- ===================================================================== +-- aether-police / Modul: Fahndungen +-- +-- Fahndungen entstehen ausschliesslich durch einen Beamten. Es gibt +-- keinen Automatismus, der jemanden aufgrund von Vorgängen zur Fahndung +-- ausschreibt — und keinen, der eine Fahndung von selbst aufhebt. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'fahndung', name = 'Fahndungen', icon = '🔍', + reihenfolge = 9, recht = 'fahndung.erstellen', + beschreibung = 'Personen- und Sachfahndung', +}) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +local GEFAHRSTUFEN = { normal = true, bewaffnet = true, gefaehrlich = true } + +--- Ermittelt die Bezeichnung eines Fahndungsziels +--- @param typ string 'person' | 'fahrzeug' +--- @param zielId string +--- @return string|nil Bezeichnung, string|nil Fehler +local function BezeichnungFuerZiel(typ, zielId) + if typ == 'person' then + local charakterId = tonumber(zielId) + if not charakterId then return nil, 'Ungültige Personenkennung.' end + + local person = MySQL.query.await( + 'SELECT firstname, lastname, dob FROM aether_characters WHERE id = ? AND deleted = 0', + { charakterId }) + if not person or not person[1] then return nil, 'Diese Person existiert nicht.' end + + return ('%s %s (geb. %s)'):format( + person[1].firstname, person[1].lastname, + tostring(person[1].dob):sub(1, 10)) + end + + -- Fahrzeug + local kennzeichen = tostring(zielId):upper() + local fahrzeug = MySQL.query.await([[ + SELECT v.plate, v.model, v.props, c.firstname, c.lastname + FROM aether_vehicles v + LEFT JOIN aether_characters c ON c.id = v.owner + WHERE UPPER(v.plate) = ? + ]], { kennzeichen }) + + if not fahrzeug or not fahrzeug[1] then + return nil, 'Kein Fahrzeug mit diesem Kennzeichen.' + end + + local name = fahrzeug[1].model + if fahrzeug[1].props then + local erfolg, props = pcall(json.decode, fahrzeug[1].props) + if erfolg and type(props) == 'table' and props.name then name = props.name end + end + + return ('%s · %s'):format(fahrzeug[1].plate, name) +end + +--- Reichert eine Fahndung um die Daten ihres Ziels an +--- @param fahndung table +local function ZielDatenErgaenzen(fahndung) + if fahndung.typ == 'person' then + local person = MySQL.query.await([[ + SELECT c.id, c.firstname, c.lastname, c.dob, p.lichtbild, + p.warnhinweis, p.gefahrenstufe + FROM aether_characters c + LEFT JOIN police_person p ON p.`character` = c.id + WHERE c.id = ? + ]], { tonumber(fahndung.ziel_id) }) + fahndung.ziel = person and person[1] or nil + else + local fahrzeug = MySQL.query.await([[ + SELECT v.plate, v.model, v.props, v.state, v.owner, + c.firstname, c.lastname + FROM aether_vehicles v + LEFT JOIN aether_characters c ON c.id = v.owner + WHERE v.plate = ? + ]], { fahndung.ziel_id }) + + if fahrzeug and fahrzeug[1] then + local eintrag = fahrzeug[1] + if eintrag.props then + local erfolg, props = pcall(json.decode, eintrag.props) + if erfolg and type(props) == 'table' then + eintrag.name = props.name + eintrag.farbe = props.farbe + end + end + eintrag.props = nil + fahndung.ziel = eintrag + end + end +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('fahndung', 'liste', nil, function(quelle, beamter, daten) + local bedingungen, werte = { '1=1' }, {} + + -- Standardmäßig nur aktive Fahndungen + if daten.archiv then + bedingungen[#bedingungen + 1] = 'f.aktiv = 0' + else + bedingungen[#bedingungen + 1] = 'f.aktiv = 1' + end + + if daten.typ and daten.typ ~= '' then + bedingungen[#bedingungen + 1] = 'f.typ = ?' + werte[#werte + 1] = daten.typ + end + if daten.gefahr and daten.gefahr ~= '' then + bedingungen[#bedingungen + 1] = 'f.gefahr = ?' + werte[#werte + 1] = daten.gefahr + end + if type(daten.begriff) == 'string' and #daten.begriff >= 2 then + bedingungen[#bedingungen + 1] = '(f.bezeichnung LIKE ? OR f.grund LIKE ?)' + local muster = '%' .. daten.begriff .. '%' + werte[#werte + 1] = muster + werte[#werte + 1] = muster + end + + local fahndungen = MySQL.query.await(([[ + SELECT f.id, f.typ, f.ziel_id, f.bezeichnung, f.grund, f.gefahr, + f.prioritaet, f.hinweise, f.bild, f.aktiv, f.erstellt_am, + f.aufgehoben_am, + c.firstname, c.lastname, p.dienstnummer, + a.firstname AS a_vorname, a.lastname AS a_nachname + FROM police_fahndung f + LEFT JOIN aether_characters c ON c.id = f.erstellt_von + LEFT JOIN police_personal p ON p.`character` = f.erstellt_von + LEFT JOIN aether_characters a ON a.id = f.aufgehoben_von + WHERE %s + ORDER BY f.prioritaet ASC, f.id DESC + LIMIT 80 + ]]):format(table.concat(bedingungen, ' AND ')), werte) or {} + + -- Zahlen für die Übersicht + local zahlen = MySQL.query.await([[ + SELECT gefahr, COUNT(*) AS anzahl FROM police_fahndung WHERE aktiv = 1 GROUP BY gefahr + ]]) or {} + + local bestand = { normal = 0, bewaffnet = 0, gefaehrlich = 0 } + for _, zeile in ipairs(zahlen) do bestand[zeile.gefahr] = zeile.anzahl end + + return { + fahndungen = fahndungen, + bestand = bestand, + rechte = { + erstellen = Rechte.Hat(beamter, 'fahndung.erstellen'), + aufheben = Rechte.Hat(beamter, 'fahndung.aufheben'), + }, + } +end) + +Kern.RegistriereAktion('fahndung', 'oeffnen', nil, function(quelle, beamter, daten) + local fahndungId = tonumber(daten.id) + if not fahndungId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT f.*, c.firstname, c.lastname, p.dienstnummer, + a.firstname AS a_vorname, a.lastname AS a_nachname + FROM police_fahndung f + LEFT JOIN aether_characters c ON c.id = f.erstellt_von + LEFT JOIN police_personal p ON p.`character` = f.erstellt_von + LEFT JOIN aether_characters a ON a.id = f.aufgehoben_von + WHERE f.id = ? + ]], { fahndungId }) + + if not zeilen or not zeilen[1] then return nil, 'Fahndung nicht gefunden.' end + + local fahndung = zeilen[1] + ZielDatenErgaenzen(fahndung) + + return { + fahndung = fahndung, + historie = Protokoll.Lies({ zielTyp = 'fahndung', zielId = fahndungId, limit = 20 }), + rechte = { + bearbeiten = Rechte.Hat(beamter, 'fahndung.erstellen'), + aufheben = Rechte.Hat(beamter, 'fahndung.aufheben'), + }, + } +end) + +-- ===================================================================== +-- AUSSCHREIBEN +-- ===================================================================== + +Kern.RegistriereAktion('fahndung', 'erstellen', 'fahndung.erstellen', function(quelle, beamter, daten) + local typ = (daten.typ == 'fahrzeug') and 'fahrzeug' or 'person' + local zielId = tostring(daten.zielId or ''):gsub('^%s+', ''):gsub('%s+$', '') + if zielId == '' then return nil, 'Kein Ziel angegeben.' end + + if typ == 'fahrzeug' then zielId = zielId:upper() end + + -- Ziel muss existieren + local bezeichnung, fehler = BezeichnungFuerZiel(typ, zielId) + if not bezeichnung then return nil, fehler end + + -- Doppelte aktive Fahndung verhindern + local vorhanden = MySQL.scalar.await( + 'SELECT id FROM police_fahndung WHERE typ = ? AND ziel_id = ? AND aktiv = 1', + { typ, zielId }) + if vorhanden then + return nil, 'Für dieses Ziel besteht bereits eine aktive Fahndung.' + end + + local grund = type(daten.grund) == 'string' + and daten.grund:gsub('^%s+', ''):gsub('%s+$', '') or '' + if #grund < 5 then + return nil, 'Bitte einen aussagekräftigen Fahndungsgrund angeben.' + end + + local gefahr = GEFAHRSTUFEN[daten.gefahr] and daten.gefahr or 'normal' + local prioritaet = math.max(1, math.min(4, tonumber(daten.prioritaet) or 3)) + + local fahndungId = MySQL.insert.await([[ + INSERT INTO police_fahndung + (typ, ziel_id, bezeichnung, grund, gefahr, prioritaet, hinweise, bild, erstellt_von) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ]], { + typ, zielId, bezeichnung, grund:sub(1, 512), gefahr, prioritaet, + type(daten.hinweise) == 'string' and daten.hinweise or nil, + type(daten.bild) == 'string' and daten.bild:sub(1, 2000) or nil, + beamter.charakterId, + }) + + Protokoll.Schreibe(beamter, 'fahndung', 'Fahndung ausgeschrieben', + { typ = 'fahndung', id = fahndungId }, + { ziel = typ .. ':' .. zielId, gefahr = gefahr, prioritaet = prioritaet }, grund) + + -- Alle Beamten im Dienst informieren; Gefahrenstufe bestimmt die Dringlichkeit + Hinweise.Erstelle(nil, { + kategorie = 'fahndung', + prioritaet = gefahr == 'gefaehrlich' and 'kritisch' + or gefahr == 'bewaffnet' and 'hoch' or 'normal', + titel = ('Fahndung: %s'):format(bezeichnung), + text = ('%s · Gefahrenstufe: %s · ausgeschrieben von %s') + :format(grund, gefahr, beamter.dienstnummer), + zielTyp = 'fahndung', zielId = fahndungId, + }) + + Kern.Loese('fahndung.erstellt', fahndungId, beamter) + + return { id = fahndungId, bezeichnung = bezeichnung } +end) + +-- ===================================================================== +-- BEARBEITEN +-- ===================================================================== + +Kern.RegistriereAktion('fahndung', 'bearbeiten', 'fahndung.erstellen', function(quelle, beamter, daten) + local fahndungId = tonumber(daten.id) + if not fahndungId then return nil, 'Ungültige Kennung.' end + + local fahndung = MySQL.query.await( + 'SELECT gefahr, prioritaet, aktiv, bezeichnung FROM police_fahndung WHERE id = ?', + { fahndungId }) + if not fahndung or not fahndung[1] then return nil, 'Fahndung nicht gefunden.' end + if fahndung[1].aktiv == 0 then return nil, 'Aufgehobene Fahndungen können nicht geändert werden.' end + + local gefahr = GEFAHRSTUFEN[daten.gefahr] and daten.gefahr or fahndung[1].gefahr + local prioritaet = math.max(1, math.min(4, tonumber(daten.prioritaet) or fahndung[1].prioritaet)) + + MySQL.update.await([[ + UPDATE police_fahndung SET grund = ?, gefahr = ?, prioritaet = ?, hinweise = ?, bild = ? + WHERE id = ? + ]], { + type(daten.grund) == 'string' and daten.grund:sub(1, 512) or nil, + gefahr, prioritaet, + type(daten.hinweise) == 'string' and daten.hinweise or nil, + type(daten.bild) == 'string' and daten.bild:sub(1, 2000) or nil, + fahndungId, + }) + + Protokoll.Schreibe(beamter, 'fahndung', 'Fahndung geändert', + { typ = 'fahndung', id = fahndungId }, { gefahr = gefahr, prioritaet = prioritaet }) + + -- Bei Höherstufung erneut alarmieren + if gefahr ~= fahndung[1].gefahr and (gefahr == 'gefaehrlich' or gefahr == 'bewaffnet') then + Hinweise.Erstelle(nil, { + kategorie = 'fahndung', + prioritaet = gefahr == 'gefaehrlich' and 'kritisch' or 'hoch', + titel = ('Gefahrenstufe erhöht: %s'):format(fahndung[1].bezeichnung), + text = ('Neue Einstufung: %s'):format(gefahr), + zielTyp = 'fahndung', zielId = fahndungId, + }) + end + + return { ok = true } +end) + +-- ===================================================================== +-- AUFHEBEN +-- +-- Eine Fahndung endet nie von selbst — auch nicht nach einer Festnahme. +-- ===================================================================== + +Kern.RegistriereAktion('fahndung', 'aufheben', 'fahndung.aufheben', function(quelle, beamter, daten) + local fahndungId = tonumber(daten.id) + if not fahndungId then return nil, 'Ungültige Kennung.' end + + local grund = type(daten.grund) == 'string' + and daten.grund:gsub('^%s+', ''):gsub('%s+$', '') or '' + if #grund < 3 then return nil, 'Bitte einen Grund für die Aufhebung angeben.' end + + local fahndung = MySQL.query.await( + 'SELECT bezeichnung, aktiv FROM police_fahndung WHERE id = ?', { fahndungId }) + if not fahndung or not fahndung[1] then return nil, 'Fahndung nicht gefunden.' end + if fahndung[1].aktiv == 0 then return nil, 'Diese Fahndung ist bereits aufgehoben.' end + + MySQL.update.await([[ + UPDATE police_fahndung + SET aktiv = 0, aufgehoben_von = ?, aufgehoben_am = NOW() + WHERE id = ? + ]], { beamter.charakterId, fahndungId }) + + Protokoll.Schreibe(beamter, 'fahndung', 'Fahndung aufgehoben', + { typ = 'fahndung', id = fahndungId }, + { bezeichnung = fahndung[1].bezeichnung }, grund) + + Hinweise.Erstelle(nil, { + kategorie = 'fahndung', prioritaet = 'niedrig', + titel = ('Fahndung aufgehoben: %s'):format(fahndung[1].bezeichnung), + text = ('%s · durch %s'):format(grund, beamter.dienstnummer), + }) + + Kern.Loese('fahndung.aufgehoben', fahndungId, beamter) + + return { ok = true } +end) + +-- ===================================================================== +-- ZIELSUCHE FÜR DIE OBERFLÄCHE +-- ===================================================================== + +Kern.RegistriereAktion('fahndung', 'zielSuche', 'fahndung.erstellen', function(quelle, beamter, daten) + local begriff = type(daten.begriff) == 'string' and daten.begriff or '' + if #begriff < 2 then return { treffer = {} } end + + local muster = '%' .. begriff .. '%' + + if daten.typ == 'fahrzeug' then + local fahrzeuge = MySQL.query.await([[ + SELECT v.plate, v.model, v.props, c.firstname, c.lastname + FROM aether_vehicles v + LEFT JOIN aether_characters c ON c.id = v.owner + WHERE UPPER(v.plate) LIKE ? OR v.model LIKE ? + LIMIT 20 + ]], { muster:upper(), muster }) or {} + + local treffer = {} + for _, fahrzeug in ipairs(fahrzeuge) do + local name = fahrzeug.model + if fahrzeug.props then + local erfolg, props = pcall(json.decode, fahrzeug.props) + if erfolg and type(props) == 'table' and props.name then name = props.name end + end + treffer[#treffer + 1] = { + id = fahrzeug.plate, titel = fahrzeug.plate .. ' · ' .. name, + unter = 'Halter: ' .. ((fahrzeug.firstname or 'unbekannt') .. ' ' .. (fahrzeug.lastname or '')), + } + end + return { treffer = treffer } + end + + local personen = MySQL.query.await([[ + SELECT id, firstname, lastname, dob FROM aether_characters + WHERE deleted = 0 AND (firstname LIKE ? OR lastname LIKE ? + OR CONCAT(firstname, ' ', lastname) LIKE ?) + LIMIT 20 + ]], { muster, muster, muster }) or {} + + local treffer = {} + for _, person in ipairs(personen) do + treffer[#treffer + 1] = { + id = tostring(person.id), + titel = person.firstname .. ' ' .. person.lastname, + unter = 'geb. ' .. tostring(person.dob):sub(1, 10), + } + end + return { treffer = treffer } +end) + +-- ===================================================================== +-- EXPORT — Auskunft für andere Module +-- ===================================================================== + +--- Besteht eine aktive Fahndung gegen ein Ziel? +exports('BestehtFahndung', function(typ, zielId) + local treffer = MySQL.scalar.await( + 'SELECT id FROM police_fahndung WHERE typ = ? AND ziel_id = ? AND aktiv = 1', + { typ, tostring(zielId) }) + return treffer ~= nil +end) diff --git a/resources/[aether]/aether-police/server/module/fahrzeug.lua b/resources/[aether]/aether-police/server/module/fahrzeug.lua new file mode 100644 index 0000000..8a9c028 --- /dev/null +++ b/resources/[aether]/aether-police/server/module/fahrzeug.lua @@ -0,0 +1,338 @@ +-- ===================================================================== +-- aether-police / Modul: Fahrzeugverwaltung +-- +-- Das System dokumentiert Tatsachen: Standort, Tankstand, Zustand und +-- wer zuletzt gefahren ist. Es entscheidet nichts — es gibt keine +-- automatische Fahrzeugvergabe und keinen automatischen Statuswechsel. +-- Zuweisung, Freigabe und Wartungsstatus setzt immer ein Beamter. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'fahrzeug', name = 'Fuhrpark', icon = '🚓', + reihenfolge = 10, recht = 'dienstfahrzeug.sehen', + beschreibung = 'Dienstfahrzeuge und Wartung', +}) + +-- Zulaessige Werte +local STATUS = { + verfuegbar = 'Verfügbar', + im_einsatz = 'Im Einsatz', + werkstatt = 'In der Werkstatt', + ausser_dienst= 'Außer Dienst', +} +local WARTUNG = { ok = 'In Ordnung', faellig = 'Wartung fällig', werkstatt = 'In Reparatur' } + +-- --------------------------------------------------------------------- +-- Übersicht +-- --------------------------------------------------------------------- + +Kern.RegistriereAktion('fahrzeug', 'liste', nil, function(quelle, beamter, daten) + local bedingungen, werte = { '1=1' }, {} + + if daten.abteilung and daten.abteilung ~= '' then + bedingungen[#bedingungen + 1] = 'f.abteilung = ?' + werte[#werte + 1] = daten.abteilung + end + if daten.status and daten.status ~= '' then + bedingungen[#bedingungen + 1] = 'f.status = ?' + werte[#werte + 1] = daten.status + end + if daten.wartung and daten.wartung ~= '' then + bedingungen[#bedingungen + 1] = 'f.wartung = ?' + werte[#werte + 1] = daten.wartung + end + if type(daten.begriff) == 'string' and #daten.begriff >= 2 then + bedingungen[#bedingungen + 1] = '(f.kennzeichen LIKE ? OR f.bezeichnung LIKE ? OR f.modell LIKE ?)' + local muster = '%' .. daten.begriff .. '%' + werte[#werte + 1] = muster + werte[#werte + 1] = muster + werte[#werte + 1] = muster + end + + local fahrzeuge = MySQL.query.await(([[ + SELECT f.*, + z.firstname AS z_vorname, z.lastname AS z_nachname, zp.dienstnummer AS z_nummer, + l.firstname AS l_vorname, l.lastname AS l_nachname, lp.dienstnummer AS l_nummer + FROM police_fahrzeug f + LEFT JOIN aether_characters z ON z.id = f.zugewiesen_an + LEFT JOIN police_personal zp ON zp.`character` = f.zugewiesen_an + LEFT JOIN aether_characters l ON l.id = f.letzter_fahrer + LEFT JOIN police_personal lp ON lp.`character` = f.letzter_fahrer + WHERE %s + ORDER BY f.abteilung, f.kennzeichen + LIMIT 100 + ]]):format(table.concat(bedingungen, ' AND ')), werte) or {} + + -- Bestandszahlen + local zahlen = MySQL.query.await( + 'SELECT status, COUNT(*) AS anzahl FROM police_fahrzeug GROUP BY status') or {} + local bestand = { verfuegbar = 0, im_einsatz = 0, werkstatt = 0, ausser_dienst = 0 } + for _, zeile in ipairs(zahlen) do bestand[zeile.status] = zeile.anzahl end + + local wartungFaellig = MySQL.scalar.await( + "SELECT COUNT(*) FROM police_fahrzeug WHERE wartung <> 'ok'") or 0 + + return { + fahrzeuge = fahrzeuge, + bestand = bestand, + wartungFaellig= wartungFaellig, + abteilungen = Polizei.Config.Abteilungen, + statusliste = STATUS, + wartungsliste = WARTUNG, + rechte = { + zuweisen = Rechte.Hat(beamter, 'dienstfahrzeug.zuweisen'), + wartung = Rechte.Hat(beamter, 'dienstfahrzeug.wartung'), + }, + } +end) + +Kern.RegistriereAktion('fahrzeug', 'oeffnen', nil, function(quelle, beamter, daten) + local fahrzeugId = tonumber(daten.id) + if not fahrzeugId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT f.*, + z.firstname AS z_vorname, z.lastname AS z_nachname, zp.dienstnummer AS z_nummer, + l.firstname AS l_vorname, l.lastname AS l_nachname, lp.dienstnummer AS l_nummer + FROM police_fahrzeug f + LEFT JOIN aether_characters z ON z.id = f.zugewiesen_an + LEFT JOIN police_personal zp ON zp.`character` = f.zugewiesen_an + LEFT JOIN aether_characters l ON l.id = f.letzter_fahrer + LEFT JOIN police_personal lp ON lp.`character` = f.letzter_fahrer + WHERE f.id = ? + ]], { fahrzeugId }) + + if not zeilen or not zeilen[1] then return nil, 'Fahrzeug nicht gefunden.' end + + return { + fahrzeug = zeilen[1], + historie = Protokoll.Lies({ zielTyp = 'fahrzeug', zielId = fahrzeugId, limit = 30 }), + beamteImDienst= Dienst.BaueListe(), + statusliste = STATUS, + wartungsliste = WARTUNG, + rechte = { + zuweisen = Rechte.Hat(beamter, 'dienstfahrzeug.zuweisen'), + wartung = Rechte.Hat(beamter, 'dienstfahrzeug.wartung'), + }, + } +end) + +-- --------------------------------------------------------------------- +-- Zuweisung — immer manuell +-- --------------------------------------------------------------------- + +Kern.RegistriereAktion('fahrzeug', 'zuweisen', 'dienstfahrzeug.zuweisen', function(quelle, beamter, daten) + local fahrzeugId = tonumber(daten.id) + if not fahrzeugId then return nil, 'Ungültige Kennung.' end + + local fahrzeug = MySQL.query.await( + 'SELECT kennzeichen, bezeichnung, status, zugewiesen_an FROM police_fahrzeug WHERE id = ?', + { fahrzeugId }) + if not fahrzeug or not fahrzeug[1] then return nil, 'Fahrzeug nicht gefunden.' end + + -- Freigeben + if daten.freigeben then + MySQL.update.await('UPDATE police_fahrzeug SET zugewiesen_an = NULL WHERE id = ?', + { fahrzeugId }) + + Protokoll.Schreibe(beamter, 'fahrzeug', 'Zuweisung aufgehoben', + { typ = 'fahrzeug', id = fahrzeugId }, { kennzeichen = fahrzeug[1].kennzeichen }) + + return { ok = true } + end + + local zielCharakter = tonumber(daten.charakterId) + if not zielCharakter then return nil, 'Keine Person ausgewählt.' end + + -- Nur Personal der Behörde + local personal = MySQL.query.await( + 'SELECT dienstnummer FROM police_personal WHERE `character` = ? AND aktiv = 1', + { zielCharakter }) + if not personal or not personal[1] then + return nil, 'Diese Person gehört nicht zum Personalbestand.' + end + + if fahrzeug[1].status == 'werkstatt' or fahrzeug[1].status == 'ausser_dienst' then + return nil, ('Das Fahrzeug ist %s und kann nicht zugewiesen werden.') + :format(STATUS[fahrzeug[1].status]) + end + + MySQL.update.await('UPDATE police_fahrzeug SET zugewiesen_an = ? WHERE id = ?', + { zielCharakter, fahrzeugId }) + + Protokoll.Schreibe(beamter, 'fahrzeug', 'Fahrzeug zugewiesen', + { typ = 'fahrzeug', id = fahrzeugId }, + { kennzeichen = fahrzeug[1].kennzeichen, an = personal[1].dienstnummer }, daten.grund) + + -- Den Betroffenen informieren, falls im Dienst + local zielQuelle = Dienst.QuelleVonCharakter(zielCharakter) + if zielQuelle then + Hinweise.Erstelle({ charakterId = zielCharakter }, { + kategorie = 'system', prioritaet = 'normal', + titel = 'Dienstfahrzeug zugewiesen', + text = ('%s (%s) wurde dir von %s zugewiesen.') + :format(fahrzeug[1].bezeichnung, fahrzeug[1].kennzeichen, beamter.dienstnummer), + zielTyp = 'fahrzeug', zielId = fahrzeugId, + }) + end + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Status und Wartung — ebenfalls manuell +-- --------------------------------------------------------------------- + +Kern.RegistriereAktion('fahrzeug', 'status', 'dienstfahrzeug.zuweisen', function(quelle, beamter, daten) + local fahrzeugId = tonumber(daten.id) + local status = tostring(daten.status or '') + if not fahrzeugId or not STATUS[status] then return nil, 'Unbekannter Status.' end + + local fahrzeug = MySQL.query.await( + 'SELECT kennzeichen FROM police_fahrzeug WHERE id = ?', { fahrzeugId }) + if not fahrzeug or not fahrzeug[1] then return nil, 'Fahrzeug nicht gefunden.' end + + MySQL.update.await('UPDATE police_fahrzeug SET status = ? WHERE id = ?', { status, fahrzeugId }) + + Protokoll.Schreibe(beamter, 'fahrzeug', 'Status geändert', + { typ = 'fahrzeug', id = fahrzeugId }, + { kennzeichen = fahrzeug[1].kennzeichen, status = status }, daten.grund) + + return { ok = true } +end) + +Kern.RegistriereAktion('fahrzeug', 'wartung', 'dienstfahrzeug.wartung', function(quelle, beamter, daten) + local fahrzeugId = tonumber(daten.id) + local wartung = tostring(daten.wartung or '') + if not fahrzeugId or not WARTUNG[wartung] then return nil, 'Unbekannter Wartungsstatus.' end + + local fahrzeug = MySQL.query.await( + 'SELECT kennzeichen, wartung FROM police_fahrzeug WHERE id = ?', { fahrzeugId }) + if not fahrzeug or not fahrzeug[1] then return nil, 'Fahrzeug nicht gefunden.' end + + MySQL.update.await('UPDATE police_fahrzeug SET wartung = ? WHERE id = ?', { wartung, fahrzeugId }) + + Protokoll.Schreibe(beamter, 'fahrzeug', 'Wartungsstatus gesetzt', + { typ = 'fahrzeug', id = fahrzeugId }, + { kennzeichen = fahrzeug[1].kennzeichen, von = fahrzeug[1].wartung, nach = wartung }, + daten.vermerk) + + -- Bei fälliger Wartung die Fuhrparkverantwortlichen erinnern + if wartung ~= 'ok' then + Hinweise.Erstelle(nil, { + kategorie = 'system', prioritaet = 'niedrig', + titel = 'Wartung: ' .. fahrzeug[1].kennzeichen, + text = ('%s — %s'):format(WARTUNG[wartung], daten.vermerk or 'ohne Vermerk'), + zielTyp = 'fahrzeug', zielId = fahrzeugId, + }) + end + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Fuhrpark pflegen +-- --------------------------------------------------------------------- + +Kern.RegistriereAktion('fahrzeug', 'anlegen', 'dienstfahrzeug.wartung', function(quelle, beamter, daten) + local kennzeichen = type(daten.kennzeichen) == 'string' + and daten.kennzeichen:gsub('%s', ''):upper() or '' + if #kennzeichen < 2 or #kennzeichen > 16 then + return nil, 'Das Kennzeichen muss 2 bis 16 Zeichen lang sein.' + end + + local vorhanden = MySQL.scalar.await( + 'SELECT id FROM police_fahrzeug WHERE kennzeichen = ?', { kennzeichen }) + if vorhanden then return nil, 'Dieses Kennzeichen ist bereits im Fuhrpark.' end + + local modell = type(daten.modell) == 'string' and daten.modell:gsub('%s', '') or '' + if #modell < 2 then return nil, 'Bitte das Fahrzeugmodell angeben.' end + + local bezeichnung = type(daten.bezeichnung) == 'string' + and daten.bezeichnung:gsub('^%s+', ''):gsub('%s+$', '') or '' + if #bezeichnung < 2 then return nil, 'Bitte eine Bezeichnung angeben.' end + + -- Abteilung prüfen + local abteilung = 'streife' + for _, eintrag in ipairs(Polizei.Config.Abteilungen) do + if eintrag.id == daten.abteilung then abteilung = daten.abteilung break end + end + + local fahrzeugId = MySQL.insert.await([[ + INSERT INTO police_fahrzeug (kennzeichen, modell, bezeichnung, abteilung) + VALUES (?, ?, ?, ?) + ]], { kennzeichen, modell:sub(1, 48), bezeichnung:sub(1, 64), abteilung }) + + Protokoll.Schreibe(beamter, 'fahrzeug', 'Fahrzeug aufgenommen', + { typ = 'fahrzeug', id = fahrzeugId }, + { kennzeichen = kennzeichen, modell = modell, abteilung = abteilung }) + + return { id = fahrzeugId } +end) + +Kern.RegistriereAktion('fahrzeug', 'entfernen', 'dienstfahrzeug.wartung', function(quelle, beamter, daten) + local fahrzeugId = tonumber(daten.id) + if not fahrzeugId then return nil, 'Ungültige Kennung.' end + + local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or '' + if #grund < 3 then return nil, 'Bitte einen Grund für die Ausmusterung angeben.' end + + local fahrzeug = MySQL.query.await( + 'SELECT kennzeichen, bezeichnung FROM police_fahrzeug WHERE id = ?', { fahrzeugId }) + if not fahrzeug or not fahrzeug[1] then return nil, 'Fahrzeug nicht gefunden.' end + + MySQL.update.await('DELETE FROM police_fahrzeug WHERE id = ?', { fahrzeugId }) + + Protokoll.Schreibe(beamter, 'fahrzeug', 'Fahrzeug ausgemustert', + { typ = 'fahrzeug', id = fahrzeugId }, + { kennzeichen = fahrzeug[1].kennzeichen, bezeichnung = fahrzeug[1].bezeichnung }, grund) + + return { ok = true } +end) + +-- ===================================================================== +-- TELEMETRIE +-- +-- Der Client meldet Tatsachen: wo das Fahrzeug steht, wie voll der Tank +-- ist, wie es um den Zustand steht und wer zuletzt gefahren ist. +-- Das ist Dokumentation, keine Entscheidung — Status und Wartung +-- bleiben davon unberührt. +-- ===================================================================== + +RegisterNetEvent('aether:police:fahrzeugMelden', function(daten) + local quelle = source + local beamter = Dienst.Hole(quelle) + if not beamter then return end + if type(daten) ~= 'table' or type(daten.kennzeichen) ~= 'string' then return end + + local kennzeichen = daten.kennzeichen:gsub('%s', ''):upper() + + local fahrzeug = MySQL.scalar.await( + 'SELECT id FROM police_fahrzeug WHERE kennzeichen = ?', { kennzeichen }) + if not fahrzeug then return end -- kein Dienstfahrzeug + + local tank = math.max(0, math.min(100, tonumber(daten.tank) or 0)) + local zustand = math.max(0, math.min(1000, tonumber(daten.zustand) or 1000)) + + MySQL.update([[ + UPDATE police_fahrzeug + SET tank = ?, zustand = ?, letzter_fahrer = ?, pos_x = ?, pos_y = ?, pos_z = ? + WHERE id = ? + ]], { + tank, zustand, beamter.charakterId, + daten.x, daten.y, daten.z, fahrzeug, + }) +end) + +-- ===================================================================== +-- EXPORT +-- ===================================================================== + +--- Ist ein Kennzeichen ein Dienstfahrzeug? +exports('IstDienstfahrzeug', function(kennzeichen) + if type(kennzeichen) ~= 'string' then return false end + local treffer = MySQL.scalar.await( + 'SELECT id FROM police_fahrzeug WHERE kennzeichen = ?', + { kennzeichen:gsub('%s', ''):upper() }) + return treffer ~= nil +end) diff --git a/resources/[aether]/aether-police/server/module/gewahrsam.lua b/resources/[aether]/aether-police/server/module/gewahrsam.lua new file mode 100644 index 0000000..d806ec2 --- /dev/null +++ b/resources/[aether]/aether-police/server/module/gewahrsam.lua @@ -0,0 +1,383 @@ +-- ===================================================================== +-- aether-police / Modul: Gewahrsamsverwaltung +-- +-- Dieses Modul enthält BEWUSST keinen Automatismus: +-- * Niemand wird automatisch eingewiesen. +-- * Keine Frist entlässt jemanden. +-- * Abgelaufene Haftzeiten bleiben offen stehen, bis ein Beamter +-- entscheidet — sichtbar an `entlassen_von` / `entlassen_am`. +-- +-- Das System führt die Frist mit und erinnert. Mehr nicht. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'gewahrsam', name = 'Gewahrsam', icon = '🔒', + reihenfolge = 5, recht = 'gewahrsam.einweisen', + beschreibung = 'Zellenbelegung und Fristen', +}) + +local Core = exports['aether-core'] + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Sucht eine Zelle in der Konfiguration +--- @param zellenId string|nil +--- @return table|nil +local function HoleZelle(zellenId) + for _, zelle in ipairs(Polizei.Config.Gewahrsam.zellen) do + if zelle.id == zellenId then return zelle end + end + return nil +end + +--- Lädt alle offenen Gewahrsamsfälle +--- @return table +local function LadeOffene() + local eintraege = MySQL.query.await([[ + SELECT g.id, g.character, g.anzeige, g.haft_minuten, g.beginn, g.faellig_am, + g.grund, g.zelle, g.eingewiesen_von, + TIMESTAMPDIFF(SECOND, NOW(), g.faellig_am) AS rest_sekunden, + c.firstname, c.lastname, c.dob, + e.firstname AS e_vorname, e.lastname AS e_nachname, + p.dienstnummer AS e_dienstnummer + FROM police_gewahrsam g + JOIN aether_characters c ON c.id = g.character + LEFT JOIN aether_characters e ON e.id = g.eingewiesen_von + LEFT JOIN police_personal p ON p.`character` = g.eingewiesen_von + WHERE g.entlassen_am IS NULL + ORDER BY g.faellig_am ASC + ]]) or {} + + for _, eintrag in ipairs(eintraege) do + eintrag.rest_sekunden = tonumber(eintrag.rest_sekunden) or 0 + eintrag.abgelaufen = eintrag.rest_sekunden <= 0 + -- Ist die Person gerade online? + eintrag.online = Core:HoleQuelleNachCharakter(eintrag.character) ~= nil + end + + return eintraege +end + +--- Verbringt eine Person in eine Zelle (nur wenn sie online ist) +--- @param charakterId number +--- @param zelle table Zellendefinition +local function VerbringeInZelle(charakterId, zelle) + if not Polizei.Config.Gewahrsam.verbringen or not zelle then return end + + local zielQuelle = Core:HoleQuelleNachCharakter(charakterId) + if not zielQuelle then return end + + TriggerClientEvent('aether:police:gewahrsamVerbringen', zielQuelle, { + x = zelle.pos.x, y = zelle.pos.y, z = zelle.pos.z, w = zelle.pos.w, + zelle = zelle.name, + }) +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('gewahrsam', 'liste', nil, function(quelle, beamter) + local offene = LadeOffene() + + -- Zellenbelegung ermitteln + local belegt = {} + for _, eintrag in ipairs(offene) do + if eintrag.zelle then belegt[eintrag.zelle] = eintrag end + end + + local zellen = {} + for _, zelle in ipairs(Polizei.Config.Gewahrsam.zellen) do + local insasse = belegt[zelle.id] + zellen[#zellen + 1] = { + id = zelle.id, name = zelle.name, + frei = insasse == nil, + insasse = insasse and { + id = insasse.id, + name = insasse.firstname .. ' ' .. insasse.lastname, + rest = insasse.rest_sekunden, + abgelaufen = insasse.abgelaufen, + } or nil, + } + end + + -- Kürzlich Entlassene (nur zur Nachvollziehbarkeit) + local verlauf = MySQL.query.await([[ + SELECT g.id, g.haft_minuten, g.beginn, g.entlassen_am, g.entlass_grund, g.grund, + c.firstname, c.lastname, + e.firstname AS e_vorname, e.lastname AS e_nachname + FROM police_gewahrsam g + JOIN aether_characters c ON c.id = g.character + LEFT JOIN aether_characters e ON e.id = g.entlassen_von + WHERE g.entlassen_am IS NOT NULL + ORDER BY g.entlassen_am DESC LIMIT 20 + ]]) or {} + + return { + offene = offene, + zellen = zellen, + verlauf = verlauf, + maxDauer = Polizei.Config.Gewahrsam.maxDauer, + verbringen = Polizei.Config.Gewahrsam.verbringen, + rechte = { + einweisen = Rechte.Hat(beamter, 'gewahrsam.einweisen'), + entlassen = Rechte.Hat(beamter, 'gewahrsam.entlassen'), + }, + } +end) + +-- ===================================================================== +-- EINWEISEN +-- +-- Geschieht nur auf ausdrückliche Bestätigung eines Beamten. Es gibt +-- keinen Weg, hier ohne dessen Zutun hineinzukommen. +-- ===================================================================== + +Kern.RegistriereAktion('gewahrsam', 'einweisen', 'gewahrsam.einweisen', function(quelle, beamter, daten) + local charakterId = tonumber(daten.charakterId) + if not charakterId then return nil, 'Keine Person ausgewählt.' end + + local person = MySQL.query.await( + 'SELECT firstname, lastname FROM aether_characters WHERE id = ? AND deleted = 0', + { charakterId }) + if not person or not person[1] then return nil, 'Diese Person existiert nicht.' end + + -- Doppelte Einweisung verhindern + local bereits = MySQL.scalar.await( + 'SELECT id FROM police_gewahrsam WHERE `character` = ? AND entlassen_am IS NULL', + { charakterId }) + if bereits then return nil, 'Diese Person befindet sich bereits in Gewahrsam.' end + + -- Haftdauer: kommt ausschliesslich vom Beamten + local dauer = math.floor(tonumber(daten.dauer) or 0) + if dauer < 1 or dauer > Polizei.Config.Gewahrsam.maxDauer then + return nil, ('Die Haftdauer muss zwischen 1 und %d Minuten liegen.') + :format(Polizei.Config.Gewahrsam.maxDauer) + end + + local grund = type(daten.grund) == 'string' + and daten.grund:gsub('^%s+', ''):gsub('%s+$', '') or '' + if #grund < 3 then return nil, 'Bitte einen Grund angeben.' end + + -- Zelle prüfen und Belegung sicherstellen + local zelle = HoleZelle(daten.zelle) + if daten.zelle and not zelle then return nil, 'Unbekannte Zelle.' end + + if zelle then + local besetzt = MySQL.scalar.await( + 'SELECT id FROM police_gewahrsam WHERE zelle = ? AND entlassen_am IS NULL', + { zelle.id }) + if besetzt then return nil, ('%s ist bereits belegt.'):format(zelle.name) end + end + + local gewahrsamId = MySQL.insert.await([[ + INSERT INTO police_gewahrsam + (`character`, anzeige, haft_minuten, faellig_am, grund, zelle, eingewiesen_von) + VALUES (?, ?, ?, DATE_ADD(NOW(), INTERVAL ? MINUTE), ?, ?, ?) + ]], { + charakterId, tonumber(daten.anzeige), dauer, dauer, grund, + zelle and zelle.id or nil, beamter.charakterId, + }) + + -- Person in die Zelle bringen, sofern konfiguriert und online + VerbringeInZelle(charakterId, zelle) + + Protokoll.Schreibe(beamter, 'gewahrsam', 'In Gewahrsam genommen', + { typ = 'person', id = charakterId }, + { gewahrsam = gewahrsamId, dauer = dauer, zelle = zelle and zelle.id }, grund) + + Hinweise.Erstelle(nil, { + kategorie = 'gewahrsam', prioritaet = 'normal', + titel = ('%s %s in Gewahrsam'):format(person[1].firstname, person[1].lastname), + text = ('%d Minuten · %s · eingewiesen von %s') + :format(dauer, grund, beamter.dienstnummer), + zielTyp = 'gewahrsam', zielId = gewahrsamId, + }) + + Kern.Loese('gewahrsam.eingewiesen', gewahrsamId, beamter) + + return { id = gewahrsamId } +end) + +-- ===================================================================== +-- ENTLASSEN +-- +-- Der einzige Weg aus dem Gewahrsam. Es gibt keinen Hintergrundprozess, +-- der das übernimmt — auch nicht bei längst abgelaufener Frist. +-- ===================================================================== + +Kern.RegistriereAktion('gewahrsam', 'entlassen', 'gewahrsam.entlassen', function(quelle, beamter, daten) + local gewahrsamId = tonumber(daten.id) + if not gewahrsamId then return nil, 'Ungültige Kennung.' end + + local eintrag = MySQL.query.await([[ + SELECT g.*, c.firstname, c.lastname, + TIMESTAMPDIFF(MINUTE, NOW(), g.faellig_am) AS rest + FROM police_gewahrsam g + JOIN aether_characters c ON c.id = g.character + WHERE g.id = ? + ]], { gewahrsamId }) + + if not eintrag or not eintrag[1] then return nil, 'Vorgang nicht gefunden.' end + local g = eintrag[1] + + if g.entlassen_am then return nil, 'Diese Person wurde bereits entlassen.' end + + local grund = type(daten.grund) == 'string' + and daten.grund:gsub('^%s+', ''):gsub('%s+$', '') or '' + + -- Vorzeitige Entlassung verlangt eine Begründung + local rest = tonumber(g.rest) or 0 + local vorzeitig = rest > 0 + if vorzeitig and #grund < 3 then + return nil, ('Die Frist läuft noch %d Minuten. Für eine vorzeitige Entlassung ist eine Begründung erforderlich.') + :format(rest) + end + + MySQL.update.await([[ + UPDATE police_gewahrsam + SET entlassen_von = ?, entlassen_am = NOW(), entlass_grund = ? + WHERE id = ? + ]], { beamter.charakterId, grund ~= '' and grund or nil, gewahrsamId }) + + -- Person an den Entlassungspunkt setzen, sofern online + if Polizei.Config.Gewahrsam.verbringen then + local zielQuelle = Core:HoleQuelleNachCharakter(g.character) + if zielQuelle then + local punkt = Polizei.Config.Gewahrsam.entlassung + TriggerClientEvent('aether:police:gewahrsamEntlassen', zielQuelle, { + x = punkt.x, y = punkt.y, z = punkt.z, w = punkt.w, + }) + end + end + + Protokoll.Schreibe(beamter, 'gewahrsam', + vorzeitig and 'Vorzeitig entlassen' or 'Entlassen', + { typ = 'person', id = g.character }, + { gewahrsam = gewahrsamId, restMinuten = rest }, grund ~= '' and grund or nil) + + Hinweise.Erstelle(nil, { + kategorie = 'gewahrsam', prioritaet = 'niedrig', + titel = ('%s %s entlassen'):format(g.firstname, g.lastname), + text = ('Entlassen durch %s%s'):format(beamter.dienstnummer, + vorzeitig and (' · vorzeitig: ' .. grund) or ''), + }) + + Kern.Loese('gewahrsam.entlassen', gewahrsamId, beamter) + + return { ok = true, vorzeitig = vorzeitig } +end) + +-- ===================================================================== +-- HAFTZEIT ANPASSEN +-- +-- Auch das ist eine bewusste Entscheidung und wird protokolliert. +-- ===================================================================== + +Kern.RegistriereAktion('gewahrsam', 'fristAendern', 'gewahrsam.entlassen', function(quelle, beamter, daten) + local gewahrsamId = tonumber(daten.id) + local minuten = math.floor(tonumber(daten.minuten) or 0) + + if not gewahrsamId then return nil, 'Ungültige Kennung.' end + if minuten == 0 then return nil, 'Bitte eine Änderung angeben.' end + if math.abs(minuten) > Polizei.Config.Gewahrsam.maxDauer then + return nil, 'Die Änderung ist zu groß.' + end + + local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or '' + if #grund < 3 then return nil, 'Bitte einen Grund für die Änderung angeben.' end + + local eintrag = MySQL.query.await( + 'SELECT `character`, entlassen_am FROM police_gewahrsam WHERE id = ?', { gewahrsamId }) + if not eintrag or not eintrag[1] then return nil, 'Vorgang nicht gefunden.' end + if eintrag[1].entlassen_am then return nil, 'Der Vorgang ist bereits abgeschlossen.' end + + MySQL.update.await([[ + UPDATE police_gewahrsam + SET faellig_am = DATE_ADD(faellig_am, INTERVAL ? MINUTE), + haft_minuten = GREATEST(0, haft_minuten + ?) + WHERE id = ? + ]], { minuten, minuten, gewahrsamId }) + + Protokoll.Schreibe(beamter, 'gewahrsam', 'Haftzeit angepasst', + { typ = 'person', id = eintrag[1].character }, + { gewahrsam = gewahrsamId, aenderung = minuten }, grund) + + return { ok = true } +end) + +-- ===================================================================== +-- ZELLE WECHSELN +-- ===================================================================== + +Kern.RegistriereAktion('gewahrsam', 'zelleWechseln', 'gewahrsam.einweisen', function(quelle, beamter, daten) + local gewahrsamId = tonumber(daten.id) + local zelle = HoleZelle(daten.zelle) + + if not gewahrsamId or not zelle then return nil, 'Unvollständige Angaben.' end + + local besetzt = MySQL.scalar.await( + 'SELECT id FROM police_gewahrsam WHERE zelle = ? AND entlassen_am IS NULL AND id <> ?', + { zelle.id, gewahrsamId }) + if besetzt then return nil, ('%s ist bereits belegt.'):format(zelle.name) end + + local eintrag = MySQL.query.await( + 'SELECT `character` FROM police_gewahrsam WHERE id = ? AND entlassen_am IS NULL', + { gewahrsamId }) + if not eintrag or not eintrag[1] then return nil, 'Vorgang nicht gefunden.' end + + MySQL.update.await('UPDATE police_gewahrsam SET zelle = ? WHERE id = ?', { zelle.id, gewahrsamId }) + VerbringeInZelle(eintrag[1].character, zelle) + + Protokoll.Schreibe(beamter, 'gewahrsam', 'Zelle gewechselt', + { typ = 'person', id = eintrag[1].character }, { zelle = zelle.id }) + + return { ok = true } +end) + +-- ===================================================================== +-- BEI ANMELDUNG PRÜFEN +-- +-- Wer während einer offenen Haft ausloggt, findet sich beim nächsten +-- Anmelden wieder in der Zelle. Die Frist läuft derweil weiter, aber +-- entlassen wird trotzdem nur durch einen Beamten. +-- ===================================================================== +AddEventHandler('aether:server:spielerGeladen', function(quelle, spieler) + if not Polizei.Config.Gewahrsam.verbringen then return end + + local eintrag = MySQL.query.await([[ + SELECT id, zelle, grund, TIMESTAMPDIFF(MINUTE, NOW(), faellig_am) AS rest + FROM police_gewahrsam + WHERE `character` = ? AND entlassen_am IS NULL + LIMIT 1 + ]], { spieler.charakterId }) + + if not eintrag or not eintrag[1] then return end + + local zelle = HoleZelle(eintrag[1].zelle) + if not zelle then return end + + -- Kurz warten, bis der Spieler wirklich in der Welt ist + Wait(3000) + + TriggerClientEvent('aether:police:gewahrsamVerbringen', quelle, { + x = zelle.pos.x, y = zelle.pos.y, z = zelle.pos.z, w = zelle.pos.w, + zelle = zelle.name, + rest = tonumber(eintrag[1].rest) or 0, + grund = eintrag[1].grund, + }) +end) + +-- ===================================================================== +-- EXPORT — Auskunft für andere Module +-- ===================================================================== + +--- Befindet sich eine Person in Gewahrsam? +exports('IstInGewahrsam', function(charakterId) + local treffer = MySQL.scalar.await( + 'SELECT id FROM police_gewahrsam WHERE `character` = ? AND entlassen_am IS NULL', + { charakterId }) + return treffer ~= nil +end) diff --git a/resources/[aether]/aether-police/server/module/kommunikation.lua b/resources/[aether]/aether-police/server/module/kommunikation.lua new file mode 100644 index 0000000..b17d901 --- /dev/null +++ b/resources/[aether]/aether-police/server/module/kommunikation.lua @@ -0,0 +1,573 @@ +-- ===================================================================== +-- aether-police / Modul: Interne Kommunikation +-- +-- Zwei Wege: Nachrichten zwischen Beamten und Aushänge der Leitung. +-- +-- Das System stellt zu, sortiert und erinnert. Es verfasst nichts, +-- leitet nichts weiter und bestätigt keine Kenntnisnahme im Namen +-- eines Beamten. Ein gelesener Posteingang ist reine Anzeige — eine +-- Kenntnisnahme dagegen ist eine Erklärung und braucht einen Klick. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'kommunikation', name = 'Kommunikation', icon = '✉', + reihenfolge = 12, recht = nil, + beschreibung = 'Nachrichten, Aushänge und Dienstanweisungen', +}) + +-- Erlaubte Kategorien eines Aushangs +local AUSHANG_KATEGORIEN = { + anweisung = 'Dienstanweisung', + information = 'Information', + warnung = 'Warnung', +} + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Baut die Bedingung, welche Nachrichten einen Beamten erreichen +--- @param beamter table Empfänger +--- @return string Bedingung, table Werte +local function EmpfangsBedingung(beamter) + return [[( + (n.empf_typ = 'person' AND n.empfaenger = ?) OR + (n.empf_typ = 'abteilung' AND n.empfaenger = ?) OR + (n.empf_typ = 'rundmeldung') + )]], { tostring(beamter.charakterId), beamter.abteilung } +end + +--- Zählt ungelesene Nachrichten eines Beamten +--- @param beamter table Empfänger +--- @return number +local function ZaehleUngelesen(beamter) + local bedingung, werte = EmpfangsBedingung(beamter) + + return MySQL.scalar.await(([[ + SELECT COUNT(*) FROM police_nachricht n + WHERE %s + AND n.absender <> ? + AND NOT EXISTS ( + SELECT 1 FROM police_nachricht_gelesen g + WHERE g.nachricht = n.id AND g.`character` = ? + ) + ]]):format(bedingung), + { werte[1], werte[2], beamter.charakterId, beamter.charakterId }) or 0 +end + +--- Zählt offene Kenntnisnahmen eines Beamten +--- @param beamter table Empfänger +--- @return number +local function ZaehleOffeneKenntnis(beamter) + return MySQL.scalar.await([[ + SELECT COUNT(*) FROM police_aushang a + WHERE a.aktiv = 1 AND a.kenntnispflicht = 1 + AND (a.abteilung IS NULL OR a.abteilung = ?) + AND NOT EXISTS ( + SELECT 1 FROM police_aushang_kenntnis k + WHERE k.aushang = a.id AND k.`character` = ? + ) + ]], { beamter.abteilung, beamter.charakterId }) or 0 +end + +--- Liefert den Namen einer Abteilung +local function AbteilungsName(id) + for _, eintrag in ipairs(Polizei.Config.Abteilungen) do + if eintrag.id == id then return eintrag.name end + end + return id +end + +-- ===================================================================== +-- POSTEINGANG +-- ===================================================================== + +Kern.RegistriereAktion('kommunikation', 'uebersicht', nil, function(quelle, beamter, daten) + local ordner = tostring(daten.ordner or 'eingang') + local bedingung, werte = EmpfangsBedingung(beamter) + local nachrichten + + if ordner == 'gesendet' then + nachrichten = MySQL.query.await([[ + SELECT n.id, n.empfaenger, n.empf_typ, n.betreff, n.text, n.gesendet, + n.absender, c.firstname, c.lastname, p.dienstnummer, + 1 AS gelesen + FROM police_nachricht n + JOIN aether_characters c ON c.id = n.absender + LEFT JOIN police_personal p ON p.`character` = n.absender + WHERE n.absender = ? + ORDER BY n.id DESC LIMIT 100 + ]], { beamter.charakterId }) or {} + else + nachrichten = MySQL.query.await(([[ + SELECT n.id, n.empfaenger, n.empf_typ, n.betreff, n.text, n.gesendet, + n.absender, c.firstname, c.lastname, p.dienstnummer, + (SELECT COUNT(*) FROM police_nachricht_gelesen g + WHERE g.nachricht = n.id AND g.`character` = ?) AS gelesen + FROM police_nachricht n + JOIN aether_characters c ON c.id = n.absender + LEFT JOIN police_personal p ON p.`character` = n.absender + WHERE %s AND n.absender <> ? + ORDER BY n.id DESC LIMIT 100 + ]]):format(bedingung), + { beamter.charakterId, werte[1], werte[2], beamter.charakterId }) or {} + end + + -- Empfängernamen und Vorschau ergänzen + for _, eintrag in ipairs(nachrichten) do + eintrag.absenderName = eintrag.firstname .. ' ' .. eintrag.lastname + eintrag.gelesen = tonumber(eintrag.gelesen) > 0 + + if eintrag.empf_typ == 'rundmeldung' then + eintrag.empfaengerName = 'Alle Einheiten' + elseif eintrag.empf_typ == 'abteilung' then + eintrag.empfaengerName = AbteilungsName(eintrag.empfaenger) + else + local ziel = MySQL.query.await([[ + SELECT c.firstname, c.lastname, p.dienstnummer + FROM aether_characters c + LEFT JOIN police_personal p ON p.`character` = c.id + WHERE c.id = ? + ]], { tonumber(eintrag.empfaenger) or 0 }) + eintrag.empfaengerName = ziel and ziel[1] + and (ziel[1].firstname .. ' ' .. ziel[1].lastname) or 'Unbekannt' + end + + -- Vorschau kurz halten, der volle Text kommt beim Öffnen + eintrag.vorschau = tostring(eintrag.text):gsub('%s+', ' '):sub(1, 120) + eintrag.text = nil + eintrag.firstname, eintrag.lastname = nil, nil + end + + return { + ordner = ordner, + nachrichten = nachrichten, + ungelesen = ZaehleUngelesen(beamter), + offeneKenntnis = ZaehleOffeneKenntnis(beamter), + abteilungen = Polizei.Config.Abteilungen, + meineAbteilung = beamter.abteilung, + rechte = { + senden = Rechte.Hat(beamter, 'nachricht.senden'), + rundmeldung = Rechte.Hat(beamter, 'nachricht.rundmeldung'), + aushang = Rechte.Hat(beamter, 'aushang.schreiben'), + archivieren = Rechte.Hat(beamter, 'aushang.archivieren'), + }, + } +end) + +Kern.RegistriereAktion('kommunikation', 'nachrichtOeffnen', nil, function(quelle, beamter, daten) + local id = tonumber(daten.id) + if not id then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT n.*, c.firstname, c.lastname, p.dienstnummer, p.rolle + FROM police_nachricht n + JOIN aether_characters c ON c.id = n.absender + LEFT JOIN police_personal p ON p.`character` = n.absender + WHERE n.id = ? + ]], { id }) + if not zeilen or not zeilen[1] then return nil, 'Nachricht nicht gefunden.' end + + local nachricht = zeilen[1] + + -- Zustellprüfung: nur Absender und tatsächliche Empfänger dürfen lesen + local darf = nachricht.absender == beamter.charakterId + or nachricht.empf_typ == 'rundmeldung' + or (nachricht.empf_typ == 'abteilung' and nachricht.empfaenger == beamter.abteilung) + or (nachricht.empf_typ == 'person' + and tonumber(nachricht.empfaenger) == beamter.charakterId) + if not darf then return nil, 'Diese Nachricht ist nicht an dich gerichtet.' end + + -- Lesestatus vermerken: das ist reine Anzeige, keine Erklärung + if nachricht.absender ~= beamter.charakterId then + MySQL.query([[ + INSERT IGNORE INTO police_nachricht_gelesen (nachricht, `character`) + VALUES (?, ?) + ]], { id, beamter.charakterId }) + end + + local rolle = Polizei.HoleRolle(nachricht.rolle) + + return { + id = nachricht.id, + betreff = nachricht.betreff, + text = nachricht.text, + gesendet = nachricht.gesendet, + absender = nachricht.absender, + absenderName = nachricht.firstname .. ' ' .. nachricht.lastname, + dienstnummer = nachricht.dienstnummer, + rolleName = rolle and rolle.name or nachricht.rolle, + empfTyp = nachricht.empf_typ, + empfaengerName = nachricht.empf_typ == 'rundmeldung' and 'Alle Einheiten' + or nachricht.empf_typ == 'abteilung' and AbteilungsName(nachricht.empfaenger) + or nil, + -- Antworten geht nur bei einer Nachricht von einer Person + antwortAn = nachricht.absender ~= beamter.charakterId and nachricht.absender or nil, + } +end) + +--- Auswahlliste möglicher Empfänger +Kern.RegistriereAktion('kommunikation', 'empfaenger', 'nachricht.senden', function(quelle, beamter) + local beamte = MySQL.query.await([[ + SELECT p.`character`, p.dienstnummer, p.rolle, p.abteilung, + c.firstname, c.lastname + FROM police_personal p + JOIN aether_characters c ON c.id = p.`character` + WHERE p.aktiv = 1 AND p.`character` <> ? + ORDER BY p.dienstnummer + ]], { beamter.charakterId }) or {} + + -- Wer gerade erreichbar ist, wird markiert — mehr nicht + local imDienst = {} + for _, eintrag in pairs(Dienst.AlleImDienst()) do + imDienst[eintrag.charakterId] = true + end + + for _, eintrag in ipairs(beamte) do + local rolle = Polizei.HoleRolle(eintrag.rolle) + eintrag.rolleName = rolle and rolle.name or eintrag.rolle + eintrag.imDienst = imDienst[eintrag.character] == true + end + + return { + beamte = beamte, + abteilungen = Polizei.Config.Abteilungen, + rundmeldung = Rechte.Hat(beamter, 'nachricht.rundmeldung'), + } +end) + +Kern.RegistriereAktion('kommunikation', 'senden', 'nachricht.senden', function(quelle, beamter, daten) + local typ = tostring(daten.empfTyp or 'person') + local text = type(daten.text) == 'string' and daten.text or '' + local betreff = type(daten.betreff) == 'string' and daten.betreff:sub(1, 160) or '' + + if #text < 2 then return nil, 'Die Nachricht ist leer.' end + if #text > 4000 then return nil, 'Die Nachricht ist zu lang.' end + if #betreff < 2 then return nil, 'Bitte einen Betreff angeben.' end + + local empfaenger, hinweisText + + if typ == 'rundmeldung' then + if not Rechte.Hat(beamter, 'nachricht.rundmeldung') then + return nil, 'Du darfst keine Rundmeldung absetzen.' + end + empfaenger = 'alle' + hinweisText = 'Rundmeldung von ' .. beamter.name + + elseif typ == 'abteilung' then + local gefunden = false + for _, eintrag in ipairs(Polizei.Config.Abteilungen) do + if eintrag.id == daten.empfaenger then gefunden = true break end + end + if not gefunden then return nil, 'Unbekannte Abteilung.' end + empfaenger = daten.empfaenger + hinweisText = 'Nachricht an ' .. AbteilungsName(empfaenger) + + else + typ = 'person' + local zielId = tonumber(daten.empfaenger) + if not zielId then return nil, 'Kein Empfänger gewählt.' end + + local vorhanden = MySQL.scalar.await( + 'SELECT `character` FROM police_personal WHERE `character` = ? AND aktiv = 1', + { zielId }) + if not vorhanden then return nil, 'Dieser Empfänger ist nicht im Dienstbestand.' end + + empfaenger = tostring(zielId) + hinweisText = 'Nachricht von ' .. beamter.name + end + + local id = MySQL.insert.await([[ + INSERT INTO police_nachricht (absender, empfaenger, empf_typ, betreff, text) + VALUES (?, ?, ?, ?, ?) + ]], { beamter.charakterId, empfaenger, typ, betreff, text }) + + -- Zustellung: nur benachrichtigen, nicht öffnen + if typ == 'person' then + Hinweise.Erstelle({ charakterId = tonumber(empfaenger) }, { + kategorie = 'system', prioritaet = 'normal', + titel = hinweisText, text = betreff, + zielTyp = 'nachricht', zielId = id, + }) + else + for _, ziel in pairs(Dienst.AlleImDienst()) do + local passt = typ == 'rundmeldung' or ziel.abteilung == empfaenger + if passt and ziel.charakterId ~= beamter.charakterId then + Hinweise.Erstelle({ charakterId = ziel.charakterId }, { + kategorie = 'system', + prioritaet = typ == 'rundmeldung' and 'hoch' or 'normal', + titel = hinweisText, text = betreff, + zielTyp = 'nachricht', zielId = id, + }) + end + end + end + + Protokoll.Schreibe(beamter, 'kommunikation', 'Nachricht gesendet', + { typ = 'nachricht', id = id }, { empfTyp = typ, empfaenger = empfaenger }) + + Kern.Loese('nachricht.gesendet', id, beamter) + + return { id = id } +end) + +-- ===================================================================== +-- AUSHÄNGE +-- ===================================================================== + +Kern.RegistriereAktion('kommunikation', 'aushaenge', nil, function(quelle, beamter, daten) + local archiv = daten.archiv == true + + local aushaenge = MySQL.query.await([[ + SELECT a.id, a.titel, a.text, a.kategorie, a.abteilung, a.angeheftet, + a.kenntnispflicht, a.frist, a.erstellt_am, a.erstellt_von, a.aktiv, + a.archiviert_am, + c.firstname, c.lastname, p.dienstnummer, + (SELECT COUNT(*) FROM police_aushang_kenntnis k WHERE k.aushang = a.id) + AS kenntnisse, + (SELECT COUNT(*) FROM police_aushang_kenntnis k + WHERE k.aushang = a.id AND k.`character` = ?) AS meineKenntnis + FROM police_aushang a + JOIN aether_characters c ON c.id = a.erstellt_von + LEFT JOIN police_personal p ON p.`character` = a.erstellt_von + WHERE a.aktiv = ? + AND (? = 1 OR a.abteilung IS NULL OR a.abteilung = ?) + ORDER BY a.angeheftet DESC, a.id DESC + LIMIT 100 + ]], { + beamter.charakterId, archiv and 0 or 1, + -- Wer Aushänge veröffentlichen darf, sieht auch die anderer Abteilungen + Rechte.Hat(beamter, 'aushang.schreiben') and 1 or 0, + beamter.abteilung, + }) or {} + + -- Wie viele Beamte kämen für eine Kenntnisnahme überhaupt in Frage? + for _, eintrag in ipairs(aushaenge) do + eintrag.autor = eintrag.firstname .. ' ' .. eintrag.lastname + eintrag.kategorieName = AUSHANG_KATEGORIEN[eintrag.kategorie] or eintrag.kategorie + eintrag.abteilungName = eintrag.abteilung and AbteilungsName(eintrag.abteilung) or nil + eintrag.bestaetigt = tonumber(eintrag.meineKenntnis) > 0 + eintrag.angeheftet = eintrag.angeheftet == 1 + eintrag.kenntnispflicht = eintrag.kenntnispflicht == 1 + + eintrag.betroffen = MySQL.scalar.await([[ + SELECT COUNT(*) FROM police_personal + WHERE aktiv = 1 AND (? IS NULL OR abteilung = ?) + ]], { eintrag.abteilung, eintrag.abteilung }) or 0 + + eintrag.firstname, eintrag.lastname, eintrag.meineKenntnis = nil, nil, nil + end + + return { + aushaenge = aushaenge, + archiv = archiv, + kategorien = AUSHANG_KATEGORIEN, + abteilungen= Polizei.Config.Abteilungen, + rechte = { + schreiben = Rechte.Hat(beamter, 'aushang.schreiben'), + archivieren = Rechte.Hat(beamter, 'aushang.archivieren'), + }, + } +end) + +Kern.RegistriereAktion('kommunikation', 'aushangErstellen', 'aushang.schreiben', function(quelle, beamter, daten) + local titel = type(daten.titel) == 'string' and daten.titel:sub(1, 160) or '' + local text = type(daten.text) == 'string' and daten.text or '' + + if #titel < 3 then return nil, 'Bitte einen Titel angeben.' end + if #text < 5 then return nil, 'Der Aushang hat keinen Inhalt.' end + if #text > 8000 then return nil, 'Der Aushang ist zu lang.' end + + local kategorie = AUSHANG_KATEGORIEN[daten.kategorie] and daten.kategorie or 'information' + + -- Abteilung: leer bedeutet gesamte Behörde + local abteilung = nil + if daten.abteilung and daten.abteilung ~= '' then + for _, eintrag in ipairs(Polizei.Config.Abteilungen) do + if eintrag.id == daten.abteilung then abteilung = eintrag.id break end + end + if not abteilung then return nil, 'Unbekannte Abteilung.' end + end + + -- Frist nur sinnvoll, wenn eine Kenntnisnahme verlangt wird + local kenntnispflicht = daten.kenntnispflicht == true + local fristMinuten = tonumber(daten.fristMinuten) + if fristMinuten and (fristMinuten < 5 or fristMinuten > 20160) then + return nil, 'Die Frist muss zwischen 5 Minuten und 14 Tagen liegen.' + end + if not kenntnispflicht then fristMinuten = nil end + + local id = MySQL.insert.await([[ + INSERT INTO police_aushang + (titel, text, kategorie, abteilung, angeheftet, kenntnispflicht, frist, erstellt_von) + VALUES (?, ?, ?, ?, ?, ?, + CASE WHEN ? IS NULL THEN NULL ELSE DATE_ADD(NOW(), INTERVAL ? MINUTE) END, ?) + ]], { + titel, text, kategorie, abteilung, + daten.angeheftet == true and 1 or 0, + kenntnispflicht and 1 or 0, + fristMinuten, fristMinuten, + beamter.charakterId, + }) + + -- Betroffene informieren, aber nichts für sie bestätigen + for _, ziel in pairs(Dienst.AlleImDienst()) do + if (abteilung == nil or ziel.abteilung == abteilung) + and ziel.charakterId ~= beamter.charakterId then + Hinweise.Erstelle({ charakterId = ziel.charakterId }, { + kategorie = 'system', + prioritaet = kategorie == 'warnung' and 'hoch' or 'normal', + titel = 'Neuer Aushang: ' .. (AUSHANG_KATEGORIEN[kategorie] or kategorie), + text = titel, + zielTyp = 'aushang', zielId = id, + }) + end + end + + Protokoll.Schreibe(beamter, 'kommunikation', 'Aushang veröffentlicht', + { typ = 'aushang', id = id }, + { kategorie = kategorie, abteilung = abteilung, kenntnispflicht = kenntnispflicht }) + + Kern.Loese('aushang.erstellt', id, beamter) + + return { id = id } +end) + +--- Kenntnisnahme: bestätigt ausschließlich der Beamte selbst +Kern.RegistriereAktion('kommunikation', 'kenntnisnahme', nil, function(quelle, beamter, daten) + local id = tonumber(daten.id) + if not id then return nil, 'Ungültige Kennung.' end + + local aushang = MySQL.query.await( + 'SELECT id, titel, abteilung, aktiv FROM police_aushang WHERE id = ?', { id }) + if not aushang or not aushang[1] then return nil, 'Aushang nicht gefunden.' end + if aushang[1].aktiv ~= 1 then return nil, 'Dieser Aushang ist archiviert.' end + + if aushang[1].abteilung and aushang[1].abteilung ~= beamter.abteilung then + return nil, 'Dieser Aushang betrifft deine Abteilung nicht.' + end + + MySQL.query.await([[ + INSERT IGNORE INTO police_aushang_kenntnis (aushang, `character`) + VALUES (?, ?) + ]], { id, beamter.charakterId }) + + Protokoll.Schreibe(beamter, 'kommunikation', 'Kenntnisnahme bestätigt', + { typ = 'aushang', id = id }, { titel = aushang[1].titel }) + + return { ok = true } +end) + +--- Wer hat bestätigt, wer noch nicht — reine Auskunft für den Verfasser +Kern.RegistriereAktion('kommunikation', 'kenntnisStand', 'aushang.schreiben', function(quelle, beamter, daten) + local id = tonumber(daten.id) + if not id then return nil, 'Ungültige Kennung.' end + + local aushang = MySQL.query.await( + 'SELECT id, titel, abteilung, frist FROM police_aushang WHERE id = ?', { id }) + if not aushang or not aushang[1] then return nil, 'Aushang nicht gefunden.' end + + local abteilung = aushang[1].abteilung + + local beamte = MySQL.query.await([[ + SELECT p.`character`, p.dienstnummer, p.abteilung, + c.firstname, c.lastname, + k.bestaetigt_am + FROM police_personal p + JOIN aether_characters c ON c.id = p.`character` + LEFT JOIN police_aushang_kenntnis k + ON k.aushang = ? AND k.`character` = p.`character` + WHERE p.aktiv = 1 AND (? IS NULL OR p.abteilung = ?) + ORDER BY k.bestaetigt_am IS NULL DESC, p.dienstnummer + ]], { id, abteilung, abteilung }) or {} + + local offen = 0 + for _, eintrag in ipairs(beamte) do + eintrag.name = eintrag.firstname .. ' ' .. eintrag.lastname + eintrag.firstname, eintrag.lastname = nil, nil + if not eintrag.bestaetigt_am then offen = offen + 1 end + end + + return { + titel = aushang[1].titel, + frist = aushang[1].frist, + beamte = beamte, + offen = offen, + gesamt = #beamte, + } +end) + +Kern.RegistriereAktion('kommunikation', 'aushangArchivieren', 'aushang.archivieren', function(quelle, beamter, daten) + local id = tonumber(daten.id) + if not id then return nil, 'Ungültige Kennung.' end + + local aushang = MySQL.query.await( + 'SELECT id, titel, aktiv FROM police_aushang WHERE id = ?', { id }) + if not aushang or not aushang[1] then return nil, 'Aushang nicht gefunden.' end + if aushang[1].aktiv ~= 1 then return nil, 'Dieser Aushang ist bereits archiviert.' end + + MySQL.update.await([[ + UPDATE police_aushang + SET aktiv = 0, archiviert_von = ?, archiviert_am = NOW() + WHERE id = ? + ]], { beamter.charakterId, id }) + + Protokoll.Schreibe(beamter, 'kommunikation', 'Aushang archiviert', + { typ = 'aushang', id = id }, { titel = aushang[1].titel }, daten.grund) + + return { ok = true } +end) + +Kern.RegistriereAktion('kommunikation', 'anheften', 'aushang.schreiben', function(quelle, beamter, daten) + local id = tonumber(daten.id) + if not id then return nil, 'Ungültige Kennung.' end + + local angeheftet = daten.angeheftet == true + + MySQL.update.await('UPDATE police_aushang SET angeheftet = ? WHERE id = ? AND aktiv = 1', + { angeheftet and 1 or 0, id }) + + Protokoll.Schreibe(beamter, 'kommunikation', + angeheftet and 'Aushang angeheftet' or 'Aushang losgelöst', + { typ = 'aushang', id = id }) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Zählt offene Post eines Beamten im Dienst +--- @param quelle number Server-ID +--- @return table { nachrichten, kenntnisnahmen } +exports('OffenePost', function(quelle) + local beamter = Dienst.Hole(quelle) + if not beamter then return { nachrichten = 0, kenntnisnahmen = 0 } end + return { + nachrichten = ZaehleUngelesen(beamter), + kenntnisnahmen = ZaehleOffeneKenntnis(beamter), + } +end) + +--- Schickt eine Nachricht aus einer anderen Ressource in den Posteingang. +--- Der Absender muss ein Beamter sein — das System schreibt nicht selbst. +--- @param absenderCharakterId number Verfassender Beamter +--- @param empfTyp string person/abteilung/rundmeldung +--- @param empfaenger string|number Ziel +--- @param betreff string Betreff +--- @param text string Inhalt +--- @return number|nil Nachrichten-ID +exports('SendeNachricht', function(absenderCharakterId, empfTyp, empfaenger, betreff, text) + if not absenderCharakterId or not betreff or not text then return nil end + + local quelle = Dienst.QuelleVonCharakter(absenderCharakterId) + local beamter = quelle and Dienst.Hole(quelle) + if not beamter then return nil end + + local ergebnis = Kern.FuehreAus(quelle, beamter, 'kommunikation', 'senden', { + empfTyp = empfTyp, empfaenger = empfaenger, + betreff = betreff, text = text, + }) + return ergebnis and ergebnis.id or nil +end) diff --git a/resources/[aether]/aether-police/server/module/mdt.lua b/resources/[aether]/aether-police/server/module/mdt.lua new file mode 100644 index 0000000..4cf8410 --- /dev/null +++ b/resources/[aether]/aether-police/server/module/mdt.lua @@ -0,0 +1,635 @@ +-- ===================================================================== +-- aether-police / Modul: MDT +-- +-- Personensuche, Fahrzeugsuche und Aktenverwaltung. +-- Das MDT zeigt und dokumentiert — es bewertet nichts und ergreift +-- keine Maßnahme. Jede Änderung wird protokolliert. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'mdt', name = 'MDT', icon = '🔎', + reihenfolge = 2, recht = 'person.suchen', + beschreibung = 'Personen, Fahrzeuge und Akten', +}) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Liest den Namen eines Beamten anhand der Charakter-ID +--- @param charakterId number|nil +--- @return string +local function NameVon(charakterId) + if not charakterId then return '—' end + local zeile = MySQL.query.await( + 'SELECT firstname, lastname FROM aether_characters WHERE id = ?', { charakterId }) + if not zeile or not zeile[1] then return '#' .. tostring(charakterId) end + return zeile[1].firstname .. ' ' .. zeile[1].lastname +end + +--- Prüft, ob eine Zeichenkette als Suchbegriff taugt +local function SuchbegriffGueltig(begriff) + return type(begriff) == 'string' and #begriff:gsub('%s', '') >= 2 +end + +-- ===================================================================== +-- PERSONENSUCHE +-- ===================================================================== + +Kern.RegistriereAktion('mdt', 'personSuchen', 'person.suchen', function(quelle, beamter, daten) + if not SuchbegriffGueltig(daten.begriff) then + return nil, 'Bitte mindestens zwei Zeichen eingeben.' + end + + local muster = '%' .. daten.begriff .. '%' + + -- Sucht über Name, Geburtsdatum und Telefonnummer + local treffer = MySQL.query.await([[ + SELECT c.id, c.firstname, c.lastname, c.dob, c.gender, c.job, + p.warnhinweis, p.gefahrenstufe, p.lichtbild, + t.number AS telefon, + (SELECT COUNT(*) FROM police_fahndung f + WHERE f.typ = 'person' AND f.ziel_id = c.id AND f.aktiv = 1) AS fahndung + FROM aether_characters c + LEFT JOIN police_person p ON p.`character` = c.id + LEFT JOIN aether_phone t ON t.`character` = c.id + WHERE c.deleted = 0 + AND (c.firstname LIKE ? OR c.lastname LIKE ? + OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? + OR c.dob LIKE ? OR t.number LIKE ?) + ORDER BY c.lastname, c.firstname + LIMIT 40 + ]], { muster, muster, muster, muster, muster }) or {} + + return { treffer = treffer } +end) + +-- ===================================================================== +-- PERSONENAKTE +-- ===================================================================== + +Kern.RegistriereAktion('mdt', 'personAkte', 'person.suchen', function(quelle, beamter, daten) + local charakterId = tonumber(daten.charakterId) + if not charakterId then return nil, 'Ungültige Kennung.' end + + -- --- Stammdaten --- + local person = MySQL.query.await([[ + SELECT c.id, c.firstname, c.lastname, c.dob, c.gender, c.nationality, c.job, + c.iban, p.lichtbild, p.fingerabdruck, p.dna, p.warnhinweis, p.gefahrenstufe + FROM aether_characters c + LEFT JOIN police_person p ON p.`character` = c.id + WHERE c.id = ? AND c.deleted = 0 + ]], { charakterId }) + + if not person or not person[1] then return nil, 'Person nicht gefunden.' end + + -- --- Telefonnummer --- + local telefon = MySQL.scalar.await( + 'SELECT number FROM aether_phone WHERE `character` = ?', { charakterId }) + + -- --- Dokumente (Führerschein, Waffenschein ...) --- + local dokumente = MySQL.query.await([[ + SELECT type, data, issued_by, issued_at, valid_until, revoked + FROM aether_documents + WHERE `character` = ? + ]], { charakterId }) or {} + + local lizenzen = {} + for _, dokument in ipairs(dokumente) do + local zusatz = {} + if dokument.data then + local erfolg, entschluesselt = pcall(json.decode, dokument.data) + if erfolg and type(entschluesselt) == 'table' then zusatz = entschluesselt end + end + -- Gültig, wenn nicht entzogen und (unbefristet oder noch nicht abgelaufen) + local gueltig = dokument.revoked == 0 + if gueltig and dokument.valid_until then + gueltig = tostring(dokument.valid_until):sub(1, 10) >= os.date('%Y-%m-%d') + end + lizenzen[#lizenzen + 1] = { + typ = dokument.type, nummer = zusatz.nummer, klassen = zusatz.klassen, + waffen = zusatz.waffen, aussteller = dokument.issued_by, + gueltigBis = dokument.valid_until and tostring(dokument.valid_until):sub(1, 10) or nil, + entzogen = dokument.revoked == 1, gueltig = gueltig, + } + end + + -- --- Bekannte Fahrzeuge --- + local fahrzeuge = MySQL.query.await([[ + SELECT v.plate, v.model, v.props, v.state, v.garage, + (SELECT COUNT(*) FROM police_fahndung f + WHERE f.typ = 'fahrzeug' AND f.ziel_id = v.plate AND f.aktiv = 1) AS fahndung + FROM aether_vehicles v WHERE v.owner = ? + ]], { charakterId }) or {} + + for _, fahrzeug in ipairs(fahrzeuge) do + if fahrzeug.props then + local erfolg, props = pcall(json.decode, fahrzeug.props) + if erfolg and type(props) == 'table' then + fahrzeug.name = props.name + fahrzeug.farbe = props.farbe + end + end + fahrzeug.props = nil + end + + -- --- Bekannte Kontakte (aus dem Telefonbuch) --- + local kontakte = MySQL.query.await([[ + SELECT k.name, k.number, + (SELECT c2.id FROM aether_phone p2 + JOIN aether_characters c2 ON c2.id = p2.`character` + WHERE p2.number = k.number LIMIT 1) AS charakterId + FROM aether_phone_contacts k + WHERE k.`character` = ? + ORDER BY k.name LIMIT 40 + ]], { charakterId }) or {} + + -- --- Notizen --- + local notizen = MySQL.query.await([[ + SELECT n.id, n.text, n.am, c.firstname, c.lastname + FROM police_person_notiz n + LEFT JOIN aether_characters c ON c.id = n.von + WHERE n.`character` = ? + ORDER BY n.id DESC LIMIT 50 + ]], { charakterId }) or {} + + -- --- Verknüpfte Akten --- + local akten = MySQL.query.await([[ + SELECT a.id, a.aktenzeichen, a.typ, a.titel, a.status, a.erstellt_am, v.rolle + FROM police_verknuepfung v + JOIN police_akte a ON a.id = v.akte + WHERE v.typ = 'person' AND v.ziel_id = ? + ORDER BY a.id DESC LIMIT 50 + ]], { tostring(charakterId) }) or {} + + -- --- Anzeigen (nur bestätigte zählen als Vorgeschichte) --- + local anzeigen = MySQL.query.await([[ + SELECT id, delikte, haft_final, geld_final, status, erstellt_am, bestaetigt_am + FROM police_anzeige + WHERE beschuldigter = ? + ORDER BY id DESC LIMIT 30 + ]], { charakterId }) or {} + + for _, anzeige in ipairs(anzeigen) do + local erfolg, liste = pcall(json.decode, anzeige.delikte) + anzeige.delikte = (erfolg and type(liste) == 'table') and liste or {} + end + + -- --- Offene Fahndung --- + local fahndung = MySQL.query.await([[ + SELECT id, grund, gefahr, prioritaet, hinweise, erstellt_am + FROM police_fahndung + WHERE typ = 'person' AND ziel_id = ? AND aktiv = 1 + LIMIT 1 + ]], { tostring(charakterId) }) + + -- --- Laufender Gewahrsam --- + local gewahrsam = MySQL.query.await([[ + SELECT id, grund, zelle, faellig_am, + TIMESTAMPDIFF(MINUTE, NOW(), faellig_am) AS rest + FROM police_gewahrsam + WHERE `character` = ? AND entlassen_am IS NULL + LIMIT 1 + ]], { charakterId }) + + return { + person = person[1], + telefon = telefon, + lizenzen = lizenzen, + fahrzeuge = fahrzeuge, + kontakte = kontakte, + notizen = notizen, + akten = akten, + anzeigen = anzeigen, + fahndung = fahndung and fahndung[1] or nil, + gewahrsam = gewahrsam and gewahrsam[1] or nil, + } +end) + +-- --------------------------------------------------------------------- +-- Notiz zu einer Person +-- --------------------------------------------------------------------- +Kern.RegistriereAktion('mdt', 'personNotiz', 'person.bearbeiten', function(quelle, beamter, daten) + local charakterId = tonumber(daten.charakterId) + local text = type(daten.text) == 'string' and daten.text:gsub('^%s+', ''):gsub('%s+$', '') or '' + + if not charakterId then return nil, 'Ungültige Kennung.' end + if #text < 3 or #text > 1000 then return nil, 'Die Notiz muss 3 bis 1000 Zeichen lang sein.' end + + local id = MySQL.insert.await( + 'INSERT INTO police_person_notiz (`character`, text, von) VALUES (?, ?, ?)', + { charakterId, text, beamter.charakterId }) + + Protokoll.Schreibe(beamter, 'mdt', 'Notiz hinzugefügt', + { typ = 'person', id = charakterId }, { notizId = id }) + + return { id = id } +end) + +Kern.RegistriereAktion('mdt', 'personNotizLoeschen', 'person.bearbeiten', function(quelle, beamter, daten) + local notizId = tonumber(daten.id) + if not notizId then return nil, 'Ungültige Kennung.' end + + -- Eigene Notizen darf jeder löschen; fremde nur ab Captain + local notiz = MySQL.query.await( + 'SELECT `character`, von FROM police_person_notiz WHERE id = ?', { notizId }) + if not notiz or not notiz[1] then return nil, 'Notiz nicht gefunden.' end + + if notiz[1].von ~= beamter.charakterId and not Rechte.Hat(beamter, 'protokoll.lesen') then + return nil, 'Du kannst nur eigene Notizen entfernen.' + end + + MySQL.update.await('DELETE FROM police_person_notiz WHERE id = ?', { notizId }) + Protokoll.Schreibe(beamter, 'mdt', 'Notiz entfernt', + { typ = 'person', id = notiz[1].character }, { notizId = notizId }) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Warnhinweis und Gefahrenstufe +-- --------------------------------------------------------------------- +Kern.RegistriereAktion('mdt', 'personWarnhinweis', 'person.warnhinweis', function(quelle, beamter, daten) + local charakterId = tonumber(daten.charakterId) + if not charakterId then return nil, 'Ungültige Kennung.' end + + local hinweis = type(daten.hinweis) == 'string' and daten.hinweis:sub(1, 255) or nil + if hinweis == '' then hinweis = nil end + + local stufe = math.max(0, math.min(3, tonumber(daten.stufe) or 0)) + + MySQL.query.await([[ + INSERT INTO police_person (`character`, warnhinweis, gefahrenstufe) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE warnhinweis = VALUES(warnhinweis), gefahrenstufe = VALUES(gefahrenstufe) + ]], { charakterId, hinweis, stufe }) + + Protokoll.Schreibe(beamter, 'mdt', + hinweis and 'Warnhinweis gesetzt' or 'Warnhinweis entfernt', + { typ = 'person', id = charakterId }, { hinweis = hinweis, stufe = stufe }, + daten.grund) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Erkennungsdienstliche Daten (Fingerabdruck, DNA, Lichtbild) +-- --------------------------------------------------------------------- +Kern.RegistriereAktion('mdt', 'personStammdaten', 'person.bearbeiten', function(quelle, beamter, daten) + local charakterId = tonumber(daten.charakterId) + if not charakterId then return nil, 'Ungültige Kennung.' end + + -- Vorhandene Werte lesen, damit leere Felder nichts überschreiben + local vorhanden = MySQL.query.await( + 'SELECT lichtbild, fingerabdruck, dna FROM police_person WHERE `character` = ?', + { charakterId }) + local alt = (vorhanden and vorhanden[1]) or {} + + local lichtbild = daten.lichtbild ~= nil and tostring(daten.lichtbild):sub(1, 2000) or alt.lichtbild + local finger = daten.fingerabdruck ~= nil and tostring(daten.fingerabdruck):sub(1, 64) or alt.fingerabdruck + local dna = daten.dna ~= nil and tostring(daten.dna):sub(1, 64) or alt.dna + + MySQL.query.await([[ + INSERT INTO police_person (`character`, lichtbild, fingerabdruck, dna) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + lichtbild = VALUES(lichtbild), fingerabdruck = VALUES(fingerabdruck), dna = VALUES(dna) + ]], { charakterId, lichtbild, finger, dna }) + + Protokoll.Schreibe(beamter, 'mdt', 'Erkennungsdienstliche Daten geändert', + { typ = 'person', id = charakterId }) + + return { ok = true } +end) + +-- ===================================================================== +-- FAHRZEUGSUCHE +-- ===================================================================== + +Kern.RegistriereAktion('mdt', 'fahrzeugSuchen', 'fahrzeug.suchen', function(quelle, beamter, daten) + if not SuchbegriffGueltig(daten.begriff) then + return nil, 'Bitte mindestens zwei Zeichen eingeben.' + end + + local muster = '%' .. daten.begriff:upper() .. '%' + local namensmuster = '%' .. daten.begriff .. '%' + + local treffer = MySQL.query.await([[ + SELECT v.plate, v.model, v.props, v.state, v.garage, v.owner, + c.firstname, c.lastname, + (SELECT COUNT(*) FROM police_fahndung f + WHERE f.typ = 'fahrzeug' AND f.ziel_id = v.plate AND f.aktiv = 1) AS fahndung + FROM aether_vehicles v + LEFT JOIN aether_characters c ON c.id = v.owner + WHERE UPPER(v.plate) LIKE ? + OR v.model LIKE ? + OR CONCAT(c.firstname, ' ', c.lastname) LIKE ? + ORDER BY v.plate LIMIT 40 + ]], { muster, namensmuster, namensmuster }) or {} + + for _, fahrzeug in ipairs(treffer) do + if fahrzeug.props then + local erfolg, props = pcall(json.decode, fahrzeug.props) + if erfolg and type(props) == 'table' then + fahrzeug.name = props.name + fahrzeug.farbe = props.farbe + fahrzeug.klasse = props.klasse + end + end + fahrzeug.props = nil + end + + return { treffer = treffer } +end) + +Kern.RegistriereAktion('mdt', 'fahrzeugAkte', 'fahrzeug.suchen', function(quelle, beamter, daten) + local kennzeichen = type(daten.kennzeichen) == 'string' and daten.kennzeichen:upper() or nil + if not kennzeichen then return nil, 'Ungültiges Kennzeichen.' end + + local fahrzeug = MySQL.query.await([[ + SELECT v.*, c.firstname, c.lastname, c.dob + FROM aether_vehicles v + LEFT JOIN aether_characters c ON c.id = v.owner + WHERE UPPER(v.plate) = ? + ]], { kennzeichen }) + + if not fahrzeug or not fahrzeug[1] then return nil, 'Kein Fahrzeug mit diesem Kennzeichen.' end + + local eintrag = fahrzeug[1] + if eintrag.props then + local erfolg, props = pcall(json.decode, eintrag.props) + if erfolg and type(props) == 'table' then + eintrag.name = props.name + eintrag.farbe = props.farbe + eintrag.klasse = props.klasse + end + eintrag.props = nil + end + + -- Fahndungsstatus + local fahndung = MySQL.query.await([[ + SELECT id, grund, gefahr, prioritaet, hinweise, erstellt_am + FROM police_fahndung + WHERE typ = 'fahrzeug' AND ziel_id = ? AND aktiv = 1 LIMIT 1 + ]], { eintrag.plate }) + + -- Verknüpfte Akten + local akten = MySQL.query.await([[ + SELECT a.id, a.aktenzeichen, a.typ, a.titel, a.status, a.erstellt_am + FROM police_verknuepfung v + JOIN police_akte a ON a.id = v.akte + WHERE v.typ = 'fahrzeug' AND v.ziel_id = ? + ORDER BY a.id DESC LIMIT 30 + ]], { eintrag.plate }) or {} + + -- Protokollhistorie zu diesem Fahrzeug + local historie = Protokoll.Lies({ zielTyp = 'fahrzeug', zielId = eintrag.plate, limit = 30 }) + + return { + fahrzeug = eintrag, + fahndung = fahndung and fahndung[1] or nil, + akten = akten, + historie = historie, + } +end) + +-- ===================================================================== +-- AKTEN +-- ===================================================================== + +Kern.RegistriereAktion('mdt', 'aktenListe', 'akte.lesen', function(quelle, beamter, daten) + local bedingungen, werte = { '1=1' }, {} + + if daten.typ and daten.typ ~= '' then + bedingungen[#bedingungen + 1] = 'a.typ = ?' + werte[#werte + 1] = daten.typ + end + if daten.status and daten.status ~= '' then + bedingungen[#bedingungen + 1] = 'a.status = ?' + werte[#werte + 1] = daten.status + end + if SuchbegriffGueltig(daten.begriff) then + bedingungen[#bedingungen + 1] = '(a.titel LIKE ? OR a.aktenzeichen LIKE ?)' + werte[#werte + 1] = '%' .. daten.begriff .. '%' + werte[#werte + 1] = '%' .. daten.begriff .. '%' + end + + local akten = MySQL.query.await(([[ + SELECT a.id, a.aktenzeichen, a.typ, a.titel, a.status, a.erstellt_am, a.version, + c.firstname, c.lastname + FROM police_akte a + LEFT JOIN aether_characters c ON c.id = a.erstellt_von + WHERE %s + ORDER BY a.id DESC LIMIT 60 + ]]):format(table.concat(bedingungen, ' AND ')), werte) or {} + + return { akten = akten } +end) + +Kern.RegistriereAktion('mdt', 'akteOeffnen', 'akte.lesen', function(quelle, beamter, daten) + local akteId = tonumber(daten.id) + if not akteId then return nil, 'Ungültige Kennung.' end + + local akte = MySQL.query.await([[ + SELECT a.*, c.firstname, c.lastname + FROM police_akte a + LEFT JOIN aether_characters c ON c.id = a.erstellt_von + WHERE a.id = ? + ]], { akteId }) + + if not akte or not akte[1] then return nil, 'Akte nicht gefunden.' end + + -- Verknüpfungen mit lesbaren Bezeichnungen anreichern + local verknuepfungen = MySQL.query.await( + 'SELECT * FROM police_verknuepfung WHERE akte = ? ORDER BY id ASC', { akteId }) or {} + + for _, verknuepfung in ipairs(verknuepfungen) do + if verknuepfung.typ == 'person' then + verknuepfung.bezeichnung = NameVon(tonumber(verknuepfung.ziel_id)) + elseif verknuepfung.typ == 'fahrzeug' then + local modell = MySQL.scalar.await( + 'SELECT model FROM aether_vehicles WHERE plate = ?', { verknuepfung.ziel_id }) + verknuepfung.bezeichnung = verknuepfung.ziel_id .. (modell and (' · ' .. modell) or '') + else + verknuepfung.bezeichnung = verknuepfung.ziel_id + end + end + + -- Versionshistorie + local versionen = MySQL.query.await([[ + SELECT v.version, v.geaendert_am, v.grund, c.firstname, c.lastname + FROM police_akte_version v + LEFT JOIN aether_characters c ON c.id = v.geaendert_von + WHERE v.akte = ? ORDER BY v.version DESC + ]], { akteId }) or {} + + return { + akte = akte[1], + verknuepfungen = verknuepfungen, + versionen = versionen, + darfBearbeiten = Rechte.Hat(beamter, 'akte.bearbeiten'), + darfFreigeben = Rechte.Hat(beamter, 'akte.freigeben'), + darfSchliessen = Rechte.Hat(beamter, 'akte.schliessen'), + } +end) + +Kern.RegistriereAktion('mdt', 'akteErstellen', 'akte.erstellen', function(quelle, beamter, daten) + local titel = type(daten.titel) == 'string' and daten.titel:gsub('^%s+', ''):gsub('%s+$', '') or '' + if #titel < 3 or #titel > 160 then return nil, 'Der Titel muss 3 bis 160 Zeichen lang sein.' end + + -- Aktenart gegen die Konfiguration prüfen + local gueltig = false + for _, art in ipairs(Polizei.Config.Aktenarten) do + if art.id == daten.typ then gueltig = true break end + end + if not gueltig then return nil, 'Unbekannte Aktenart.' end + + local aktenzeichen = Kern.ErzeugeKennung('AZ', 'police_akte', 'aktenzeichen') + + local akteId = MySQL.insert.await([[ + INSERT INTO police_akte (aktenzeichen, typ, titel, inhalt, erstellt_von) + VALUES (?, ?, ?, ?, ?) + ]], { aktenzeichen, daten.typ, titel, + type(daten.inhalt) == 'string' and daten.inhalt or nil, beamter.charakterId }) + + -- Erste Version festhalten + MySQL.insert('INSERT INTO police_akte_version (akte, version, inhalt, geaendert_von, grund) VALUES (?, 1, ?, ?, ?)', + { akteId, daten.inhalt, beamter.charakterId, 'Akte angelegt' }) + + -- Optionale Erstverknüpfung (z. B. direkt aus einer Personenakte heraus) + if daten.verknuepfeTyp and daten.verknuepfeId then + MySQL.insert('INSERT INTO police_verknuepfung (akte, typ, ziel_id, rolle) VALUES (?, ?, ?, ?)', + { akteId, tostring(daten.verknuepfeTyp), tostring(daten.verknuepfeId), daten.verknuepfeRolle }) + end + + Protokoll.Schreibe(beamter, 'mdt', 'Akte angelegt', + { typ = 'akte', id = akteId }, { aktenzeichen = aktenzeichen, typ = daten.typ }) + + Kern.Loese('akte.erstellt', akteId, beamter) + + return { id = akteId, aktenzeichen = aktenzeichen } +end) + +Kern.RegistriereAktion('mdt', 'akteBearbeiten', 'akte.bearbeiten', function(quelle, beamter, daten) + local akteId = tonumber(daten.id) + if not akteId then return nil, 'Ungültige Kennung.' end + + local akte = MySQL.query.await('SELECT * FROM police_akte WHERE id = ?', { akteId }) + if not akte or not akte[1] then return nil, 'Akte nicht gefunden.' end + + if akte[1].status == 'geschlossen' and not Rechte.Hat(beamter, 'akte.freigeben') then + return nil, 'Diese Akte ist geschlossen und kann nicht mehr bearbeitet werden.' + end + + local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or nil + if not grund or #grund < 3 then + return nil, 'Bitte gib einen Grund für die Änderung an.' + end + + local neueVersion = (akte[1].version or 1) + 1 + + MySQL.update.await('UPDATE police_akte SET titel = ?, inhalt = ?, version = ? WHERE id = ?', + { type(daten.titel) == 'string' and daten.titel:sub(1, 160) or akte[1].titel, + daten.inhalt, neueVersion, akteId }) + + MySQL.insert('INSERT INTO police_akte_version (akte, version, inhalt, geaendert_von, grund) VALUES (?, ?, ?, ?, ?)', + { akteId, neueVersion, daten.inhalt, beamter.charakterId, grund }) + + Protokoll.Schreibe(beamter, 'mdt', 'Akte bearbeitet', + { typ = 'akte', id = akteId }, { version = neueVersion }, grund) + + return { version = neueVersion } +end) + +--- Status einer Akte ändern — jede Stufe braucht ein eigenes Recht +Kern.RegistriereAktion('mdt', 'akteStatus', 'akte.lesen', function(quelle, beamter, daten) + local akteId = tonumber(daten.id) + local status = tostring(daten.status or '') + if not akteId then return nil, 'Ungültige Kennung.' end + + if status ~= 'offen' and status ~= 'pruefung' and status ~= 'geschlossen' then + return nil, 'Unbekannter Status.' + end + + -- Freigabe und Schließen sind gesondert geschützt + if status == 'geschlossen' and not Rechte.Hat(beamter, 'akte.schliessen') then + return nil, 'Dir fehlt die Berechtigung, Akten zu schließen.' + end + if status == 'pruefung' and not Rechte.Hat(beamter, 'akte.bearbeiten') then + return nil, 'Dir fehlt die Berechtigung dafür.' + end + + -- Beim Schließen wird festgehalten, WER es entschieden hat + if status == 'geschlossen' then + MySQL.update.await( + 'UPDATE police_akte SET status = ?, freigabe_von = ?, freigabe_am = NOW() WHERE id = ?', + { status, beamter.charakterId, akteId }) + else + MySQL.update.await('UPDATE police_akte SET status = ? WHERE id = ?', { status, akteId }) + end + + Protokoll.Schreibe(beamter, 'mdt', 'Aktenstatus geändert', + { typ = 'akte', id = akteId }, { status = status }, daten.grund) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Verknüpfungen +-- --------------------------------------------------------------------- +Kern.RegistriereAktion('mdt', 'akteVerknuepfen', 'akte.bearbeiten', function(quelle, beamter, daten) + local akteId = tonumber(daten.akteId) + local typ = tostring(daten.typ or '') + local zielId = tostring(daten.zielId or '') + + if not akteId or zielId == '' then return nil, 'Unvollständige Angaben.' end + + local erlaubt = { person = true, fahrzeug = true, beweis = true, einsatz = true, bericht = true } + if not erlaubt[typ] then return nil, 'Unbekannte Verknüpfungsart.' end + + -- Doppelte Verknüpfungen vermeiden + local vorhanden = MySQL.scalar.await( + 'SELECT id FROM police_verknuepfung WHERE akte = ? AND typ = ? AND ziel_id = ?', + { akteId, typ, zielId }) + if vorhanden then return nil, 'Diese Verknüpfung besteht bereits.' end + + local id = MySQL.insert.await( + 'INSERT INTO police_verknuepfung (akte, typ, ziel_id, rolle) VALUES (?, ?, ?, ?)', + { akteId, typ, zielId, daten.rolle }) + + Protokoll.Schreibe(beamter, 'mdt', 'Verknüpfung hinzugefügt', + { typ = 'akte', id = akteId }, { ziel = typ .. ':' .. zielId, rolle = daten.rolle }) + + return { id = id } +end) + +Kern.RegistriereAktion('mdt', 'akteVerknuepfungLoesen', 'akte.bearbeiten', function(quelle, beamter, daten) + local id = tonumber(daten.id) + if not id then return nil, 'Ungültige Kennung.' end + + local eintrag = MySQL.query.await('SELECT akte, typ, ziel_id FROM police_verknuepfung WHERE id = ?', { id }) + if not eintrag or not eintrag[1] then return nil, 'Verknüpfung nicht gefunden.' end + + MySQL.update.await('DELETE FROM police_verknuepfung WHERE id = ?', { id }) + + Protokoll.Schreibe(beamter, 'mdt', 'Verknüpfung entfernt', + { typ = 'akte', id = eintrag[1].akte }, + { ziel = eintrag[1].typ .. ':' .. eintrag[1].ziel_id }) + + return { ok = true } +end) + +-- --------------------------------------------------------------------- +-- Konfiguration für die Oberfläche +-- --------------------------------------------------------------------- +Kern.RegistriereAktion('mdt', 'stammdaten', nil, function() + return { + aktenarten = Polizei.Config.Aktenarten, + rollen = { + { id = 'beschuldigt', name = 'Beschuldigt' }, + { id = 'zeuge', name = 'Zeuge' }, + { id = 'geschaedigt', name = 'Geschädigt' }, + { id = 'beteiligt', name = 'Beteiligt' }, + }, + } +end) diff --git a/resources/[aether]/aether-police/server/module/personal.lua b/resources/[aether]/aether-police/server/module/personal.lua new file mode 100644 index 0000000..330c758 --- /dev/null +++ b/resources/[aether]/aether-police/server/module/personal.lua @@ -0,0 +1,564 @@ +-- ===================================================================== +-- aether-police / Modul: Personalverwaltung +-- +-- Rollen und Einzelrechte werden hier vergeben. Es gelten drei feste +-- Schranken, die sich nicht umgehen lassen: +-- 1. Niemand verwaltet Gleichrangige oder Höherstehende. +-- 2. Niemand vergibt eine Rolle auf oder über dem eigenen Rang. +-- 3. Niemand vergibt ein Recht, das er selbst nicht besitzt. +-- Zusätzlich kann sich niemand selbst bearbeiten. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'personal', name = 'Personal', icon = '👮', + reihenfolge = 11, recht = 'personal.sehen', + beschreibung = 'Dienstgrade, Rechte und Personalakten', +}) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Schlägt die nächste freie Dienstnummer vor +--- @return string +local function NaechsteDienstnummer() + local praefix = Polizei.Config.Kuerzel .. '-' + + -- Alle vergebenen Nummern einmal holen, dann die erste Lücke suchen + local vergeben = {} + local zeilen = MySQL.query.await( + 'SELECT dienstnummer FROM police_personal WHERE dienstnummer LIKE ?', + { praefix .. '%' }) or {} + for _, zeile in ipairs(zeilen) do + vergeben[zeile.dienstnummer] = true + end + + for nummer = 1, 9999 do + local kandidat = ('%s%04d'):format(praefix, nummer) + if not vergeben[kandidat] then return kandidat end + end + + return praefix .. tostring(os.time()):sub(-4) +end + +--- Wandelt eine JSON-Liste in eine Menge um +local function ZuMenge(roh) + local menge = {} + if not roh then return menge end + local erfolg, liste = pcall(json.decode, roh) + if erfolg and type(liste) == 'table' then + for _, wert in ipairs(liste) do menge[wert] = true end + end + return menge +end + +--- Wandelt eine Menge zurück in eine sortierte Liste +local function ZuListe(menge) + local liste = {} + for wert in pairs(menge) do liste[#liste + 1] = wert end + table.sort(liste) + return liste +end + +--- Prüft, ob ein Beamter einen anderen Personaldatensatz verwalten darf +--- @param beamter table Handelnder +--- @param ziel table Zeile aus police_personal +--- @return boolean, string|nil +local function DarfVerwalten(beamter, ziel) + if ziel.character == beamter.charakterId then + return false, 'Du kannst deinen eigenen Datensatz nicht bearbeiten.' + end + if not Rechte.StehtUeber(beamter, ziel.rolle) then + return false, 'Du kannst nur rangniedrigere Beamte verwalten.' + end + return true +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('personal', 'liste', nil, function(quelle, beamter, daten) + local bedingungen, werte = { '1=1' }, {} + + if daten.ehemalige then + bedingungen[#bedingungen + 1] = 'p.aktiv = 0' + else + bedingungen[#bedingungen + 1] = 'p.aktiv = 1' + end + + if daten.abteilung and daten.abteilung ~= '' then + bedingungen[#bedingungen + 1] = 'p.abteilung = ?' + werte[#werte + 1] = daten.abteilung + end + if daten.rolle and daten.rolle ~= '' then + bedingungen[#bedingungen + 1] = 'p.rolle = ?' + werte[#werte + 1] = daten.rolle + end + if type(daten.begriff) == 'string' and #daten.begriff >= 2 then + bedingungen[#bedingungen + 1] = + '(p.dienstnummer LIKE ? OR c.firstname LIKE ? OR c.lastname LIKE ?)' + local muster = '%' .. daten.begriff .. '%' + werte[#werte + 1] = muster + werte[#werte + 1] = muster + werte[#werte + 1] = muster + end + + local personal = MySQL.query.await(([[ + SELECT p.`character`, p.dienstnummer, p.rolle, p.abteilung, p.eingestellt, + p.aktiv, p.notiz, p.rechte_extra, p.rechte_entzug, + c.firstname, c.lastname, + (SELECT COALESCE(SUM(d.sekunden), 0) FROM police_dienst d + WHERE d.`character` = p.`character`) AS dienstzeit + FROM police_personal p + JOIN aether_characters c ON c.id = p.`character` + WHERE %s + ORDER BY p.dienstnummer + LIMIT 200 + ]]):format(table.concat(bedingungen, ' AND ')), werte) or {} + + -- Wer ist gerade im Dienst? + local imDienst = {} + for _, eintrag in pairs(Dienst.AlleImDienst()) do + imDienst[eintrag.charakterId] = true + end + + for _, eintrag in ipairs(personal) do + local rolle = Polizei.HoleRolle(eintrag.rolle) + eintrag.rolleName = rolle and rolle.name or eintrag.rolle + eintrag.stufe = Polizei.Stufe(eintrag.rolle) + eintrag.imDienst = imDienst[eintrag.character] == true + -- Anzahl abweichender Einzelrechte für die Übersicht + local extra = ZuMenge(eintrag.rechte_extra) + local entzug = ZuMenge(eintrag.rechte_entzug) + local anzahl = 0 + for _ in pairs(extra) do anzahl = anzahl + 1 end + for _ in pairs(entzug) do anzahl = anzahl + 1 end + eintrag.abweichungen = anzahl + eintrag.rechte_extra, eintrag.rechte_entzug = nil, nil + end + + -- Verteilung nach Rollen + local verteilung = MySQL.query.await( + 'SELECT rolle, COUNT(*) AS anzahl FROM police_personal WHERE aktiv = 1 GROUP BY rolle') or {} + + return { + personal = personal, + verteilung = verteilung, + rollen = Polizei.Rollen, + abteilungen= Polizei.Config.Abteilungen, + meineStufe = beamter.stufe, + rechte = { + einstellen = Rechte.Hat(beamter, 'personal.einstellen'), + rolle = Rechte.Hat(beamter, 'personal.rolle'), + rechte = Rechte.Hat(beamter, 'personal.rechte'), + }, + } +end) + +Kern.RegistriereAktion('personal', 'oeffnen', nil, function(quelle, beamter, daten) + local charakterId = tonumber(daten.charakterId) + if not charakterId then return nil, 'Ungültige Kennung.' end + + local zeilen = MySQL.query.await([[ + SELECT p.*, c.firstname, c.lastname, c.dob + FROM police_personal p + JOIN aether_characters c ON c.id = p.`character` + WHERE p.`character` = ? + ]], { charakterId }) + + if not zeilen or not zeilen[1] then return nil, 'Personaldatensatz nicht gefunden.' end + + local eintrag = zeilen[1] + local rolle = Polizei.HoleRolle(eintrag.rolle) + + -- Rechte aufschlüsseln: woher kommt welches Recht? + local ausRolle = Polizei.RechteDerRolle(eintrag.rolle) + local extra = ZuMenge(eintrag.rechte_extra) + local entzug = ZuMenge(eintrag.rechte_entzug) + + local rechteUebersicht = {} + for _, gruppe in ipairs(Polizei.Rechte) do + local eintraege = {} + for _, recht in ipairs(gruppe.eintraege) do + local herkunft = 'keins' + if entzug[recht.id] then herkunft = 'entzogen' + elseif extra[recht.id] then herkunft = 'zusaetzlich' + elseif ausRolle[recht.id] then herkunft = 'rolle' end + + eintraege[#eintraege + 1] = { + id = recht.id, name = recht.name, herkunft = herkunft, + -- Darf der Handelnde dieses Recht überhaupt vergeben? + vergebbar = Rechte.Hat(beamter, recht.id), + } + end + rechteUebersicht[#rechteUebersicht + 1] = { gruppe = gruppe.gruppe, eintraege = eintraege } + end + + -- Dienstzeiten + local dienste = MySQL.query.await([[ + SELECT beginn, ende, sekunden, abteilung + FROM police_dienst WHERE `character` = ? + ORDER BY id DESC LIMIT 20 + ]], { charakterId }) or {} + + local gesamt = MySQL.scalar.await( + 'SELECT COALESCE(SUM(sekunden), 0) FROM police_dienst WHERE `character` = ?', + { charakterId }) or 0 + + local darf, grund = DarfVerwalten(beamter, eintrag) + + return { + personal = { + charakterId = eintrag.character, + name = eintrag.firstname .. ' ' .. eintrag.lastname, + geburt = tostring(eintrag.dob):sub(1, 10), + dienstnummer= eintrag.dienstnummer, + rolle = eintrag.rolle, + rolleName = rolle and rolle.name or eintrag.rolle, + abteilung = eintrag.abteilung, + eingestellt = eintrag.eingestellt, + aktiv = eintrag.aktiv == 1, + notiz = eintrag.notiz, + }, + rechte = rechteUebersicht, + dienste = dienste, + gesamtzeit = gesamt, + rollen = Polizei.Rollen, + abteilungen = Polizei.Config.Abteilungen, + meineStufe = beamter.stufe, + darfVerwalten= darf, + sperrgrund = grund, + meineRechte = { + rolle = Rechte.Hat(beamter, 'personal.rolle'), + rechte = Rechte.Hat(beamter, 'personal.rechte'), + einstellen = Rechte.Hat(beamter, 'personal.einstellen'), + }, + } +end) + +-- ===================================================================== +-- EINSTELLEN +-- ===================================================================== + +Kern.RegistriereAktion('personal', 'einstellen', 'personal.einstellen', function(quelle, beamter, daten) + local charakterId = tonumber(daten.charakterId) + if not charakterId then return nil, 'Keine Person ausgewählt.' end + + local person = MySQL.query.await( + 'SELECT firstname, lastname FROM aether_characters WHERE id = ? AND deleted = 0', + { charakterId }) + if not person or not person[1] then return nil, 'Diese Person existiert nicht.' end + + -- Bereits im Bestand? + local vorhanden = MySQL.query.await( + 'SELECT dienstnummer, aktiv FROM police_personal WHERE `character` = ?', { charakterId }) + if vorhanden and vorhanden[1] and vorhanden[1].aktiv == 1 then + return nil, 'Diese Person ist bereits im Personalbestand.' + end + + -- Rolle prüfen: niemals auf oder über dem eigenen Rang + local rollenId = tostring(daten.rolle or 'anwaerter') + local rolle = Polizei.HoleRolle(rollenId) + if not rolle then return nil, 'Unbekannte Rolle.' end + if rolle.stufe >= beamter.stufe then + return nil, 'Du kannst niemanden auf deinen Rang oder darüber einstellen.' + end + + -- Abteilung prüfen + local abteilung = 'streife' + for _, eintrag in ipairs(Polizei.Config.Abteilungen) do + if eintrag.id == daten.abteilung then abteilung = daten.abteilung break end + end + + -- Dienstnummer: eigene Angabe oder Vorschlag + local dienstnummer = type(daten.dienstnummer) == 'string' + and daten.dienstnummer:gsub('%s', ''):upper() or '' + if dienstnummer == '' then dienstnummer = NaechsteDienstnummer() end + if #dienstnummer > 16 then return nil, 'Die Dienstnummer ist zu lang.' end + + local belegt = MySQL.scalar.await( + 'SELECT `character` FROM police_personal WHERE dienstnummer = ? AND `character` <> ?', + { dienstnummer, charakterId }) + if belegt then return nil, 'Diese Dienstnummer ist bereits vergeben.' end + + -- Wiedereinstellung oder Neuanlage + if vorhanden and vorhanden[1] then + MySQL.update.await([[ + UPDATE police_personal + SET dienstnummer = ?, rolle = ?, abteilung = ?, aktiv = 1, + rechte_extra = NULL, rechte_entzug = NULL, eingestellt = NOW() + WHERE `character` = ? + ]], { dienstnummer, rollenId, abteilung, charakterId }) + else + MySQL.insert.await([[ + INSERT INTO police_personal (`character`, dienstnummer, rolle, abteilung, notiz) + VALUES (?, ?, ?, ?, ?) + ]], { charakterId, dienstnummer, rollenId, abteilung, + type(daten.notiz) == 'string' and daten.notiz:sub(1, 255) or nil }) + end + + Protokoll.Schreibe(beamter, 'personal', 'Beamten eingestellt', + { typ = 'person', id = charakterId }, + { dienstnummer = dienstnummer, rolle = rollenId, abteilung = abteilung }) + + Hinweise.Erstelle(nil, { + kategorie = 'system', prioritaet = 'niedrig', + titel = 'Neuer Kollege', + text = ('%s %s (%s) wurde als %s eingestellt.') + :format(person[1].firstname, person[1].lastname, dienstnummer, rolle.name), + }) + + Kern.Loese('personal.eingestellt', charakterId, beamter) + + return { dienstnummer = dienstnummer } +end) + +--- Vorschlag für die nächste Dienstnummer +Kern.RegistriereAktion('personal', 'nummerVorschlag', 'personal.einstellen', function() + return { dienstnummer = NaechsteDienstnummer() } +end) + +--- Personensuche für die Einstellung +Kern.RegistriereAktion('personal', 'personSuche', 'personal.einstellen', function(quelle, beamter, daten) + local begriff = type(daten.begriff) == 'string' and daten.begriff or '' + if #begriff < 2 then return { treffer = {} } end + + local muster = '%' .. begriff .. '%' + local treffer = MySQL.query.await([[ + SELECT c.id, c.firstname, c.lastname, c.dob, + p.dienstnummer, p.aktiv + FROM aether_characters c + LEFT JOIN police_personal p ON p.`character` = c.id + WHERE c.deleted = 0 + AND (c.firstname LIKE ? OR c.lastname LIKE ? + OR CONCAT(c.firstname, ' ', c.lastname) LIKE ?) + LIMIT 20 + ]], { muster, muster, muster }) or {} + + return { treffer = treffer } +end) + +-- ===================================================================== +-- ROLLE UND ABTEILUNG +-- ===================================================================== + +Kern.RegistriereAktion('personal', 'rolleAendern', 'personal.rolle', function(quelle, beamter, daten) + local charakterId = tonumber(daten.charakterId) + if not charakterId then return nil, 'Ungültige Kennung.' end + + local ziel = MySQL.query.await( + 'SELECT `character`, rolle, dienstnummer FROM police_personal WHERE `character` = ? AND aktiv = 1', + { charakterId }) + if not ziel or not ziel[1] then return nil, 'Personaldatensatz nicht gefunden.' end + + local darf, grund = DarfVerwalten(beamter, ziel[1]) + if not darf then return nil, grund end + + local neueRolle = Polizei.HoleRolle(tostring(daten.rolle or '')) + if not neueRolle then return nil, 'Unbekannte Rolle.' end + + -- Schranke 2: nie auf oder über den eigenen Rang befördern + if neueRolle.stufe >= beamter.stufe then + return nil, 'Du kannst niemanden auf deinen Rang oder darüber setzen.' + end + + local vermerk = type(daten.vermerk) == 'string' and daten.vermerk:sub(1, 255) or '' + if #vermerk < 3 then return nil, 'Bitte einen Vermerk zur Änderung angeben.' end + + local alteStufe = Polizei.Stufe(ziel[1].rolle) + local richtung = neueRolle.stufe > alteStufe and 'Beförderung' or + neueRolle.stufe < alteStufe and 'Degradierung' or 'Rollenwechsel' + + MySQL.update.await('UPDATE police_personal SET rolle = ? WHERE `character` = ?', + { neueRolle.id, charakterId }) + + -- Läuft die Person gerade im Dienst, Rechte sofort neu laden + local zielQuelle = Dienst.QuelleVonCharakter(charakterId) + if zielQuelle then + local neu = Dienst.LadeBeamten(zielQuelle, charakterId) + if neu then + local aktuell = Dienst.Hole(zielQuelle) + aktuell.rolle = neu.rolle + aktuell.rolleName = neu.rolleName + aktuell.stufe = neu.stufe + aktuell.rechte = neu.rechte + end + + Hinweise.Erstelle({ charakterId = charakterId }, { + kategorie = 'system', prioritaet = 'normal', + titel = richtung, + text = ('Dein Dienstgrad wurde auf %s gesetzt: %s'):format(neueRolle.name, vermerk), + }) + end + + Protokoll.Schreibe(beamter, 'personal', richtung, + { typ = 'person', id = charakterId }, + { von = ziel[1].rolle, nach = neueRolle.id, dienstnummer = ziel[1].dienstnummer }, vermerk) + + return { ok = true } +end) + +Kern.RegistriereAktion('personal', 'abteilungAendern', 'personal.rolle', function(quelle, beamter, daten) + local charakterId = tonumber(daten.charakterId) + if not charakterId then return nil, 'Ungültige Kennung.' end + + local ziel = MySQL.query.await( + 'SELECT `character`, rolle FROM police_personal WHERE `character` = ? AND aktiv = 1', + { charakterId }) + if not ziel or not ziel[1] then return nil, 'Personaldatensatz nicht gefunden.' end + + local darf, grund = DarfVerwalten(beamter, ziel[1]) + if not darf then return nil, grund end + + local abteilung = nil + for _, eintrag in ipairs(Polizei.Config.Abteilungen) do + if eintrag.id == daten.abteilung then abteilung = eintrag break end + end + if not abteilung then return nil, 'Unbekannte Abteilung.' end + + MySQL.update.await('UPDATE police_personal SET abteilung = ? WHERE `character` = ?', + { abteilung.id, charakterId }) + + Protokoll.Schreibe(beamter, 'personal', 'Abteilung geändert', + { typ = 'person', id = charakterId }, { abteilung = abteilung.id }) + + return { ok = true } +end) + +-- ===================================================================== +-- EINZELRECHTE +-- +-- Schranke 3: Niemand kann ein Recht vergeben, das er selbst nicht hat. +-- ===================================================================== + +Kern.RegistriereAktion('personal', 'rechtSetzen', 'personal.rechte', function(quelle, beamter, daten) + local charakterId = tonumber(daten.charakterId) + local rechtId = tostring(daten.recht or '') + local modus = tostring(daten.modus or '') -- zusaetzlich | entzogen | rolle + + if not charakterId or rechtId == '' then return nil, 'Unvollständige Angaben.' end + if modus ~= 'zusaetzlich' and modus ~= 'entzogen' and modus ~= 'rolle' then + return nil, 'Unbekannter Modus.' + end + + -- Existiert das Recht überhaupt? + local bekannt = false + for _, id in ipairs(Polizei.AlleRechte()) do + if id == rechtId then bekannt = true break end + end + if not bekannt then return nil, 'Unbekanntes Recht.' end + + local ziel = MySQL.query.await( + 'SELECT `character`, rolle, rechte_extra, rechte_entzug, dienstnummer FROM police_personal WHERE `character` = ? AND aktiv = 1', + { charakterId }) + if not ziel or not ziel[1] then return nil, 'Personaldatensatz nicht gefunden.' end + + local darf, grund = DarfVerwalten(beamter, ziel[1]) + if not darf then return nil, grund end + + -- Schranke 3: nur eigene Rechte weitergeben + if modus == 'zusaetzlich' and not Rechte.Hat(beamter, rechtId) then + return nil, 'Du kannst nur Rechte vergeben, die du selbst besitzt.' + end + + local extra = ZuMenge(ziel[1].rechte_extra) + local entzug = ZuMenge(ziel[1].rechte_entzug) + + -- Erst beide Listen bereinigen, dann neu setzen + extra[rechtId] = nil + entzug[rechtId] = nil + + if modus == 'zusaetzlich' then extra[rechtId] = true + elseif modus == 'entzogen' then entzug[rechtId] = true end + -- modus == 'rolle' lässt beide leer: es gilt wieder die Rolle + + MySQL.update.await( + 'UPDATE police_personal SET rechte_extra = ?, rechte_entzug = ? WHERE `character` = ?', + { json.encode(ZuListe(extra)), json.encode(ZuListe(entzug)), charakterId }) + + -- Im Dienst? Dann sofort wirksam machen + local zielQuelle = Dienst.QuelleVonCharakter(charakterId) + if zielQuelle then + local neu = Dienst.LadeBeamten(zielQuelle, charakterId) + if neu then Dienst.Hole(zielQuelle).rechte = neu.rechte end + end + + Protokoll.Schreibe(beamter, 'personal', 'Einzelrecht geändert', + { typ = 'person', id = charakterId }, + { recht = rechtId, modus = modus, dienstnummer = ziel[1].dienstnummer }, daten.vermerk) + + return { ok = true } +end) + +-- ===================================================================== +-- NOTIZ UND ENTLASSUNG +-- ===================================================================== + +Kern.RegistriereAktion('personal', 'notiz', 'personal.rolle', function(quelle, beamter, daten) + local charakterId = tonumber(daten.charakterId) + if not charakterId then return nil, 'Ungültige Kennung.' end + + local ziel = MySQL.query.await( + 'SELECT `character`, rolle FROM police_personal WHERE `character` = ?', { charakterId }) + if not ziel or not ziel[1] then return nil, 'Personaldatensatz nicht gefunden.' end + + local darf, grund = DarfVerwalten(beamter, ziel[1]) + if not darf then return nil, grund end + + MySQL.update.await('UPDATE police_personal SET notiz = ? WHERE `character` = ?', + { type(daten.notiz) == 'string' and daten.notiz:sub(1, 255) or nil, charakterId }) + + Protokoll.Schreibe(beamter, 'personal', 'Personalnotiz geändert', + { typ = 'person', id = charakterId }) + + return { ok = true } +end) + +Kern.RegistriereAktion('personal', 'entlassen', 'personal.einstellen', function(quelle, beamter, daten) + local charakterId = tonumber(daten.charakterId) + if not charakterId then return nil, 'Ungültige Kennung.' end + + local grund = type(daten.grund) == 'string' and daten.grund:sub(1, 255) or '' + if #grund < 5 then return nil, 'Eine Entlassung erfordert eine Begründung.' end + + local ziel = MySQL.query.await([[ + SELECT p.`character`, p.rolle, p.dienstnummer, c.firstname, c.lastname + FROM police_personal p + JOIN aether_characters c ON c.id = p.`character` + WHERE p.`character` = ? AND p.aktiv = 1 + ]], { charakterId }) + if not ziel or not ziel[1] then return nil, 'Personaldatensatz nicht gefunden.' end + + local darf, sperre = DarfVerwalten(beamter, ziel[1]) + if not darf then return nil, sperre end + + -- Datensatz bleibt bestehen, nur inaktiv gesetzt + MySQL.update.await('UPDATE police_personal SET aktiv = 0 WHERE `character` = ?', { charakterId }) + + -- Läuft die Person im Dienst, wird sie abgemeldet + local zielQuelle = Dienst.QuelleVonCharakter(charakterId) + if zielQuelle then + Dienst.Beenden(zielQuelle) + TriggerClientEvent('aether:police:schliessen', zielQuelle) + end + + -- Zugewiesene Dienstfahrzeuge freigeben + MySQL.update('UPDATE police_fahrzeug SET zugewiesen_an = NULL WHERE zugewiesen_an = ?', + { charakterId }) + + Protokoll.Schreibe(beamter, 'personal', 'Beamten entlassen', + { typ = 'person', id = charakterId }, + { dienstnummer = ziel[1].dienstnummer, rolle = ziel[1].rolle }, grund) + + Hinweise.Erstelle(nil, { + kategorie = 'system', prioritaet = 'normal', + titel = 'Personalveränderung', + text = ('%s %s (%s) wurde aus dem Dienst entlassen.') + :format(ziel[1].firstname, ziel[1].lastname, ziel[1].dienstnummer), + }) + + Kern.Loese('personal.entlassen', charakterId, beamter) + + return { ok = true } +end) diff --git a/resources/[aether]/aether-police/server/module/statistik.lua b/resources/[aether]/aether-police/server/module/statistik.lua new file mode 100644 index 0000000..2a2802f --- /dev/null +++ b/resources/[aether]/aether-police/server/module/statistik.lua @@ -0,0 +1,436 @@ +-- ===================================================================== +-- aether-police / Modul: Statistik +-- +-- Bewusst eine Auswertung der BEHÖRDE, nicht der Beamten. +-- +-- Es gibt hier keine Ranglisten, keine Punkte und keine Kennzahl, an +-- der sich die Arbeit eines einzelnen Beamten messen ließe. Wer wie +-- viel gearbeitet hat, steht in seiner Personalakte und geht seinen +-- Vorgesetzten etwas an — nicht einer Tabelle, die jemanden bewertet. +-- +-- Alle Zahlen sind Zählungen. Keine davon löst etwas aus. +-- ===================================================================== + +Kern.RegistriereModul({ + id = 'statistik', name = 'Statistik', icon = '📊', + reihenfolge = 13, recht = 'statistik.sehen', + beschreibung = 'Auswertung der Behörde über einen Zeitraum', +}) + +-- Auswählbare Zeiträume in Tagen +local ZEITRAEUME = { 7, 30, 90 } + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Begrenzt den Zeitraum auf die zugelassenen Werte +--- @param tage any Angabe der Oberfläche +--- @return number +local function PruefeZeitraum(tage) + local wert = tonumber(tage) or 30 + for _, erlaubt in ipairs(ZEITRAEUME) do + if wert == erlaubt then return wert end + end + return 30 +end + +--- Führt eine Zählung aus und liefert immer eine Zahl +--- @param sql string Abfrage +--- @param werte table Parameter +--- @return number +local function Zaehle(sql, werte) + return tonumber(MySQL.scalar.await(sql, werte)) or 0 +end + +--- Baut aus Zeilen { schluessel, anzahl } eine Verteilung mit Anteilen +--- @param zeilen table Ergebniszeilen +--- @param namen table|nil Übersetzungstabelle für die Schlüssel +--- @return table +local function Verteilung(zeilen, namen) + local gesamt = 0 + for _, zeile in ipairs(zeilen or {}) do + gesamt = gesamt + (tonumber(zeile.anzahl) or 0) + end + + local liste = {} + for _, zeile in ipairs(zeilen or {}) do + local anzahl = tonumber(zeile.anzahl) or 0 + liste[#liste + 1] = { + schluessel = zeile.schluessel, + name = (namen and namen[zeile.schluessel]) or zeile.schluessel or 'unbekannt', + anzahl = anzahl, + anteil = gesamt > 0 and math.floor((anzahl / gesamt) * 100 + 0.5) or 0, + } + end + + return { eintraege = liste, gesamt = gesamt } +end + +--- Liest die Namen aus einer Konfigurationsliste { id, name } +--- @param liste table Konfiguration +--- @return table [id] = name +local function NamenAus(liste) + local namen = {} + for _, eintrag in ipairs(liste or {}) do namen[eintrag.id] = eintrag.name end + return namen +end + +-- ===================================================================== +-- ÜBERSICHT +-- ===================================================================== + +Kern.RegistriereAktion('statistik', 'uebersicht', nil, function(quelle, beamter, daten) + local tage = PruefeZeitraum(daten.tage) + + -- --- Einsätze --- + local einsaetze = { + gesamt = Zaehle([[ + SELECT COUNT(*) FROM police_einsatz + WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { tage }), + offen = Zaehle([[ + SELECT COUNT(*) FROM police_einsatz WHERE geschlossen_am IS NULL + ]], {}), + } + + einsaetze.nachKategorie = Verteilung(MySQL.query.await([[ + SELECT kategorie AS schluessel, COUNT(*) AS anzahl + FROM police_einsatz + WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + GROUP BY kategorie ORDER BY anzahl DESC + ]], { tage }), NamenAus(Polizei.Config.Einsatzkategorien)) + + einsaetze.nachPrioritaet = Verteilung(MySQL.query.await([[ + SELECT prioritaet AS schluessel, COUNT(*) AS anzahl + FROM police_einsatz + WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + GROUP BY prioritaet ORDER BY prioritaet + ]], { tage })) + + -- Wie lange bleibt ein Einsatz offen? Prozessdauer, keine Bewertung. + einsaetze.laufzeitMinuten = tonumber(MySQL.scalar.await([[ + SELECT AVG(TIMESTAMPDIFF(MINUTE, erstellt_am, geschlossen_am)) + FROM police_einsatz + WHERE geschlossen_am IS NOT NULL + AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { tage })) or 0 + + -- --- Anzeigen --- + local anzeigen = { + gesamt = Zaehle([[ + SELECT COUNT(*) FROM police_anzeige + WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { tage }), + bestaetigt = Zaehle([[ + SELECT COUNT(*) FROM police_anzeige + WHERE status = 'bestaetigt' AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { tage }), + entwurf = Zaehle([[ + SELECT COUNT(*) FROM police_anzeige WHERE status = 'entwurf' + ]], {}), + } + + -- Wie oft weicht die Entscheidung vom Katalogvorschlag ab? + -- Das ist eine Aussage über den KATALOG, nicht über die Beamten: + -- viele Abweichungen heißen, dass der Vorschlag nicht passt. + anzeigen.abweichungen = Zaehle([[ + SELECT COUNT(*) FROM police_anzeige + WHERE status = 'bestaetigt' + AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + AND (haft_final <> haft_empfohlen OR geld_final <> geld_empfohlen) + ]], { tage }) + + anzeigen.haftDurchschnitt = tonumber(MySQL.scalar.await([[ + SELECT AVG(haft_final) FROM police_anzeige + WHERE status = 'bestaetigt' AND haft_final IS NOT NULL + AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { tage })) or 0 + + anzeigen.geldDurchschnitt = tonumber(MySQL.scalar.await([[ + SELECT AVG(geld_final) FROM police_anzeige + WHERE status = 'bestaetigt' AND geld_final IS NOT NULL + AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { tage })) or 0 + + -- --- Gewahrsam --- + local gewahrsam = { + einweisungen = Zaehle([[ + SELECT COUNT(*) FROM police_gewahrsam + WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { tage }), + laufend = Zaehle('SELECT COUNT(*) FROM police_gewahrsam WHERE entlassen_am IS NULL', {}), + } + + gewahrsam.haftDurchschnitt = tonumber(MySQL.scalar.await([[ + SELECT AVG(haft_minuten) FROM police_gewahrsam + WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { tage })) or 0 + + -- Wie lange nach Fristablauf wurde tatsächlich entlassen? + -- Reine Prozessdauer — das System entlässt niemanden von selbst, + -- deshalb ist der Verzug eine Frage der Erreichbarkeit, nicht der Schuld. + gewahrsam.verzugMinuten = tonumber(MySQL.scalar.await([[ + SELECT AVG(TIMESTAMPDIFF(MINUTE, faellig_am, entlassen_am)) + FROM police_gewahrsam + WHERE entlassen_am IS NOT NULL + AND beginn >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { tage })) or 0 + + gewahrsam.vorzeitig = Zaehle([[ + SELECT COUNT(*) FROM police_gewahrsam + WHERE entlassen_am IS NOT NULL AND entlassen_am < faellig_am + AND beginn >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { tage }) + + -- --- Akten, Berichte, Fahndungen --- + local vorgaenge = { + akten = Zaehle([[ + SELECT COUNT(*) FROM police_akte + WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { tage }), + aktenOffen = Zaehle("SELECT COUNT(*) FROM police_akte WHERE status <> 'geschlossen'", {}), + berichte = Zaehle([[ + SELECT COUNT(*) FROM police_bericht + WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { tage }), + berichteEntwurf = Zaehle("SELECT COUNT(*) FROM police_bericht WHERE status = 'entwurf'", {}), + fahndungen = Zaehle([[ + SELECT COUNT(*) FROM police_fahndung + WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { tage }), + fahndungenAktiv = Zaehle('SELECT COUNT(*) FROM police_fahndung WHERE aktiv = 1', {}), + beweise = Zaehle([[ + SELECT COUNT(*) FROM police_beweis + WHERE erfasst_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { tage }), + asservate = Zaehle("SELECT COUNT(*) FROM police_asservat WHERE status = 'eingelagert'", {}), + } + + vorgaenge.aktenNachTyp = Verteilung(MySQL.query.await([[ + SELECT typ AS schluessel, COUNT(*) AS anzahl + FROM police_akte + WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + GROUP BY typ ORDER BY anzahl DESC + ]], { tage }), NamenAus(Polizei.Config.Aktenarten)) + + -- --- Personal und Dienst --- + local personal = { + bestand = Zaehle('SELECT COUNT(*) FROM police_personal WHERE aktiv = 1', {}), + imDienst = 0, + } + for _ in pairs(Dienst.AlleImDienst()) do personal.imDienst = personal.imDienst + 1 end + + personal.nachAbteilung = Verteilung(MySQL.query.await([[ + SELECT abteilung AS schluessel, COUNT(*) AS anzahl + FROM police_personal WHERE aktiv = 1 + GROUP BY abteilung ORDER BY anzahl DESC + ]], {}), NamenAus(Polizei.Config.Abteilungen)) + + personal.dienstStunden = math.floor((tonumber(MySQL.scalar.await([[ + SELECT COALESCE(SUM(sekunden), 0) FROM police_dienst + WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { tage })) or 0) / 3600) + + -- --- Verlauf: Einsätze und Anzeigen je Tag --- + local verlauf = MySQL.query.await([[ + SELECT tag, + SUM(einsatz) AS einsaetze, + SUM(anzeige) AS anzeigen + FROM ( + SELECT DATE(erstellt_am) AS tag, 1 AS einsatz, 0 AS anzeige + FROM police_einsatz + WHERE erstellt_am >= DATE_SUB(CURDATE(), INTERVAL ? DAY) + UNION ALL + SELECT DATE(erstellt_am) AS tag, 0 AS einsatz, 1 AS anzeige + FROM police_anzeige + WHERE erstellt_am >= DATE_SUB(CURDATE(), INTERVAL ? DAY) + ) AS zusammen + GROUP BY tag ORDER BY tag + ]], { tage, tage }) or {} + + return { + tage = tage, + zeitraeume = ZEITRAEUME, + einsaetze = einsaetze, + anzeigen = anzeigen, + gewahrsam = gewahrsam, + vorgaenge = vorgaenge, + personal = personal, + verlauf = verlauf, + } +end) + +-- ===================================================================== +-- DIENSTABDECKUNG +-- +-- Wann ist wie viel Personal da? Ein Planungswerkzeug für die Leitung. +-- Die Zahlen sagen nichts darüber aus, WER Dienst hatte. +-- ===================================================================== + +Kern.RegistriereAktion('statistik', 'abdeckung', nil, function(quelle, beamter, daten) + local tage = PruefeZeitraum(daten.tage) + + local dienste = MySQL.query.await([[ + SELECT UNIX_TIMESTAMP(beginn) AS beginn, + UNIX_TIMESTAMP(COALESCE(ende, NOW())) AS ende, + abteilung + FROM police_dienst + WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY) + LIMIT 5000 + ]], { tage }) or {} + + -- Dienstsekunden auf die 24 Stunden des Tages verteilen + local stunden = {} + for i = 0, 23 do stunden[i] = 0 end + + for _, dienst in ipairs(dienste) do + local beginn = tonumber(dienst.beginn) or 0 + local ende = tonumber(dienst.ende) or beginn + + -- Schicht stundenweise durchlaufen, damit Nachtschichten korrekt zählen + local zeiger = beginn + local schutz = 0 + while zeiger < ende and schutz < 500 do + schutz = schutz + 1 + + local stunde = tonumber(os.date('%H', zeiger)) or 0 + -- Ende der laufenden Stunde + local grenze = zeiger + (3600 - (zeiger % 3600)) + if grenze > ende then grenze = ende end + + stunden[stunde] = stunden[stunde] + (grenze - zeiger) + zeiger = grenze + end + end + + -- In durchschnittlich gleichzeitig anwesende Beamte umrechnen + local liste, hoechstwert = {}, 0 + for i = 0, 23 do + -- Sekunden geteilt durch (Tage × 3600) = mittlere Besetzung dieser Stunde + local mittel = stunden[i] / (tage * 3600) + if mittel > hoechstwert then hoechstwert = mittel end + liste[#liste + 1] = { + stunde = i, + mittel = math.floor(mittel * 100 + 0.5) / 100, + } + end + + return { + tage = tage, + stunden = liste, + hoechstwert = math.floor(hoechstwert * 100 + 0.5) / 100, + schichten = #dienste, + nachAbteilung = Verteilung(MySQL.query.await([[ + SELECT abteilung AS schluessel, COUNT(*) AS anzahl + FROM police_dienst + WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY) + GROUP BY abteilung ORDER BY anzahl DESC + ]], { tage }), NamenAus(Polizei.Config.Abteilungen)), + } +end) + +-- ===================================================================== +-- KATALOGAUSWERTUNG +-- +-- Welche Delikte kommen wie oft vor und wie oft wird vom Vorschlag +-- abgewichen? Grundlage für die Pflege des Katalogs — ausdrücklich +-- keine Aussage über die Beamten, die entschieden haben. +-- ===================================================================== + +Kern.RegistriereAktion('statistik', 'katalog', nil, function(quelle, beamter, daten) + local tage = PruefeZeitraum(daten.tage) + + local anzeigen = MySQL.query.await([[ + SELECT delikte, haft_empfohlen, geld_empfohlen, haft_final, geld_final + FROM police_anzeige + WHERE status = 'bestaetigt' + AND erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + LIMIT 2000 + ]], { tage }) or {} + + -- Häufigkeit je Delikt und Abweichungen der Anzeigen, in denen es vorkam + local zaehler = {} + + for _, anzeige in ipairs(anzeigen) do + local erfolg, liste = pcall(json.decode, anzeige.delikte) + if erfolg and type(liste) == 'table' then + -- Wich die Entscheidung dieser Anzeige vom Vorschlag ab? + local abweichung = + (tonumber(anzeige.haft_final) or 0) ~= (tonumber(anzeige.haft_empfohlen) or 0) + or (tonumber(anzeige.geld_final) or 0) ~= (tonumber(anzeige.geld_empfohlen) or 0) + + for _, delikt in ipairs(liste) do + local kennung = delikt.kennung or '?' + zaehler[kennung] = zaehler[kennung] or { + kennung = kennung, + name = delikt.name or kennung, + paragraph = delikt.paragraph, + anzahl = 0, abweichungen = 0, + } + zaehler[kennung].anzahl = zaehler[kennung].anzahl + 1 + if abweichung then + zaehler[kennung].abweichungen = zaehler[kennung].abweichungen + 1 + end + end + end + end + + local delikte = {} + for _, eintrag in pairs(zaehler) do + eintrag.abweichungsAnteil = eintrag.anzahl > 0 + and math.floor((eintrag.abweichungen / eintrag.anzahl) * 100 + 0.5) or 0 + delikte[#delikte + 1] = eintrag + end + + table.sort(delikte, function(a, b) return a.anzahl > b.anzahl end) + + -- Nur die häufigsten übertragen + local gekuerzt = {} + for i = 1, math.min(#delikte, 30) do gekuerzt[i] = delikte[i] end + + -- Delikte im Katalog, die im Zeitraum nie verwendet wurden + local ungenutzt = {} + local katalog = MySQL.query.await( + 'SELECT kennung, bezeichnung, kategorie FROM police_katalog WHERE aktiv = 1') or {} + for _, eintrag in ipairs(katalog) do + if not zaehler[eintrag.kennung] then + ungenutzt[#ungenutzt + 1] = eintrag + end + end + + return { + tage = tage, + delikte = gekuerzt, + anzeigen = #anzeigen, + ungenutzt = ungenutzt, + katalogGesamt = #katalog, + } +end) + +-- --------------------------------------------------------------------- +-- Schnittstelle für andere Ressourcen +-- --------------------------------------------------------------------- + +--- Liefert die Kennzahlen des Zeitraums als einfache Tabelle +--- @param tage number Zeitraum in Tagen (7, 30 oder 90) +--- @return table +exports('HoleKennzahlen', function(tage) + local geprueft = PruefeZeitraum(tage) + return { + tage = geprueft, + einsaetze = Zaehle([[ + SELECT COUNT(*) FROM police_einsatz + WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { geprueft }), + anzeigen = Zaehle([[ + SELECT COUNT(*) FROM police_anzeige + WHERE erstellt_am >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { geprueft }), + einweisungen = Zaehle([[ + SELECT COUNT(*) FROM police_gewahrsam + WHERE beginn >= DATE_SUB(NOW(), INTERVAL ? DAY) + ]], { geprueft }), + personal = Zaehle('SELECT COUNT(*) FROM police_personal WHERE aktiv = 1', {}), + } +end) diff --git a/resources/[aether]/aether-police/server/protokoll.lua b/resources/[aether]/aether-police/server/protokoll.lua new file mode 100644 index 0000000..94a0d68 --- /dev/null +++ b/resources/[aether]/aether-police/server/protokoll.lua @@ -0,0 +1,66 @@ +-- ===================================================================== +-- aether-police — Protokollierung +-- +-- Jede verändernde Aktion wird festgehalten: wer, wann, was, warum. +-- Lesende Zugriffe werden bewusst nicht protokolliert, damit das +-- Protokoll aussagekräftig bleibt. +-- ===================================================================== + +Protokoll = {} + +--- Schreibt einen Protokolleintrag +--- @param beamter table|nil Handelnder Beamter +--- @param modul string Modulkennung +--- @param aktion string Was wurde getan +--- @param ziel table|nil { typ, id } +--- @param details table|nil Beliebige Zusatzdaten +--- @param grund string|nil Begründung, falls angegeben +function Protokoll.Schreibe(beamter, modul, aktion, ziel, details, grund) + MySQL.insert([[ + INSERT INTO police_protokoll + (`character`, dienstnummer, modul, aktion, ziel_typ, ziel_id, details, grund) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ]], { + beamter and beamter.charakterId or nil, + beamter and beamter.dienstnummer or nil, + modul, aktion, + ziel and ziel.typ or nil, + ziel and tostring(ziel.id) or nil, + details and json.encode(details) or nil, + grund, + }) +end + +--- Liest Protokolleinträge mit einfachen Filtern +--- @param filter table { modul?, charakterId?, zielTyp?, zielId?, limit? } +--- @return table +function Protokoll.Lies(filter) + filter = filter or {} + local bedingungen, werte = {}, {} + + if filter.modul then + bedingungen[#bedingungen + 1] = 'modul = ?' + werte[#werte + 1] = filter.modul + end + if filter.charakterId then + bedingungen[#bedingungen + 1] = '`character` = ?' + werte[#werte + 1] = filter.charakterId + end + if filter.zielTyp and filter.zielId then + bedingungen[#bedingungen + 1] = 'ziel_typ = ? AND ziel_id = ?' + werte[#werte + 1] = filter.zielTyp + werte[#werte + 1] = tostring(filter.zielId) + end + + local wo = #bedingungen > 0 and ('WHERE ' .. table.concat(bedingungen, ' AND ')) or '' + werte[#werte + 1] = math.min(filter.limit or 100, 500) + + return MySQL.query.await(([[ + SELECT p.*, c.firstname, c.lastname + FROM police_protokoll p + LEFT JOIN aether_characters c ON c.id = p.`character` + %s + ORDER BY p.id DESC + LIMIT ? + ]]):format(wo), werte) or {} +end diff --git a/resources/[aether]/aether-police/server/rechte.lua b/resources/[aether]/aether-police/server/rechte.lua new file mode 100644 index 0000000..7bdc70c --- /dev/null +++ b/resources/[aether]/aether-police/server/rechte.lua @@ -0,0 +1,61 @@ +-- ===================================================================== +-- aether-police — Rechteprüfung +-- +-- Ein Beamter hat ein Recht, wenn seine Rolle es vorsieht ODER es ihm +-- einzeln gewährt wurde — und es ihm nicht einzeln entzogen wurde. +-- Der Entzug hat immer Vorrang. +-- ===================================================================== + +Rechte = {} + +--- Wandelt eine JSON-Liste in eine Menge um +--- @param roh string|nil JSON-Array +--- @return table { [wert] = true } +local function ZuMenge(roh) + local menge = {} + if not roh then return menge end + + local erfolg, liste = pcall(json.decode, roh) + if erfolg and type(liste) == 'table' then + for _, wert in ipairs(liste) do menge[wert] = true end + end + return menge +end + +--- Baut die vollständige Rechtemenge eines Beamten +--- @param personal table Zeile aus police_personal +--- @return table { [rechtId] = true } +function Rechte.Berechne(personal) + local menge = Polizei.RechteDerRolle(personal.rolle) + + -- Einzeln gewährte Rechte ergänzen + for recht in pairs(ZuMenge(personal.rechte_extra)) do + menge[recht] = true + end + + -- Einzeln entzogene Rechte haben Vorrang + for recht in pairs(ZuMenge(personal.rechte_entzug)) do + menge[recht] = nil + end + + return menge +end + +--- Prüft ein einzelnes Recht +--- @param beamter table Beamtendaten (mit Feld `rechte`) +--- @param recht string Recht-Kennung +--- @return boolean +function Rechte.Hat(beamter, recht) + if not beamter or not beamter.rechte then return false end + if not recht then return true end + return beamter.rechte[recht] == true +end + +--- Prüft, ob ein Beamter über einem anderen steht. +--- Verhindert, dass jemand Gleichrangige oder Höhere verwaltet. +--- @param beamter table Handelnder +--- @param zielRolle string Rolle der Zielperson +--- @return boolean +function Rechte.StehtUeber(beamter, zielRolle) + return Polizei.Stufe(beamter.rolle) > Polizei.Stufe(zielRolle) +end diff --git a/resources/[aether]/aether-police/shared/config.lua b/resources/[aether]/aether-police/shared/config.lua new file mode 100644 index 0000000..21665cf --- /dev/null +++ b/resources/[aether]/aether-police/shared/config.lua @@ -0,0 +1,127 @@ +-- ===================================================================== +-- aether-police — Grundkonfiguration +-- +-- GRUNDPRINZIP: Das System unterstützt Entscheidungen — es trifft +-- niemals selbst welche. Alle Zeitangaben hier dienen ausschliesslich +-- der ERINNERUNG. Nichts davon löst eine Maßnahme aus. +-- ===================================================================== + +Polizei = Polizei or {} +Polizei.Config = {} + +-- Anzeigename der Behörde +Polizei.Config.Behoerde = 'Los Santos Police Department' +Polizei.Config.Kuerzel = 'LSPD' + +-- Reichweite zu Terminals (Meter) +Polizei.Config.TerminalWeite = 2.5 + +-- Terminals an den Wachen. Dort lässt sich das Tablet aufrufen. +Polizei.Config.Terminals = { + { name = 'Mission Row', pos = vector3(441.0, -982.1, 30.7) }, + { name = 'Sandy Shores', pos = vector3(1853.1, 3689.5, 34.3) }, + { name = 'Paleto Bay', pos = vector3(-448.6, 6012.4, 31.7) }, +} + +-- Abteilungen (frei erweiterbar) +Polizei.Config.Abteilungen = { + { id = 'streife', name = 'Streifendienst' }, + { id = 'kripo', name = 'Kriminalpolizei' }, + { id = 'verkehr', name = 'Verkehrspolizei' }, + { id = 'sek', name = 'Spezialeinsatzkommando' }, + { id = 'leitstelle', name = 'Leitstelle' }, +} + +-- Dienststatus, die ein Beamter wählen kann +Polizei.Config.Dienststatus = { + { id = 'verfuegbar', name = 'Verfügbar', farbe = '#34d39a' }, + { id = 'einsatz', name = 'Im Einsatz', farbe = '#f5a742' }, + { id = 'pause', name = 'Pause', farbe = '#7c8797' }, + { id = 'nicht', name = 'Nicht verfügbar', farbe = '#f0506e' }, +} + +-- Einsatzkategorien +Polizei.Config.Einsatzkategorien = { + { id = 'notruf', name = 'Notruf', farbe = '#f0506e' }, + { id = 'verkehr', name = 'Verkehr', farbe = '#f5a742' }, + { id = 'gewalt', name = 'Gewaltdelikt', farbe = '#e0503f' }, + { id = 'eigentum', name = 'Eigentumsdelikt', farbe = '#7c5cf6' }, + { id = 'unterstuetzung', name = 'Unterstützung', farbe = '#4aa8ff' }, + { id = 'panik', name = 'Notfall Beamter', farbe = '#ff2e57' }, + { id = 'sonstiges', name = 'Sonstiges', farbe = '#8a93a5' }, +} + +-- Aktenarten +Polizei.Config.Aktenarten = { + { id = 'straf', name = 'Strafakte' }, + { id = 'ermittlung', name = 'Ermittlungsakte' }, + { id = 'einsatz', name = 'Einsatzakte' }, + { id = 'unfall', name = 'Unfallakte' }, + { id = 'vernehmung', name = 'Vernehmung' }, + { id = 'beschluss', name = 'Beschluss' }, +} + +-- Beweisarten +Polizei.Config.Beweisarten = { + { id = 'foto', name = 'Foto' }, + { id = 'video', name = 'Video' }, + { id = 'fingerabdruck', name = 'Fingerabdruck' }, + { id = 'dna', name = 'DNA-Spur' }, + { id = 'huelse', name = 'Patronenhülse' }, + { id = 'dokument', name = 'Dokument' }, + { id = 'aussage', name = 'Aussage' }, + { id = 'gegenstand', name = 'Beweisstück' }, +} + +-- Asservat-Kategorien +Polizei.Config.Asservatkategorien = { + { id = 'waffe', name = 'Waffe' }, + { id = 'drogen', name = 'Betäubungsmittel' }, + { id = 'bargeld', name = 'Bargeld' }, + { id = 'schmuck', name = 'Schmuck' }, + { id = 'datentraeger', name = 'Datenträger' }, + { id = 'dokument', name = 'Dokument' }, + { id = 'sonstiges', name = 'Sonstiges' }, +} + +-- --------------------------------------------------------------------- +-- Gewahrsam +-- +-- WICHTIG: Es gibt keinen Automatismus. Das System führt die Frist mit +-- und erinnert — eingewiesen und entlassen wird ausschliesslich durch +-- einen Beamten. +-- --------------------------------------------------------------------- +Polizei.Config.Gewahrsam = { + -- Wird die Person beim Einweisen in die Zelle verbracht? + -- false = rein verwaltend; das Verbringen geschieht dann im Rollenspiel. + verbringen = true, + + -- Verfügbare Zellen + zellen = { + { id = 'A1', name = 'Zelle A1', pos = vector4(461.6, -994.4, 24.9, 90.0) }, + { id = 'A2', name = 'Zelle A2', pos = vector4(461.6, -997.7, 24.9, 90.0) }, + { id = 'A3', name = 'Zelle A3', pos = vector4(461.6, -1001.1, 24.9, 90.0) }, + { id = 'B1', name = 'Zelle B1', pos = vector4(464.5, -992.7, 24.9, 270.0) }, + { id = 'B2', name = 'Zelle B2', pos = vector4(464.5, -996.1, 24.9, 270.0) }, + }, + + -- Wohin die Person nach der Entlassung gesetzt wird + entlassung = vector4(441.0, -978.9, 30.7, 180.0), + + -- Hoechstdauer, die ein Beamter eintragen kann (Minuten) + maxDauer = 720, +} + +-- --------------------------------------------------------------------- +-- Erinnerungen (NUR Hinweise — keine davon führt etwas aus) +-- --------------------------------------------------------------------- +Polizei.Config.Erinnerungen = { + -- Bei welchen Restzeiten im Gewahrsam erinnert wird (Minuten) + gewahrsamStufen = { 15, 5, 0 }, + -- Wie oft die Fristen geprüft werden (Millisekunden) + pruefIntervall = 60000, + -- Nach wie vielen Minuten ein Bericht als „offen" gemeldet wird + berichtOffenNach = 30, + -- Wie viele Minuten vor Ablauf an eine offene Kenntnisnahme erinnert wird + aushangVorwarnung = 60, +} diff --git a/resources/[aether]/aether-police/shared/rollen.lua b/resources/[aether]/aether-police/shared/rollen.lua new file mode 100644 index 0000000..9fc5764 --- /dev/null +++ b/resources/[aether]/aether-police/shared/rollen.lua @@ -0,0 +1,208 @@ +-- ===================================================================== +-- aether-police — Rollen und Rechte +-- +-- Rechte sind einzelne, benannte Schalter. Eine Rolle ist nur ein +-- Vorschlagspaket — jedes Recht kann pro Beamtem zusaetzlich gewaehrt +-- oder entzogen werden (Spalten `rechte_extra` / `rechte_entzug`). +-- ===================================================================== + +Polizei = Polizei or {} + +-- --------------------------------------------------------------------- +-- Katalog aller Rechte +-- Gruppiert nur fuer die Darstellung in der Administration. +-- --------------------------------------------------------------------- +Polizei.Rechte = { + { gruppe = 'Dienst', eintraege = { + { id = 'dienst.antreten', name = 'Dienst antreten' }, + { id = 'dienst.leitstelle', name = 'Leitstelle übernehmen' }, + { id = 'dienst.spezial', name = 'Spezialdienst wählen' }, + }}, + { gruppe = 'Personen & Fahrzeuge', eintraege = { + { id = 'person.suchen', name = 'Personen suchen' }, + { id = 'person.bearbeiten', name = 'Personendaten bearbeiten' }, + { id = 'person.warnhinweis', name = 'Warnhinweise setzen' }, + { id = 'fahrzeug.suchen', name = 'Fahrzeuge abfragen' }, + }}, + { gruppe = 'Akten & Berichte', eintraege = { + { id = 'akte.lesen', name = 'Akten einsehen' }, + { id = 'akte.erstellen', name = 'Akten anlegen' }, + { id = 'akte.bearbeiten', name = 'Akten bearbeiten' }, + { id = 'akte.freigeben', name = 'Akten freigeben' }, + { id = 'akte.schliessen', name = 'Akten schließen' }, + { id = 'bericht.erstellen', name = 'Berichte schreiben' }, + { id = 'bericht.freigeben', name = 'Berichte freigeben' }, + }}, + { gruppe = 'Maßnahmen', eintraege = { + { id = 'anzeige.erstellen', name = 'Anzeige erfassen' }, + { id = 'anzeige.bestaetigen', name = 'Anzeige bestätigen' }, + { id = 'anzeige.abweichen', name = 'Vom Katalogvorschlag abweichen' }, + { id = 'gewahrsam.einweisen', name = 'In Gewahrsam nehmen' }, + { id = 'gewahrsam.entlassen', name = 'Aus Gewahrsam entlassen' }, + { id = 'fahndung.erstellen', name = 'Fahndung ausschreiben' }, + { id = 'fahndung.aufheben', name = 'Fahndung aufheben' }, + }}, + { gruppe = 'Asservate & Beweise', eintraege = { + { id = 'beweis.erfassen', name = 'Beweise erfassen' }, + { id = 'beweis.loeschen', name = 'Beweise entfernen' }, + { id = 'asservat.einlagern', name = 'Asservate einlagern' }, + { id = 'asservat.ausgeben', name = 'Asservate ausgeben' }, + { id = 'asservat.vernichten', name = 'Asservate vernichten' }, + }}, + { gruppe = 'Einsätze', eintraege = { + { id = 'einsatz.erstellen', name = 'Einsatz anlegen' }, + { id = 'einsatz.zuweisen', name = 'Einheiten zuweisen' }, + { id = 'einsatz.schliessen', name = 'Einsatz abschließen' }, + }}, + { gruppe = 'Fahrzeugverwaltung', eintraege = { + { id = 'dienstfahrzeug.sehen', name = 'Dienstfahrzeuge einsehen' }, + { id = 'dienstfahrzeug.zuweisen', name = 'Dienstfahrzeuge zuweisen' }, + { id = 'dienstfahrzeug.wartung', name = 'Wartungsstatus setzen' }, + }}, + { gruppe = 'Kommunikation', eintraege = { + { id = 'nachricht.senden', name = 'Nachrichten schreiben' }, + { id = 'nachricht.rundmeldung', name = 'Rundmeldung an alle senden' }, + { id = 'aushang.schreiben', name = 'Aushänge veröffentlichen' }, + { id = 'aushang.archivieren', name = 'Aushänge archivieren' }, + }}, + { gruppe = 'Verwaltung', eintraege = { + { id = 'personal.sehen', name = 'Personalliste einsehen' }, + { id = 'personal.rolle', name = 'Rollen zuweisen' }, + { id = 'personal.rechte', name = 'Einzelrechte vergeben' }, + { id = 'personal.einstellen', name = 'Personal einstellen/entlassen' }, + { id = 'katalog.bearbeiten', name = 'Strafenkatalog pflegen' }, + { id = 'protokoll.lesen', name = 'Protokoll einsehen' }, + { id = 'statistik.sehen', name = 'Statistik einsehen' }, + { id = 'admin.module', name = 'Module verwalten' }, + }}, +} + +--- Liefert alle Rechte-IDs als flache Liste +--- @return table +function Polizei.AlleRechte() + local liste = {} + for _, gruppe in ipairs(Polizei.Rechte) do + for _, recht in ipairs(gruppe.eintraege) do + liste[#liste + 1] = recht.id + end + end + return liste +end + +-- --------------------------------------------------------------------- +-- Rollen — aufsteigend nach Verantwortung +-- `rechte` ist ein Vorschlagspaket, kein festes Gesetz. +-- --------------------------------------------------------------------- +Polizei.Rollen = { + { + id = 'anwaerter', name = 'Anwärter', stufe = 0, + rechte = { + 'dienst.antreten', 'person.suchen', 'fahrzeug.suchen', + 'akte.lesen', 'einsatz.erstellen', 'dienstfahrzeug.sehen', + 'nachricht.senden', + }, + }, + { + id = 'officer', name = 'Officer', stufe = 1, + rechte = { + 'dienst.antreten', 'person.suchen', 'fahrzeug.suchen', + 'akte.lesen', 'akte.erstellen', 'bericht.erstellen', + 'anzeige.erstellen', 'beweis.erfassen', + 'einsatz.erstellen', 'dienstfahrzeug.sehen', 'nachricht.senden', + }, + }, + { + id = 'senior_officer', name = 'Senior Officer', stufe = 2, + rechte = { + 'dienst.antreten', 'person.suchen', 'person.bearbeiten', 'fahrzeug.suchen', + 'akte.lesen', 'akte.erstellen', 'akte.bearbeiten', + 'bericht.erstellen', 'anzeige.erstellen', 'anzeige.bestaetigen', + 'beweis.erfassen', 'asservat.einlagern', + 'einsatz.erstellen', 'einsatz.schliessen', 'dienstfahrzeug.sehen', + 'nachricht.senden', + }, + }, + { + id = 'sergeant', name = 'Sergeant', stufe = 3, + rechte = { + 'dienst.antreten', 'dienst.leitstelle', 'dienst.spezial', + 'person.suchen', 'person.bearbeiten', 'person.warnhinweis', 'fahrzeug.suchen', + 'akte.lesen', 'akte.erstellen', 'akte.bearbeiten', 'akte.schliessen', + 'bericht.erstellen', 'anzeige.erstellen', 'anzeige.bestaetigen', + 'gewahrsam.einweisen', 'gewahrsam.entlassen', + 'fahndung.erstellen', 'fahndung.aufheben', + 'beweis.erfassen', 'asservat.einlagern', 'asservat.ausgeben', + 'einsatz.erstellen', 'einsatz.zuweisen', 'einsatz.schliessen', + 'dienstfahrzeug.sehen', 'dienstfahrzeug.zuweisen', + 'nachricht.senden', 'nachricht.rundmeldung', + }, + }, + { + id = 'lieutenant', name = 'Lieutenant', stufe = 4, + erbt = 'sergeant', + rechte = { + 'akte.freigeben', 'bericht.freigeben', 'anzeige.abweichen', + 'personal.sehen', 'statistik.sehen', 'dienstfahrzeug.wartung', + 'aushang.schreiben', + }, + }, + { + id = 'captain', name = 'Captain', stufe = 5, + erbt = 'lieutenant', + rechte = { + 'personal.rolle', 'beweis.loeschen', 'asservat.vernichten', + 'protokoll.lesen', 'aushang.archivieren', + }, + }, + { + id = 'deputy_chief', name = 'Deputy Chief', stufe = 6, + erbt = 'captain', + rechte = { 'personal.rechte', 'personal.einstellen', 'katalog.bearbeiten' }, + }, + { + id = 'chief', name = 'Chief of Police', stufe = 7, + erbt = 'deputy_chief', + rechte = { 'admin.module' }, + }, +} + +--- Sucht eine Rolle +--- @param rollenId string Kennung +--- @return table|nil +function Polizei.HoleRolle(rollenId) + for _, rolle in ipairs(Polizei.Rollen) do + if rolle.id == rollenId then return rolle end + end + return nil +end + +--- Loest die Rechte einer Rolle auf (inklusive Vererbung) +--- @param rollenId string Kennung +--- @return table Menge der Rechte { [rechtId] = true } +function Polizei.RechteDerRolle(rollenId) + local menge = {} + local besucht = {} + local aktuelle = rollenId + + -- Vererbungskette abarbeiten; Zyklen werden abgefangen + while aktuelle and not besucht[aktuelle] do + besucht[aktuelle] = true + local rolle = Polizei.HoleRolle(aktuelle) + if not rolle then break end + + for _, recht in ipairs(rolle.rechte or {}) do + menge[recht] = true + end + aktuelle = rolle.erbt + end + + return menge +end + +--- Liefert die Stufe einer Rolle (fuer Vergleiche „darf ich das bei dem?") +--- @param rollenId string Kennung +--- @return number +function Polizei.Stufe(rollenId) + local rolle = Polizei.HoleRolle(rollenId) + return rolle and rolle.stufe or -1 +end diff --git a/resources/[aether]/aether-shop/client/main.lua b/resources/[aether]/aether-shop/client/main.lua new file mode 100644 index 0000000..ac3bacb --- /dev/null +++ b/resources/[aether]/aether-shop/client/main.lua @@ -0,0 +1,149 @@ +-- ===================================================================== +-- Aether Shop — Clientlogik +-- Setzt Karten-Markierungen, erkennt Filialen und oeffnet die Oberflaeche. +-- Gekauft wird ausschliesslich ueber den Server. +-- ===================================================================== + +local istOffen = false +local istGeladen = false + +-- Taste E zum Betreten +local TASTE_BENUTZEN = 38 + +--- Schickt eine Nachricht an die Oberflaeche +local function SendeAnNui(nachricht) + SendNuiMessage(json.encode(nachricht)) +end + +--- Zeigt eine Meldung oben links im Spiel +local function ZeigeSpielmeldung(text) + if not text or text == '' then return end + BeginTextCommandThefeedPost('STRING') + AddTextComponentSubstringPlayerName(text) + EndTextCommandThefeedPostTicker(false, true) +end + +--- Schliesst den Laden +local function Schliessen() + if not istOffen then return end + istOffen = false + SetNuiFocus(false, false) + SendeAnNui({ aktion = 'schliessen' }) + TriggerServerEvent('aether:shop:schliessen') + pcall(function() exports['aether-hud']:SetzeSichtbar(true) end) +end + +-- --------------------------------------------------------------------- +-- Karten-Markierungen setzen +-- --------------------------------------------------------------------- +CreateThread(function() + for _, laden in pairs(Shop.Laeden) do + for _, standort in ipairs(laden.standorte) do + local blip = AddBlipForCoord(standort.x, standort.y, standort.z) + SetBlipSprite(blip, laden.blip.sprite) + SetBlipColour(blip, laden.blip.farbe) + SetBlipScale(blip, 0.7) + SetBlipAsShortRange(blip, true) + BeginTextCommandSetBlipName('STRING') + AddTextComponentString(laden.name) + EndTextCommandSetBlipName(blip) + end + end +end) + +-- --------------------------------------------------------------------- +-- Ereignisse +-- --------------------------------------------------------------------- +AddEventHandler('aether:client:bereit', function() + istGeladen = true +end) + +--- Server erlaubt das Oeffnen und schickt den Katalog +RegisterNetEvent('aether:shop:zeige', function(katalog, geld) + istOffen = true + SetNuiFocus(true, true) + pcall(function() exports['aether-hud']:SetzeSichtbar(false) end) + SendeAnNui({ aktion = 'oeffnen', katalog = katalog, geld = geld }) +end) + +--- Kauf erfolgreich: Beleg und aktualisierter Katalog +RegisterNetEvent('aether:shop:gekauft', function(ergebnis) + ergebnis.aktion = 'gekauft' + SendeAnNui(ergebnis) +end) + +--- Ablehnung einer Aktion +RegisterNetEvent('aether:shop:fehler', function(text) + if istOffen then + SendeAnNui({ aktion = 'fehler', text = text }) + else + ZeigeSpielmeldung(text) + end +end) + +-- --------------------------------------------------------------------- +-- Filiale in der Naehe erkennen +-- --------------------------------------------------------------------- +CreateThread(function() + while true do + local pause = 800 + + if istGeladen and not istOffen then + local position = GetEntityCoords(PlayerPedId()) + + -- Naechstgelegene Filiale suchen + local trefferId, trefferLaden, trefferPos = nil, nil, nil + for ladenId, laden in pairs(Shop.Laeden) do + for _, standort in ipairs(laden.standorte) do + if #(position - standort) <= Shop.INTERAKTION_WEITE then + trefferId, trefferLaden, trefferPos = ladenId, laden, standort + break + end + end + if trefferId then break end + end + + if trefferId then + pause = 0 -- fuer die Textanzeige jeden Frame laufen + + SetTextFont(4) + SetTextScale(0.34, 0.34) + SetTextColour(255, 255, 255, 215) + SetTextCentre(true) + SetTextOutline() + SetTextEntry('STRING') + AddTextComponentString('[E] ' .. trefferLaden.name) + SetDrawOrigin(trefferPos.x, trefferPos.y, trefferPos.z + 0.9, 0) + DrawText(0.0, 0.0) + ClearDrawOrigin() + + if IsControlJustReleased(0, TASTE_BENUTZEN) then + TriggerServerEvent('aether:shop:oeffnen', trefferId) + end + end + end + + Wait(pause) + end +end) + +-- --------------------------------------------------------------------- +-- NUI-Callbacks +-- --------------------------------------------------------------------- +RegisterNUICallback('schliessen', function(_, rueckgabe) + Schliessen() + rueckgabe({ ok = true }) +end) + +RegisterNUICallback('kaufen', function(daten, rueckgabe) + TriggerServerEvent('aether:shop:kaufen', daten.warenkorb or {}, daten.zahlung or 'bargeld') + rueckgabe({ ok = true }) +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen +-- --------------------------------------------------------------------- +AddEventHandler('onResourceStop', function(ressource) + if ressource ~= GetCurrentResourceName() then return end + SetNuiFocus(false, false) +end) diff --git a/resources/[aether]/aether-shop/fxmanifest.lua b/resources/[aether]/aether-shop/fxmanifest.lua new file mode 100644 index 0000000..a2f71cc --- /dev/null +++ b/resources/[aether]/aether-shop/fxmanifest.lua @@ -0,0 +1,34 @@ +fx_version 'cerulean' +game 'gta5' +lua54 'yes' + +name 'aether-shop' +author 'Jerrit Fritzsche' +description 'Läden (24/7, Ammu-Nation) mit Warenkorb, Bestand und Beleg' +version '0.1.0' + +ui_page 'html/index.html' + +files { + 'html/index.html', +} + +-- Katalog teilen sich Client und Server; Core-Dateien mitladen +shared_scripts { + '@aether-core/shared/config.lua', + '@aether-core/shared/utils.lua', + 'shared/shops.lua', +} + +client_scripts { + 'client/main.lua', +} + +server_scripts { + 'server/main.lua', +} + +dependencies { + 'aether-core', + 'aether-inventory', +} diff --git a/resources/[aether]/aether-shop/html/index.html b/resources/[aether]/aether-shop/html/index.html new file mode 100644 index 0000000..733c935 --- /dev/null +++ b/resources/[aether]/aether-shop/html/index.html @@ -0,0 +1,540 @@ + + + + + + + + +Aether — Shop + + +
+
+ +
+

LS 24/7

+
● Geöffnet · Alles für den Alltag
+
+
+
+
Bargeld
+
Konto
+
+
+ +
+
+
+ +
+
+
+
Warenkorb
+
+
+
+
+ +
+
+
+ + + + + diff --git a/resources/[aether]/aether-shop/server/main.lua b/resources/[aether]/aether-shop/server/main.lua new file mode 100644 index 0000000..0678b8b --- /dev/null +++ b/resources/[aether]/aether-shop/server/main.lua @@ -0,0 +1,262 @@ +-- ===================================================================== +-- Aether Shop — Serverlogik +-- Der Client schickt nur den Warenkorb. Preise, Bestand, Lizenz, +-- Deckung und Traglast werden hier komplett neu geprueft. +-- ===================================================================== + +local Core = exports['aether-core'] +local Inventory = exports['aether-inventory'] + +-- Bestaende der Laeden im Speicher: [ladenId][produktId] = Menge +local bestaende = {} + +-- Merkt sich, an welchem Laden ein Spieler gerade steht +local offeneLaeden = {} + +-- --------------------------------------------------------------------- +-- Bestandsverwaltung +-- --------------------------------------------------------------------- + +--- Setzt alle Bestaende auf die Ausgangswerte des Katalogs +local function BestaendeAufbauen() + for ladenId, laden in pairs(Shop.Laeden) do + bestaende[ladenId] = {} + for _, produktId in ipairs(laden.produkte) do + local produkt = Shop.Produkte[produktId] + bestaende[ladenId][produktId] = produkt and produkt.bestand or 0 + end + end +end + +--- Fuellt die Bestaende langsam wieder auf (alle 15 Minuten um 10 %) +CreateThread(function() + BestaendeAufbauen() + while true do + Wait(15 * 60 * 1000) + for ladenId, laden in pairs(Shop.Laeden) do + for _, produktId in ipairs(laden.produkte) do + local produkt = Shop.Produkte[produktId] + if produkt then + local maximum = produkt.bestand or 0 + local aktuell = bestaende[ladenId][produktId] or 0 + if aktuell < maximum then + local nachschub = math.max(1, math.floor(maximum * 0.1)) + bestaende[ladenId][produktId] = math.min(maximum, aktuell + nachschub) + end + end + end + end + end +end) + +-- --------------------------------------------------------------------- +-- Hilfsfunktionen +-- --------------------------------------------------------------------- + +--- Baut den Katalog eines Ladens fuer die Anzeige (mit aktuellem Bestand) +--- @param ladenId string Kennung des Ladens +--- @return table|nil +local function BaueKatalog(ladenId) + local laden = Shop.Laeden[ladenId] + if not laden then return nil end + + local produkte = {} + for _, produktId in ipairs(laden.produkte) do + local produkt = Shop.Produkte[produktId] + if produkt then + produkte[produktId] = { + name = produkt.name, kat = produkt.kat, icon = produkt.icon, + preis = produkt.preis, rabatt = produkt.rabatt, lizenz = produkt.lizenz, + beschreibung = produkt.beschreibung, menge = produkt.menge, + bestand = bestaende[ladenId] and bestaende[ladenId][produktId] or 0, + } + end + end + + return { + id = ladenId, name = laden.name, logo = laden.logo, tl = laden.tl, + accent = laden.accent, ink = laden.ink, warnung = laden.warnung, + produkte = produkte, + reihenfolge = laden.produkte, + } +end + +--- Prueft, ob ein Spieler einen gueltigen Waffenschein besitzt. +--- Sobald `aether-documents` migriert ist, greift die echte Pruefung. +--- @param quelle number Server-ID +--- @return boolean +local function HatWaffenschein(quelle) + if not Shop.LizenzPflicht then return true end + + -- Optionale Abfrage beim Dokumente-Modul + local erfolg, ergebnis = pcall(function() + return exports['aether-documents']:HatWaffenschein(quelle) + end) + if erfolg and type(ergebnis) == 'boolean' then return ergebnis end + + -- Modul noch nicht vorhanden: Rueckfallregel aus der Konfiguration + return Shop.OhneDokumenteErlauben +end + +--- Lehnt eine Aktion mit Begruendung ab +local function Ablehnen(quelle, grund) + TriggerClientEvent('aether:shop:fehler', quelle, grund) +end + +-- --------------------------------------------------------------------- +-- Laden oeffnen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:shop:oeffnen', function(ladenId) + local quelle = source + if not Core:HoleCharakterId(quelle) then return end + if type(ladenId) ~= 'string' then return end + + local laden = Shop.Laeden[ladenId] + if not laden then return end + + -- Entfernungspruefung: Der Spieler muss wirklich an einer Filiale stehen + local position = GetEntityCoords(GetPlayerPed(quelle)) + local inReichweite = false + for _, standort in ipairs(laden.standorte) do + if #(position - standort) <= (Shop.INTERAKTION_WEITE + 2.0) then + inReichweite = true + break + end + end + if not inReichweite then + Ablehnen(quelle, 'Du bist nicht an diesem Laden.') + return + end + + offeneLaeden[quelle] = ladenId + + local daten = Core:HoleSpielerInfo(quelle) + TriggerClientEvent('aether:shop:zeige', quelle, BaueKatalog(ladenId), { + bargeld = daten and daten.bargeld or 0, + konto = daten and daten.konto or 0, + }) +end) + +-- --------------------------------------------------------------------- +-- Kaufen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:shop:kaufen', function(warenkorb, zahlung) + local quelle = source + local charakterId = Core:HoleCharakterId(quelle) + if not charakterId then return end + + local ladenId = offeneLaeden[quelle] + if not ladenId then Ablehnen(quelle, 'Kein Laden geöffnet.') return end + if type(warenkorb) ~= 'table' then return end + + zahlung = (zahlung == 'karte') and 'konto' or 'bargeld' + + -- ---- Warenkorb vollstaendig neu durchrechnen ---- + local gesamt = 0 + local posten = {} + + for produktId, menge in pairs(warenkorb) do + if type(produktId) ~= 'string' or type(menge) ~= 'number' then return end + menge = math.floor(menge) + + if menge < 1 or menge > Shop.MAX_MENGE then + Ablehnen(quelle, 'Ungültige Menge.') + return + end + + -- Fuehrt dieser Laden das Produkt ueberhaupt? + if not Shop.FuehrtProdukt(ladenId, produktId) then + Ablehnen(quelle, 'Dieses Produkt gibt es hier nicht.') + return + end + + local produkt = Shop.Produkte[produktId] + if not produkt then Ablehnen(quelle, 'Unbekanntes Produkt.') return end + + -- Bestand pruefen + local vorrat = bestaende[ladenId][produktId] or 0 + if menge > vorrat then + Ablehnen(quelle, ('%s ist nicht so oft vorrätig.'):format(produkt.name)) + return + end + + -- Waffenschein pruefen + if produkt.lizenz and not HatWaffenschein(quelle) then + Ablehnen(quelle, ('Für %s brauchst du einen gültigen Waffenschein.'):format(produkt.name)) + return + end + + -- Preis kommt IMMER vom Server, nie aus dem Warenkorb des Clients + local einzelpreis = Shop.Preis(produkt) + gesamt = gesamt + einzelpreis * menge + + posten[#posten + 1] = { + produktId = produktId, + name = produkt.name, + menge = menge, + summe = einzelpreis * menge, + item = produkt.item, + stueck = (produkt.menge or 1) * menge, -- tatsaechliche Stueckzahl im Inventar + } + end + + if #posten == 0 then Ablehnen(quelle, 'Der Warenkorb ist leer.') return end + + -- ---- Passt alles ins Inventar? ---- + for _, eintrag in ipairs(posten) do + if not Inventory:PasstNoch(quelle, eintrag.item, eintrag.stueck) then + Ablehnen(quelle, 'Du kannst nicht so viel tragen.') + return + end + end + + -- ---- Bezahlen (prueft die Deckung) ---- + if not Core:EntferneGeld(quelle, zahlung, gesamt, 'Einkauf ' .. Shop.Laeden[ladenId].name) then + Ablehnen(quelle, zahlung == 'bargeld' and 'Nicht genug Bargeld.' or 'Konto nicht gedeckt.') + return + end + + -- ---- Waren aushaendigen ---- + local fehlgeschlagen = {} + for _, eintrag in ipairs(posten) do + if Inventory:GebeItem(quelle, eintrag.item, eintrag.stueck) then + bestaende[ladenId][eintrag.produktId] = + (bestaende[ladenId][eintrag.produktId] or 0) - eintrag.menge + else + fehlgeschlagen[#fehlgeschlagen + 1] = eintrag + end + end + + -- Konnte etwas nicht uebergeben werden, den Anteil zurueckerstatten + if #fehlgeschlagen > 0 then + local erstattung = 0 + for _, eintrag in ipairs(fehlgeschlagen) do erstattung = erstattung + eintrag.summe end + Core:FuegeGeldHinzu(quelle, zahlung, erstattung, 'Erstattung Einkauf') + Ablehnen(quelle, ('%d Artikel passten nicht — %d € wurden erstattet.') + :format(#fehlgeschlagen, erstattung)) + end + + Aether.Info(('Einkauf: Charakter #%d bei %s für %d €') + :format(charakterId, Shop.Laeden[ladenId].name, gesamt)) + + -- Beleg und aktualisierten Katalog zurueckschicken + local daten = Core:HoleSpielerInfo(quelle) + TriggerClientEvent('aether:shop:gekauft', quelle, { + posten = posten, + gesamt = gesamt, + zahlung = zahlung, + katalog = BaueKatalog(ladenId), + geld = { bargeld = daten and daten.bargeld or 0, konto = daten and daten.konto or 0 }, + }) +end) + +-- --------------------------------------------------------------------- +-- Aufraeumen +-- --------------------------------------------------------------------- +RegisterNetEvent('aether:shop:schliessen', function() + offeneLaeden[source] = nil +end) + +AddEventHandler('playerDropped', function() + offeneLaeden[source] = nil +end) diff --git a/resources/[aether]/aether-shop/shared/shops.lua b/resources/[aether]/aether-shop/shared/shops.lua new file mode 100644 index 0000000..974f8b0 --- /dev/null +++ b/resources/[aether]/aether-shop/shared/shops.lua @@ -0,0 +1,140 @@ +-- ===================================================================== +-- Aether Shop — Warenkatalog, Laeden und Standorte +-- Der Server ist die einzige Quelle fuer Preise und Bestand. +-- Der Client bekommt den Katalog nur zum Anzeigen. +-- ===================================================================== + +Shop = Shop or {} + +-- --------------------------------------------------------------------- +-- Grundeinstellungen +-- --------------------------------------------------------------------- +Shop.INTERAKTION_WEITE = 2.0 -- Reichweite zum Oeffnen (Meter) +Shop.MAX_MENGE = 100 -- Hoechstmenge je Position im Warenkorb + +-- Wird ein Waffenschein verlangt? +Shop.LizenzPflicht = true + +-- Rueckfallregel, falls `aether-documents` nicht laeuft: dann kann niemand +-- einen Waffenschein nachweisen und lizenzpflichtige Waren waeren gesperrt. +-- Seit der Migration von aether-documents greift die echte Pruefung. +Shop.OhneDokumenteErlauben = false + +-- --------------------------------------------------------------------- +-- Produkt-Registry +-- item = Kennung im Inventar (aether-inventory) +-- menge = wie viele Stueck ein Kauf liefert (z. B. Munitionspackung) +-- rabatt = 0..1, macht ein Angebot +-- lizenz = Waffenschein noetig +-- --------------------------------------------------------------------- +Shop.Produkte = { + -- --- 24/7 --- + wasser = { name = 'Wasserflasche', kat = 'Getränke', icon = '🥤', preis = 4, item = 'wasser', bestand = 40, beschreibung = 'Löscht den Durst.' }, + cola = { name = 'Cola', kat = 'Getränke', icon = '🥫', preis = 6, item = 'cola', bestand = 30, beschreibung = 'Eiskalt, koffeinhaltig.' }, + energy = { name = 'Energydrink', kat = 'Getränke', icon = '⚡', preis = 9, item = 'energy', bestand = 24, beschreibung = 'Gibt Extra-Ausdauer.' }, + kaffee = { name = 'Kaffee to go', kat = 'Getränke', icon = '☕', preis = 5, item = 'kaffee', bestand = 18, beschreibung = 'Wach werden.' }, + sandwich = { name = 'Sandwich', kat = 'Snacks', icon = '🥪', preis = 8, item = 'sandwich', bestand = 25, beschreibung = 'Stillt den Hunger.' }, + chips = { name = 'Chips', kat = 'Snacks', icon = '🍟', preis = 5, item = 'chips', bestand = 32, beschreibung = 'Knuspriger Snack.' }, + schoko = { name = 'Schokoriegel', kat = 'Snacks', icon = '🍫', preis = 3, item = 'schoko', bestand = 50, beschreibung = 'Süße Energie.' }, + burger = { name = 'Burger', kat = 'Snacks', icon = '🍔', preis = 12, item = 'burger', bestand = 15, rabatt = 0.25, beschreibung = 'Deftige Mahlzeit.' }, + verband = { name = 'Verband', kat = 'Gesundheit', icon = '🩹', preis = 15, item = 'verband', bestand = 20, beschreibung = 'Stoppt Blutungen.' }, + tablette = { name = 'Schmerztabletten', kat = 'Gesundheit', icon = '💊', preis = 20, item = 'tablette', bestand = 16, beschreibung = 'Lindert Schmerzen.' }, + medkit = { name = 'Erste-Hilfe-Kit', kat = 'Gesundheit', icon = '🧳', preis = 120, item = 'medkit', bestand = 8, beschreibung = 'Behandelt schwere Wunden.' }, + repkit = { name = 'Reparaturkit', kat = 'Werkzeug', icon = '🧰', preis = 250, item = 'reparaturkit', bestand = 10, beschreibung = 'Repariert Fahrzeuge.' }, + dietrich = { name = 'Dietrich', kat = 'Werkzeug', icon = '🗝️', preis = 45, item = 'dietrich', bestand = 22, beschreibung = 'Öffnet einfache Schlösser.' }, + kabel = { name = 'Kabelbinder', kat = 'Werkzeug', icon = '🔗', preis = 30, item = 'kabel', bestand = 40, beschreibung = 'Vielseitig einsetzbar.' }, + lampe = { name = 'Taschenlampe', kat = 'Werkzeug', icon = '🔦', preis = 35, item = 'lampe', bestand = 14, beschreibung = 'Leuchtet den Weg.' }, + handy = { name = 'Smartphone', kat = 'Elektronik', icon = '📱', preis = 450, item = 'handy', bestand = 6, beschreibung = 'Modernes Handy.' }, + sim = { name = 'SIM-Karte', kat = 'Elektronik', icon = '📶', preis = 60, item = 'sim', bestand = 30, beschreibung = 'Fürs Handy nötig.' }, + akku = { name = 'Akku', kat = 'Elektronik', icon = '🔋', preis = 40, item = 'akku', bestand = 45, beschreibung = 'Ersatz-Akku fürs Handy.' }, + funk = { name = 'Funkgerät', kat = 'Elektronik', icon = '📻', preis = 180, item = 'funk', bestand = 9, beschreibung = 'Kurzstrecken-Funk.' }, + boerse = { name = 'Börse', kat = 'Zubehör', icon = '👛', preis = 90, item = 'boerse', bestand = 12, beschreibung = 'Fasst bis zu 10 Dokumente.' }, + rucksack = { name = 'Rucksack', kat = 'Zubehör', icon = '🎒', preis = 300, item = 'rucksack', bestand = 7, rabatt = 0.15, beschreibung = '+ Tragekapazität.' }, + + -- --- Ammu-Nation --- + w_pistole = { name = 'Pistole P99', kat = 'Waffen', icon = '🔫', preis = 3500, item = 'pistole', bestand = 5, lizenz = true, beschreibung = '9mm Halbautomatik.' }, + w_smg = { name = 'MP5 (MPX)', kat = 'Waffen', icon = '🔫', preis = 8500, item = 'smg', bestand = 3, lizenz = true, beschreibung = 'Maschinenpistole.' }, + w_flinte = { name = 'Schrotflinte 870', kat = 'Waffen', icon = '🔫', preis = 5200, item = 'flinte', bestand = 4, lizenz = true, beschreibung = 'Pump-Action Shotgun.' }, + a_9mm = { name = '9mm Munition (×30)', kat = 'Munition', icon = '🧨', preis = 120, item = 'munition9', menge = 30, bestand = 60, beschreibung = 'Patronen für 9mm.' }, + a_12 = { name = 'Schrot 12/70 (×10)', kat = 'Munition', icon = '🧨', preis = 90, item = 'schrot12', menge = 10, bestand = 40, beschreibung = 'Schrotpatronen.' }, + at_sd = { name = 'Schalldämpfer', kat = 'Aufsätze', icon = '🔇', preis = 900, item = 'schalldaempfer', bestand = 10, lizenz = true, beschreibung = 'Dämpft den Mündungsknall.' }, + at_lampe = { name = 'Waffenlampe', kat = 'Aufsätze', icon = '🔦', preis = 400, item = 'waffenlampe', bestand = 12, beschreibung = 'Taktische Beleuchtung.' }, + at_laser = { name = 'Laservisier', kat = 'Aufsätze', icon = '🎯', preis = 650, item = 'laservisier', bestand = 8, beschreibung = 'Bessere Zielerfassung.' }, + s_weste = { name = 'Schutzweste', kat = 'Schutz', icon = '🦺', preis = 1200, item = 'weste', bestand = 6, beschreibung = 'Reduziert Schaden.' }, + s_helm = { name = 'Ballistikhelm', kat = 'Schutz', icon = '⛑️', preis = 800, item = 'helm', bestand = 8, beschreibung = 'Schützt den Kopf.' }, +} + +-- --------------------------------------------------------------------- +-- Laeden — nutzen dieselbe Oberflaeche, listen nur andere Produkte +-- --------------------------------------------------------------------- +Shop.Laeden = { + ['247'] = { + name = 'LS 24/7', logo = '🛒', tl = 'Alles für den Alltag', + accent = '#4ade80', ink = '#06210f', warnung = nil, + blip = { sprite = 52, farbe = 2 }, + produkte = { + 'wasser', 'cola', 'energy', 'kaffee', 'sandwich', 'chips', 'schoko', 'burger', + 'verband', 'tablette', 'medkit', 'repkit', 'dietrich', 'kabel', 'lampe', + 'handy', 'sim', 'akku', 'funk', 'boerse', 'rucksack', + }, + -- Standorte der 24/7-Filialen + standorte = { + vector3(25.7, -1347.3, 29.5), + vector3(-3038.9, 585.9, 7.9), + vector3(-3241.9, 1001.4, 12.8), + vector3(1729.2, 6414.1, 35.0), + vector3(1697.9, 4924.4, 42.1), + vector3(547.4, 2671.7, 42.1), + vector3(2679.0, 3280.1, 55.2), + vector3(1961.4, 3740.6, 32.3), + vector3(293.8, -1447.0, 29.9), + vector3(-2968.2, 390.9, 15.0), + }, + }, + ['ammu'] = { + name = 'Ammu-Nation', logo = '🔫', tl = 'Waffen & Ausrüstung', + accent = '#f0684a', ink = '#2a0c05', + warnung = 'Für Waffen & bestimmte Aufsätze ist ein gültiger Waffenschein erforderlich.', + blip = { sprite = 110, farbe = 1 }, + produkte = { + 'w_pistole', 'w_smg', 'w_flinte', 'a_9mm', 'a_12', + 'at_sd', 'at_lampe', 'at_laser', 's_weste', 's_helm', + }, + standorte = { + vector3(22.1, -1107.4, 29.8), + vector3(810.2, -2157.3, 29.6), + vector3(1693.4, 3760.2, 34.7), + vector3(-330.2, 6083.9, 31.5), + vector3(252.6, -50.0, 69.9), + vector3(-662.1, -935.3, 21.8), + vector3(-1305.3, -393.5, 36.7), + vector3(-1118.9, 2698.9, 18.6), + vector3(2567.7, 294.4, 108.7), + }, + }, +} + +--- Liefert den effektiven Preis eines Produkts (mit Rabatt) +--- @param produkt table Produktdefinition +--- @return number Preis in Euro +function Shop.Preis(produkt) + if not produkt then return 0 end + local preis = produkt.preis or 0 + if produkt.rabatt then + preis = math.floor(preis * (1 - produkt.rabatt) + 0.5) + end + return preis +end + +--- Prueft, ob ein Laden ein bestimmtes Produkt fuehrt +--- @param ladenId string Kennung des Ladens +--- @param produktId string Kennung des Produkts +--- @return boolean +function Shop.FuehrtProdukt(ladenId, produktId) + local laden = Shop.Laeden[ladenId] + if not laden then return false end + for _, kennung in ipairs(laden.produkte) do + if kennung == produktId then return true end + end + return false +end diff --git a/server.cfg b/server.cfg new file mode 100644 index 0000000..f5cc5b6 --- /dev/null +++ b/server.cfg @@ -0,0 +1,102 @@ +# ===================================================================== +# Aether Framework — Serverkonfiguration +# Start: FXServer.exe +exec server.cfg (bzw. ./run.sh +exec server.cfg) +# ===================================================================== + +# --------------------------------------------------------------------- +# Netzwerk +# --------------------------------------------------------------------- +endpoint_add_tcp "0.0.0.0:30120" +endpoint_add_udp "0.0.0.0:30120" + +# --------------------------------------------------------------------- +# Serverdaten +# --------------------------------------------------------------------- +sv_hostname "Aether Roleplay | Los Santos" +sv_projectName "Aether Framework" +sv_projectDesc "Deutschsprachiges GTA-RP auf eigenem Framework" +sv_maxclients 48 +sv_enforceGameBuild 3095 # Aktueller Spielbuild (fuer neuere DLC-Inhalte) +sets locale "de-DE" +sets tags "roleplay, deutsch, custom framework" + +# Lizenzschluessel von https://keymaster.fivem.net eintragen +sv_licenseKey "HIER_DEINEN_LIZENZSCHLUESSEL_EINTRAGEN" + +# Steam-Web-API-Key (optional, fuer Steam-Identifier) +# set steam_webApiKey "HIER_DEINEN_STEAM_KEY" + +# --------------------------------------------------------------------- +# Datenbank (oxmysql) +# Format: mysql://BENUTZER:PASSWORT@HOST/DATENBANK?charset=utf8mb4 +# --------------------------------------------------------------------- +set mysql_connection_string "mysql://root:passwort@localhost/aether?charset=utf8mb4" + +# --------------------------------------------------------------------- +# Ressourcen-Pfad +# --------------------------------------------------------------------- +# Nur noetig, wenn resources/ nicht neben der server.cfg liegt: +# set resources_path "resources" + +# --------------------------------------------------------------------- +# Basisressourcen (Reihenfolge ist wichtig) +# --------------------------------------------------------------------- +# --- System (gehoeren zum FiveM-Server, muessen laufen) --------------- +ensure sessionmanager # Verbindungsverwaltung — ohne das kommt niemand rein +ensure mapmanager # Kartenverwaltung +ensure spawnmanager # Spawn-Steuerung (der Core schaltet Auto-Spawn ab) +ensure hardcap # Begrenzung auf sv_maxclients +ensure chat # Chat (spaeter durch eigenes Modul ersetzbar) + +# --- Abhaengigkeiten ------------------------------------------------- +# oxmysql muss separat heruntergeladen werden: +# https://github.com/overextended/oxmysql/releases -> resources/[standalone]/oxmysql +ensure oxmysql # Datenbankanbindung — muss vor aether-core laufen + +# --- Aether ---------------------------------------------------------- +ensure aether-core # Fundament: Spieler, Charaktere, Geld, Zustand +ensure aether-loading # Ladebildschirm +ensure aether-character # Charakterauswahl und -ersteller +ensure aether-hud # In-Game-HUD (Status, Voice, Geld, Tacho) +ensure aether-inventory # Inventar, Items, Boden-Drops (Taste I) +ensure aether-bank # Geldautomat (PIN, Ein-/Auszahlen, Überweisen) +ensure aether-documents # Börse: Ausweis, Führerschein, Waffenschein (F2) +ensure aether-shop # Läden: 24/7 und Ammu-Nation +ensure aether-garage # Garagen: Ein-/Ausparken, Fahrzeugzustand +ensure aether-phone # AetherOS: Telefon, Nachrichten, Kontakte (F1) +ensure aether-jobs # Jobcenter: 8 Jobs mit Level und Auftraegen (J) +ensure aether-death # Verletzt-Zustand, Rettungsdienst, Krankenhaus +ensure aether-police # Polizei-Managementsystem (Tablet: F5) +ensure aether-medical # Medizin-Managementsystem (Klinik-PC: F6) +ensure aether-admin # Admin-Panel mit Live-Kameras (Taste: F9) + +# Weitere Module werden hier ergaenzt, sobald sie migriert sind: +# ensure aether-immobilien +# ensure aether-fuel +# ... + +# --------------------------------------------------------------------- +# Rechte +# Eigene Lizenz per "status" in der Serverkonsole herausfinden +# --------------------------------------------------------------------- +add_ace group.admin command allow +add_ace group.admin command.quit deny +# add_principal identifier.license:DEINE_LIZENZ group.admin + +# --------------------------------------------------------------------- +# aether-admin — Panel & Live-Kameras +# Die Panel-Rechte kommen aus aether_users.admin_group (user/support/ +# admin/owner), nicht aus ACE. Eigene Gruppe in der DB setzen, z. B.: +# UPDATE aether_users SET admin_group='owner' WHERE license='DEINE_LIZENZ'; +# +# Für die WebRTC-Live-Kameras muss der Media-Server laufen +# (siehe resources/[aether]/aether-admin/media-server/README.md): +# --------------------------------------------------------------------- +set aether_admin_media_secret "" # MUSS gesetzt werden (= Media-Server-Geheimnis) +set aether_admin_media_http "http://127.0.0.1:8788" # HTTP-Token-Endpunkt (server-zu-server) + +# --------------------------------------------------------------------- +# Spielregeln +# --------------------------------------------------------------------- +sv_scriptHookAllowed 0 # ScriptHook verbieten (Cheat-Schutz) +# sv_endpointPrivacy true # IPs der Spieler nicht oeffentlich zeigen diff --git a/sql/aether.sql b/sql/aether.sql new file mode 100644 index 0000000..6975d7e --- /dev/null +++ b/sql/aether.sql @@ -0,0 +1,281 @@ +-- ===================================================================== +-- Aether Framework — Datenbank-Schema (Grundlage) +-- Einspielen mit: mysql -u root -p aether < sql/aether.sql +-- ===================================================================== + +CREATE DATABASE IF NOT EXISTS `aether` + DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE `aether`; + +-- --------------------------------------------------------------------- +-- Benutzer (ein Eintrag pro FiveM-Account, erkannt an der Lizenz) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_users` ( + `license` VARCHAR(64) NOT NULL COMMENT 'Rockstar-Lizenz als eindeutige Kennung', + `name` VARCHAR(64) NOT NULL DEFAULT 'Unbekannt' COMMENT 'Zuletzt genutzter Spielername', + `discord` VARCHAR(64) DEFAULT NULL COMMENT 'Discord-ID, falls vorhanden', + `is_banned` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Gebannt ja/nein', + `ban_reason` VARCHAR(255) DEFAULT NULL COMMENT 'Grund des Banns', + `ban_until` DATETIME DEFAULT NULL COMMENT 'Bann-Ende (NULL = dauerhaft)', + `admin_group` VARCHAR(32) NOT NULL DEFAULT 'user' COMMENT 'Rechtegruppe: user/support/admin/owner', + `playtime` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Gesamtspielzeit in Minuten', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_seen` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`license`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Charaktere (mehrere pro Benutzer — Multicharacter) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_characters` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `license` VARCHAR(64) NOT NULL COMMENT 'Besitzer des Charakters', + `firstname` VARCHAR(32) NOT NULL, + `lastname` VARCHAR(32) NOT NULL, + `dob` DATE NOT NULL COMMENT 'Geburtsdatum', + `gender` VARCHAR(16) NOT NULL DEFAULT 'm' COMMENT 'Geschlecht', + `nationality` VARCHAR(48) NOT NULL DEFAULT 'Los Santos' COMMENT 'Herkunft', + `appearance` LONGTEXT DEFAULT NULL COMMENT 'Aussehen als JSON (Charakter-Ersteller)', + -- Geld (passend zum HUD: Bargeld / Konto / Schwarzgeld) + `cash` INT NOT NULL DEFAULT 0, + `bank` INT NOT NULL DEFAULT 0, + `dirty_money` INT NOT NULL DEFAULT 0, + `iban` VARCHAR(24) DEFAULT NULL COMMENT 'Kontonummer fuer das Bank-Modul', + `bank_pin` VARCHAR(8) NOT NULL DEFAULT '1234' COMMENT 'PIN des Bankautomaten', + -- Arbeit und Zugehoerigkeit + `job` VARCHAR(32) NOT NULL DEFAULT 'unemployed', + `job_grade` INT NOT NULL DEFAULT 0, + `faction` VARCHAR(32) DEFAULT NULL COMMENT 'Fraktion/Gang, falls Mitglied', + `faction_rank` INT NOT NULL DEFAULT 0, + -- Zustand (passend zu den HUD-Statusringen) + `health` INT NOT NULL DEFAULT 200, + `hunger` INT NOT NULL DEFAULT 100, + `thirst` INT NOT NULL DEFAULT 100, + `stamina` INT NOT NULL DEFAULT 100, + `position` VARCHAR(128) DEFAULT NULL COMMENT 'Letzte Position als JSON {x,y,z,heading}', + `playtime` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Spielzeit dieses Charakters in Minuten', + `is_dead` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Liegt der Charakter verletzt am Boden?', + `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Weich geloescht (Slot freigegeben)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_played` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_license` (`license`), + CONSTRAINT `fk_char_user` FOREIGN KEY (`license`) + REFERENCES `aether_users` (`license`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Inventar (ein Eintrag je Item-Stapel an einem Ort) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_inventory` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `owner_type` VARCHAR(16) NOT NULL DEFAULT 'character' COMMENT 'character/vehicle/stash/ground', + `owner_id` VARCHAR(64) NOT NULL COMMENT 'Charakter-ID, Kennzeichen oder Lager-Name', + `slot` INT NOT NULL DEFAULT 0 COMMENT 'Platz im jeweiligen Behaelter', + `item` VARCHAR(48) NOT NULL COMMENT 'Item-Kennung aus der Registry', + `amount` INT NOT NULL DEFAULT 1, + `metadata` LONGTEXT DEFAULT NULL COMMENT 'Zusatzdaten als JSON (Seriennummer, Akku, Aufsaetze)', + PRIMARY KEY (`id`), + KEY `idx_owner` (`owner_type`, `owner_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Fallengelassene Items (bleiben ueber Serverneustarts erhalten) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_drops` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `item` VARCHAR(48) NOT NULL COMMENT 'Item-Kennung aus der Registry', + `amount` INT NOT NULL DEFAULT 1, + `metadata` LONGTEXT DEFAULT NULL COMMENT 'Zusatzdaten als JSON', + `x` FLOAT NOT NULL, + `y` FLOAT NOT NULL, + `z` FLOAT NOT NULL, + `prop` VARCHAR(48) NOT NULL COMMENT 'Modell des sichtbaren Kartons', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Fahrzeuge (Garagen-Modul) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_vehicles` ( + `plate` VARCHAR(12) NOT NULL COMMENT 'Kennzeichen als eindeutige Kennung', + `owner` INT UNSIGNED NOT NULL COMMENT 'Charakter-ID des Besitzers', + `model` VARCHAR(48) NOT NULL COMMENT 'Fahrzeugmodell (Spawnname)', + `props` LONGTEXT DEFAULT NULL COMMENT 'Tuning/Farben als JSON', + `garage` VARCHAR(48) NOT NULL DEFAULT 'zentralgarage' COMMENT 'Aktueller Standort', + `state` VARCHAR(16) NOT NULL DEFAULT 'in' COMMENT 'in = eingeparkt, out = draussen, impound = beschlagnahmt', + `fuel` FLOAT NOT NULL DEFAULT 100 COMMENT 'Tankfuellstand in Prozent', + `engine` FLOAT NOT NULL DEFAULT 1000 COMMENT 'Motorzustand', + `body` FLOAT NOT NULL DEFAULT 1000 COMMENT 'Karosseriezustand', + `mileage` INT NOT NULL DEFAULT 0 COMMENT 'Kilometerstand', + -- Letzte bekannte Position; ausgeparkte Fahrzeuge stehen nach einem + -- Serverneustart wieder genau hier. + `pos_x` FLOAT DEFAULT NULL, + `pos_y` FLOAT DEFAULT NULL, + `pos_z` FLOAT DEFAULT NULL, + `heading` FLOAT DEFAULT NULL, + PRIMARY KEY (`plate`), + KEY `idx_owner` (`owner`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Dokumente (Boerse: Ausweis, Fuehrerschein, Waffenschein ...) +-- Name und Geburtsdatum kommen live aus aether_characters, damit sie +-- nicht doppelt gepflegt werden muessen. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_documents` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `character` INT UNSIGNED NOT NULL COMMENT 'Besitzer des Dokuments', + `type` VARCHAR(16) NOT NULL COMMENT 'perso/fs/ws/gk/vk', + `wallet` INT NOT NULL DEFAULT 0 COMMENT 'In welcher Boerse es steckt (0 = erste)', + `data` LONGTEXT DEFAULT NULL COMMENT 'Dokumentdaten als JSON (Nummer, Klassen, Waffen ...)', + `issued_by` VARCHAR(64) DEFAULT NULL COMMENT 'Ausstellende Stelle', + `issued_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `valid_until` DATE DEFAULT NULL COMMENT 'Gueltig bis (NULL = unbefristet)', + `revoked` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Entzogen ja/nein', + PRIMARY KEY (`id`), + KEY `idx_character` (`character`), + UNIQUE KEY `uniq_doc` (`character`, `type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Handy: Geraetedaten je Charakter +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_phone` ( + `character` INT UNSIGNED NOT NULL, + `number` VARCHAR(16) NOT NULL COMMENT 'Telefonnummer, z. B. 555-0182', + `pin` VARCHAR(8) DEFAULT NULL COMMENT 'Entsperrcode (NULL = Ersteinrichtung noetig)', + `faceid` TINYINT(1) NOT NULL DEFAULT 0, + `theme` VARCHAR(16) NOT NULL DEFAULT 'dark', + `wallpaper` VARCHAR(32) NOT NULL DEFAULT 'wp-aurora', + `dock` VARCHAR(128) NOT NULL DEFAULT '["phone","messages","bank","camera"]', + `battery` INT NOT NULL DEFAULT 100 COMMENT 'Akkustand in Prozent', + `unlocked` VARCHAR(255) NOT NULL DEFAULT '{}' COMMENT 'Freigeschaltete Apps als JSON', + PRIMARY KEY (`character`), + UNIQUE KEY `uniq_number` (`number`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Handy: Kontakte +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_phone_contacts` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `character` INT UNSIGNED NOT NULL COMMENT 'Besitzer der Kontaktliste', + `name` VARCHAR(48) NOT NULL, + `number` VARCHAR(16) NOT NULL, + PRIMARY KEY (`id`), + KEY `idx_character` (`character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Handy: Nachrichten (zwischen Telefonnummern, nicht Charakteren — +-- so bleiben sie auch nach einem SIM-Wechsel nachvollziehbar) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_phone_messages` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `sender` VARCHAR(16) NOT NULL COMMENT 'Absendernummer', + `receiver` VARCHAR(16) NOT NULL COMMENT 'Empfaengernummer', + `text` VARCHAR(512) NOT NULL, + `gelesen` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_paar` (`sender`, `receiver`), + KEY `idx_empfaenger` (`receiver`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Handy: Anrufverlauf +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_phone_calls` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `caller` VARCHAR(16) NOT NULL COMMENT 'Anrufernummer', + `callee` VARCHAR(16) NOT NULL COMMENT 'Angerufene Nummer', + `verpasst` TINYINT(1) NOT NULL DEFAULT 0, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_caller` (`caller`), + KEY `idx_callee` (`callee`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Banktransaktionen (Bank- und Handy-Modul) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_transactions` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `character` INT UNSIGNED NOT NULL COMMENT 'Betroffener Charakter', + `type` VARCHAR(16) NOT NULL COMMENT 'deposit/withdraw/transfer/salary/fine', + `amount` INT NOT NULL COMMENT 'Betrag (positiv = Eingang)', + `balance` INT NOT NULL COMMENT 'Kontostand nach der Buchung', + `label` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Verwendungszweck', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_character` (`character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Jobs: Level und Erfahrung je Charakter und Job +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_jobs` ( + `character` INT UNSIGNED NOT NULL, + `job` VARCHAR(32) NOT NULL, + `level` INT NOT NULL DEFAULT 1, + `xp` INT NOT NULL DEFAULT 0, + `verdient` INT NOT NULL DEFAULT 0 COMMENT 'Gesamtverdienst in diesem Job', + `auftraege` INT NOT NULL DEFAULT 0 COMMENT 'Abgeschlossene Auftraege', + PRIMARY KEY (`character`, `job`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Fraktionen: Personenakten (Straftaten, Notizen, Konfisziertes) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_records` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `character` INT UNSIGNED NOT NULL COMMENT 'Betroffene Person', + `faction` VARCHAR(32) NOT NULL COMMENT 'Welche Fraktion hat den Eintrag erstellt', + `type` VARCHAR(16) NOT NULL COMMENT 'straftat/notiz/konfisziert/behandlung', + `text` VARCHAR(512) NOT NULL, + `betrag` INT NOT NULL DEFAULT 0 COMMENT 'Strafe/Rechnung, falls zutreffend', + `haft` INT NOT NULL DEFAULT 0 COMMENT 'Haftzeit in Minuten', + `created_by` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'Name des Beamten', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_character` (`character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Fraktionen: Fahndungen +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_wanted` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `character` INT UNSIGNED NOT NULL, + `faction` VARCHAR(32) NOT NULL, + `grund` VARCHAR(255) NOT NULL, + `stufe` VARCHAR(16) NOT NULL DEFAULT 'gesucht' COMMENT 'gesucht/bewaffnet/gefaehrlich', + `aktiv` TINYINT(1) NOT NULL DEFAULT 1, + `created_by` VARCHAR(64) NOT NULL DEFAULT '', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_character` (`character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Gefaengnis: laufende Haftstrafen +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_jail` ( + `character` INT UNSIGNED NOT NULL, + `rest` INT NOT NULL DEFAULT 0 COMMENT 'Restliche Haftzeit in Sekunden', + `grund` VARCHAR(255) NOT NULL DEFAULT '', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Staatskasse (eine Zeile; Strafen und Rechnungen fliessen hierher) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_treasury` ( + `id` INT UNSIGNED NOT NULL DEFAULT 1, + `betrag` BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +INSERT IGNORE INTO `aether_treasury` (`id`, `betrag`) VALUES (1, 12480000); diff --git a/sql/aether_admin.sql b/sql/aether_admin.sql new file mode 100644 index 0000000..7c9e9c3 --- /dev/null +++ b/sql/aether_admin.sql @@ -0,0 +1,204 @@ +-- ===================================================================== +-- aether-admin — Datenbankschema +-- +-- Grundsatz: Das Admin-Panel führt Buch und greift durch, aber es +-- erfindet keine Wahrheit. Rechte und Bans leben in `aether_users` +-- (aus aether-core) — dort prüft der Core sie bereits beim Connect. +-- Dieses Schema ergänzt nur, was der Core nicht kennt: Verwarnungen, +-- Reports, Notizen, das Prüfprotokoll und die verwaltbaren Datensätze +-- einzelner Panel-Bereiche. +-- +-- Voraussetzung: sql/aether.sql (aether_users, aether_characters) ist +-- eingespielt. `admin_group` und die Bann-Spalten existieren dort schon. +-- ===================================================================== + +-- --------------------------------------------------------------------- +-- Verwarnungen +-- +-- Eine Verwarnung ist eine dokumentierte Ermahnung, mehr nicht. Sie +-- sperrt nichts und läuft nicht ab — sie steht in der Akte, bis ein +-- Admin sie entfernt. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_admin_warns` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `license` VARCHAR(64) NOT NULL COMMENT 'Betroffener Benutzer', + `character` INT UNSIGNED DEFAULT NULL COMMENT 'Charakter, falls bekannt', + `name` VARCHAR(64) NOT NULL COMMENT 'Anzeigename zum Zeitpunkt', + `grund` VARCHAR(255) NOT NULL, + `admin_license` VARCHAR(64) DEFAULT NULL, + `admin_name` VARCHAR(64) NOT NULL DEFAULT 'System', + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_license` (`license`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Bann-Verlauf +-- +-- Die Durchsetzung liegt in `aether_users` (is_banned/ban_reason/ +-- ban_until) — der Core sperrt darüber beim Connect. Diese Tabelle ist +-- das Gedächtnis: jeder Bann und jede Entbannung bleibt nachlesbar, +-- auch für Identifier ohne Benutzerkonto (Offline-Ban). +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_admin_bans` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `license` VARCHAR(64) DEFAULT NULL COMMENT 'Lizenz, falls bekannt', + `name` VARCHAR(64) NOT NULL, + `grund` VARCHAR(255) NOT NULL, + `admin_license` VARCHAR(64) DEFAULT NULL, + `admin_name` VARCHAR(64) NOT NULL DEFAULT 'System', + `bis` DATETIME DEFAULT NULL COMMENT 'NULL = dauerhaft', + `aktiv` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '0 = wieder entbannt', + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `aufgehoben_am` DATETIME DEFAULT NULL, + `aufgehoben_von` VARCHAR(64) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_license` (`license`), + KEY `idx_aktiv` (`aktiv`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Reports / Support-Tickets +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_admin_reports` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `melder_license` VARCHAR(64) DEFAULT NULL, + `melder_name` VARCHAR(64) NOT NULL, + `betreff` VARCHAR(160) NOT NULL, + `nachricht` TEXT DEFAULT NULL, + `prioritaet` TINYINT NOT NULL DEFAULT 3 COMMENT '1 hoch … 3 niedrig', + `status` VARCHAR(16) NOT NULL DEFAULT 'offen' + COMMENT 'offen/bearbeitung/geschlossen', + `bearbeiter` VARCHAR(64) DEFAULT NULL COMMENT 'Admin, der übernommen hat', + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `aktualisiert_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Nachrichtenverlauf eines Reports (Antworten des Teams / Rückfragen) +CREATE TABLE IF NOT EXISTS `aether_admin_report_msgs` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `report` INT UNSIGNED NOT NULL, + `autor` VARCHAR(64) NOT NULL, + `ist_admin` TINYINT(1) NOT NULL DEFAULT 1, + `nachricht` TEXT NOT NULL, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_report` (`report`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Interne Spielernotizen (nur für das Team sichtbar) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_admin_notes` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `license` VARCHAR(64) NOT NULL, + `name` VARCHAR(64) NOT NULL, + `notiz` TEXT NOT NULL, + `admin_name` VARCHAR(64) NOT NULL DEFAULT 'System', + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_license` (`license`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Prüfprotokoll (Audit-Log) +-- +-- Jede eingreifende Aktion hinterlässt eine Spur: wer, was, an wem, +-- mit welchem Detail. Einträge werden nur geschrieben und gelesen. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_admin_log` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `admin_license` VARCHAR(64) DEFAULT NULL, + `admin_name` VARCHAR(64) NOT NULL DEFAULT 'System', + `aktion` VARCHAR(64) NOT NULL, + `ziel` VARCHAR(96) DEFAULT NULL, + `detail` VARCHAR(512) DEFAULT NULL, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_admin` (`admin_license`), + KEY `idx_zeit` (`erstellt_am`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Ankündigungen (Verlauf der Rundmeldungen) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_admin_broadcasts` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `typ` VARCHAR(16) NOT NULL DEFAULT 'info', + `nachricht` VARCHAR(512) NOT NULL, + `admin_name` VARCHAR(64) NOT NULL DEFAULT 'System', + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Staatsprojekte (Abstimmungen; später koppelbar an eine Handy-App) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_admin_projects` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `titel` VARCHAR(160) NOT NULL, + `beschreibung` TEXT DEFAULT NULL, + `kosten` BIGINT NOT NULL DEFAULT 0, + `ja` INT UNSIGNED NOT NULL DEFAULT 0, + `nein` INT UNSIGNED NOT NULL DEFAULT 0, + `abstimmung_offen` TINYINT(1) NOT NULL DEFAULT 1, + `aktiviert` TINYINT(1) NOT NULL DEFAULT 0, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Immobilien +-- +-- Solange kein eigenständiges Immobilien-Resource läuft, verwaltet +-- das Panel die Objekte selbst. `HoleImmobilien` ist als Export +-- vorgesehen, damit ein späteres Modul die Hoheit übernehmen kann. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_admin_properties` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `name` VARCHAR(96) NOT NULL, + `ort` VARCHAR(96) DEFAULT NULL, + `preis` BIGINT NOT NULL DEFAULT 0, + `besitzer` INT UNSIGNED DEFAULT NULL COMMENT 'Charakter aus aether_characters', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Fraktions-Metadaten +-- +-- Mitgliedschaft ergibt sich aus aether_characters.faction. Hier +-- liegen nur die Zusatzangaben (Gehalt, Farbe), die der Charakter +-- nicht kennt. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_admin_factions` ( + `name` VARCHAR(48) NOT NULL, + `gehalt` INT NOT NULL DEFAULT 0 COMMENT 'Payday pro Stunde', + `farbe` VARCHAR(9) NOT NULL DEFAULT '#34d39a', + `aktiv` TINYINT(1) NOT NULL DEFAULT 1, + PRIMARY KEY (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Persistierte Server-Einstellungen (Schlüssel/Wert) +-- z. B. staatskasse, steuersatz, pvp, whitelist, servername … +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `aether_admin_settings` ( + `schluessel` VARCHAR(48) NOT NULL, + `wert` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`schluessel`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Grundbestand (nur einmalig, wenn leer) +-- --------------------------------------------------------------------- +INSERT INTO `aether_admin_settings` (`schluessel`, `wert`) VALUES + ('staatskasse', '12480000'), + ('steuersatz', '8'), + ('pvp', '1'), + ('whitelist', '1'), + ('voice', '1'), + ('economy', '1') +ON DUPLICATE KEY UPDATE `schluessel` = `schluessel`; diff --git a/sql/aether_medical.sql b/sql/aether_medical.sql new file mode 100644 index 0000000..1d81c8d --- /dev/null +++ b/sql/aether_medical.sql @@ -0,0 +1,607 @@ +-- ===================================================================== +-- aether-medical — Datenbankschema +-- +-- Grundsatz, der sich durch das ganze Schema zieht: +-- Das System unterstuetzt medizinische Entscheidungen — es trifft +-- niemals selbst welche. +-- +-- Deshalb hat JEDER Vorgang, der eine Entscheidung darstellt, eine +-- Spalte mit dem verantwortlichen Mediziner. Es gibt keine Spalte, +-- die das System ohne einen Menschen dahinter fuellen wuerde. +-- Aufnahmen, Entlassungen, Diagnosen, Medikamentengaben und +-- Todesfeststellungen tragen immer einen Namen. +-- ===================================================================== + +-- --------------------------------------------------------------------- +-- Personal +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_personal` ( + `character` INT UNSIGNED NOT NULL COMMENT 'Charakter aus aether_characters', + `personalnummer` VARCHAR(16) NOT NULL COMMENT 'z. B. LSMC-0421', + `rolle` VARCHAR(32) NOT NULL DEFAULT 'praktikant', + `station` VARCHAR(32) NOT NULL DEFAULT 'notaufnahme', + `rechte_extra` LONGTEXT DEFAULT NULL COMMENT 'Zusaetzlich gewaehrte Rechte als JSON', + `rechte_entzug` LONGTEXT DEFAULT NULL COMMENT 'Einzeln entzogene Rechte als JSON', + `eingestellt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `aktiv` TINYINT(1) NOT NULL DEFAULT 1, + `notiz` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`character`), + UNIQUE KEY `uniq_personalnummer` (`personalnummer`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Dienstzeiten (nur Protokoll, keine Auswertung zur Bewertung) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_dienst` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `character` INT UNSIGNED NOT NULL, + `beginn` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `ende` DATETIME DEFAULT NULL, + `sekunden` INT UNSIGNED NOT NULL DEFAULT 0, + `station` VARCHAR(32) DEFAULT NULL, + `funktion` VARCHAR(24) NOT NULL DEFAULT 'station' + COMMENT 'rtw/notarzt/station/op/leitstelle', + PRIMARY KEY (`id`), + KEY `idx_character` (`character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Patientenstammdaten +-- Werden ausschliesslich von Medizinern gepflegt. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_patient` ( + `character` INT UNSIGNED NOT NULL, + `blutgruppe` VARCHAR(8) DEFAULT NULL COMMENT 'Erst nach Bestimmung im Labor', + `allergien` VARCHAR(512) DEFAULT NULL, + `vorerkrankungen` VARCHAR(1000) DEFAULT NULL, + `dauermedikation` VARCHAR(1000) DEFAULT NULL, + `implantate` VARCHAR(512) DEFAULT NULL, + `organspender` VARCHAR(16) DEFAULT NULL COMMENT 'ja/nein/unbekannt', + `notfallkontakt` VARCHAR(160) DEFAULT NULL, + `hinweis` VARCHAR(512) DEFAULT NULL COMMENT 'Warnhinweis, z. B. Gerinnungsstoerung', + `lichtbild` VARCHAR(512) DEFAULT NULL COMMENT 'Bildadresse fuer die Patientenakte', + `aktualisiert_von` INT UNSIGNED DEFAULT NULL, + `aktualisiert_am` DATETIME DEFAULT NULL, + PRIMARY KEY (`character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Kurze Vermerke zu einem Patienten ausserhalb einer Akte +CREATE TABLE IF NOT EXISTS `medical_patient_notiz` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `patient` INT UNSIGNED NOT NULL, + `text` VARCHAR(1000) NOT NULL, + `von` INT UNSIGNED NOT NULL, + `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_patient` (`patient`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Akten und ihre Versionen +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_akte` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `aktenzeichen` VARCHAR(24) NOT NULL, + `patient` INT UNSIGNED DEFAULT NULL, + `typ` VARCHAR(24) NOT NULL DEFAULT 'behandlung', + `titel` VARCHAR(160) NOT NULL, + `status` VARCHAR(16) NOT NULL DEFAULT 'entwurf' + COMMENT 'entwurf/offen/freigegeben/geschlossen', + `inhalt` LONGTEXT DEFAULT NULL, + `erstellt_von` INT UNSIGNED NOT NULL, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `freigabe_von` INT UNSIGNED DEFAULT NULL, + `freigabe_am` DATETIME DEFAULT NULL, + `version` INT UNSIGNED NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_aktenzeichen` (`aktenzeichen`), + KEY `idx_patient` (`patient`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `medical_akte_version` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `akte` INT UNSIGNED NOT NULL, + `version` INT UNSIGNED NOT NULL, + `inhalt` LONGTEXT DEFAULT NULL, + `geaendert_von` INT UNSIGNED NOT NULL, + `geaendert_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `kommentar` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_akte` (`akte`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Verknuepfungen zwischen Vorgaengen (Akte <-> Einsatz <-> OP <-> Labor) +CREATE TABLE IF NOT EXISTS `medical_verknuepfung` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `von_typ` VARCHAR(24) NOT NULL, + `von_id` VARCHAR(64) NOT NULL, + `zu_typ` VARCHAR(24) NOT NULL, + `zu_id` VARCHAR(64) NOT NULL, + `von` INT UNSIGNED DEFAULT NULL, + `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_von` (`von_typ`, `von_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Aufnahme +-- Es gibt KEINE automatische Aufnahme und KEINE automatische +-- Entlassung. Beides traegt immer einen Mediziner als Verantwortlichen. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_aufnahme` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `patient` INT UNSIGNED NOT NULL, + `grund` VARCHAR(512) NOT NULL, + `prioritaet` TINYINT NOT NULL DEFAULT 3 COMMENT '1 = sofort … 4 = nachrangig', + `station` VARCHAR(32) DEFAULT NULL, + `zimmer` INT UNSIGNED DEFAULT NULL, + `status` VARCHAR(16) NOT NULL DEFAULT 'wartend' + COMMENT 'wartend/aufgenommen/entlassen', + `aufgenommen_von` INT UNSIGNED NOT NULL COMMENT 'Wer hat aufgenommen', + `aufgenommen_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- Entlassung geschieht ausschliesslich manuell: + `entlassen_von` INT UNSIGNED DEFAULT NULL, + `entlassen_am` DATETIME DEFAULT NULL, + `entlass_grund` VARCHAR(512) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_patient` (`patient`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Behandlungsdokumentation +-- Das System speichert AUSSCHLIESSLICH die Dokumentation. Es heilt +-- nichts, es diagnostiziert nichts, es schlaegt keine Diagnose vor. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_behandlung` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `kennung` VARCHAR(24) NOT NULL, + `patient` INT UNSIGNED NOT NULL, + `aufnahme` INT UNSIGNED DEFAULT NULL, + `einsatz` INT UNSIGNED DEFAULT NULL, + `symptome` TEXT DEFAULT NULL COMMENT 'Was der Patient schildert', + `untersuchung` TEXT DEFAULT NULL COMMENT 'Was der Mediziner erhebt', + `diagnose` TEXT DEFAULT NULL COMMENT 'Vom Mediziner gestellt, nie berechnet', + `massnahmen` TEXT DEFAULT NULL, + `empfehlung` TEXT DEFAULT NULL, + `nachsorge` TEXT DEFAULT NULL, + `status` VARCHAR(16) NOT NULL DEFAULT 'laufend' + COMMENT 'laufend/abgeschlossen', + `erstellt_von` INT UNSIGNED NOT NULL, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `abgeschlossen_von` INT UNSIGNED DEFAULT NULL, + `abgeschlossen_am` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_kennung` (`kennung`), + KEY `idx_patient` (`patient`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Verabreichte Medikamente — immer mit dem Mediziner, der sie gegeben hat +CREATE TABLE IF NOT EXISTS `medical_behandlung_medikament` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `behandlung` INT UNSIGNED NOT NULL, + `medikament` INT UNSIGNED NOT NULL, + `dosierung` VARCHAR(64) DEFAULT NULL, + `menge` INT NOT NULL DEFAULT 1, + `gegeben_von` INT UNSIGNED NOT NULL, + `gegeben_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `hinweis` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_behandlung` (`behandlung`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Operationen +-- Werden vollstaendig manuell dokumentiert. Keine Durchfuehrung +-- durch das System, kein automatischer Ausgang. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_operation` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `kennung` VARCHAR(24) NOT NULL, + `patient` INT UNSIGNED NOT NULL, + `aufnahme` INT UNSIGNED DEFAULT NULL, + `art` VARCHAR(160) NOT NULL, + `saal` INT UNSIGNED DEFAULT NULL, + `geplant_fuer` DATETIME DEFAULT NULL COMMENT 'Ab hier erinnert das System nur', + `beginn` DATETIME DEFAULT NULL COMMENT 'Vom OP-Team eingetragen', + `ende` DATETIME DEFAULT NULL COMMENT 'Vom OP-Team eingetragen', + `verlauf` TEXT DEFAULT NULL, + `komplikationen` TEXT DEFAULT NULL, + `nachsorge` TEXT DEFAULT NULL, + `status` VARCHAR(16) NOT NULL DEFAULT 'geplant' + COMMENT 'geplant/laufend/abgeschlossen/abgesagt', + `erstellt_von` INT UNSIGNED NOT NULL, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_kennung` (`kennung`), + KEY `idx_patient` (`patient`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `medical_operation_team` ( + `operation` INT UNSIGNED NOT NULL, + `character` INT UNSIGNED NOT NULL, + `funktion` VARCHAR(48) NOT NULL DEFAULT 'assistenz' + COMMENT 'operateur/assistenz/anaesthesie/pflege', + PRIMARY KEY (`operation`, `character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Medikamente +-- Bestandsfuehrung. Eine Ausgabe bucht der Mediziner selbst — +-- das System gibt nichts aus und verordnet nichts. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_medikament` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `kennung` VARCHAR(24) NOT NULL, + `name` VARCHAR(120) NOT NULL, + `wirkstoff` VARCHAR(120) DEFAULT NULL, + `kategorie` VARCHAR(48) NOT NULL DEFAULT 'sonstiges', + `dosierung_standard` VARCHAR(64) DEFAULT NULL COMMENT 'Nur ein Vorschlag', + `einheit` VARCHAR(24) NOT NULL DEFAULT 'Stück', + `bestand` INT NOT NULL DEFAULT 0, + `mindestbestand` INT NOT NULL DEFAULT 0 COMMENT 'Ab hier erinnert das System', + `lagerort` VARCHAR(64) DEFAULT NULL, + `verfaellt_am` DATE DEFAULT NULL, + `hinweis` VARCHAR(255) DEFAULT NULL COMMENT 'z. B. Wechselwirkungen', + `betaeubungsmittel` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Erfordert besondere Doku', + `aktiv` TINYINT(1) NOT NULL DEFAULT 1, + `sortierung` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_kennung` (`kennung`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `medical_medikament_buchung` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `medikament` INT UNSIGNED NOT NULL, + `art` VARCHAR(16) NOT NULL COMMENT 'eingang/ausgabe/ruecknahme/verfall/korrektur', + `menge` INT NOT NULL, + `bestand_danach` INT NOT NULL, + `patient` INT UNSIGNED DEFAULT NULL, + `grund` VARCHAR(255) DEFAULT NULL, + `gebucht_von` INT UNSIGNED NOT NULL, + `gebucht_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_medikament` (`medikament`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Labor +-- Das Labor speichert Ergebnisse. Es stellt keine Diagnose — +-- die Bewertung eines Befundes bleibt dem Mediziner ueberlassen. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_labor` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `kennung` VARCHAR(24) NOT NULL, + `patient` INT UNSIGNED NOT NULL, + `art` VARCHAR(32) NOT NULL COMMENT 'blut/dna/urin/gewebe/toxikologie', + `fragestellung` VARCHAR(512) DEFAULT NULL COMMENT 'Wonach wird gesucht', + `dringend` TINYINT(1) NOT NULL DEFAULT 0, + `status` VARCHAR(16) NOT NULL DEFAULT 'offen' + COMMENT 'offen/in_arbeit/fertig/verworfen', + `auftrag_von` INT UNSIGNED NOT NULL, + `auftrag_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `bearbeitet_von` INT UNSIGNED DEFAULT NULL, + `bearbeitet_am` DATETIME DEFAULT NULL, + `ergebnis` TEXT DEFAULT NULL COMMENT 'Messwerte, vom Labor eingetragen', + `befund` TEXT DEFAULT NULL COMMENT 'Bewertung durch einen Mediziner', + `befund_von` INT UNSIGNED DEFAULT NULL, + `befund_am` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_kennung` (`kennung`), + KEY `idx_patient` (`patient`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Blutbank +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_blut` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `konserve` VARCHAR(24) NOT NULL, + `blutgruppe` VARCHAR(8) NOT NULL, + `menge_ml` INT NOT NULL DEFAULT 500, + `lagerort` VARCHAR(64) DEFAULT NULL, + `eingelagert_von` INT UNSIGNED NOT NULL, + `eingelagert_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `verfaellt_am` DATE DEFAULT NULL COMMENT 'Ab hier erinnert das System nur', + `status` VARCHAR(16) NOT NULL DEFAULT 'vorraetig' + COMMENT 'vorraetig/reserviert/ausgegeben/verworfen', + `reserviert_fuer` INT UNSIGNED DEFAULT NULL, + `reserviert_von` INT UNSIGNED DEFAULT NULL, + `ausgegeben_von` INT UNSIGNED DEFAULT NULL, + `ausgegeben_am` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_konserve` (`konserve`), + KEY `idx_blutgruppe` (`blutgruppe`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Krankenhaus: Stationen, Zimmer, Belegung +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_station` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `kennung` VARCHAR(24) NOT NULL, + `name` VARCHAR(120) NOT NULL, + `art` VARCHAR(24) NOT NULL DEFAULT 'normal' + COMMENT 'notaufnahme/normal/intensiv/op/behandlung', + `etage` VARCHAR(24) DEFAULT NULL, + `aktiv` TINYINT(1) NOT NULL DEFAULT 1, + `sortierung` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_kennung` (`kennung`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `medical_zimmer` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `station` INT UNSIGNED NOT NULL, + `kennung` VARCHAR(24) NOT NULL, + `betten` INT NOT NULL DEFAULT 1, + `art` VARCHAR(24) NOT NULL DEFAULT 'bett' + COMMENT 'bett/behandlung/op/intensiv/isolierung', + `status` VARCHAR(16) NOT NULL DEFAULT 'frei' + COMMENT 'frei/belegt/reinigung/gesperrt — immer manuell gesetzt', + `hinweis` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_station` (`station`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `medical_belegung` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `zimmer` INT UNSIGNED NOT NULL, + `bett` INT NOT NULL DEFAULT 1, + `patient` INT UNSIGNED NOT NULL, + `aufnahme` INT UNSIGNED DEFAULT NULL, + `belegt_von` INT UNSIGNED NOT NULL, + `belegt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `frei_von` INT UNSIGNED DEFAULT NULL, + `frei_am` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_zimmer` (`zimmer`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Intensivstation +-- Keine automatischen Statusaenderungen. Jeder Verlaufseintrag +-- stammt von einem Menschen. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_intensiv` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `patient` INT UNSIGNED NOT NULL, + `aufnahme` INT UNSIGNED DEFAULT NULL, + `zimmer` INT UNSIGNED DEFAULT NULL, + `geraete` VARCHAR(512) DEFAULT NULL COMMENT 'Angeschlossene Geraete als Text', + `verantwortlich` INT UNSIGNED DEFAULT NULL COMMENT 'Zustaendiger Arzt', + `zustand` VARCHAR(24) NOT NULL DEFAULT 'stabil' + COMMENT 'stabil/kritisch/besserung — immer manuell gesetzt', + `notiz` VARCHAR(1000) DEFAULT NULL, + `aufgenommen_von` INT UNSIGNED NOT NULL, + `aufgenommen_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `beendet_von` INT UNSIGNED DEFAULT NULL, + `beendet_am` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_patient` (`patient`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `medical_intensiv_verlauf` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `intensiv` INT UNSIGNED NOT NULL, + `text` VARCHAR(1000) NOT NULL, + `von` INT UNSIGNED NOT NULL, + `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_intensiv` (`intensiv`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Einsaetze und Leitstelle +-- Ein Notruf erzeugt einen OFFENEN Einsatz. Zugewiesen wird +-- ausschliesslich durch die Leitstelle, nie durch das System. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_einsatz` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `nummer` VARCHAR(24) NOT NULL, + `titel` VARCHAR(160) NOT NULL, + `kategorie` VARCHAR(32) NOT NULL DEFAULT 'notruf', + `prioritaet` TINYINT NOT NULL DEFAULT 3, + `ort` VARCHAR(160) DEFAULT NULL, + `pos_x` FLOAT DEFAULT NULL, + `pos_y` FLOAT DEFAULT NULL, + `pos_z` FLOAT DEFAULT NULL, + `beschreibung` TEXT DEFAULT NULL, + `status` VARCHAR(16) NOT NULL DEFAULT 'offen' + COMMENT 'offen/laufend/abgeschlossen', + `quelle` VARCHAR(24) NOT NULL DEFAULT 'manuell' + COMMENT 'manuell/notruf/polizei/system', + `erstellt_von` INT UNSIGNED DEFAULT NULL, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `geschlossen_von` INT UNSIGNED DEFAULT NULL, + `geschlossen_am` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_nummer` (`nummer`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `medical_einsatz_kraft` ( + `einsatz` INT UNSIGNED NOT NULL, + `character` INT UNSIGNED NOT NULL, + `seit` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `bis` DATETIME DEFAULT NULL, + PRIMARY KEY (`einsatz`, `character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `medical_einsatz_notiz` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `einsatz` INT UNSIGNED NOT NULL, + `text` VARCHAR(1000) NOT NULL, + `von` INT UNSIGNED NOT NULL, + `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_einsatz` (`einsatz`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Berichtswesen +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_bericht` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `nummer` VARCHAR(24) NOT NULL, + `patient` INT UNSIGNED DEFAULT NULL, + `behandlung` INT UNSIGNED DEFAULT NULL, + `einsatz` INT UNSIGNED DEFAULT NULL, + `titel` VARCHAR(160) NOT NULL, + `symptome` TEXT DEFAULT NULL, + `untersuchung` TEXT DEFAULT NULL, + `diagnose` TEXT DEFAULT NULL, + `massnahmen` TEXT DEFAULT NULL, + `empfehlung` TEXT DEFAULT NULL, + `anlagen` LONGTEXT DEFAULT NULL COMMENT 'JSON: Bilder und Dokumente', + `status` VARCHAR(16) NOT NULL DEFAULT 'entwurf' + COMMENT 'entwurf/eingereicht/freigegeben', + `erstellt_von` INT UNSIGNED NOT NULL, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `unterschriften` LONGTEXT DEFAULT NULL COMMENT 'JSON: wer hat gegengezeichnet', + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_nummer` (`nummer`), + KEY `idx_patient` (`patient`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Fuhrpark +-- Standort, Tank und Zustand sind GEMELDETE Tatsachen. +-- Zuweisung und Wartungsstatus setzt immer ein Mensch. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_fahrzeug` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `kennzeichen` VARCHAR(12) NOT NULL, + `modell` VARCHAR(48) NOT NULL, + `bezeichnung` VARCHAR(64) NOT NULL COMMENT 'z. B. RTW 1', + `art` VARCHAR(24) NOT NULL DEFAULT 'rtw' + COMMENT 'rtw/nef/ktw/hubschrauber/sonstiges', + `station` VARCHAR(32) DEFAULT NULL, + `status` VARCHAR(24) NOT NULL DEFAULT 'verfuegbar' + COMMENT 'verfuegbar/im_einsatz/werkstatt/ausser_dienst', + `wartung` VARCHAR(16) NOT NULL DEFAULT 'ok' COMMENT 'ok/faellig/werkstatt', + `ausstattung` VARCHAR(512) DEFAULT NULL, + `tank` FLOAT DEFAULT NULL COMMENT 'Gemeldet', + `zustand` FLOAT DEFAULT NULL COMMENT 'Gemeldet', + `letzter_fahrer` INT UNSIGNED DEFAULT NULL COMMENT 'Gemeldet', + `pos_x` FLOAT DEFAULT NULL, + `pos_y` FLOAT DEFAULT NULL, + `pos_z` FLOAT DEFAULT NULL, + `gemeldet_am` DATETIME DEFAULT NULL, + `zugewiesen_an` INT UNSIGNED DEFAULT NULL COMMENT 'Manuell zugewiesen', + `notiz` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_kennzeichen` (`kennzeichen`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Interne Kommunikation +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_nachricht` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `absender` INT UNSIGNED NOT NULL, + `empfaenger` VARCHAR(64) NOT NULL COMMENT 'Charakter-ID, Station oder "alle"', + `empf_typ` VARCHAR(16) NOT NULL DEFAULT 'person' + COMMENT 'person/station/rundmeldung', + `betreff` VARCHAR(160) DEFAULT NULL, + `text` TEXT NOT NULL, + `gesendet` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_empfaenger` (`empfaenger`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `medical_nachricht_gelesen` ( + `nachricht` INT UNSIGNED NOT NULL, + `character` INT UNSIGNED NOT NULL, + `gelesen_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`nachricht`, `character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `medical_aushang` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `titel` VARCHAR(160) NOT NULL, + `text` TEXT NOT NULL, + `kategorie` VARCHAR(24) NOT NULL DEFAULT 'information' + COMMENT 'anweisung/information/warnung/hygiene', + `station` VARCHAR(32) DEFAULT NULL COMMENT 'NULL = gesamte Klinik', + `angeheftet` TINYINT(1) NOT NULL DEFAULT 0, + `kenntnispflicht` TINYINT(1) NOT NULL DEFAULT 0, + `frist` DATETIME DEFAULT NULL, + `erstellt_von` INT UNSIGNED NOT NULL, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `archiviert_von` INT UNSIGNED DEFAULT NULL, + `archiviert_am` DATETIME DEFAULT NULL, + `aktiv` TINYINT(1) NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + KEY `idx_aktiv` (`aktiv`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `medical_aushang_kenntnis` ( + `aushang` INT UNSIGNED NOT NULL, + `character` INT UNSIGNED NOT NULL, + `bestaetigt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`aushang`, `character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Hinweise und Erinnerungen +-- Sie erinnern ausschliesslich. Kein Hinweis fuehrt etwas aus. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_hinweis` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `character` INT UNSIGNED DEFAULT NULL COMMENT 'NULL = an alle im Dienst', + `kategorie` VARCHAR(32) NOT NULL + COMMENT 'einsatz/patient/labor/medikament/blut/op/doku/system', + `prioritaet` VARCHAR(16) NOT NULL DEFAULT 'normal', + `titel` VARCHAR(160) NOT NULL, + `text` VARCHAR(512) DEFAULT NULL, + `ziel_typ` VARCHAR(24) DEFAULT NULL COMMENT 'Wohin springt ein Klick', + `ziel_id` VARCHAR(64) DEFAULT NULL, + `gelesen` TINYINT(1) NOT NULL DEFAULT 0, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_character` (`character`, `gelesen`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Protokoll — wer, wann, was, warum +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_protokoll` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `character` INT UNSIGNED DEFAULT NULL, + `personalnummer` VARCHAR(16) DEFAULT NULL, + `modul` VARCHAR(32) NOT NULL, + `aktion` VARCHAR(64) NOT NULL, + `ziel_typ` VARCHAR(24) DEFAULT NULL, + `ziel_id` VARCHAR(64) DEFAULT NULL, + `details` LONGTEXT DEFAULT NULL COMMENT 'JSON: was genau geaendert wurde', + `grund` VARCHAR(255) DEFAULT NULL, + `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_modul` (`modul`), + KEY `idx_character` (`character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Systemeinstellungen (aktive Module, Vorlagen) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_einstellung` ( + `schluessel` VARCHAR(64) NOT NULL, + `wert` LONGTEXT DEFAULT NULL, + PRIMARY KEY (`schluessel`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Vorlagen fuer Dokumentation (Administration pflegt sie) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `medical_vorlage` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `kennung` VARCHAR(24) NOT NULL, + `bereich` VARCHAR(24) NOT NULL COMMENT 'behandlung/bericht/op/labor', + `name` VARCHAR(120) NOT NULL, + `inhalt` LONGTEXT DEFAULT NULL COMMENT 'JSON: vorbelegte Felder', + `aktiv` TINYINT(1) NOT NULL DEFAULT 1, + `sortierung` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_kennung` (`kennung`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/sql/aether_police.sql b/sql/aether_police.sql new file mode 100644 index 0000000..06d5e57 --- /dev/null +++ b/sql/aether_police.sql @@ -0,0 +1,453 @@ +-- ===================================================================== +-- aether-police — Datenbankschema +-- +-- Einspielen: mysql -u root -p aether < sql/aether_police.sql +-- +-- Hinweis zum Grundprinzip: Keine dieser Tabellen enthaelt Automatismen. +-- Fristen (z. B. Haftzeit) werden nur MITGEFUEHRT, damit das System +-- erinnern kann. Ausgefuehrt wird ausschliesslich durch einen Beamten — +-- erkennbar an den Feldern `entlassen_von` / `entlassen_am`. +-- ===================================================================== + +USE `aether`; + +-- --------------------------------------------------------------------- +-- Personal: Zugehoerigkeit, Rolle und individuelle Rechte +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_personal` ( + `character` INT UNSIGNED NOT NULL COMMENT 'Charakter aus aether_characters', + `dienstnummer` VARCHAR(16) NOT NULL COMMENT 'Dienstnummer, z. B. LSPD-0421', + `rolle` VARCHAR(32) NOT NULL DEFAULT 'anwaerter', + `abteilung` VARCHAR(32) NOT NULL DEFAULT 'streife' COMMENT 'streife/kripo/verkehr/sek/leitstelle', + `rechte_extra` LONGTEXT DEFAULT NULL COMMENT 'Zusaetzlich gewaehrte Rechte als JSON-Array', + `rechte_entzug` LONGTEXT DEFAULT NULL COMMENT 'Einzeln entzogene Rechte als JSON-Array', + `eingestellt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `aktiv` TINYINT(1) NOT NULL DEFAULT 1, + `notiz` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`character`), + UNIQUE KEY `uniq_dienstnummer` (`dienstnummer`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Dienstzeiten (nur Protokoll, keine Auswertung zur Bewertung) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_dienst` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `character` INT UNSIGNED NOT NULL, + `beginn` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `ende` DATETIME DEFAULT NULL, + `sekunden` INT NOT NULL DEFAULT 0, + `abteilung` VARCHAR(32) NOT NULL DEFAULT 'streife', + PRIMARY KEY (`id`), + KEY `idx_character` (`character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Personenakten — Stammdaten der Polizei zu einer Person +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_person` ( + `character` INT UNSIGNED NOT NULL, + `lichtbild` LONGTEXT DEFAULT NULL COMMENT 'Bild-URL oder Data-URI', + `fingerabdruck` VARCHAR(64) DEFAULT NULL, + `dna` VARCHAR(64) DEFAULT NULL, + `warnhinweis` VARCHAR(255) DEFAULT NULL COMMENT 'z. B. gewaltbereit, bewaffnet', + `gefahrenstufe` TINYINT NOT NULL DEFAULT 0 COMMENT '0 = keine, 3 = hoch', + `aktualisiert` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Akten (Straf-, Ermittlungs-, Einsatz-, Unfallakte, Vernehmung ...) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_akte` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `aktenzeichen` VARCHAR(32) NOT NULL, + `typ` VARCHAR(24) NOT NULL COMMENT 'straf/ermittlung/einsatz/unfall/vernehmung/beschluss', + `titel` VARCHAR(160) NOT NULL, + `status` VARCHAR(24) NOT NULL DEFAULT 'offen' COMMENT 'offen/pruefung/geschlossen', + `inhalt` LONGTEXT DEFAULT NULL, + `erstellt_von` INT UNSIGNED NOT NULL, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `freigabe_von` INT UNSIGNED DEFAULT NULL COMMENT 'Wer hat freigegeben (nie automatisch)', + `freigabe_am` DATETIME DEFAULT NULL, + `version` INT NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_az` (`aktenzeichen`), + KEY `idx_typ` (`typ`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Versionshistorie jeder Akte (Dokumentenverwaltung) +CREATE TABLE IF NOT EXISTS `police_akte_version` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `akte` INT UNSIGNED NOT NULL, + `version` INT NOT NULL, + `inhalt` LONGTEXT DEFAULT NULL, + `geaendert_von` INT UNSIGNED NOT NULL, + `geaendert_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `grund` VARCHAR(255) DEFAULT NULL COMMENT 'Warum wurde geaendert', + PRIMARY KEY (`id`), + KEY `idx_akte` (`akte`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Verknuepfungen: Akte <-> Person / Fahrzeug / Beweis / Einsatz +CREATE TABLE IF NOT EXISTS `police_verknuepfung` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `akte` INT UNSIGNED NOT NULL, + `typ` VARCHAR(16) NOT NULL COMMENT 'person/fahrzeug/beweis/einsatz/bericht', + `ziel_id` VARCHAR(64) NOT NULL COMMENT 'ID oder Kennzeichen des Ziels', + `rolle` VARCHAR(32) DEFAULT NULL COMMENT 'beschuldigt/zeuge/geschaedigt/beteiligt', + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_akte` (`akte`), + KEY `idx_ziel` (`typ`, `ziel_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Anzeigen — erfasste Delikte. Immer von einem Beamten bestaetigt. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_anzeige` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `akte` INT UNSIGNED DEFAULT NULL, + `beschuldigter` INT UNSIGNED NOT NULL, + `delikte` LONGTEXT NOT NULL COMMENT 'JSON: [{id,name,haft,geld,paragraph}]', + `haft_empfohlen` INT NOT NULL DEFAULT 0 COMMENT 'Vorschlag des Katalogs (Minuten)', + `geld_empfohlen` INT NOT NULL DEFAULT 0 COMMENT 'Vorschlag des Katalogs', + `haft_final` INT NOT NULL DEFAULT 0 COMMENT 'Vom Beamten festgelegt', + `geld_final` INT NOT NULL DEFAULT 0 COMMENT 'Vom Beamten festgelegt', + `begruendung` VARCHAR(512) DEFAULT NULL COMMENT 'Pflicht bei Abweichung vom Vorschlag', + `status` VARCHAR(16) NOT NULL DEFAULT 'entwurf' COMMENT 'entwurf/bestaetigt/verworfen', + `erstellt_von` INT UNSIGNED NOT NULL, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `bestaetigt_von` INT UNSIGNED DEFAULT NULL, + `bestaetigt_am` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_beschuldigter` (`beschuldigter`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Gewahrsam — reine Fristenverwaltung, KEINE automatische Entlassung +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_gewahrsam` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `character` INT UNSIGNED NOT NULL, + `anzeige` INT UNSIGNED DEFAULT NULL, + `haft_minuten` INT NOT NULL COMMENT 'Vom Beamten festgelegte Dauer', + `beginn` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `faellig_am` DATETIME NOT NULL COMMENT 'Ab hier erinnert das System nur', + `grund` VARCHAR(512) NOT NULL DEFAULT '', + `zelle` VARCHAR(32) DEFAULT NULL, + `eingewiesen_von` INT UNSIGNED NOT NULL, + -- Entlassung geschieht ausschliesslich manuell: + `entlassen_von` INT UNSIGNED DEFAULT NULL, + `entlassen_am` DATETIME DEFAULT NULL, + `entlass_grund` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_character` (`character`), + KEY `idx_offen` (`entlassen_am`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Einsaetze und Dispatch +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_einsatz` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `nummer` VARCHAR(24) NOT NULL COMMENT 'Einsatznummer', + `titel` VARCHAR(160) NOT NULL, + `kategorie` VARCHAR(32) NOT NULL DEFAULT 'sonstiges', + `prioritaet` TINYINT NOT NULL DEFAULT 3 COMMENT '1 = hoechste', + `ort` VARCHAR(160) DEFAULT NULL, + `pos_x` FLOAT DEFAULT NULL, + `pos_y` FLOAT DEFAULT NULL, + `pos_z` FLOAT DEFAULT NULL, + `beschreibung` TEXT DEFAULT NULL, + `status` VARCHAR(24) NOT NULL DEFAULT 'offen' COMMENT 'offen/zugewiesen/vorort/abgeschlossen', + `quelle` VARCHAR(32) NOT NULL DEFAULT 'manuell' COMMENT 'manuell/notruf/panik/extern', + `erstellt_von` INT UNSIGNED DEFAULT NULL COMMENT 'NULL bei eingehendem Notruf', + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `geschlossen_von` INT UNSIGNED DEFAULT NULL, + `geschlossen_am` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_nummer` (`nummer`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `police_einsatz_beamter` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `einsatz` INT UNSIGNED NOT NULL, + `character` INT UNSIGNED NOT NULL, + `beigetreten` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `verlassen` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_einsatz` (`einsatz`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `police_einsatz_notiz` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `einsatz` INT UNSIGNED NOT NULL, + `text` VARCHAR(1000) NOT NULL, + `von` INT UNSIGNED NOT NULL, + `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_einsatz` (`einsatz`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Fahndungen — immer manuell angelegt +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_fahndung` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `typ` VARCHAR(16) NOT NULL DEFAULT 'person' COMMENT 'person/fahrzeug', + `ziel_id` VARCHAR(64) NOT NULL COMMENT 'Charakter-ID oder Kennzeichen', + `bezeichnung` VARCHAR(160) NOT NULL, + `grund` VARCHAR(512) NOT NULL, + `gefahr` VARCHAR(24) NOT NULL DEFAULT 'normal' COMMENT 'normal/bewaffnet/gefaehrlich', + `prioritaet` TINYINT NOT NULL DEFAULT 3, + `hinweise` TEXT DEFAULT NULL, + `bild` LONGTEXT DEFAULT NULL, + `aktiv` TINYINT(1) NOT NULL DEFAULT 1, + `erstellt_von` INT UNSIGNED NOT NULL, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `aufgehoben_von` INT UNSIGNED DEFAULT NULL, + `aufgehoben_am` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_ziel` (`typ`, `ziel_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Beweise +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_beweis` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `kennung` VARCHAR(32) NOT NULL COMMENT 'Beweismittelnummer', + `akte` INT UNSIGNED DEFAULT NULL, + `art` VARCHAR(24) NOT NULL COMMENT 'foto/video/fingerabdruck/dna/huelse/dokument/aussage/gegenstand', + `bezeichnung` VARCHAR(160) NOT NULL, + `beschreibung` TEXT DEFAULT NULL, + `fundort` VARCHAR(160) DEFAULT NULL, + `gefunden_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `datei` LONGTEXT DEFAULT NULL COMMENT 'Bild-/Video-URL', + `erfasst_von` INT UNSIGNED NOT NULL, + `erfasst_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_kennung` (`kennung`), + KEY `idx_akte` (`akte`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Asservatenkammer +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_asservat` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `kennung` VARCHAR(32) NOT NULL, + `kategorie` VARCHAR(24) NOT NULL COMMENT 'waffe/drogen/bargeld/schmuck/datentraeger/dokument/sonstiges', + `bezeichnung` VARCHAR(160) NOT NULL, + `menge` VARCHAR(48) DEFAULT NULL, + `lagerort` VARCHAR(64) NOT NULL DEFAULT 'Asservatenkammer A', + `verantwortlich` INT UNSIGNED NOT NULL, + `akte` INT UNSIGNED DEFAULT NULL, + `beweis` INT UNSIGNED DEFAULT NULL, + `status` VARCHAR(24) NOT NULL DEFAULT 'eingelagert' COMMENT 'eingelagert/ausgegeben/vernichtet/zurueckgegeben', + `eingelagert_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_kennung` (`kennung`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS `police_asservat_historie` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `asservat` INT UNSIGNED NOT NULL, + `aktion` VARCHAR(48) NOT NULL, + `von` INT UNSIGNED NOT NULL, + `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `notiz` VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_asservat` (`asservat`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Dienstfahrzeuge +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_fahrzeug` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `kennzeichen` VARCHAR(16) NOT NULL, + `modell` VARCHAR(48) NOT NULL, + `bezeichnung` VARCHAR(64) NOT NULL, + `abteilung` VARCHAR(32) NOT NULL DEFAULT 'streife', + `zugewiesen_an` INT UNSIGNED DEFAULT NULL COMMENT 'Manuell zugewiesen', + `letzter_fahrer` INT UNSIGNED DEFAULT NULL, + `tank` FLOAT NOT NULL DEFAULT 100, + `zustand` FLOAT NOT NULL DEFAULT 1000, + `wartung` VARCHAR(24) NOT NULL DEFAULT 'ok' COMMENT 'ok/faellig/werkstatt', + `status` VARCHAR(24) NOT NULL DEFAULT 'verfuegbar', + `pos_x` FLOAT DEFAULT NULL, + `pos_y` FLOAT DEFAULT NULL, + `pos_z` FLOAT DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_kennzeichen` (`kennzeichen`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Berichte +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_bericht` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `nummer` VARCHAR(32) NOT NULL, + `einsatz` INT UNSIGNED DEFAULT NULL, + `akte` INT UNSIGNED DEFAULT NULL, + `titel` VARCHAR(160) NOT NULL, + `beschreibung` LONGTEXT DEFAULT NULL, + `massnahmen` LONGTEXT DEFAULT NULL, + `beteiligte` LONGTEXT DEFAULT NULL COMMENT 'JSON', + `anlagen` LONGTEXT DEFAULT NULL COMMENT 'JSON', + `status` VARCHAR(24) NOT NULL DEFAULT 'entwurf' COMMENT 'entwurf/eingereicht/freigegeben', + `erstellt_von` INT UNSIGNED NOT NULL, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `unterschriften` LONGTEXT DEFAULT NULL COMMENT 'JSON: wer hat unterschrieben', + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_nummer` (`nummer`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Interne Kommunikation +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_nachricht` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `absender` INT UNSIGNED NOT NULL, + `empfaenger` VARCHAR(64) NOT NULL COMMENT 'Charakter-ID, Abteilung oder "alle"', + `empf_typ` VARCHAR(16) NOT NULL DEFAULT 'person' COMMENT 'person/abteilung/rundmeldung', + `betreff` VARCHAR(160) DEFAULT NULL, + `text` TEXT NOT NULL, + `gelesen` TINYINT(1) NOT NULL DEFAULT 0, + `gesendet` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_empfaenger` (`empfaenger`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Benachrichtigungen (Hinweise, Erinnerungen, Fristen) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_hinweis` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `character` INT UNSIGNED DEFAULT NULL COMMENT 'NULL = an alle im Dienst', + `kategorie` VARCHAR(32) NOT NULL COMMENT 'einsatz/fahndung/gewahrsam/bericht/akte/beweis/system', + `prioritaet` VARCHAR(16) NOT NULL DEFAULT 'normal' COMMENT 'niedrig/normal/hoch/kritisch', + `titel` VARCHAR(160) NOT NULL, + `text` VARCHAR(512) DEFAULT NULL, + `ziel_typ` VARCHAR(24) DEFAULT NULL COMMENT 'Wohin springt ein Klick', + `ziel_id` VARCHAR(64) DEFAULT NULL, + `gelesen` TINYINT(1) NOT NULL DEFAULT 0, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_character` (`character`, `gelesen`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Protokollierung — wer, wann, was, warum +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_protokoll` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `character` INT UNSIGNED DEFAULT NULL, + `dienstnummer` VARCHAR(16) DEFAULT NULL, + `modul` VARCHAR(32) NOT NULL, + `aktion` VARCHAR(64) NOT NULL, + `ziel_typ` VARCHAR(24) DEFAULT NULL, + `ziel_id` VARCHAR(64) DEFAULT NULL, + `details` LONGTEXT DEFAULT NULL COMMENT 'JSON: was genau geaendert wurde', + `grund` VARCHAR(255) DEFAULT NULL, + `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_modul` (`modul`), + KEY `idx_character` (`character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Strafenkatalog — vom Administrator pflegbar +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_katalog` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `kennung` VARCHAR(24) NOT NULL, + `kategorie` VARCHAR(48) NOT NULL, + `bezeichnung` VARCHAR(160) NOT NULL, + `paragraph` VARCHAR(48) DEFAULT NULL COMMENT 'Gesetzliche Grundlage', + `haft` INT NOT NULL DEFAULT 0 COMMENT 'Empfehlung in Minuten', + `geld` INT NOT NULL DEFAULT 0 COMMENT 'Empfehlung in Euro', + `hinweis` VARCHAR(512) DEFAULT NULL, + `aktiv` TINYINT(1) NOT NULL DEFAULT 1, + `sortierung` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_kennung` (`kennung`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Systemeinstellungen (aktive Module, Vorlagen, Kategorien) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_einstellung` ( + `schluessel` VARCHAR(64) NOT NULL, + `wert` LONGTEXT DEFAULT NULL, + PRIMARY KEY (`schluessel`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Notizen zu einer Person (kurze Vermerke ausserhalb einer Akte) +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_person_notiz` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `character` INT UNSIGNED NOT NULL COMMENT 'Betroffene Person', + `text` VARCHAR(1000) NOT NULL, + `von` INT UNSIGNED NOT NULL COMMENT 'Verfassender Beamter', + `am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_character` (`character`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Lesestatus einer Nachricht je Empfaenger +-- Noetig, weil Abteilungs- und Rundmeldungen viele Empfaenger haben. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_nachricht_gelesen` ( + `nachricht` INT UNSIGNED NOT NULL, + `character` INT UNSIGNED NOT NULL, + `gelesen_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`nachricht`, `character`), + CONSTRAINT `fk_nlesen_nachricht` FOREIGN KEY (`nachricht`) + REFERENCES `police_nachricht` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Aushaenge und Dienstanweisungen +-- Das System veroeffentlicht nichts von selbst und setzt nichts auf +-- gelesen — die Kenntnisnahme bestaetigt jeder Beamte eigenhaendig. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_aushang` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `titel` VARCHAR(160) NOT NULL, + `text` TEXT NOT NULL, + `kategorie` VARCHAR(24) NOT NULL DEFAULT 'information' + COMMENT 'anweisung/information/warnung', + `abteilung` VARCHAR(32) DEFAULT NULL COMMENT 'NULL = gesamte Behoerde', + `angeheftet` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Bleibt oben stehen', + `kenntnispflicht` TINYINT(1) NOT NULL DEFAULT 0 + COMMENT 'Beamte sollen die Kenntnisnahme bestaetigen', + `frist` DATETIME DEFAULT NULL + COMMENT 'Bis wann erinnert das System an die Kenntnisnahme', + `erstellt_von` INT UNSIGNED NOT NULL, + `erstellt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- Archivierung geschieht ausschliesslich manuell: + `archiviert_von` INT UNSIGNED DEFAULT NULL, + `archiviert_am` DATETIME DEFAULT NULL, + `aktiv` TINYINT(1) NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + KEY `idx_aktiv` (`aktiv`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- --------------------------------------------------------------------- +-- Bestaetigte Kenntnisnahmen zu einem Aushang +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `police_aushang_kenntnis` ( + `aushang` INT UNSIGNED NOT NULL, + `character` INT UNSIGNED NOT NULL, + `bestaetigt_am` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`aushang`, `character`), + CONSTRAINT `fk_kenntnis_aushang` FOREIGN KEY (`aushang`) + REFERENCES `police_aushang` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/sql/updates/001_fahrzeug_position.sql b/sql/updates/001_fahrzeug_position.sql new file mode 100644 index 0000000..009cd72 --- /dev/null +++ b/sql/updates/001_fahrzeug_position.sql @@ -0,0 +1,17 @@ +-- ===================================================================== +-- Nachtrag 001 — Position ausgeparkter Fahrzeuge +-- +-- Nur noetig, wenn die Tabelle `aether_vehicles` bereits VOR dieser +-- Aenderung angelegt wurde. Bei einer frischen Datenbank sind die +-- Spalten schon in sql/aether.sql enthalten. +-- +-- Einspielen: mysql -u root -p aether < sql/updates/001_fahrzeug_position.sql +-- Hinweis: Laeuft die Abfrage mit "Duplicate column name", sind die +-- Spalten bereits vorhanden — dann ist nichts zu tun. +-- ===================================================================== + +ALTER TABLE `aether_vehicles` + ADD COLUMN `pos_x` FLOAT DEFAULT NULL COMMENT 'Letzte bekannte Position', + ADD COLUMN `pos_y` FLOAT DEFAULT NULL, + ADD COLUMN `pos_z` FLOAT DEFAULT NULL, + ADD COLUMN `heading` FLOAT DEFAULT NULL; diff --git a/untitled.md b/untitled.md new file mode 100644 index 0000000..e69de29