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

701 lines
28 KiB
R
Raw 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_vds48.R" # liefert: daten_vds48
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R" # liefert: pseudo
AKZENT_FARBE = "#8B2635"
VDS48_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die Interpretation obliegt der behandelnden Person. ",
"Es liegen keine publizierten Normwerte oder Cutoffs fuer dieses Instrument vor; ",
"die Werte sind ausschliesslich im intraindividuellen Profilvergleich zu interpretieren."
)
library(shiny)
library(dplyr)
library(ggplot2)
library(haven)
library(officer)
# 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 ####
# Antworttext (z.B. "sehr gut") zum konkreten Rohwert einer Zeile, gelesen aus
# dem labels-Attribut der ORIGINAL-Spalte (vor Subsetting) - nie aus einer
# hartkodierten Positionsannahme, da formr's interne Choice-Indexierung sich
# je Item-Setup unterscheiden kann.
vds48_label_text = function(spalte_voll, wert_roh) {
if (is.null(wert_roh) || length(wert_roh) == 0 || is.na(wert_roh[1])) return(NA_character_)
labels_attr = attr(spalte_voll, "labels")
if (!is.null(labels_attr) && length(labels_attr) > 0) {
treffer = which(as.numeric(labels_attr) == as.numeric(unclass(wert_roh[1])))
if (length(treffer) > 0) return(names(labels_attr)[treffer[1]])
}
NA_character_
}
# Rohwert (0-3) ausschliesslich per TEXTABGLEICH des Antwort-Labels bestimmt,
# NIE aus dem numerischen Index der Spalte (VDS48 ist absteigend kodiert:
# Choice 1 = "sehr gut" = Rohwert 3). "sehr gut" wird vor "gut" geprueft, da
# "gut" als Teilstring in "sehr gut" enthalten ist. Kein Fallback auf den
# numerischen Rohwert bei fehlendem labels-Attribut - das waere exakt die
# Index-Annahme, die hier vermieden werden soll.
vds48_rohwert_aus_text = function(label_text) {
if (is.null(label_text) || length(label_text) == 0 || is.na(label_text[1])) return(NA_real_)
txt = tolower(trimws(label_text[1]))
if (grepl("sehr", txt) && grepl("gut", txt)) return(3)
if (grepl("gut", txt)) return(2)
if (grepl("etwas", txt)) return(1)
if (grepl("nicht", txt)) return(0)
NA_real_
}
# Itemtext aus dem label-Attribut der Spalte (Fragebogenwortlaut, nicht zu
# verwechseln mit dem labels-Attribut der Antwortoptionen). Entfernt
# formr-Nummerierungsartefakte am Anfang (z.B. "1. " oder das markdown-
# escapte "1\. "). Fallback: hartkodierter Klartext aus VDS48_ITEM_TEXTE
# (Datenaufbereitung), falls das Label leer/NA ist.
vds48_item_text = function(spalte_voll, item_nr) {
txt = if (is.null(spalte_voll)) NA_character_ else attr(spalte_voll, "label")
if (is.null(txt) || length(txt) == 0 || is.na(txt[1]) || trimws(as.character(txt[1])) == "") {
return(VDS48_ITEM_TEXTE[item_nr])
}
sub("^[0-9]+\\\\?\\.\\s*", "", trimws(as.character(txt[1])))
}
# Mittelwert (= Summe/N) einer Item-Teilmenge. Fehlt mindestens ein Item,
# wird NA zurueckgegeben (Skala gilt als "unvollstaendig") statt mit
# na.rm = TRUE einen aus weniger als N Items berechneten Wert
# stillschweigend anzuzeigen - die Missing-Value-Regel ist in der Quelle
# nicht dokumentiert.
vds48_teilmittelwert = function(rohwerte, idx) {
teil = rohwerte[idx]
n = length(teil)
fehlend = sum(is.na(teil))
wert = if (fehlend == 0) sum(teil) / n else NA_real_
list(wert = wert, vollstaendig = (fehlend == 0), n_fehlend = fehlend, n_gesamt = n)
}
# Hellt eine Hex-Farbe um `anteil` (0-1) in Richtung Weiss auf. Dient dazu,
# die vier Badge-Stufen (0-3) als Abstufungen der AKZENT_FARBE zu erzeugen,
# statt Ampelfarben zu verwenden - ein hoher Rohwert ist bei VDS48 inhaltlich
# positiv, kein Symptom-Score.
hex_aufhellen = function(hex, anteil) {
rgb_orig = as.integer(grDevices::col2rgb(hex))
rgb_neu = round(rgb_orig + (255 - rgb_orig) * anteil)
grDevices::rgb(rgb_neu[1], rgb_neu[2], rgb_neu[3], maxColorValue = 255)
}
VDS48_BADGE_FARBEN = c(
"0" = hex_aufhellen(AKZENT_FARBE, 0.80),
"1" = hex_aufhellen(AKZENT_FARBE, 0.55),
"2" = hex_aufhellen(AKZENT_FARBE, 0.28),
"3" = AKZENT_FARBE
)
VDS48_BADGE_TEXT_FARBEN = c(
"0" = "#333333",
"1" = "#333333",
"2" = "white",
"3" = "white"
)
# Sieben horizontale Gauge-Balken (Bereich 0-3), ohne Klassifikationszonen/
# Farbzonen, da kein Cutoff existiert. Heller Hintergrund-Track je Zeile,
# Balken + Punktmarkierung des aktuellen Werts in AKZENT_FARBE. Unvollstaendige
# Skalen (NA) zeigen "unvollstaendig" statt eines Balkens. Die Namen laufen
# als echte Achsenbeschriftung (axis.text.y) mit, nicht als Text-Geom im
# Datenbereich - so steht dem 0-3-Balken die volle Plotbreite zur Verfuegung
# (Balkenbereich frisst sonst durch die manuell platzierten Labels nur einen
# Teil der Plotflaeche).
make_vds48_gauges = function(gauge_df) {
gauge_df$name_kurz = factor(gauge_df$name_kurz, levels = rev(gauge_df$name_kurz))
df_ok = gauge_df[gauge_df$vollstaendig, ]
df_na = gauge_df[!gauge_df$vollstaendig, ]
p = ggplot(gauge_df, aes(x = name_kurz)) +
geom_col(aes(y = 3), fill = "#E5E5E5", width = 0.62)
if (nrow(df_ok) > 0) {
p = p +
geom_col(data = df_ok, aes(y = wert), fill = AKZENT_FARBE, width = 0.62) +
geom_text(data = df_ok, aes(y = wert, label = sprintf("%.2f", wert)),
hjust = -0.2, size = 3.4, color = AKZENT_FARBE, fontface = "bold")
}
if (nrow(df_na) > 0) {
p = p + geom_text(data = df_na, aes(y = 1.5, label = "unvollständig"),
hjust = 0.5, size = 3, color = "#999999", fontface = "italic")
}
p +
coord_flip(ylim = c(0, 3.45)) +
scale_y_continuous(breaks = 0:3) +
labs(x = NULL, y = NULL) +
theme_minimal(base_size = 12) +
theme(
axis.text.y = element_text(face = "bold", color = "#333333", size = 10.5),
panel.grid.major.y = element_blank(),
panel.grid.minor = element_blank(),
axis.text.x = element_text(color = "#999999", size = 9),
axis.ticks.x = element_line(color = "#D9D9D9"),
plot.margin = margin(t = 10, r = 20, b = 5, l = 5)
)
}
# Datenaufbereitung ####
# Domaene (w = Mentalisieren der Welt, s = Mentalisieren des Selbst) und
# Prozess (s = spuueren/wahrnehmen, e = erkennen, v = verstehen,
# a = akzeptieren) je Item. Der Buchstabe "s" wird fuer zwei verschiedene
# Dinge verwendet (Domaene "Selbst" vs. Prozess "spueren") und deshalb strikt
# in getrennten Spalten gefuehrt.
VDS48_ITEM_KLASSIFIKATION = data.frame(
item_nr = 1:30,
domaene = c("w","w","w","w","w","w","s","s","w","w",
"w","s","s","w","s","s","s","s","s","s",
"s","s","s","s","w","s","s","s","s","s"),
prozess = c("e","a","v","e","a","v","a","v","a","v",
"e","s","s","e","s","a","s","s","a","s",
"s","s","e","e","e","e","e","e","e","e"),
stringsAsFactors = FALSE
)
# Fallback-Klartexte (ohne fuehrende Nummer), falls das label-Attribut der
# Spalte leer/NA ist. Quelle: formr-Bogenexport vds48.xlsx (choice1 = "sehr
# gut" ... choice4 = "nicht", bestaetigt exakt die Kodierung aus Abschnitt 1).
VDS48_ITEM_TEXTE = c(
"Sowohl den positiven als auch den negativen Aspekt meiner Mutter erkennen",
"Meine Mutter akzeptieren",
"Meine Mutter verstehen",
"Sowohl den positiven als auch den negativen Aspekt meines Vaters erkennen",
"Meinen Vater akzeptieren",
"Meinen Vater verstehen",
"Mich akzeptieren",
"Mich verstehen",
"Die mir heute wichtigen Menschen akzeptieren",
"Die mir heute wichtigen Menschen verstehen",
"Mich an schmerzliche Ereignisse und Verhältnisse meiner Kindheit erinnern",
"Spüren, was ich als Kind wirklich gebraucht hätte",
"Mir vorstellen und nachspüren, wie sich diese Befriedigung anfühlt",
"Ich bin zuversichtlich, von anderen Menschen zu bekommen, was ich brauche",
"Meinen Körper spüren",
"Meinen Körper akzeptieren",
"Auf meine Körperimpulse achten",
"Meine Gefühle wahrnehmen",
"Meine Gefühle akzeptieren",
"Bei meinen Gefühlen bleiben",
"Meine Bedürfnisse wahrnehmen",
"Spüren, was ich will und was ich nicht will",
"Anderen signalisieren, was ich will und was ich nicht will",
"Mich im Vergleich mit anderen Menschen realistisch einschätzen, also so wie ich wirklich bin",
"Andere Menschen realistisch einschätzen, also so wie sie wirklich sind",
"Einengende/hemmende Gebote, Verbote entlarven, die ich aus der Kindheit übernommen habe und ungeprüft auf mein heutiges Leben übertrage",
"Einengende/hemmende \"Weisheiten\" über das Funktionieren der zwischenmenschlichen Welt entlarven, die ich aus der Kindheit übernommen habe und ungeprüft auf mein heutiges Leben übertrage",
"Erkennen, dass ein heutiges Verhaltensmuster aus der Kindheit stammt, das versucht, das zu bekommen, was ich in der Kindheit nicht erhielt",
"Erkennen, dass ein heutiges Verhaltensmuster aus der Kindheit stammt, das versucht, eine damalige Bedrohung noch heute zu minimieren",
"Erkennen, dass ein heutiges Verhaltensmuster aus der Kindheit stammt, das versucht, meine Wut und meinen Ärger so gering wie möglich zu halten"
)
# Definition der sieben Gauges: kurzer Chart-Label, voller Anzeigename und
# der Feldname im Ergebnis (siehe Server), in der in Abschnitt 7
# vorgegebenen Reihenfolge.
VDS48_GAUGE_DEF = data.frame(
feld = c("selbst", "welt", "gesamt", "prozess_s", "prozess_e", "prozess_v", "prozess_a"),
name_kurz = c("Selbst", "Welt", "Gesamt", "s Spüren", "e Erkennen", "v Verstehen", "a Akzeptieren"),
name_voll = c(
"Mentalisieren des Selbst",
"Mentalisieren der Welt",
"Gesamtwert Mentalisierungsfähigkeit",
"s Wahrnehmen, Spüren",
"e Erkennen",
"v Verstehen",
"a Akzeptieren"
),
stringsAsFactors = FALSE
)
# 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 .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: flex-start; 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: 26px; flex-shrink: 0; }
.item-text { flex: 1; color: #333; font-size: 0.92em; }
.item-tag { color: #999; font-size: 0.8em; margin-left: 6px; white-space: nowrap; }
.stufe-badge {
border-radius: 4px; padding: 2px 9px; font-weight: 700;
font-size: 0.82em; white-space: nowrap; display: inline-block; flex-shrink: 0;
}
.skalen-tabelle { width: 100%; border-collapse: collapse; margin: 10px 0 4px; }
.skalen-tabelle td { padding: 6px 8px; font-size: 0.93em; border-bottom: 1px solid #F0F0F0; }
.skalen-tabelle td.skalen-wert { text-align: right; font-weight: 700; color: #8B2635; min-width: 70px; }
.disclaimer-block {
font-size: 0.82em; color: #777; font-style: italic;
margin: 4px 0 16px; padding: 10px 4px 0; border-top: 1px solid rgba(0,0,0,.08);
}
"
app_css = gsub("#8B2635", AKZENT_FARBE, app_css, fixed = TRUE)
app_css = paste0(app_css,
".stufe-badge-0 { background: ", VDS48_BADGE_FARBEN[["0"]], "; color: ", VDS48_BADGE_TEXT_FARBEN[["0"]], "; }\n",
".stufe-badge-1 { background: ", VDS48_BADGE_FARBEN[["1"]], "; color: ", VDS48_BADGE_TEXT_FARBEN[["1"]], "; }\n",
".stufe-badge-2 { background: ", VDS48_BADGE_FARBEN[["2"]], "; color: ", VDS48_BADGE_TEXT_FARBEN[["2"]], "; }\n",
".stufe-badge-3 { background: ", VDS48_BADGE_FARBEN[["3"]], "; color: ", VDS48_BADGE_TEXT_FARBEN[["3"]], "; }\n"
)
ui = fluidPage(
tags$head(
tags$meta(charset = "UTF-8"),
tags$style(HTML(app_css))
),
div(class = "app-header",
tags$h2("VDS48 Mentalisierung und Theory of Mind"),
tags$p("Prof. Dr. Dr. Serge Sulz | 30 Items, Mentalisieren des Selbst und der Welt, kein Cutoff")
),
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_vds48_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_tag = fp_text(font.size = 9, color = "#999999")
fp_warnung = fp_text(italic = TRUE, font.size = 10, color = "#BF360C")
fp_disclaimer = fp_text(font.size = 9, italic = TRUE, color = "#777777")
fp_badge = function(wert) {
wert_key = if (!is.na(wert) && wert >= 0 && wert <= 3) as.character(as.integer(wert)) else NA_character_
if (is.na(wert_key)) return(fp_text(color = "#888888", bold = TRUE, shading.color = "#EFEFEF", font.size = 10))
fp_text(color = VDS48_BADGE_TEXT_FARBEN[[wert_key]], bold = TRUE,
shading.color = VDS48_BADGE_FARBEN[[wert_key]], font.size = 10)
}
doc = body_add_fpar(doc, fpar(ftext("VDS48 Mentalisierung und Theory of Mind", 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_str, 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("Skalenwerte (Bereich 03, kein Cutoff)", fp_abschnitt)))
doc = body_add_gg(doc, make_vds48_gauges(erg$gauge_df), width = 6.5, height = 4.2)
doc = body_add_par(doc, "", style = "Normal")
for (i in seq_len(nrow(erg$gauge_df))) {
zeile = erg$gauge_df[i, ]
wert_tx = if (isTRUE(zeile$vollstaendig)) sprintf("%.2f / 3", zeile$wert) else "unvollständig"
doc = body_add_fpar(doc, fpar(
ftext(paste0(zeile$name_voll, ": "), fp_label),
ftext(wert_tx, fp_normal)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("VDS48 Einzelitems", fp_abschnitt)))
for (i in seq_len(30)) {
it = erg$items[[i]]
doc = body_add_fpar(doc, fpar(
ftext(paste0(i, ". ", it$text, " "), fp_normal),
ftext(paste0(" ", if (is.na(it$rohwert)) "k. A." else as.integer(it$rohwert), " "), fp_badge(it$rohwert)),
ftext(paste0(" [", it$domaene, "·", it$prozess, "]"), fp_tag)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(VDS48_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 = 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", chiffre = chiffre))
}
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)))
}
# Schritt 1: Download-Skript sourcen
ok_dl = tryCatch({
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok_dl$ok) return(list(typ = "skript_fehler", meldung = ok_dl$msg))
# Schritt 2: pseudonyme.db suchen (bis zu 5 Ebenen ueber dem Pseudonym-Skript)
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 = "db_nicht_gefunden"))
# Schritt 3: Pseudonym-Skript sourcen (relativer DB-Zugriff, daher setwd + on.exit)
alter_wd = getwd()
on.exit(setwd(alter_wd), add = TRUE)
setwd(db_ordner)
ok_ps = tryCatch({
source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok_ps$ok) return(list(typ = "skript_fehler", meldung = ok_ps$msg))
if (!exists("daten_vds48", envir = .GlobalEnv) || !exists("pseudo", envir = .GlobalEnv)) {
return(list(typ = "daten_fehlen"))
}
daten = get("daten_vds48", envir = .GlobalEnv)
pseudo_df = get("pseudo", envir = .GlobalEnv)
if (!("session" %in% names(daten))) {
return(list(typ = "daten_fehlen",
meldung = "Erwartete Spalte 'session' nicht in 'daten_vds48' gefunden."))
}
# Schritt 4: Chiffre-Rueckaufloesung, falls Pseudonym eingegeben wurde
if (nchar(trimws(input$pseudonym)) > 0) {
pw_treffer = pseudo_df[pseudo_df$pseudonym == trimws(input$pseudonym), ]
if (nrow(pw_treffer) > 0) chiffre = toupper(trimws(pw_treffer$chiffre[1]))
}
# Schritt 5: Chiffre -> moegliche Pseudonyme (Session-IDs)
treffer_ps = pseudo_df[toupper(trimws(pseudo_df$chiffre)) == chiffre, ]
if (nrow(treffer_ps) == 0) return(list(typ = "chiffre_nicht_gefunden", chiffre = chiffre))
alle_session_ids = unique(treffer_ps$pseudonym)
if (nchar(trimws(input$pseudonym)) > 0) alle_session_ids = trimws(input$pseudonym)
# Schritt 6: passende Datensaetze in daten_vds48 finden
treffer_daten = daten[daten$session %in% alle_session_ids, ]
if (nrow(treffer_daten) == 0) return(list(typ = "kein_treffer", chiffre = chiffre))
mehrfach_warnung = NULL
if (nrow(treffer_daten) > 1) {
n = nrow(treffer_daten)
if ("created" %in% names(treffer_daten)) {
treffer_daten = treffer_daten[order(treffer_daten$created, decreasing = TRUE), ]
}
treffer_daten = treffer_daten[1, , drop = FALSE]
mehrfach_warnung = paste0(
"Mehrere Ausfüllungen gefunden (", n, " Einträge) — es wird die neueste angezeigt."
)
}
zeile = treffer_daten[1, , drop = FALSE]
# Ausfuelldatum: Spaltenname 'created' folgt der in diesem Projekt
# durchgaengig verwendeten formr-Export-Konvention (siehe Sub-Apps
# VDS19+, SMI etc.). Vor produktivem Einsatz mit echten Daten anhand von
# names(daten_vds48) verifizieren.
ausfuelldatum = tryCatch(
as.Date(as.POSIXct(as.character(zeile[["created"]][1]))),
error = function(e) Sys.Date()
)
ausfuelldatum_str = tryCatch(format(ausfuelldatum, "%d.%m.%Y"), error = function(e) format(Sys.Date(), "%d.%m.%Y"))
# Schritt 7: Rohwerte, Itemtexte und Domaene/Prozess fuer alle 30 Items
items = lapply(1:30, function(i) {
feld = sprintf("vds48_%02d", i)
spalte_voll = daten[[feld]]
wert_roh = if (feld %in% names(zeile)) zeile[[feld]][1] else NA
label_text = if (is.null(spalte_voll)) NA_character_ else vds48_label_text(spalte_voll, wert_roh)
rohwert = vds48_rohwert_aus_text(label_text)
text = vds48_item_text(spalte_voll, i)
list(
item_nr = i,
feld = feld,
text = text,
rohwert = rohwert,
domaene = VDS48_ITEM_KLASSIFIKATION$domaene[i],
prozess = VDS48_ITEM_KLASSIFIKATION$prozess[i]
)
})
rohwerte = sapply(items, function(it) it$rohwert)
idx_s = which(VDS48_ITEM_KLASSIFIKATION$domaene == "s")
idx_w = which(VDS48_ITEM_KLASSIFIKATION$domaene == "w")
idx_e = which(VDS48_ITEM_KLASSIFIKATION$prozess == "e")
idx_v = which(VDS48_ITEM_KLASSIFIKATION$prozess == "v")
idx_a = which(VDS48_ITEM_KLASSIFIKATION$prozess == "a")
idx_sp = which(VDS48_ITEM_KLASSIFIKATION$prozess == "s")
selbst = vds48_teilmittelwert(rohwerte, idx_s)
welt = vds48_teilmittelwert(rohwerte, idx_w)
gesamt = vds48_teilmittelwert(rohwerte, 1:30)
prozess_s = vds48_teilmittelwert(rohwerte, idx_sp)
prozess_e = vds48_teilmittelwert(rohwerte, idx_e)
prozess_v = vds48_teilmittelwert(rohwerte, idx_v)
prozess_a = vds48_teilmittelwert(rohwerte, idx_a)
gauge_df = VDS48_GAUGE_DEF
gauge_df$wert = c(selbst$wert, welt$wert, gesamt$wert,
prozess_s$wert, prozess_e$wert, prozess_v$wert, prozess_a$wert)
gauge_df$vollstaendig = c(selbst$vollstaendig, welt$vollstaendig, gesamt$vollstaendig,
prozess_s$vollstaendig, prozess_e$vollstaendig,
prozess_v$vollstaendig, prozess_a$vollstaendig)
list(
typ = "erfolg",
chiffre = chiffre,
ausfuelldatum_str = ausfuelldatum_str,
mehrfach_warnung = mehrfach_warnung,
items = items,
gauge_df = gauge_df
)
})
vds48_fehlermeldung = function(d) {
switch(d$typ,
"leere_eingabe" = d$meldung,
"format_fehler" = paste0("Ungültige Chiffre '", d$chiffre, "'. Erwartet: ein Großbuchstabe + 6 Ziffern (z.B. P000123)."),
"skript_fehler" = paste0("Fehler beim Sourcen eines externen Skripts: ", d$meldung),
"db_nicht_gefunden" = "Die Datei 'pseudonyme.db' konnte in den übergeordneten Verzeichnissen nicht gefunden werden.",
"daten_fehlen" = if (!is.null(d$meldung)) d$meldung else "Nach dem Sourcen der Skripte fehlen die erwarteten Objekte 'daten_vds48' oder 'pseudo'.",
"chiffre_nicht_gefunden" = paste0("Chiffre '", d$chiffre, "' wurde in der Pseudonym-Datenbank nicht gefunden."),
"kein_treffer" = paste0("Kein VDS48-Datensatz für Chiffre '", d$chiffre, "' gefunden."),
"Unbekannter Fehler."
)
}
output$fehler_ui = renderUI({
req(input$btn_suchen)
d = ergebnis()
if (d$typ != "erfolg") div(class = "alert-fehler", vds48_fehlermeldung(d))
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis()
if (d$typ != "erfolg" || is.null(d$mehrfach_warnung)) return(NULL)
div(class = "alert-warnung", d$mehrfach_warnung)
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis()
if (d$typ != "erfolg") return(NULL)
skalen_zeilen = lapply(seq_len(nrow(d$gauge_df)), function(i) {
zeile = d$gauge_df[i, ]
tags$tr(
tags$td(zeile$name_voll),
tags$td(class = "skalen-wert",
if (isTRUE(zeile$vollstaendig)) sprintf("%.2f / 3", zeile$wert)
else tags$span(style = "color:#999; font-style:italic; font-weight:400;", "unvollständig")
)
)
})
item_zeilen = lapply(d$items, function(it) {
sk = if (!is.na(it$rohwert) && it$rohwert >= 0 && it$rohwert <= 3) as.character(as.integer(it$rohwert)) else NA_character_
div(class = "item-zeile",
div(class = "item-nr", paste0(it$item_nr, ".")),
div(class = "item-text", it$text,
tags$span(class = "item-tag", paste0("[", it$domaene, "·", it$prozess, "]"))
),
if (!is.na(sk))
tags$span(class = paste0("stufe-badge stufe-badge-", sk), sk)
else
tags$span(style = "background:#EFEFEF; color:#888; border-radius:4px; padding:2px 9px; font-weight:700; font-size:0.82em; white-space:nowrap;", "k. A.")
)
})
tagList(
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "VDS48 Übersicht"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfülldatum: "), d$ausfuelldatum_str
),
plotOutput("gauge_plot", height = "380px", width = "100%"),
tags$table(class = "skalen-tabelle", tags$tbody(skalen_zeilen))
),
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "VDS48 Einzelitems"),
div(item_zeilen)
),
div(class = "disclaimer-block", VDS48_DISCLAIMER)
)
})
output$gauge_plot = renderPlot({
req(input$btn_suchen)
d = ergebnis()
req(d$typ == "erfolg")
make_vds48_gauges(d$gauge_df)
}, bg = "transparent")
output$download_word = downloadHandler(
filename = function() {
d = tryCatch(ergebnis(), error = function(e) NULL)
erfolgreich = is.list(d) && identical(d$typ, "erfolg")
chiffre_esc = if (erfolgreich && nchar(d$chiffre) > 0) gsub("[^A-Za-z0-9_-]", "_", d$chiffre) else "export"
datum_fn = if (erfolgreich) {
tryCatch(format(as.Date(d$ausfuelldatum_str, "%d.%m.%Y"), "%Y%m%d"),
error = function(e) format(Sys.Date(), "%Y%m%d"))
} else {
format(Sys.Date(), "%Y%m%d")
}
paste0("VDS48_", chiffre_esc, "_", datum_fn, ".docx")
},
content = function(file) {
d = tryCatch(ergebnis(), error = function(e) NULL)
erfolgreich = is.list(d) && identical(d$typ, "erfolg")
if (!erfolgreich) {
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_vds48_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)