DiagnostikApps/CTQ/app.R
2026-09-22 18:35:43 +02:00

866 lines
30 KiB
R
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Präambel ####
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_ctq.R"
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R"
AKZENT_FARBE = "#8B2635"
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)
CTQ_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die Interpretation obliegt der behandelnden Person. ",
"Die angezeigte Klassifikation (None/Low/Moderate/Severe) stammt aus dem amerikanischen ",
"CTQ-Manual (Bernstein & Fink, 1998) und liegt fuer die deutsche Fassung nicht als eigene ",
"Norm vor; sie dient nur zur groben Orientierung, nicht als gesicherte deutsche Normierung. ",
"Die Subskala Bagatellisierung/Verleugnung hat im Manual keine eigene Klassifikationsstufe ",
"und ist als Validitaetshinweis (moegliche Verzerrung der uebrigen Angaben) zu lesen, nicht ",
"als klinische Belastungsdimension."
)
CTQ_BADGE_FARBEN = c(
"1" = "#4CAF50",
"2" = "#FFC107",
"3" = "#FF9800",
"4" = "#E53935",
"5" = "#4A0000"
)
CTQ_BADGE_TEXT_FARBEN = c(
"1" = "white",
"2" = "#333333",
"3" = "white",
"4" = "white",
"5" = "white"
)
CTQ_KLASS_WORD_FARBEN = list(
"None (or minimal)" = list(bg = "#E8F5E9", text = "#2E7D32"),
"Low (to moderate)" = list(bg = "#FFF9C4", text = "#F57F17"),
"Moderate (to severe)" = list(bg = "#FFF3E0", text = "#E65100"),
"Severe (to extreme)" = list(bg = "#FFEBEE", text = "#B71C1C")
)
library(shiny)
library(dplyr)
library(ggplot2)
library(haven)
library(officer)
# Infrastruktur ####
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 .form-group { margin-bottom: 0; }
.input-panel label { font-weight: 600; color: #333; }
.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;
}
.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: #FFF3E0; border-left: 5px solid #E65100;
padding: 10px 16px; border-radius: 4px; color: #BF360C;
margin-bottom: 12px; font-size: 0.93em; font-weight: 500;
}
.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;
}
.meta-block { margin-bottom: 10px; color: #555; font-size: 0.95em; }
.meta-block strong { color: #222; }
.subskala-karte {
border-radius: 6px; padding: 14px 18px; margin-bottom: 12px;
background: #FAFAFA; border: 1px solid #E8E8E8;
}
.subskala-titel { font-weight: 700; color: #333; font-size: 0.97rem; margin-bottom: 4px; }
.subskala-score { font-size: 1.9rem; font-weight: 800; color: #8B2635; display: inline-block; }
.klass-badge {
display: inline-block; border-radius: 4px; padding: 3px 10px;
font-weight: 600; font-size: 0.83em; margin-left: 10px; vertical-align: middle;
}
.klass-none { background: #E8F5E9; color: #2E7D32; border: 1px solid #A5D6A7; }
.klass-low { background: #FFF9C4; color: #B7770D; border: 1px solid #FFE082; }
.klass-moderate { background: #FFF3E0; color: #E65100; border: 1px solid #FFCC80; }
.klass-severe { background: #FFEBEE; color: #B71C1C; border: 1px solid #EF9A9A; }
.disclaimer-box {
font-size: 0.83em; color: #666; font-style: italic;
background: #FAFAFA; border: 1px solid #E0E0E0;
padding: 10px 14px; border-radius: 4px; margin-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: 30px; flex-shrink: 0; }
.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; flex-shrink: 0;
}
.stufe-badge-1 { background: #4CAF50; color: white; }
.stufe-badge-2 { background: #FFC107; color: #333333; }
.stufe-badge-3 { background: #FF9800; color: white; }
.stufe-badge-4 { background: #E53935; color: white; }
.stufe-badge-5 { background: #4A0000; color: white; }
.sk-gruppe-titel {
font-weight: 700; color: #555; font-size: 0.88em; text-transform: uppercase;
letter-spacing: 0.05em; margin-top: 14px; margin-bottom: 4px;
}
.item-01-hinweis {
font-size: 0.82em; color: #888; font-style: italic; margin-left: 4px;
}
"
app_css = gsub("#8B2635", AKZENT_FARBE, app_css, fixed = TRUE)
# Helper ####
clean_item_label = function(text) {
if (is.null(text) || length(text) == 0 || is.na(text[1])) return(NA_character_)
sub("^\\d+[.)\\s]\\s*", "", trimws(as.character(text[1])))
}
ctq_get_anker = function(original_col, wert) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_character_)
lbl = attr(original_col, "labels")
if (!is.null(lbl) && length(lbl) > 0) {
pos = which(as.numeric(lbl) == as.numeric(wert[1]))
if (length(pos) > 0) return(names(lbl)[pos[1]])
}
NA_character_
}
bagatellisierung_itemscore = function(rohwert) {
if (is.na(rohwert)) return(NA_real_)
if (rohwert == 5) return(1)
return(0)
}
ctq_validiere_labels = function(daten_ctq) {
anker_erwartet = c("überhaupt nicht", "sehr selten", "einige male",
"häufig", "sehr häufig")
for (nr in 2:28) {
var = paste0("ctq_", sprintf("%02d", nr))
col = daten_ctq[[var]]
lbl = attr(col, "labels")
if (is.null(lbl) || length(lbl) < 5)
stop(paste0(
"Labels fuer ", var, " fehlen oder unvollstaendig (", length(lbl), " statt 5). ",
"Bitte formr-Export pruefen."
))
namen_norm = tolower(trimws(names(lbl)))
fehlend = anker_erwartet[!anker_erwartet %in% namen_norm]
if (length(fehlend) > 0)
stop(paste0(
"Labels fuer ", var, " nicht wie erwartet. ",
"Fehlende Anker: [", paste(fehlend, collapse = ", "), "]. ",
"Gefunden: [", paste(names(lbl), collapse = ", "), "]. ",
"Bitte formr-Export pruefen."
))
}
invisible(TRUE)
}
ctq_klassifiziere = function(score, grenzen, labels) {
if (is.na(score)) return(NA_character_)
if (score <= grenzen[1]) return(labels[1])
if (score <= grenzen[2]) return(labels[2])
if (score <= grenzen[3]) return(labels[3])
return(labels[4])
}
klass_css_klasse = function(klass_label) {
if (is.null(klass_label) || is.na(klass_label)) return("")
switch(klass_label,
"None (or minimal)" = "klass-none",
"Low (to moderate)" = "klass-low",
"Moderate (to severe)" = "klass-moderate",
"Severe (to extreme)" = "klass-severe",
""
)
}
berechne_sk_score = function(sk_name, sk, zeile, daten_ctq) {
if (isTRUE(sk$sonderkodierung)) {
item_scores = sapply(sk$items, function(nr) {
var = paste0("ctq_", sprintf("%02d", nr))
roh = as.numeric(zeile[[var]][1])
bagatellisierung_itemscore(roh)
})
return(sum(item_scores, na.rm = TRUE))
}
item_scores = sapply(sk$items, function(nr) {
var = paste0("ctq_", sprintf("%02d", nr))
roh = as.numeric(zeile[[var]][1])
if (is.na(roh)) return(NA_real_)
if (nr %in% sk$invertiert) 6 - roh else roh
})
sum(item_scores, na.rm = TRUE)
}
make_ctq_gauge = function(score, x_min, x_max, titel, grenzen = NULL) {
score_num = as.numeric(score)
if (!is.null(grenzen)) {
zone_df = data.frame(
xmin = c(x_min - 0.5, grenzen + 0.5),
xmax = c(grenzen + 0.5, x_max + 0.5),
fill = c("#C8E6C9", "#FFF9C4", "#FFE0B2", "#FFCDD2"),
stringsAsFactors = FALSE
)
} else {
zone_df = data.frame(
xmin = x_min - 0.5,
xmax = x_max + 0.5,
fill = "#E3F2FD",
stringsAsFactors = FALSE
)
}
ggplot() +
geom_rect(data = zone_df,
aes(xmin = xmin, xmax = xmax, ymin = 0, ymax = 1, fill = fill),
color = NA) +
scale_fill_identity() +
geom_rect(aes(xmin = x_min - 0.5, xmax = x_max + 0.5, ymin = 0, ymax = 1),
fill = NA, color = "#9E9E9E", linewidth = 0.6) +
geom_segment(aes(x = score_num, xend = score_num, y = -0.25, yend = 1.25),
color = AKZENT_FARBE, linewidth = 2.5) +
geom_label(aes(x = score_num, y = 1.6, label = paste0("Score: ", score_num)),
fill = AKZENT_FARBE, color = "white", fontface = "bold",
linewidth = 0, size = 4) +
scale_x_continuous(
limits = c(x_min - 1, x_max + 1),
breaks = if (!is.null(grenzen)) sort(unique(c(x_min, grenzen, x_max)))
else x_min:x_max
) +
scale_y_continuous(limits = c(-0.8, 2.0)) +
theme_minimal(base_size = 11) +
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 = paste0(titel, " (", x_min, "-", x_max, ")"), y = NULL)
}
# Datenaufbereitung ####
ctq_subskalen = list(
emotionale_vernachlaessigung = list(
items = c(2, 5, 7, 13, 19, 26, 28),
invertiert = c(2, 5, 7, 13, 19, 26, 28),
label = "Emotionale Vernachlässigung"
),
sexueller_missbrauch = list(
items = c(20, 21, 23, 24, 27),
invertiert = c(),
label = "Sexueller Missbrauch"
),
koerperlicher_missbrauch_vernachlaessigung = list(
items = c(4, 6, 9, 11, 12, 15, 17),
invertiert = c(),
label = "Körperlicher Missbrauch und Vernachlässigung"
),
emotionaler_missbrauch = list(
items = c(3, 8, 14, 18, 25),
invertiert = c(),
label = "Emotionaler Missbrauch"
),
bagatellisierung = list(
items = c(10, 16, 22),
invertiert = c(),
label = "Bagatellisierung/Verleugnung",
sonderkodierung = TRUE
)
)
ctq_klassifikation = list(
emotionale_vernachlaessigung = list(
grenzen = c(9, 14, 17),
labels = c("None (or minimal)", "Low (to moderate)", "Moderate (to severe)", "Severe (to extreme)")
),
sexueller_missbrauch = list(
grenzen = c(5, 7, 12),
labels = c("None (or minimal)", "Low (to moderate)", "Moderate (to severe)", "Severe (to extreme)")
),
koerperlicher_missbrauch_vernachlaessigung = list(
grenzen = c(7, 9, 12),
labels = c("None (or minimal)", "Low (to moderate)", "Moderate (to severe)", "Severe (to extreme)")
),
emotionaler_missbrauch = list(
grenzen = c(8, 12, 15),
labels = c("None (or minimal)", "Low (to moderate)", "Moderate (to severe)", "Severe (to extreme)")
)
)
item_zu_skala = local({
erg = list()
for (sk_name in names(ctq_subskalen)) {
for (nr in ctq_subskalen[[sk_name]]$items) {
erg[[as.character(nr)]] = sk_name
}
}
erg
})
# UI ####
ui = fluidPage(
tags$head(
tags$meta(charset = "UTF-8"),
tags$style(HTML(app_css))
),
div(class = "app-header",
tags$h2("CTQ Childhood Trauma Questionnaire"),
tags$p("Bernstein & Fink, 1998 | Deutsche Fassung | Einzelauswertung")
),
div(class = "container-fluid",
div(class = "input-panel",
div(style = "min-width: 360px; white-space: nowrap;",
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")
),
div(style = "min-width: 200px;",
textInput("chiffre", label = "Patientenchiffre",
placeholder = "z.B. P000123", width = "100%")
),
actionButton("btn_suchen", "Auswerten", class = "btn btn-primary btn-laden"),
div(style = "margin-left: auto;",
downloadButton("download_word", "Word-Export (.docx)")
)
),
uiOutput("fehler_ui"),
uiOutput("warnung_ui"),
uiOutput("ergebnis_ui")
)
)
# Word-Export ####
erstelle_ctq_docx = function(erg) {
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_disclaimer = fp_text(font.size = 9, italic = TRUE, color = "#777777")
fp_sk_titel = fp_text(bold = TRUE, font.size = 12)
doc = body_add_fpar(doc, fpar(
ftext("CTQ Childhood Trauma Questionnaire", fp_titel)
))
doc = body_add_fpar(doc, fpar(
ftext("Chiffre: ", fp_label), ftext(erg$chiffre, fp_normal),
ftext(" Ausfuelldatum: ", fp_label), ftext(erg$ausfuelldatum, fp_normal)
))
if (!is.null(erg$info_mehrere)) {
doc = body_add_fpar(doc, fpar(
ftext(erg$info_mehrere, fp_text(font.size = 10, italic = TRUE, color = "#555555"))
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Subskalen-Scores", fp_abschnitt)))
for (sk_name in names(erg$sk_erg)) {
sk_res = erg$sk_erg[[sk_name]]
score_txt = as.character(sk_res$score)
if (!is.na(sk_res$klass)) {
klass_farbe = CTQ_KLASS_WORD_FARBEN[[sk_res$klass]]
fp_klass = fp_text(bold = TRUE, font.size = 11,
color = klass_farbe$text, shading.color = klass_farbe$bg)
doc = body_add_fpar(doc, fpar(
ftext(paste0(sk_res$label, ": "), fp_sk_titel),
ftext(score_txt, fp_text(bold = TRUE, font.size = 13, color = AKZENT_FARBE)),
ftext(" ", fp_normal),
ftext(paste0(" ", sk_res$klass, " "), fp_klass)
))
} else {
doc = body_add_fpar(doc, fpar(
ftext(paste0(sk_res$label, " (Validitaetshinweis): "), fp_sk_titel),
ftext(paste0(score_txt, " / 3"), fp_text(bold = TRUE, font.size = 13, color = AKZENT_FARBE))
))
}
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Einzelitems", fp_abschnitt)))
sk_reihenfolge = names(ctq_subskalen)
for (sk_name in sk_reihenfolge) {
sk_label = ctq_subskalen[[sk_name]]$label
doc = body_add_fpar(doc, fpar(
ftext(sk_label, fp_text(bold = TRUE, font.size = 11, color = "#555555"))
))
items_dieser_sk = Filter(function(it) {
!is.null(it$sk_name) && !is.na(it$sk_name) && it$sk_name == sk_name
}, erg$items_erg)
for (it in items_dieser_sk) {
roh_key = if (!is.na(it$rohwert) && it$rohwert >= 1 && it$rohwert <= 5)
as.character(as.integer(it$rohwert)) else "1"
badge_farbe = CTQ_BADGE_FARBEN[[roh_key]]
badge_text_farbe = CTQ_BADGE_TEXT_FARBEN[[roh_key]]
fp_badge = fp_text(color = badge_text_farbe, bold = TRUE,
shading.color = badge_farbe, font.size = 10)
item_txt = if (!is.na(it$item_text)) it$item_text else paste0("Item ", it$nr)
anker_txt = if (!is.na(it$anker_text)) it$anker_text else paste0("Stufe ", it$rohwert)
doc = body_add_fpar(doc, fpar(
ftext(paste0(it$nr, ". ", item_txt, " "), fp_normal),
ftext(paste0(" ", anker_txt, " "), fp_badge)
))
}
}
item1 = Filter(function(it) it$nr == 1, erg$items_erg)
if (length(item1) > 0) {
it = item1[[1]]
doc = body_add_fpar(doc, fpar(
ftext("Item 1 (nicht in Subskalenbildung einbezogen)",
fp_text(bold = TRUE, font.size = 11, color = "#888888"))
))
roh_key = if (!is.na(it$rohwert) && it$rohwert >= 1 && it$rohwert <= 5)
as.character(as.integer(it$rohwert)) else "1"
badge_farbe = CTQ_BADGE_FARBEN[[roh_key]]
badge_text_farbe = CTQ_BADGE_TEXT_FARBEN[[roh_key]]
fp_badge = fp_text(color = badge_text_farbe, bold = TRUE,
shading.color = badge_farbe, font.size = 10)
item_txt = if (!is.na(it$item_text)) it$item_text else "Item 1"
anker_txt = if (!is.na(it$anker_text)) it$anker_text else paste0("Stufe ", it$rohwert)
doc = body_add_fpar(doc, fpar(
ftext(paste0("1. ", item_txt, " "), fp_normal),
ftext(paste0(" ", anker_txt, " "), fp_badge)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(CTQ_DISCLAIMER, fp_disclaimer)))
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)))
}
})
ergebnis_r = eventReactive(input$btn_suchen, {
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 = paste0(
"Ungueltige Chiffre '", 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)))
db_ordner = local({
ordner = dirname(normalizePath(PFAD_PSEUDONYM_SKRIPT, mustWork = FALSE))
gefunden = NULL
for (i in 1:5) {
if (file.exists(file.path(ordner, "pseudonyme.db"))) { gefunden = ordner; break }
elternteil = dirname(ordner)
if (elternteil == ordner) break
ordner = elternteil
}
gefunden
})
alter_wd = getwd()
wd_ziel = if (!is.null(db_ordner)) db_ordner else
dirname(normalizePath(PFAD_PSEUDONYM_SKRIPT, mustWork = FALSE))
setwd(wd_ziel)
on.exit(setwd(alter_wd), add = TRUE)
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_ctq", envir = .GlobalEnv))
return(list(error = paste0(
"Objekt 'daten_ctq' nach dem Sourcen nicht gefunden. ",
"Bitte Download-Skript pruefen."
)))
if (!exists("pseudo", envir = .GlobalEnv))
return(list(error = paste0(
"Objekt 'pseudo' nach dem Sourcen nicht gefunden. ",
"Bitte Pseudonym-Skript pruefen."
)))
daten_ctq = get("daten_ctq", 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, "' wurde in der Pseudonym-Datenbank nicht gefunden."
)))
alle_session_ids = unique(treffer_ps$pseudonym)
if (nchar(trimws(input$pseudonym)) > 0) alle_session_ids = trimws(input$pseudonym)
treffer_dat = daten_ctq[daten_ctq$session %in% alle_session_ids, ]
if (nrow(treffer_dat) == 0)
return(list(error = paste0(
"Kein CTQ-Datensatz fuer Chiffre '", chiffre, "' gefunden. ",
"(", length(alle_session_ids), " Pseudonym(e) geprueft)"
)))
info_mehrere = NULL
if (nrow(treffer_dat) > 1) {
n = nrow(treffer_dat)
treffer_dat = treffer_dat[order(treffer_dat$created, decreasing = TRUE), ]
datum_neu = tryCatch(
format(as.POSIXct(treffer_dat$created[1]), "%d.%m.%Y %H:%M"),
error = function(e) "unbekanntes Datum"
)
info_mehrere = paste0(
"Mehrere Ausfuellungen gefunden (", n, " Eintraege). ",
"Angezeigt wird die neueste vom ", datum_neu, "."
)
treffer_dat = treffer_dat[1, , drop = FALSE]
}
zeile = treffer_dat[1, , drop = FALSE]
datum_str = tryCatch(
format(as.POSIXct(zeile[["created"]][1]), "%d.%m.%Y"),
error = function(e) format(Sys.Date(), "%d.%m.%Y")
)
val_result = tryCatch(
{ ctq_validiere_labels(daten_ctq); list(ok = TRUE) },
error = function(e) list(ok = FALSE, msg = e$message)
)
if (!val_result$ok)
return(list(error = paste0("Label-Validierung fehlgeschlagen: ", val_result$msg)))
sk_erg = lapply(names(ctq_subskalen), function(sk_name) {
sk = ctq_subskalen[[sk_name]]
score = berechne_sk_score(sk_name, sk, zeile, daten_ctq)
klass = if (!isTRUE(sk$sonderkodierung)) {
ki = ctq_klassifikation[[sk_name]]
ctq_klassifiziere(score, ki$grenzen, ki$labels)
} else NA_character_
list(
name = sk_name,
label = sk$label,
score = score,
klass = klass,
n_items = length(sk$items),
sonderkodierung = isTRUE(sk$sonderkodierung)
)
})
names(sk_erg) = names(ctq_subskalen)
items_erg = lapply(1:28, function(nr) {
var = paste0("ctq_", sprintf("%02d", nr))
original_col = daten_ctq[[var]]
roh_wert = as.numeric(zeile[[var]][1])
sk_name = item_zu_skala[[as.character(nr)]]
list(
nr = nr,
var = var,
item_text = clean_item_label(attr(original_col, "label")),
anker_text = ctq_get_anker(original_col, zeile[[var]]),
rohwert = roh_wert,
sk_name = sk_name,
sk_label = if (!is.null(sk_name)) ctq_subskalen[[sk_name]]$label else NA_character_
)
})
list(
chiffre = chiffre,
ausfuelldatum = datum_str,
info_mehrere = info_mehrere,
sk_erg = sk_erg,
items_erg = items_erg,
error = NULL
)
})
output$fehler_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error)) div(class = "alert-fehler", d$error) else NULL
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error) || is.null(d$info_mehrere)) return(NULL)
div(class = "alert-warnung", d$info_mehrere)
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error)) return(NULL)
sk_karten = lapply(names(d$sk_erg), function(sk_name) {
sk_res = d$sk_erg[[sk_name]]
n_items = sk_res$n_items
x_min = if (sk_res$sonderkodierung) 0 else n_items
x_max = if (sk_res$sonderkodierung) 3 else n_items * 5
grenzen = if (!sk_res$sonderkodierung && !is.null(ctq_klassifikation[[sk_name]]))
ctq_klassifikation[[sk_name]]$grenzen else NULL
plot_id = paste0("gauge_", sk_name)
klass_badge_ui = if (!is.na(sk_res$klass)) {
span(class = paste0("klass-badge ", klass_css_klasse(sk_res$klass)), sk_res$klass)
} else NULL
titel_gauge = if (sk_res$sonderkodierung)
paste0(sk_res$label, " (Validitaetshinweis)")
else sk_res$label
div(class = "subskala-karte",
div(class = "subskala-titel",
sk_res$label,
if (sk_res$sonderkodierung)
tags$small(class = "item-01-hinweis", "(Validitaetshinweis, kein Belastungswert)")
),
div(
span(class = "subskala-score", sk_res$score),
if (!sk_res$sonderkodierung)
span(style = "color:#999; font-size:0.9rem; margin-left:4px;",
paste0("/ ", x_max))
else
span(style = "color:#999; font-size:0.9rem; margin-left:4px;", "/ 3"),
klass_badge_ui
),
plotOutput(plot_id, height = "130px")
)
})
items_ui = lapply(names(ctq_subskalen), function(sk_name) {
sk_label = ctq_subskalen[[sk_name]]$label
items_dieser_sk = Filter(function(it) {
!is.null(it$sk_name) && !is.na(it$sk_name) && it$sk_name == sk_name
}, d$items_erg)
item_zeilen = lapply(items_dieser_sk, function(it) {
roh = it$rohwert
roh_key = if (!is.na(roh) && roh >= 1 && roh <= 5)
as.character(as.integer(roh)) else "1"
anker_txt = if (!is.na(it$anker_text)) it$anker_text else paste0("Stufe ", roh)
item_txt = if (!is.na(it$item_text)) it$item_text else paste0("Item ", it$nr)
div(class = "item-zeile",
div(class = "item-nr", paste0(it$nr, ".")),
div(class = "item-text", item_txt),
span(class = paste0("stufe-badge stufe-badge-", roh_key), anker_txt)
)
})
tagList(
div(class = "sk-gruppe-titel", sk_label),
tagList(item_zeilen)
)
})
item1_data = Filter(function(it) it$nr == 1, d$items_erg)
item1_ui = if (length(item1_data) > 0) {
it = item1_data[[1]]
roh = it$rohwert
roh_key = if (!is.na(roh) && roh >= 1 && roh <= 5)
as.character(as.integer(roh)) else "1"
anker_txt = if (!is.na(it$anker_text)) it$anker_text else paste0("Stufe ", roh)
item_txt = if (!is.na(it$item_text)) it$item_text else "Item 1"
tagList(
div(class = "sk-gruppe-titel",
"Item 1",
tags$small(class = "item-01-hinweis", "(nicht in Subskalenbildung einbezogen)")
),
div(class = "item-zeile",
div(class = "item-nr", "1."),
div(class = "item-text", item_txt),
span(class = paste0("stufe-badge stufe-badge-", roh_key), anker_txt)
)
)
} else NULL
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "CTQ Childhood Trauma Questionnaire"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfuelldatum: "), d$ausfuelldatum
),
tags$hr(),
tags$h5("Subskalen-Scores"),
fluidRow(
column(6, sk_karten[[1]]),
column(6, sk_karten[[2]])
),
fluidRow(
column(6, sk_karten[[3]]),
column(6, sk_karten[[4]])
),
fluidRow(
column(6, offset = 3, sk_karten[[5]])
),
tags$hr(),
div(class = "disclaimer-box",
tags$strong("Hinweis zur Klassifikation: "),
paste0(
"Die Einstufungen (None/Low/Moderate/Severe) entstammen ausschliesslich dem ",
"amerikanischen CTQ-Manual (Bernstein & Fink, 1998). ",
"Fuer die deutsche Fassung liegt keine eigene Norm vor. ",
"Vollstaendiger Hinweis am Ende des Word-Exports."
)
),
tags$hr(),
tags$h5("Einzelitems"),
div(items_ui),
item1_ui
)
})
local({
sk_names = names(ctq_subskalen)
for (sk_name in sk_names) {
local({
sn = sk_name
plot_id = paste0("gauge_", sn)
output[[plot_id]] = renderPlot({
req(input$btn_suchen)
d = ergebnis_r()
req(is.null(d$error))
sk_res = d$sk_erg[[sn]]
sk_def = ctq_subskalen[[sn]]
n_items = length(sk_def$items)
x_min = if (isTRUE(sk_def$sonderkodierung)) 0 else n_items
x_max = if (isTRUE(sk_def$sonderkodierung)) 3 else n_items * 5
grenzen = if (!isTRUE(sk_def$sonderkodierung))
ctq_klassifikation[[sn]]$grenzen else NULL
titel = if (isTRUE(sk_def$sonderkodierung))
paste0(sk_def$label, " (Validitaetshinweis)") else sk_def$label
make_ctq_gauge(sk_res$score, x_min, x_max, titel, grenzen)
}, bg = "transparent")
})
}
})
output$download_word = downloadHandler(
filename = function() {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
chiffre_fn = if (is.list(d) && is.null(d$error) && nchar(d$chiffre) > 0)
gsub("[^A-Za-z0-9]", "", d$chiffre) else "export"
ausfuelldatum_fn = if (is.list(d) && is.null(d$error) && !is.null(d$ausfuelldatum))
tryCatch(
format(as.Date(d$ausfuelldatum, "%d.%m.%Y"), "%Y%m%d"),
error = function(e) format(Sys.Date(), "%Y%m%d")
)
else
format(Sys.Date(), "%Y%m%d")
paste0("CTQ_", chiffre_fn, "_", ausfuelldatum_fn, ".docx")
},
content = function(file) {
d = tryCatch(ergebnis_r(), 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 'Auswerten' klicken.",
style = "Normal")
print(doc, target = file)
return()
}
doc = tryCatch(
erstelle_ctq_docx(d),
error = function(e) {
err_doc = read_docx()
body_add_par(err_doc,
paste0("Fehler beim Erstellen des Word-Dokuments: ", e$message),
style = "Normal")
}
)
print(doc, target = file)
}
)
}
# Start ####
shinyApp(ui, server)