Initial commit

This commit is contained in:
Jonas Karneboge 2026-09-22 18:35:43 +02:00
commit 3cba772836
1341 changed files with 532924 additions and 0 deletions

933
PCL5-SCL5/app.R Normal file
View file

@ -0,0 +1,933 @@
# Präambel ####
AKZENT_FARBE = "#8B2635"
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_pcl5lec5.R"
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R"
PCL5SCL5_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die Interpretation der Ergebnisse obliegt der ",
"behandelnden Person."
)
library(shiny)
library(dplyr)
library(ggplot2)
library(haven)
library(officer)
LEC5_ANTWORTEN = c(
"1" = "mir persönlich zugestoßen",
"2" = "Zeuge davon gewesen",
"3" = "davon erfahren",
"4" = "im Rahmen meines Berufs",
"5" = "unsicher",
"6" = "nicht zutreffend"
)
LEC5_ITEM_TEXTE = c(
"Naturkatastrophe (z.B. Überschwemmung, Orkan, Tornado, Erdbeben)",
"Feuer oder Explosion",
"Verkehrsunfall (z.B. Autounfall, Schiffsunglück, Zugunglück, Flugzeugabsturz)",
"Schwerer Unfall bei der Arbeit, zuhause oder während einer Freizeitaktivität",
"Einem Schadstoff ausgesetzt sein (z.B. gefährliche Chemikalien, Strahlung)",
"Gewalttätiger Angriff (z.B. überfallen, geschlagen, getreten oder zusammengeschlagen werden)",
"Angriff mit einer Waffe (z.B. verletzt oder bedroht werden mit einer Schusswaffe, einem Messer oder einer Bombe)",
"Sexueller Übergriff (Vergewaltigung, versuchte Vergewaltigung, zu irgendeiner Art von sexueller Handlung durch Gewalt oder Androhung von Gewalt gezwungen werden)",
"Andere unerwünschte oder unangenehme sexuelle Erfahrung",
"Kampfhandlungen oder Aufenthalt in einem Kriegsgebiet (beim Militär oder als Zivilist)",
"Gefangenschaft (z.B. gekidnappt, entführt, als Geisel genommen werden, Kriegsgefangener)",
"Lebensbedrohliche Erkrankung oder Verletzung",
"Schweres menschliches Leid",
"Plötzlicher gewalttätiger Tod (z.B. Mord, Suizid)",
"Plötzlicher Unfalltod",
"Schwere Verletzung, Schaden oder Tod, die/den Sie jemand anderem zugefügt haben",
"Irgendein anderes sehr belastendes Ereignis oder Erlebnis"
)
PCL5_ITEM_TEXTE = c(
"Wiederholte, beunruhigende und ungewollte Erinnerungen an das belastende Erlebnis?",
"Wiederholte, beunruhigende Träume von dem belastenden Erlebnis?",
"Sich plötzlich fühlen oder sich verhalten, als ob das belastende Erlebnis tatsächlich wieder stattfinden würde (als ob Sie tatsächlich wieder dort wären und es wiedererleben würden)?",
"Sich emotional sehr belastet fühlen, wenn Sie etwas an das Erlebnis erinnert hat?",
"Starke körperliche Reaktionen haben, wenn Sie etwas an das belastende Erlebnis erinnert hat (z.B. Herzklopfen, Schwierigkeiten beim Atmen, schwitzen)?",
"Vermeidung von Erinnerungen, Gedanken oder Gefühlen in Bezug auf das belastende Erlebnis?",
"Vermeidung äußerer Auslöser für Erinnerungen an das belastende Erlebnis (z.B. Personen, Plätze, Gespräche, Aktivitäten, Gegenstände oder Situationen)?",
"Schwierigkeiten, sich an wichtige Teile des belastenden Erlebnisses zu erinnern?",
"Starke negative Überzeugungen über sich selbst, andere Menschen oder die Welt haben (z.B. Gedanken haben wie: Ich bin schlecht, mit mir stimmt ernsthaft etwas nicht, man kann niemandem vertrauen, die Welt ist absolut gefährlich)?",
"Sich selbst oder jemand anderem Vorwürfe machen in Bezug auf das belastende Erlebnis oder was danach passiert ist?",
"Starke negative Gefühle haben, wie zum Beispiel Angst, Schrecken, Ärger, Schuld oder Scham?",
"Verlust von Interesse an Aktivitäten, die Ihnen früher Spaß gemacht haben?",
"Sich von anderen Menschen entfernt oder wie abgeschnitten fühlen?",
"Schwierigkeiten, positive Gefühle zu erleben (z.B. keine Freude empfinden können oder keine liebevollen Gefühle haben können gegenüber Menschen, die Ihnen nahestehen)?",
"Reizbares Verhalten, Wutausbrüche oder aggressives Verhalten?",
"Zu viele Risiken eingehen oder Dinge tun, die Ihnen Schaden zufügen könnten?",
"In erhöhter Alarmbereitschaft, wachsam oder auf der Hut sein?",
"Sich nervös oder schreckhaft fühlen?",
"Konzentrationsschwierigkeiten haben?",
"Schwierigkeiten, ein- oder durchzuschlafen?"
)
PCL5_STUFEN_TEXT = c(
"0" = "überhaupt nicht",
"1" = "ein wenig",
"2" = "ziemlich",
"3" = "stark",
"4" = "sehr stark"
)
PCL5_BADGE_FARBEN = c(
"0" = "#4CAF50",
"1" = "#F48FB1",
"2" = "#EF5350",
"3" = "#B71C1C",
"4" = "#4A0000"
)
PCL5_BADGE_TEXT_FARBEN = c(
"0" = "white",
"1" = "#333333",
"2" = "white",
"3" = "white",
"4" = "white"
)
CLUSTER_B = 1:5
CLUSTER_C = 6:7
CLUSTER_D = 8:14
CLUSTER_E = 15:20
TEIL2_FRAGEN_TEXT = list(
teil2_a_text = "Beschreibung des sonstigen belastenden Ereignisses (Item 17)",
teil2_q1 = "Beschreibung des schlimmsten Ereignisses",
teil2_q2 = "Wie lange ist das Ereignis her?",
teil2_q3 = "Wie haben Sie das Ereignis erlebt?",
teil2_q3_sonstiges = "Sonstiges Erläuterung",
teil2_q4 = "War jemand in Lebensgefahr?",
teil2_q5 = "Wurde jemand schwer verletzt oder getötet?",
teil2_q6 = "Beinhaltete das Ereignis sexuelle Gewalt?",
teil2_q7 = "Art des Todesfalls (sofern relevant)",
teil2_q8 = "Wie oft ist das Ereignis vorgekommen?",
teil2_q8_anzahl = "Anzahl der Vorkommnisse"
)
# Infrastruktur ####
APP_VERZEICHNIS = normalizePath(getwd())
absPath = function(pfad) {
if (grepl("^([A-Za-z]:[/\\\\]|/)", pfad)) return(pfad)
file.path(APP_VERZEICHNIS, pfad)
}
PFAD_DOWNLOAD_SKRIPT = normalizePath(absPath(PFAD_DOWNLOAD_SKRIPT), mustWork = FALSE)
PFAD_PSEUDONYM_SKRIPT = normalizePath(absPath(PFAD_PSEUDONYM_SKRIPT), mustWork = FALSE)
# Helper ####
# Beobachtetes Format der mc_multiple-Werte: "1, 2, 3" (Komma + Leerzeichen).
# Tatsaechlich vorgefundener Fall (Fall A vs. Fall B) empirisch zu bestimmen --
# nach erstem echten Datenlauf bitte Kommentar aktualisieren.
parse_lec5_value = function(original_col, zeile_val) {
# is.na() zuerst, DANN as.character() -- vermeidet vctrs-Typfehler bei haven_labelled
if (is.na(zeile_val)) return(character(0))
raw_str = as.character(zeile_val)
if (trimws(raw_str) == "" || raw_str == "NA") return(character(0))
teile = trimws(strsplit(raw_str, "[,;]+")[[1]])
nums = suppressWarnings(as.integer(teile))
nums = nums[!is.na(nums)]
if (length(nums) == 0) return(character(0))
lbl_attr = attr(original_col, "labels")
if (!is.null(lbl_attr) && length(lbl_attr) > 0) {
# as.vector() entfernt die haven_labelled-Klasse, damit vctrs == nicht
# mit Typkonflikt abbricht; Vergleich als character (mc_multiple-Labels
# koennen character-kodiert sein).
lbl_plain = as.vector(lbl_attr)
names(lbl_plain) = names(lbl_attr)
sapply(nums, function(n) {
nm = names(lbl_plain)[as.character(lbl_plain) == as.character(n)]
if (length(nm) > 0) nm[1] else as.character(n)
})
} else {
sapply(nums, function(n) {
k = as.character(n)
if (k %in% names(LEC5_ANTWORTEN)) LEC5_ANTWORTEN[[k]] else as.character(n)
})
}
}
# labels-Attribut aus der Originalspalte lesen (vor Subsetting), da es nach
# Subsetting auf einzelne Zeile verloren gehen kann.
pcl5_get_level = function(original_col, zeile_val) {
if (is.na(zeile_val)) return(NA_integer_)
lbl_attr = attr(original_col, "labels")
if (!is.null(lbl_attr) && length(lbl_attr) > 0) {
# as.vector() entfernt haven_labelled-Klasse vor Vergleich
lbl_plain = as.vector(lbl_attr)
names(lbl_plain) = names(lbl_attr)
lbl_sorted = sort(lbl_plain)
pos = which(lbl_sorted == as.numeric(zeile_val))
if (length(pos) > 0) return(as.integer(pos[1]) - 1L)
}
NA_integer_
}
# NULL und length-0-Vektoren (entstehen wenn Spalte im Datensatz fehlt)
# werden zu NA_character_ -- verhindert logical(0) in is.na()-Aufrufen.
safe_char = function(x) {
if (is.null(x) || length(x) == 0) return(NA_character_)
v = suppressWarnings(as.character(x[1]))
if (length(v) == 0 || is.na(v)) return(NA_character_)
v
}
get_mc_label = function(original_col, zeile_val) {
if (is.null(zeile_val) || length(zeile_val) == 0) return(NA_character_)
if (is.na(zeile_val[1])) return(NA_character_)
lbl_attr = attr(original_col, "labels")
if (!is.null(lbl_attr) && length(lbl_attr) > 0) {
lbl_plain = as.vector(lbl_attr)
names(lbl_plain) = names(lbl_attr)
nm = names(lbl_plain)[as.character(lbl_plain) == as.character(zeile_val)]
if (length(nm) > 0) return(nm[1])
}
as.character(zeile_val)
}
dsm5_check = function(levels) {
b_count = sum(levels[CLUSTER_B] >= 2, na.rm = TRUE)
c_count = sum(levels[CLUSTER_C] >= 2, na.rm = TRUE)
d_count = sum(levels[CLUSTER_D] >= 2, na.rm = TRUE)
e_count = sum(levels[CLUSTER_E] >= 2, na.rm = TRUE)
list(
b_count = b_count, b_ok = b_count >= 1,
c_count = c_count, c_ok = c_count >= 1,
d_count = d_count, d_ok = d_count >= 2,
e_count = e_count, e_ok = e_count >= 2,
gesamt = (b_count >= 1) && (c_count >= 1) && (d_count >= 2) && (e_count >= 2)
)
}
make_gauge_plot = function(score) {
ggplot() +
geom_rect(aes(xmin = 0, xmax = 33, ymin = 0, ymax = 1),
fill = "#E8F5E9", color = NA) +
geom_rect(aes(xmin = 33, xmax = 80, ymin = 0, ymax = 1),
fill = "#FFEBEE", color = NA) +
geom_rect(aes(xmin = 0, xmax = 80, ymin = 0, ymax = 1),
fill = NA, color = "#9E9E9E", linewidth = 0.6) +
geom_vline(xintercept = 33, color = "#E65100", linetype = "dashed", linewidth = 1) +
geom_segment(aes(x = score, xend = score, y = -0.25, yend = 1.25),
color = AKZENT_FARBE, linewidth = 2.5) +
geom_label(aes(x = score, y = 1.6, label = paste0("Score: ", score)),
fill = AKZENT_FARBE, color = "white", fontface = "bold",
linewidth = 0, size = 4) +
annotate("text", x = 33, y = -0.55, label = "Cutoff: 33",
color = "#E65100", size = 3.2, hjust = 0.5) +
annotate("text", x = 16, y = 0.5, label = "< 33",
color = "#2E7D32", size = 3.5, fontface = "italic") +
annotate("text", x = 57, y = 0.5, label = "≥ 33",
color = "#B71C1C", size = 3.5, fontface = "italic") +
scale_x_continuous(limits = c(0, 83),
breaks = c(0, 10, 20, 33, 40, 50, 60, 70, 80)) +
scale_y_continuous(limits = c(-0.8, 2.0)) +
theme_minimal(base_size = 12) +
theme(
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
panel.grid.major.y = element_blank(),
panel.grid.minor = element_blank(),
axis.title.y = element_blank(),
plot.margin = margin(t = 5, r = 10, b = 5, l = 10)
) +
labs(x = "PCL-5 Summenscore (080)", y = NULL)
}
# UI ####
app_css = "
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f5f5f5; }
.app-header {
background: #8B2635; color: white; padding: 18px 24px 14px;
margin-bottom: 20px; border-radius: 0 0 6px 6px;
}
.app-header h2 { margin: 0; font-size: 1.5rem; font-weight: 600; }
.app-header p { margin: 4px 0 0; opacity: 0.85; font-size: 0.9rem; }
.input-panel {
background: white; border-radius: 6px; padding: 16px 20px;
margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.12);
display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap;
}
.input-panel label { font-weight: 600; color: #333; }
.input-panel .form-group { margin-bottom: 0; }
.btn-laden {
background: #8B2635 !important; color: white !important;
border: none !important; border-radius: 4px !important;
padding: 8px 20px !important; font-weight: 600 !important;
cursor: pointer; white-space: nowrap;
}
.btn-laden:hover { background: #6d1e29 !important; }
.alert-fehler {
background: #FFEBEE; border-left: 5px solid #C62828;
padding: 12px 16px; border-radius: 4px; color: #B71C1C;
margin-bottom: 12px; font-weight: 500;
}
.alert-warnung {
background: #FFF8E1; border-left: 5px solid #F9A825;
padding: 12px 16px; border-radius: 4px; color: #6D4C41;
margin-bottom: 12px;
}
.abschnitt-karte {
background: white; border-radius: 6px; padding: 20px 24px;
margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.12);
}
.abschnitt-titel {
color: #8B2635; font-size: 1.15rem; font-weight: 700;
border-bottom: 2px solid #8B2635; padding-bottom: 8px; margin-bottom: 14px;
}
.lec5-item {
padding: 8px 10px; margin-bottom: 6px;
background: #FAFAFA; border-left: 3px solid #8B2635; border-radius: 2px;
}
.lec5-item-nr { font-weight: 700; color: #8B2635; margin-right: 4px; }
.lec5-item-qualitaet { color: #555; font-style: italic; margin-top: 3px; font-size: 0.9em; }
.teil2-zeile { margin-bottom: 10px; }
.teil2-label { font-weight: 600; color: #444; font-size: 0.88em;
text-transform: uppercase; letter-spacing: 0.03em; }
.teil2-wert { color: #222; margin-top: 2px; }
.cluster-box {
display: inline-block; padding: 10px 14px; border-radius: 6px;
margin: 4px; text-align: center; min-width: 130px;
vertical-align: top;
}
.cluster-ok { background: #E8F5E9; border: 1px solid #A5D6A7; }
.cluster-nok { background: #FFEBEE; border: 1px solid #EF9A9A; }
.cluster-name { font-weight: 700; font-size: 1em; color: #333; }
.cluster-score { font-size: 0.95em; color: #555; margin: 2px 0; }
.cluster-kriterium { font-size: 0.85em; font-weight: 600; }
.cluster-kriterium-ok { color: #2E7D32; }
.cluster-kriterium-nok { color: #C62828; }
.dsm5-ergebnis {
text-align: center; padding: 14px; border-radius: 6px;
margin: 10px 0; font-size: 1.1rem; font-weight: 700;
}
.dsm5-erfuellt { background: #FFEBEE; color: #B71C1C; border: 2px solid #EF9A9A; }
.dsm5-nichterfuellt { background: #E8F5E9; color: #2E7D32; border: 2px solid #A5D6A7; }
.dsm5-disclaimer {
font-size: 0.82em; color: #777; font-style: italic;
margin-top: 8px; border-top: 1px solid #eee; padding-top: 8px;
}
.item-zeile {
display: flex; align-items: flex-start; gap: 10px;
padding: 7px 0; border-bottom: 1px solid #F0F0F0;
}
.item-nr { font-weight: 600; color: #8B2635; min-width: 28px; }
.item-text { flex: 1; color: #333; font-size: 0.92em; }
.stufe-badge {
border-radius: 4px; padding: 2px 9px; font-weight: 700;
font-size: 0.82em; white-space: nowrap; display: inline-block;
}
.stufe-badge-0 { background: #4CAF50; color: white; }
.stufe-badge-1 { background: #F48FB1; color: #333; }
.stufe-badge-2 { background: #EF5350; color: white; }
.stufe-badge-3 { background: #B71C1C; color: white; }
.stufe-badge-4 { background: #4A0000; color: white; }
.score-zahl { font-size: 2rem; font-weight: 800; color: #8B2635; }
.cutoff-info { font-size: 0.88em; color: #555; margin-top: 4px; }
.keine-ereignisse { color: #777; font-style: italic; }
"
app_css = gsub("#8B2635", AKZENT_FARBE, app_css, fixed = TRUE)
ui = fluidPage(
tags$head(
tags$meta(charset = "UTF-8"),
tags$style(HTML(app_css))
),
div(class = "app-header",
tags$h2("PCL-5 / LEC-5 — Einzelauswertung"),
tags$p("Fragebogen: PCL-5 mit LEC-5 und erweitertem Kriterium A (Deutsche Fassung)")
),
div(class = "container-fluid",
div(class = "input-panel",
div(
tags$label("Patientenchiffre", `for` = "chiffre"),
tags$br(),
textInput("pseudonym",
label = tagList(
"Pseudonym",
tags$span(style = "font-weight: normal; font-style: italic; font-size: 0.78em; color: #888; margin-left: 4px; white-space: nowrap;",
"optional, hat Vorrang vor Chiffre")
),
placeholder = "optional", width = "340px"),
textInput("chiffre", label = NULL, placeholder = "z.B. P000123",
width = "180px")
),
div(style = "padding-bottom: 1px;",
actionButton("btn_laden", "Daten laden", class = "btn-laden")
),
div(style = "padding-bottom: 1px; margin-left: auto;",
downloadButton("download_docx", "Word-Bericht herunterladen",
style = paste0("background:", AKZENT_FARBE, "; color:white; border:none;",
" font-weight:600; padding:8px 20px; border-radius:4px;")
)
)
),
uiOutput("fehler_ui"),
uiOutput("warnung_ui"),
uiOutput("lec5_ui"),
uiOutput("teil2_ui"),
uiOutput("pcl5_ui")
)
)
# Word-Export ####
erstelle_pcl5scl5_docx = function(d) {
doc = read_docx()
fp_titel = fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 18)
fp_abschnitt = fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 13)
fp_label = fp_text(bold = TRUE, font.size = 11)
fp_normal = fp_text(font.size = 11)
fp_klein = fp_text(font.size = 9, italic = TRUE, color = "#666666")
fp_score_gut = fp_text(bold = TRUE, font.size = 12, color = "#2E7D32")
fp_score_krit = fp_text(bold = TRUE, font.size = 12, color = "#C62828")
doc = body_add_fpar(doc,
fpar(ftext("PCL-5 / LEC-5 — Einzelauswertung", fp_titel)))
doc = body_add_fpar(doc,
fpar(ftext(paste0("Chiffre: ", d$chiffre,
" | Ausfuelldatum: ", format(d$datum, "%d.%m.%Y")), fp_normal)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc,
fpar(ftext("LEC-5: Belastende Lebensereignisse", fp_abschnitt)))
relevante = Filter(function(x) x$hat_relevant, d$lec5)
if (length(relevante) == 0) {
doc = body_add_fpar(doc,
fpar(ftext("Keine belastenden Ereignisse angegeben.", fp_normal)))
} else {
for (item in relevante) {
doc = body_add_fpar(doc, fpar(
ftext(paste0("Item ", item$nr, " "), fp_label),
ftext(item$text, fp_normal),
ftext(paste0(": ", paste(item$antworten, collapse = ", ")), fp_normal)
))
}
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc,
fpar(ftext("Angaben zum schlimmsten Ereignis", fp_abschnitt)))
teil2_felder = list(
list(label = "Sonstiges Ereignis (Freitext)", val = d$teil2$a_text),
list(label = "Beschreibung", val = d$teil2$q1),
list(label = "Wie lange her?", val = d$teil2$q2),
list(label = "Wie erlebt?", val = d$teil2$q3),
list(label = "Sonstiges (Erlaeuterung)", val = d$teil2$q3_sonstiges),
list(label = "Lebensgefahr?", val = d$teil2$q4),
list(label = "Verletzt/getoetet?", val = d$teil2$q5),
list(label = "Sexuelle Gewalt?", val = d$teil2$q6),
list(label = "Art des Todesfalls", val = d$teil2$q7),
list(label = "Wie oft?", val = d$teil2$q8),
list(label = "Anzahl", val = d$teil2$q8_anzahl)
)
for (f in teil2_felder) {
v = if (is.null(f$val) || length(f$val) == 0) NA_character_ else f$val[1]
if (!is.na(v) && nchar(trimws(v)) > 0 && v != "NA") {
doc = body_add_fpar(doc, fpar(
ftext(paste0(f$label, ": "), fp_label),
ftext(v, fp_normal)
))
}
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc,
fpar(ftext("PCL-5: Auswertung", fp_abschnitt)))
score = d$pcl5$summenscore
fp_s = if (isTRUE(score >= 33)) fp_score_krit else fp_score_gut
doc = body_add_fpar(doc, fpar(
ftext("Summenscore: ", fp_label),
ftext(paste0(score, " / 80"), fp_s)
))
cluster_nms = c(
b = "Cluster B (Intrusion, Items 1-5)",
c = "Cluster C (Vermeidung, Items 6-7)",
d = "Cluster D (Kognition/Stimmung, Items 8-14)",
e = "Cluster E (Arousal, Items 15-20)"
)
for (k in c("b", "c", "d", "e")) {
cs = d$pcl5$cluster[[k]]
doc = body_add_fpar(doc, fpar(
ftext(paste0(cluster_nms[k], ": "), fp_label),
ftext(paste0(cs$score, " / ", cs$max), fp_normal)
))
}
doc = body_add_par(doc, "", style = "Normal")
dsm = d$pcl5$dsm5
gesamt_ok = isTRUE(dsm$gesamt)
fp_dsm = if (gesamt_ok) fp_score_krit else fp_score_gut
dsm_txt = if (gesamt_ok)
"Erfuellt (Kriterien B, C, D, E alle erfuellt)"
else
"Nicht erfuellt"
doc = body_add_fpar(doc, fpar(
ftext("Vorlaeufige DSM-5-Kriterienregel: ", fp_label),
ftext(dsm_txt, fp_dsm)
))
doc = body_add_fpar(doc,
fpar(ftext(paste0(
"Hinweis: Dies stellt kein automatisiertes klinisches Urteil dar. ",
"Auswertungslogik gemaess 'Anwendung und Auswertung PCL-5', ",
"nicht gegen zweite Quelle verifiziert."
), fp_klein)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc,
fpar(ftext("PCL-5 Einzelitems", fp_abschnitt)))
for (i in 1:20) {
lvl = d$pcl5$levels[i]
lvl_key = if (!is.na(lvl) && lvl >= 0 && lvl <= 4) as.character(lvl) else "0"
fp_badge = fp_text(
color = PCL5_BADGE_TEXT_FARBEN[[lvl_key]],
bold = TRUE,
shading.color = PCL5_BADGE_FARBEN[[lvl_key]],
font.size = 10
)
doc = body_add_fpar(doc, fpar(
ftext(paste0(i, ". ", PCL5_ITEM_TEXTE[i], " "), fp_normal),
ftext(paste0(" ", PCL5_STUFEN_TEXT[[lvl_key]], " "), fp_badge)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(PCL5SCL5_DISCLAIMER, fp_klein)))
doc
}
# Server ####
server = function(input, output, session) {
# --- pseudonym-support-injection v1 ---
observe({
query = parseQueryString(session$clientData$url_search)
if (!is.null(query$pseudonym) && nchar(trimws(query$pseudonym)) > 0) {
updateTextInput(session, "pseudonym", value = trimws(query$pseudonym))
}
})
observe({
query = parseQueryString(session$clientData$url_search)
if (!is.null(query$chiffre) && nchar(trimws(query$chiffre)) > 0) {
updateTextInput(session, "chiffre", value = toupper(trimws(query$chiffre)))
}
})
patientendaten = eventReactive(input$btn_laden, {
chiffre = toupper(trimws(input$chiffre))
if ((nchar(trimws(input$pseudonym)) == 0 && nchar(chiffre) == 0))
return(list(error = "Bitte eine Patientenchiffre eingeben."))
if (!(nchar(trimws(input$pseudonym)) > 0 || grepl("^[A-Z][0-9]{6}$", chiffre)))
return(list(error = "Ungueltige Chiffre. Erwartet: ein Grossbuchstabe gefolgt von 6 Ziffern, z.B. P000123."))
if (!file.exists(PFAD_DOWNLOAD_SKRIPT))
return(list(error = paste0("Download-Skript nicht gefunden:\n", PFAD_DOWNLOAD_SKRIPT)))
if (!file.exists(PFAD_PSEUDONYM_SKRIPT))
return(list(error = paste0("Pseudonym-Skript nicht gefunden:\n", PFAD_PSEUDONYM_SKRIPT)))
res_dl = tryCatch({
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!res_dl$ok)
return(list(error = paste0("Fehler im Download-Skript: ", res_dl$msg)))
basis_dir = normalizePath(dirname(PFAD_PSEUDONYM_SKRIPT))
db_dir = NULL
current = basis_dir
for (i in 0:5) {
if (file.exists(file.path(current, "pseudonyme.db"))) {
db_dir = current
break
}
parent = dirname(current)
if (parent == current) break
current = parent
}
wd_ziel = if (!is.null(db_dir)) db_dir else basis_dir
old_wd = getwd()
on.exit(setwd(old_wd), add = TRUE)
setwd(wd_ziel)
res_ps = tryCatch({
source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
if (nchar(trimws(input$pseudonym)) > 0) {
.pw_wert = trimws(input$pseudonym)
.pw_tab = get("pseudo", envir = .GlobalEnv)
.pw_treffer = .pw_tab[.pw_tab$pseudonym == .pw_wert, ]
if (nrow(.pw_treffer) > 0) chiffre = toupper(trimws(.pw_treffer$chiffre[1]))
}
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!res_ps$ok)
return(list(error = paste0("Fehler im Pseudonym-Skript: ", res_ps$msg)))
if (!exists("daten_pcl5lec5", envir = .GlobalEnv))
return(list(error = "Objekt 'daten_pcl5lec5' nach dem Sourcen nicht gefunden."))
if (!exists("pseudo", envir = .GlobalEnv))
return(list(error = "Objekt 'pseudo' nach dem Sourcen nicht gefunden."))
daten = get("daten_pcl5lec5", envir = .GlobalEnv)
pseudo_df = get("pseudo", envir = .GlobalEnv)
treffer_ps = pseudo_df[pseudo_df$chiffre == chiffre, ]
if (nrow(treffer_ps) == 0)
return(list(error = paste0("Chiffre '", chiffre, "' nicht in der Pseudonym-Datenbank gefunden.")))
warnungen = character(0)
alle_session_ids = unique(treffer_ps$pseudonym)
if (nchar(trimws(input$pseudonym)) > 0) alle_session_ids = trimws(input$pseudonym)
treffer_dat = daten[daten$session %in% alle_session_ids, ]
if (nrow(treffer_dat) == 0)
return(list(error = paste0(
"Keine Daten fuer Chiffre '", chiffre, "' in daten_pcl5lec5 gefunden. (",
length(alle_session_ids), " Pseudonym(e) geprueft)"
)))
if (nrow(treffer_dat) > 1) {
treffer_dat = treffer_dat[order(treffer_dat$created, decreasing = TRUE), ]
warnungen = c(warnungen, paste0(
"Mehrere Durchlaeufe fuer diese Chiffre gefunden. ",
"Der neueste vom ", format(treffer_dat$created[1], "%d.%m.%Y %H:%M"),
" wird angezeigt."
))
treffer_dat = treffer_dat[1, , drop = FALSE]
}
zeile = treffer_dat[1, , drop = FALSE]
lec5_liste = lapply(1:17, function(i) {
var = paste0("lec5_", sprintf("%02d", i))
col = daten[[var]]
val = zeile[[var]]
antworten = parse_lec5_value(col, val)
raw_str = if (!is.na(val)) as.character(val) else ""
teile = suppressWarnings(as.integer(trimws(strsplit(raw_str, "[,;]+")[[1]])))
teile = teile[!is.na(teile)]
# Relevant = mindestens eine Option ist nicht "nicht zutreffend" (Ziffer 6)
hat_relevant = length(teile) > 0 && !all(teile == 6)
list(
nr = i,
text = LEC5_ITEM_TEXTE[i],
antworten = antworten,
hat_relevant = hat_relevant
)
})
teil2 = list(
a_text = safe_char(zeile[["teil2_a_text"]]),
q1 = safe_char(zeile[["teil2_q1"]]),
q2 = safe_char(zeile[["teil2_q2"]]),
q3 = get_mc_label(daten[["teil2_q3"]], zeile[["teil2_q3"]]),
q3_sonstiges = safe_char(zeile[["teil2_q3_sonstiges"]]),
q4 = get_mc_label(daten[["teil2_q4"]], zeile[["teil2_q4"]]),
q5 = get_mc_label(daten[["teil2_q5"]], zeile[["teil2_q5"]]),
q6 = get_mc_label(daten[["teil2_q6"]], zeile[["teil2_q6"]]),
q7 = get_mc_label(daten[["teil2_q7"]], zeile[["teil2_q7"]]),
q8 = get_mc_label(daten[["teil2_q8"]], zeile[["teil2_q8"]]),
q8_anzahl = safe_char(zeile[["teil2_q8_anzahl"]]),
q3_raw = zeile[["teil2_q3"]],
q8_raw = zeile[["teil2_q8"]]
)
pcl5_levels = sapply(1:20, function(i) {
var = paste0("pcl5_", sprintf("%02d", i))
pcl5_get_level(daten[[var]], zeile[[var]])
})
summenscore = sum(pcl5_levels, na.rm = TRUE)
cluster_b_sum = sum(pcl5_levels[CLUSTER_B], na.rm = TRUE)
cluster_c_sum = sum(pcl5_levels[CLUSTER_C], na.rm = TRUE)
cluster_d_sum = sum(pcl5_levels[CLUSTER_D], na.rm = TRUE)
cluster_e_sum = sum(pcl5_levels[CLUSTER_E], na.rm = TRUE)
dsm5 = dsm5_check(pcl5_levels)
pcl5 = list(
levels = pcl5_levels,
summenscore = summenscore,
cluster = list(
b = list(score = cluster_b_sum, max = 20),
c = list(score = cluster_c_sum, max = 8),
d = list(score = cluster_d_sum, max = 28),
e = list(score = cluster_e_sum, max = 24)
),
dsm5 = dsm5
)
list(
chiffre = chiffre,
datum = as.Date(zeile[["created"]]),
warnungen = warnungen,
lec5 = lec5_liste,
teil2 = teil2,
pcl5 = pcl5,
error = NULL
)
})
output$fehler_ui = renderUI({
req(input$btn_laden)
d = patientendaten()
if (!is.null(d$error))
div(class = "alert-fehler", icon("exclamation-triangle"), " ", d$error)
})
output$warnung_ui = renderUI({
req(input$btn_laden)
d = patientendaten()
if (!is.null(d$error) || length(d$warnungen) == 0) return(NULL)
tagList(lapply(d$warnungen, function(w)
div(class = "alert-warnung", icon("exclamation-circle"), " ", w)
))
})
output$lec5_ui = renderUI({
req(input$btn_laden)
d = patientendaten()
if (!is.null(d$error)) return(NULL)
relevante = Filter(function(x) x$hat_relevant, d$lec5)
inhalt = if (length(relevante) == 0) {
tags$p(class = "keine-ereignisse",
"Keine belastenden Ereignisse angegeben (alle Items als 'nicht zutreffend' beantwortet).")
} else {
tagList(lapply(relevante, function(item) {
div(class = "lec5-item",
div(
tags$span(class = "lec5-item-nr", paste0("Item ", item$nr)),
tags$span(item$text)
),
div(class = "lec5-item-qualitaet",
paste(item$antworten, collapse = " • "))
)
}))
}
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "LEC-5: Belastende Lebensereignisse"),
inhalt
)
})
output$teil2_ui = renderUI({
req(input$btn_laden)
d = patientendaten()
if (!is.null(d$error)) return(NULL)
t2 = d$teil2
zeile_ui = function(label, wert) {
if (is.null(wert) || length(wert) == 0) return(NULL)
w = wert[1]
if (is.na(w) || w == "" || w == "NA") return(NULL)
div(class = "teil2-zeile",
div(class = "teil2-label", label),
div(class = "teil2-wert", w)
)
}
q3_roh = suppressWarnings(as.numeric(t2$q3_raw))
q8_roh = suppressWarnings(as.numeric(t2$q8_raw))
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Angaben zum schlimmsten Ereignis"),
zeile_ui("Sonstiges Ereignis (Item 17 Freitext)", t2$a_text),
zeile_ui("Beschreibung des schlimmsten Ereignisses", t2$q1),
zeile_ui("Wie lange ist das Ereignis her?", t2$q2),
zeile_ui("Wie erlebt?", t2$q3),
if (!is.na(q3_roh) && q3_roh == 5)
zeile_ui("Sonstiges Erläuterung", t2$q3_sonstiges),
zeile_ui("War jemand in Lebensgefahr?", t2$q4),
zeile_ui("Wurde jemand schwer verletzt oder getötet?", t2$q5),
zeile_ui("Sexuelle Gewalt?", t2$q6),
zeile_ui("Art des Todesfalls (sofern relevant)", t2$q7),
zeile_ui("Wie oft vorgekommen?", t2$q8),
if (!is.na(q8_roh) && q8_roh == 2)
zeile_ui("Anzahl der Vorkommnisse", t2$q8_anzahl)
)
})
output$pcl5_ui = renderUI({
req(input$btn_laden)
d = patientendaten()
if (!is.null(d$error)) return(NULL)
p = d$pcl5
score = p$summenscore
cutoff_ok = isTRUE(score >= 33)
cluster_info = list(
list(name1 = "Cluster B", name2 = "(Intrusion)", key = "b", min_ok = 1),
list(name1 = "Cluster C", name2 = "(Vermeidung)", key = "c", min_ok = 1),
list(name1 = "Cluster D", name2 = "(Kognition)", key = "d", min_ok = 2),
list(name1 = "Cluster E", name2 = "(Arousal)", key = "e", min_ok = 2)
)
dsm5 = p$dsm5
cluster_boxes = lapply(cluster_info, function(cl) {
dat = p$cluster[[cl$key]]
cnt = dsm5[[paste0(cl$key, "_count")]]
ok_flg = dsm5[[paste0(cl$key, "_ok")]]
div(class = paste0("cluster-box ", if (ok_flg) "cluster-ok" else "cluster-nok"),
div(class = "cluster-name",
tags$strong(cl$name1), tags$br(), cl$name2),
div(class = "cluster-score",
paste0(dat$score, " / ", dat$max, " Pkt")),
div(class = paste0("cluster-kriterium ",
if (ok_flg) "cluster-kriterium-ok" else "cluster-kriterium-nok"),
paste0(cnt, " von mind. ", cl$min_ok,
if (ok_flg) " ✓" else " ✗"))
)
})
dsm5_gesamt_ok = isTRUE(dsm5$gesamt)
dsm5_klasse = if (dsm5_gesamt_ok) "dsm5-erfuellt" else "dsm5-nichterfuellt"
dsm5_text = if (dsm5_gesamt_ok)
"Voraussetzungen der DSM-5-Kriterienregel erfüllt"
else
"Voraussetzungen der DSM-5-Kriterienregel nicht erfüllt"
pcl5_items_ui = lapply(1:20, function(i) {
lvl = p$levels[i]
lvl_key = if (!is.na(lvl) && lvl >= 0 && lvl <= 4) as.character(lvl) else NA
stufen_txt = if (!is.na(lvl_key)) PCL5_STUFEN_TEXT[[lvl_key]] else "NA"
badge_cls = if (!is.na(lvl_key)) paste0("stufe-badge stufe-badge-", lvl_key) else "stufe-badge"
cluster_lbl = if (i %in% CLUSTER_B) "B" else if (i %in% CLUSTER_C) "C" else
if (i %in% CLUSTER_D) "D" else "E"
div(class = "item-zeile",
div(class = "item-nr", paste0(i, ".")),
div(class = "item-text",
tags$small(paste0("[", cluster_lbl, "] "), style = "color:#999;"),
PCL5_ITEM_TEXTE[i]),
div(span(class = badge_cls, stufen_txt))
)
})
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "PCL-5: Auswertung"),
fluidRow(
column(3,
div(
div(class = "score-zahl", score),
div("Summenscore (080)"),
div(class = "cutoff-info",
if (cutoff_ok)
tags$span(style = "color:#B71C1C; font-weight:600;",
"≥ 33: Weitere Abklärung empfohlen")
else
tags$span(style = "color:#2E7D32; font-weight:600;",
"< 33: Unterhalb Cutoff")
),
div(class = "cutoff-info", style = "margin-top:8px;",
"Hinweis: Der Cutoff von 33 ist eine Orientierungsgröße. ",
"Er kann je nach Kontext (Screening vs. Diagnose, Risikopopulation) ",
"nach oben oder unten verschoben werden."
)
)
),
column(9,
plotOutput("gauge_plot", height = "160px")
)
),
tags$hr(),
div(style = "margin-bottom: 14px;",
tags$h5("Cluster-Subscores und DSM-5-Kriterienregel"),
div(style = "margin-bottom: 10px;", tagList(cluster_boxes)),
div(class = paste0("dsm5-ergebnis ", dsm5_klasse), dsm5_text),
div(class = "dsm5-disclaimer",
"Hinweis: Diese Auswertung stellt kein automatisiertes klinisches Urteil dar. ",
"Die Ergebnisse dienen als Orientierung für die klinische Einschätzung ",
"und ersetzen keine fachkundige diagnostische Beurteilung. ",
tags$em("(Auswertungslogik gemäß 'Anwendung und Auswertung PCL-5', ",
"nicht gegen zweite Quelle verifiziert.)")
)
),
tags$hr(),
div(
tags$h5("PCL-5 Einzelitems"),
pcl5_items_ui
)
)
})
output$gauge_plot = renderPlot({
req(input$btn_laden)
d = patientendaten()
req(is.null(d$error))
make_gauge_plot(d$pcl5$summenscore)
}, bg = "transparent")
output$download_docx = downloadHandler(
filename = function() {
d = tryCatch(patientendaten(), error = function(e) NULL)
chiffre = if (is.list(d) && is.null(d$error) &&
!is.null(d$chiffre) && nchar(d$chiffre) > 0)
d$chiffre else "export"
datum_fn = if (is.list(d) && is.null(d$error) && !is.null(d$datum))
format(d$datum, "%Y%m%d") else format(Sys.Date(), "%Y%m%d")
paste0("PCL5LEC5_", chiffre, "_", datum_fn, ".docx")
},
content = function(file) {
d = tryCatch(patientendaten(), error = function(e) NULL)
daten_ok = is.list(d) && is.null(d$error)
if (!daten_ok) {
doc = read_docx()
doc = body_add_par(doc,
"Kein Datensatz geladen. Bitte zuerst Chiffre eingeben und 'Daten laden' klicken.",
style = "Normal")
print(doc, target = file)
return()
}
doc = tryCatch(
erstelle_pcl5scl5_docx(d),
error = function(e) {
err_doc = read_docx()
body_add_par(err_doc,
paste0("Fehler beim Erstellen des Dokuments: ", e$message),
style = "Normal")
}
)
print(doc, target = file)
}
)
}
# Start ####
shinyApp(ui, server)