aether-framework/resources/[aether]/aether-medical/server/module/vorlage.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

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)