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

662 lines
25 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_vds30_11.R" # liefert: daten_vds30_11
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R" # liefert: pseudo
AKZENT_FARBE = "#8B2635"
VDS30_11_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die Interpretation obliegt der behandelnden Person. ",
"Cutoff-Markierungen weisen auf einen Verdacht hin, sie sind kein diagnostisches Urteil."
)
VDS30_11_DISCLAIMER = gsub("fuer", "für", VDS30_11_DISCLAIMER, fixed = TRUE)
library(shiny)
library(dplyr)
library(ggplot2)
library(haven)
library(officer)
library(DBI)
library(RSQLite)
# 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 ####
# Itemnamen einer Skala programmatisch bauen (vds11_su01 ... vds11_su10),
# statt 110 Namen auszuschreiben.
vds11_item_namen = function(praefix) sprintf("vds11_%s%02d", praefix, 1:10)
# OFFENER PUNKT FUER DEN ERSTEN TESTLAUF: Das genaue Exportformat von formr-Items
# vom Typ "mc" (im Unterschied zum an anderer Stelle in diesem Projekt etablierten
# mc_button) ist fuer dieses Instrument noch nicht gegen einen echten Testdurchlauf
# verifiziert. Die Funktion deckt defensiv beide plausiblen Faelle ab:
# - haven_labelled Spalte mit labels-Attribut: der Stufenwert (0-3) wird ueber
# die fuehrende Ziffer des Label-Texts ("0 = nicht", "1 = leicht", ...)
# aufgeloest, NIEMALS ueber den moeglicherweise abweichenden Rohcode.
# - bereits rein numerische Spalte (0-3): wird direkt uebernommen.
# Nach dem ersten echten Testdatensatz pruefen, ob die extrahierten Werte
# plausibel sind (Bereich 0-3, keine NA bei vollstaendig ausgefuelltem Bogen).
vds11_item_werte_spalte = function(daten, feldname) {
if (!(feldname %in% names(daten))) return(rep(NA_real_, nrow(daten)))
x = daten[[feldname]]
labels_attr = attr(x, "labels")
if (is.null(labels_attr)) return(as.numeric(x))
treffer = match(as.numeric(x), as.numeric(labels_attr))
as.numeric(sub("^(\\d+).*", "\\1", names(labels_attr)[treffer]))
}
# Skalen-Mittelwert ueber 10 Itemrohwerte. Fehlt auch nur eines der Items (NA),
# wird NICHT stillschweigend aus den uebrigen gemittelt, sondern NA
# zurueckgegeben (keine Scheinpraezision) - die Anzeige muss das explizit als
# "unvollstaendig ausgefuellt" kennzeichnen.
vds11_skala_mittelwert = function(werte) {
n_gesamt = length(werte)
n_vorhanden = sum(!is.na(werte))
mittelwert = if (n_vorhanden == n_gesamt) mean(werte) else NA_real_
list(mittelwert = mittelwert, n_vorhanden = n_vorhanden, n_gesamt = n_gesamt)
}
# Cutoff-Pruefung: Mittelwert >= CUTOFF_WERT. NA bleibt NA (nicht beurteilbar).
vds11_cutoff_erreicht = function(mittelwert) {
if (is.na(mittelwert)) return(NA)
mittelwert >= CUTOFF_WERT
}
# Einzelne horizontale Profil-Leiste (0-3-Skala) fuer eine Skalenzeile.
# Cutoff-Skalen erhalten eine gestrichelte Linie bei CUTOFF_WERT und wechseln
# bei Erreichen auf Akzentfarbe; SS/KO (hat_cutoff = FALSE) bleiben neutral
# grau und ohne Cutoff-Linie.
vds11_balken_plot = function(zeile) {
y_balken = if (is.na(zeile$mittelwert)) 0 else zeile$mittelwert
ist_cutoff_erreicht = isTRUE(zeile$hat_cutoff) && !is.na(zeile$cutoff_erreicht) && zeile$cutoff_erreicht
balkenfarbe = if (!zeile$hat_cutoff) {
"#9E9E9E"
} else if (ist_cutoff_erreicht) {
AKZENT_FARBE
} else {
"#C98A93"
}
df = data.frame(x = "", y = y_balken)
p = ggplot(df, aes(x = x, y = y)) +
geom_col(fill = balkenfarbe, width = 0.5) +
coord_flip() +
scale_y_continuous(limits = c(0, 3), breaks = 0:3, expand = c(0, 0)) +
labs(x = NULL, y = NULL) +
theme_minimal(base_size = 11) +
theme(
axis.text.y = element_blank(),
panel.grid.minor = element_blank(),
plot.margin = margin(t = 2, r = 8, b = 2, l = 2)
)
if (isTRUE(zeile$hat_cutoff)) {
p = p + geom_hline(yintercept = CUTOFF_WERT, linetype = "dashed", color = "#555555")
}
p
}
# Datenaufbereitung ####
VDS11_SKALEN = data.frame(
praefix = c("su","de","zw","pa","hi","sc","na","bo","pr","ss","ko"),
bezeichnung = c("Selbstunsicher-ängstlich","Dependent","Zwanghaft","Passiv-aggressiv",
"Histrionisch","Schizoid","Narzisstisch","Borderline / emotional instabil",
"paranoid","stark-selbständig","vorausschauend"),
hat_cutoff = c(TRUE,TRUE,TRUE,TRUE,TRUE,TRUE,TRUE,TRUE,TRUE,FALSE,FALSE),
stringsAsFactors = FALSE
)
CUTOFF_WERT = 2.1
# Praefixe der 9 Skalen, die in den NEU-Gesamtwert (Neurotizismus) eingehen.
VDS11_NEU_PRAEFIXE = VDS11_SKALEN$praefix[VDS11_SKALEN$hat_cutoff]
# UI ####
app_css = "
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f5f5f5; }
.container-fluid { max-width: 1100px; }
.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; }
.item-zeile {
display: flex; align-items: center; gap: 10px;
padding: 7px 0; border-bottom: 1px solid #F0F0F0;
}
.item-zeile:last-child { border-bottom: none; }
.item-nr { font-weight: 600; color: #8B2635; min-width: 30px; flex-shrink: 0; }
.item-text { flex: 1; color: #333; font-size: 0.92em; }
.skala-zeile {
display: flex; align-items: center; gap: 14px;
padding: 8px 0; border-bottom: 1px solid #F0F0F0;
}
.skala-zeile:last-child { border-bottom: none; }
.skala-zeile .skala-bezeichnung { min-width: 230px; color: #333; font-size: 0.95em; }
.skala-zeile .skala-balken { flex: 1; }
.skala-zeile .skala-wert { min-width: 190px; text-align: right; color: #333; font-size: 0.9em; }
.skala-cutoff .skala-bezeichnung,
.skala-cutoff .skala-wert { font-weight: 700; color: #8B2635; }
.skala-kein-cutoff-hinweis {
font-size: 0.78em; color: #999; font-style: italic; min-width: 170px; text-align: right;
}
.neu-zeile .skala-bezeichnung,
.neu-zeile .skala-wert { font-size: 1.05em; }
"
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("VDS30-11 Persönlichkeitsauswertung"),
tags$p("11 Subskalen (je 10 Items, 03), Cutoff ≥ 2,1 für 9 Skalen + Gesamtwert NEU")
),
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_vds30_11_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_cutoff = fp_text(bold = TRUE, color = AKZENT_FARBE, font.size = 11)
fp_hinweis = fp_text(italic = TRUE, font.size = 9, color = "#777777")
fp_warnung = fp_text(font.size = 10, italic = TRUE, color = "#BF360C")
fp_disclaimer = fp_text(font.size = 9, italic = TRUE, color = "#777777")
doc = body_add_fpar(doc, fpar(ftext("VDS30-11 — Persönlichkeitsauswertung", fp_titel)))
doc = body_add_fpar(doc, fpar(
ftext("Chiffre: ", fp_label), ftext(erg$chiffre, fp_normal),
ftext(" Ausfülldatum: ", fp_label), ftext(erg$ausfuelldatum, fp_normal)
))
if (!is.null(erg$mehrfach_warnung)) {
doc = body_add_fpar(doc, fpar(ftext(erg$mehrfach_warnung, fp_warnung)))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Profil der 11 Subskalen", fp_abschnitt)))
for (r in seq_len(nrow(erg$profil_df))) {
zeile = erg$profil_df[r, ]
wert_text = if (is.na(zeile$mittelwert)) {
"unvollständig ausgefüllt, kein Mittelwert berechenbar"
} else {
format(round(zeile$mittelwert, 2), nsmall = 2)
}
ist_cutoff = isTRUE(zeile$hat_cutoff) && !is.na(zeile$cutoff_erreicht) && zeile$cutoff_erreicht
fp_wert = if (ist_cutoff) fp_cutoff else fp_normal
wert_anzeige = if (ist_cutoff) paste0("* ", wert_text) else wert_text
laeufe = list(
ftext(paste0(zeile$bezeichnung, " (", zeile$praefix, "): "), fp_label),
ftext(wert_anzeige, fp_wert)
)
if (ist_cutoff) {
laeufe = c(laeufe, list(ftext(" Verdacht auf Achse-II-Auffälligkeit", fp_cutoff)))
}
if (!zeile$hat_cutoff) {
laeufe = c(laeufe, list(ftext(" kein Cutoff dokumentiert, eigene Ergänzung ohne dokumentierten Cutoff", fp_hinweis)))
}
doc = body_add_fpar(doc, do.call(fpar, laeufe))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Gesamtwert Neurotizismus (NEU)", fp_abschnitt)))
neu_wert_text = if (is.na(erg$neu$mittelwert)) {
"unvollständig ausgefüllt, kein Mittelwert berechenbar"
} else {
format(round(erg$neu$mittelwert, 2), nsmall = 2)
}
neu_ist_cutoff = !is.na(erg$neu$cutoff_erreicht) && erg$neu$cutoff_erreicht
fp_neu_wert = if (neu_ist_cutoff) fp_cutoff else fp_label
neu_anzeige = if (neu_ist_cutoff) paste0("* ", neu_wert_text) else neu_wert_text
neu_laeufe = list(
ftext("Mittelwert über alle 90 Items der 9 Skalen SUPR: ", fp_label),
ftext(neu_anzeige, fp_neu_wert)
)
if (neu_ist_cutoff) {
neu_laeufe = c(neu_laeufe, list(ftext(" Verdacht auf Achse-II-Auffälligkeit", fp_cutoff)))
}
doc = body_add_fpar(doc, do.call(fpar, neu_laeufe))
doc = body_add_par(doc, "", style = "Normal")
if (!is.null(erg$mehrfach_warnung)) {
doc = body_add_fpar(doc, fpar(ftext(erg$mehrfach_warnung, fp_warnung)))
doc = body_add_par(doc, "", style = "Normal")
}
doc = body_add_fpar(doc, fpar(ftext(VDS30_11_DISCLAIMER, fp_disclaimer)))
doc
}
# Server ####
server = function(input, output, session) {
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)))
}
})
# Skripte werden NICHT beim App-Start gesourct, nur beim Klick auf "Auswerten".
ergebnis_r = eventReactive(input$btn_suchen, {
chiffre = toupper(trimws(input$chiffre))
if (nchar(trimws(input$pseudonym)) == 0 && nchar(chiffre) == 0) {
return(list(typ = "leere_eingabe", meldung = "Bitte Chiffre oder Pseudonym eingeben."))
}
if (nchar(trimws(input$pseudonym)) == 0 && !grepl("^[A-Z][0-9]{6}$", chiffre)) {
return(list(typ = "format_fehler", meldung = paste0(
"Ungültige Chiffre '", chiffre, "'. Erwartet: ein Großbuchstabe + 6 Ziffern (z.B. P000123)."
)))
}
if (!file.exists(PFAD_DOWNLOAD_SKRIPT)) {
return(list(typ = "skript_fehler", meldung = paste0(
"Download-Skript nicht gefunden:\n", PFAD_DOWNLOAD_SKRIPT
)))
}
if (!file.exists(PFAD_PSEUDONYM_SKRIPT)) {
return(list(typ = "skript_fehler", meldung = paste0(
"Pseudonym-Skript nicht gefunden:\n", PFAD_PSEUDONYM_SKRIPT
)))
}
ok = tryCatch({
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok$ok) return(list(typ = "skript_fehler", meldung = paste0("Fehler im Download-Skript: ", ok$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
})
if (is.null(db_ordner)) {
return(list(typ = "skript_fehler", meldung = paste0(
"pseudonyme.db nicht gefunden (bis 5 Ebenen oberhalb von ",
dirname(normalizePath(PFAD_PSEUDONYM_SKRIPT, mustWork = FALSE)), " gesucht)."
)))
}
alter_wd = getwd()
on.exit(setwd(alter_wd), add = TRUE)
setwd(db_ordner)
ok = tryCatch({
source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok$ok) return(list(typ = "skript_fehler", meldung = paste0("Fehler im Pseudonym-Skript: ", ok$msg)))
if (!exists("daten_vds30_11", envir = .GlobalEnv) ||
!is.data.frame(get("daten_vds30_11", envir = .GlobalEnv))) {
return(list(typ = "skript_fehler", meldung = paste0(
"Objekt 'daten_vds30_11' nach dem Sourcen nicht gefunden oder kein Dataframe. ",
"Bitte Download-Skript prüfen."
)))
}
if (!exists("pseudo", envir = .GlobalEnv) ||
!is.data.frame(get("pseudo", envir = .GlobalEnv))) {
return(list(typ = "skript_fehler", meldung = paste0(
"Objekt 'pseudo' nach dem Sourcen nicht gefunden oder kein Dataframe. ",
"Bitte Pseudonym-Skript prüfen."
)))
}
daten_vds30_11 = get("daten_vds30_11", envir = .GlobalEnv)
pseudo = get("pseudo", envir = .GlobalEnv)
if (nchar(trimws(input$pseudonym)) > 0) {
pw_treffer = pseudo[pseudo$pseudonym == trimws(input$pseudonym), ]
if (nrow(pw_treffer) > 0) chiffre = toupper(trimws(pw_treffer$chiffre[1]))
}
treffer_ps = pseudo[toupper(trimws(as.character(pseudo$chiffre))) == chiffre, ]
if (nrow(treffer_ps) == 0) {
return(list(typ = "kein_datensatz", meldung = 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)
# OFFENER PUNKT FUER DEN ERSTEN TESTLAUF: Der Spaltenname der Session-ID im
# Download-Objekt ist fuer diese formr-Instanz nicht verifiziert. formr-
# Rohexporte verwenden typischerweise "session", das ist hier aber nicht
# bestaetigt.
if (!("session" %in% names(daten_vds30_11))) {
return(list(typ = "skript_fehler", meldung = paste0(
"Erwartete Spalte 'session' in 'daten_vds30_11' nicht gefunden. ",
"Bitte Spaltennamen im Download-Skript pruefen."
)))
}
zeilen_index = which(daten_vds30_11$session %in% alle_session_ids)
if (length(zeilen_index) == 0) {
return(list(typ = "kein_datensatz", meldung = paste0(
"Kein VDS30-11-Datensatz für Chiffre '", chiffre, "' gefunden. ",
"(", length(alle_session_ids), " Pseudonym(e) geprüft)"
)))
}
# OFFENER PUNKT FUER DEN ERSTEN TESTLAUF: Der Spaltenname des Erstellungs-
# zeitstempels ist ebenfalls nicht verifiziert, ueblicherweise "created".
sortier_spalte = intersect(c("created", "ended", "expired"), names(daten_vds30_11))
sortier_spalte = if (length(sortier_spalte) > 0) sortier_spalte[1] else NA_character_
mehrfach_warnung = NULL
if (length(zeilen_index) > 1) {
n = length(zeilen_index)
if (!is.na(sortier_spalte)) {
reihenfolge = order(daten_vds30_11[[sortier_spalte]][zeilen_index], decreasing = TRUE)
zeilen_index = zeilen_index[reihenfolge]
datum_neu = tryCatch(
format(as.POSIXct(daten_vds30_11[[sortier_spalte]][zeilen_index[1]]), "%d.%m.%Y %H:%M"),
error = function(e) "unbekanntes Datum"
)
} else {
datum_neu = "unbekanntes Datum (keine Datumsspalte gefunden)"
}
mehrfach_warnung = paste0(
"Mehrere Ausfüllungen gefunden (", n, " Einträge). ",
"Angezeigt wird die neueste vom ", datum_neu, "."
)
}
zeile_idx = zeilen_index[1]
ausfuelldatum = NA_character_
if (!is.na(sortier_spalte)) {
ausfuelldatum = tryCatch({
d = format(as.POSIXct(daten_vds30_11[[sortier_spalte]][zeile_idx]), "%d.%m.%Y")
if (is.na(d)) stop("NA")
d
}, error = function(e) NA_character_)
}
if (is.na(ausfuelldatum)) ausfuelldatum = format(Sys.Date(), "%d.%m.%Y")
# Skalen-Mittelwerte fuer alle 11 Skalen berechnen.
profil_df = do.call(rbind, lapply(seq_len(nrow(VDS11_SKALEN)), function(i) {
praefix = VDS11_SKALEN$praefix[i]
felder = vds11_item_namen(praefix)
werte = sapply(felder, function(f) vds11_item_werte_spalte(daten_vds30_11, f)[zeile_idx])
erg_sk = vds11_skala_mittelwert(werte)
cutoff = if (VDS11_SKALEN$hat_cutoff[i]) vds11_cutoff_erreicht(erg_sk$mittelwert) else NA
data.frame(
praefix = praefix,
bezeichnung = VDS11_SKALEN$bezeichnung[i],
hat_cutoff = VDS11_SKALEN$hat_cutoff[i],
mittelwert = erg_sk$mittelwert,
n_vorhanden = erg_sk$n_vorhanden,
n_gesamt = erg_sk$n_gesamt,
cutoff_erreicht = cutoff,
stringsAsFactors = FALSE
)
}))
# NEU (Gesamtwert Neurotizismus): Mittelwert ueber alle 90 Items der 9
# Skalen SU-PR. Gleiches NA-Handling: fehlt eines der 90 Items, ist NEU NA.
neu_felder = unlist(lapply(VDS11_NEU_PRAEFIXE, vds11_item_namen))
neu_werte = sapply(neu_felder, function(f) vds11_item_werte_spalte(daten_vds30_11, f)[zeile_idx])
neu_erg = vds11_skala_mittelwert(neu_werte)
neu = list(
mittelwert = neu_erg$mittelwert,
n_vorhanden = neu_erg$n_vorhanden,
n_gesamt = neu_erg$n_gesamt,
cutoff_erreicht = vds11_cutoff_erreicht(neu_erg$mittelwert)
)
list(
typ = "ok",
chiffre = chiffre,
ausfuelldatum = ausfuelldatum,
mehrfach_warnung = mehrfach_warnung,
profil_df = profil_df,
neu = neu
)
})
# Statische Plot-Outputs fuer alle 11 Skalen + NEU. Praefixe sind zur Laufzeit
# fest (aus VDS11_SKALEN bekannt), daher hier einmalig ausserhalb der
# eventReactive-Kette registriert; local() friert praefix_lokal je
# Schleifendurchlauf ein (verhindert das uebliche Spaetauswertungs-Problem
# in R-Schleifen).
for (praefix_iter in c(VDS11_SKALEN$praefix, "neu")) {
local({
praefix_lokal = praefix_iter
output[[paste0("plot_skala_", praefix_lokal)]] = renderPlot({
d = ergebnis_r()
req(identical(d$typ, "ok"))
if (praefix_lokal == "neu") {
zeile = data.frame(
mittelwert = d$neu$mittelwert, hat_cutoff = TRUE,
cutoff_erreicht = d$neu$cutoff_erreicht
)
} else {
zeile = d$profil_df[d$profil_df$praefix == praefix_lokal, ]
}
vds11_balken_plot(zeile)
}, bg = "transparent")
})
}
output$fehler_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!identical(d$typ, "ok")) div(class = "alert-fehler", d$meldung)
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!identical(d$typ, "ok")) return(NULL)
if (is.null(d$mehrfach_warnung)) return(NULL)
div(class = "alert-warnung", d$mehrfach_warnung)
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!identical(d$typ, "ok")) return(NULL)
skala_zeile_ui = function(praefix, bezeichnung, mittelwert, hat_cutoff, cutoff_erreicht) {
ist_cutoff = isTRUE(hat_cutoff) && !is.na(cutoff_erreicht) && cutoff_erreicht
wert_text = if (is.na(mittelwert)) {
"unvollständig ausgefüllt, kein Mittelwert berechenbar"
} else {
paste0(if (ist_cutoff) "* " else "", format(round(mittelwert, 2), nsmall = 2),
if (ist_cutoff) " Verdacht auf Achse-II-Auffälligkeit" else "")
}
div(class = paste("skala-zeile", if (ist_cutoff) "skala-cutoff" else ""),
div(class = "skala-bezeichnung", bezeichnung),
div(class = "skala-balken", plotOutput(paste0("plot_skala_", praefix), height = "42px")),
div(class = "skala-wert", wert_text),
if (!hat_cutoff) div(class = "skala-kein-cutoff-hinweis", "kein Cutoff dokumentiert")
)
}
skalen_ui = lapply(seq_len(nrow(d$profil_df)), function(i) {
z = d$profil_df[i, ]
skala_zeile_ui(z$praefix, z$bezeichnung, z$mittelwert, z$hat_cutoff, z$cutoff_erreicht)
})
neu_ist_cutoff = !is.na(d$neu$cutoff_erreicht) && d$neu$cutoff_erreicht
neu_wert_text = if (is.na(d$neu$mittelwert)) {
"unvollständig ausgefüllt, kein Mittelwert berechenbar"
} else {
paste0(if (neu_ist_cutoff) "* " else "", format(round(d$neu$mittelwert, 2), nsmall = 2),
if (neu_ist_cutoff) " Verdacht auf Achse-II-Auffälligkeit" else "")
}
neu_ui = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Gesamtwert Neurotizismus (NEU)"),
div(class = paste("skala-zeile neu-zeile", if (neu_ist_cutoff) "skala-cutoff" else ""),
div(class = "skala-bezeichnung", "NEU (Mittelwert über alle 90 Items der 9 Skalen SUPR)"),
div(class = "skala-balken", plotOutput("plot_skala_neu", height = "48px")),
div(class = "skala-wert", neu_wert_text)
)
)
tagList(
div(class = "abschnitt-karte",
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfülldatum: "), d$ausfuelldatum
)
),
neu_ui,
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Profil der 11 Subskalen"),
skalen_ui
),
div(class = "disclaimer-zeile", VDS30_11_DISCLAIMER)
)
})
output$download_word = downloadHandler(
filename = function() {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
chiffre_esc = if (is.list(d) && identical(d$typ, "ok") && nchar(d$chiffre) > 0) {
gsub("[^A-Za-z0-9_-]", "_", d$chiffre)
} else {
"export"
}
datum_fn = tryCatch(
format(as.Date(d$ausfuelldatum, "%d.%m.%Y"), "%Y%m%d"),
error = function(e) format(Sys.Date(), "%Y%m%d")
)
if (is.na(datum_fn) || length(datum_fn) == 0) datum_fn = format(Sys.Date(), "%Y%m%d")
paste0("VDS30-11_", chiffre_esc, "_", datum_fn, ".docx")
},
content = function(file) {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
daten_ok = is.list(d) && identical(d$typ, "ok")
if (!daten_ok) {
doc = read_docx()
doc = body_add_par(doc,
"Kein Datensatz geladen. Bitte zuerst Chiffre oder Pseudonym eingeben und 'Auswerten' klicken.",
style = "Normal")
print(doc, target = file)
return()
}
doc = tryCatch(
erstelle_vds30_11_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)