- 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>
367 lines
14 KiB
Lua
367 lines
14 KiB
Lua
-- =====================================================================
|
|
-- 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)
|