aether-framework/resources/[aether]/aether-medical/server/module/dashboard.lua
Jerrit Fritzsche e4b82dd685 Aether-Framework: Fachmodule, Admin-Panel und Docker-Datenbank
- 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 <noreply@anthropic.com>
2026-07-27 18:55:32 +02:00

227 lines
8.4 KiB
Lua

-- =====================================================================
-- 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)