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

781 lines
29 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_vds20.R" # liefert: daten_vds20bu
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R" # liefert: pseudo
AKZENT_FARBE = "#8B2635"
VDS20BU_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die Interpretation obliegt der behandelnden Person. ",
"Oberhalb der genannten Schwellenwerte ist laut Testautor keine weitergehende ",
"Abstufung des Schweregrads aus der Anzahl der Kreuzchen ableitbar."
)
# 13 Items, jeweils 1 Punkt bei Ankreuzen, keine Umpolung, keine Gewichtung.
# skala: "kindheit" (Items 1-9) oder "heute" (Items 10-13).
VDS20BU_ITEMS = data.frame(
nr = 1:13,
feld = paste0("vds20bu_", sprintf("%02d", 1:13)),
text = c(
"Es gab in den ersten beiden Lebensjahren Trennungen von der Mutter",
"Ich war in den ersten beiden Jahren sehr anhänglich",
"Meine Mutter war in den ersten beiden Lebensjahren sehr gestresst",
"Sie reagierte sehr ungeduldig, wenn sie im Stress war",
"Sie reagierte wütend, wenn sie auf mich ärgerlich war",
"Sie drohte mit Weggehen oder Wegschicken, wenn sie ärgerlich war",
"Sie gab wenig Körperkontakt",
"Sie gab wenig Geborgenheit",
"Sie gab wenig Sicherheit, Schutz, Zuverlässigkeit",
"Ich habe heute noch Angst vor Trennung oder Kontrollverlust",
"Ich möchte weggehen, wenn ich mich über jemand extrem ärgere",
"Ich bin eher ein anhänglicher Mensch oder ich kann mich schwer binden",
"Ich kann nicht gut allein sein oder unter Menschen sein ist anstrengend"
),
skala = c(rep("kindheit", 9), rep("heute", 4)),
stringsAsFactors = FALSE
)
VDS20BU_FREITEXT_FELDER = c(
"vds20bu_f1", "vds20bu_f2", "vds20bu_f3", "vds20bu_f4",
"vds20bu_f5", "vds20bu_f6", "vds20bu_notizen"
)
VDS20BU_FREITEXT_FRAGEN = c(
vds20bu_f1 = "Auf welche Weise war Ihre Beziehung zu Ihren Eltern eine unsichere Bindung?",
vds20bu_f2 = "Was fehlte, was konnten Ihre Eltern Ihnen nicht geben?",
vds20bu_f3 = "Was wurde aus Ihren Bedürfnissen, Ängsten und Ihrer Wut? Heute",
vds20bu_f4 = "Welche ungünstigen Persönlichkeitszüge ergaben sich?",
vds20bu_f5 = "Inwiefern hat das sich auf Ihr Leben und Ihre Beziehungsgestaltung ausgewirkt?",
vds20bu_f6 = "Was fehlt Ihnen heute im Leben und in Ihren Beziehungen?",
vds20bu_notizen = "Weitere Notizen"
)
# Skalendefinitionen: Range, Cutoff (ab diesem Wert "unsicher").
VDS20BU_SKALEN = data.frame(
key = c("gesamt", "kindheit", "heute"),
name = c("Gesamt", "Kindheit", "Heute"),
max = c(13, 9, 4),
cutoff = c(4, 3, 2),
stringsAsFactors = FALSE
)
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 ####
# Robuste Konvertierung eines rohen Item-Werts (formr-Itemtyp "check") auf 0/1.
# Das tatsaechliche Exportformat von "check"-Items ist in diesem Projekt bislang
# nicht dokumentiert (anders als "mc_button" oder "rating_button") - deshalb werden
# hier alle plausiblen Kodierungen abgedeckt (logisch, numerisch 0/1, haven_labelled,
# Zeichenketten). Bei unbekanntem/nicht interpretierbarem Wert wird NA zurueckgegeben,
# nie ein geratener Wert.
zu_binaer = function(x) {
if (is.null(x) || length(x) == 0) return(NA_integer_)
if (is.na(x[1])) return(NA_integer_)
if (inherits(x, "haven_labelled")) {
zahl = suppressWarnings(as.numeric(haven::zap_labels(x))[1])
if (!is.na(zahl) && zahl %in% c(0, 1)) return(as.integer(zahl))
lbl = attr(x, "labels")
if (!is.null(lbl) && length(lbl) > 0 && !is.na(zahl)) {
pos = which(as.vector(lbl) == zahl)
if (length(pos) > 0) {
text = tolower(trimws(names(lbl)[pos[1]]))
if (grepl("check|wahr|richtig|zutreffend|^ja$|^x$|^1$", text)) return(1L)
if (grepl("uncheck|falsch|nicht zutreffend|^nein$|^0$", text)) return(0L)
}
}
return(NA_integer_)
}
wert = x[1]
if (is.logical(wert)) return(as.integer(wert))
if (is.numeric(wert)) {
if (wert %in% c(0, 1)) return(as.integer(wert))
return(NA_integer_)
}
if (is.character(wert)) {
txt = tolower(trimws(wert))
if (txt %in% c("checked", "true", "wahr", "1", "ja", "x")) return(1L)
if (txt %in% c("", "unchecked", "false", "falsch", "0", "nein")) return(0L)
return(NA_integer_)
}
NA_integer_
}
# Teilsumme ueber einen Vektor 0/1-Werte. Sobald ein Wert NA ist, ist die Summe
# fuer diese Skala nicht auswertbar (NA) - nie stillschweigend als 0 gewertet.
vds20bu_teilsumme = function(werte) {
if (any(is.na(werte))) return(NA_integer_)
as.integer(sum(werte))
}
vds20bu_klassifiziere = function(summe, cutoff) {
if (is.na(summe)) {
return(list(klasse = NA_character_, label = "nicht auswertbar (fehlende Werte)"))
}
if (summe >= cutoff) {
return(list(klasse = "unsicher", label = "unsicher"))
}
list(klasse = "unauffaellig", label = "unauffällig")
}
# Sucht die erste vorhandene, fuer diese Zeile nicht-NA Datumsspalte. "created" ist
# das ueblichere formr-Zeitstempelfeld, wird aber vor Verwendung geprueft statt
# blind angenommen.
vds20bu_finde_datumsspalte = function(daten, zeile) {
kandidaten = c("created", "ended", "modified", "expired")
for (k in kandidaten) {
if (k %in% names(daten)) {
wert = zeile[[k]][1]
if (!is.null(wert) && !is.na(wert)) return(k)
}
}
NA_character_
}
vds20bu_parse_datum = function(roh_wert) {
tryCatch({
d = as.POSIXct(roh_wert)
if (is.na(d)) return(NULL)
d
}, error = function(e) NULL)
}
# Spaltenname der Sitzungskennung in daten_vds20bu per Musterabgleich - der genaue
# Spaltenname ist nicht verifiziert.
vds20bu_finde_session_spalte = function(daten) {
namen = names(daten)
treffer = namen[grepl("^session$|session", namen, ignore.case = TRUE)]
if (length(treffer) == 0) return(NA_character_)
treffer[1]
}
# 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; }
.skalen-reihe { display: flex; gap: 16px; flex-wrap: wrap; }
.skala-kachel {
flex: 1; min-width: 220px; padding: 14px 18px; border-radius: 6px;
background: #fafafa; border: 1px solid #eee;
}
.skala-name { font-weight: 700; color: #333; margin-bottom: 6px; font-size: 0.98em; }
.skala-score { font-size: 1.9rem; font-weight: 800; color: #8B2635; }
.skala-max { font-size: 0.82em; color: #888; margin-left: 3px; }
.skala-badge {
display: inline-block; margin-left: 10px; padding: 2px 11px; border-radius: 12px;
font-weight: 700; font-size: 0.82em; vertical-align: middle;
}
.skala-badge-unsicher { background: #FFEBEE; color: #B71C1C; }
.skala-badge-unauffaellig { background: #E8F5E9; color: #2E7D32; }
.skala-badge-na { background: #EEEEEE; color: #777777; font-style: italic; }
.skala-balken {
position: relative; height: 12px; background: #e6e6e6; border-radius: 6px;
margin-top: 10px;
}
.skala-balken-fuellung { position: absolute; top: 0; left: 0; height: 100%; border-radius: 6px; }
.skala-balken-fuellung-unsicher { background: #C62828; }
.skala-balken-fuellung-unauffaellig { background: #43A047; }
.skala-balken-cutoff-marker {
position: absolute; top: -3px; height: 18px; width: 2px; background: #333;
}
.skala-cutoff-text { font-size: 0.78em; color: #777; margin-top: 4px; }
.item-gruppe-titel { font-weight: 700; color: #8B2635; margin: 16px 0 8px; font-size: 0.95em; }
.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: 26px; flex-shrink: 0; }
.item-text { flex: 1; color: #333; font-size: 0.92em; }
.check-badge {
border-radius: 4px; padding: 2px 11px; font-weight: 700;
font-size: 0.82em; white-space: nowrap; display: inline-block; flex-shrink: 0;
min-width: 118px; text-align: center;
}
.check-badge-ja { background: #FFCDD2; color: #B71C1C; }
.check-badge-nein { background: #E0E0E0; color: #616161; }
.check-badge-na { background: #EEEEEE; color: #777777; font-style: italic; }
.freitext-block { margin-bottom: 16px; }
.freitext-frage { font-weight: 600; color: #333; margin-bottom: 4px; }
.freitext-antwort {
color: #333; white-space: pre-wrap; padding: 9px 12px;
background: #fafafa; border-radius: 4px; border-left: 3px solid #ddd;
}
.freitext-antwort-leer { color: #999; font-style: italic; }
.disclaimer-zeile {
font-size: 0.82em; color: #777; font-style: italic;
margin-top: 10px; border-top: 1px solid rgba(0,0,0,.1); padding-top: 8px;
}
"
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("VDS20-BU Bindungsunsicherheit"),
tags$p("Verhaltensdiagnostiksystem VDS | Prof. Dr. Dr. Serge Sulz")
),
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_vds20bu_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_gruppe = fp_text(bold = TRUE, font.size = 11, color = AKZENT_FARBE)
doc = body_add_fpar(doc, fpar(ftext("VDS20-BU - Auswertung Bindungsunsicherheit", fp_titel)))
doc = body_add_fpar(doc, fpar(
ftext("Chiffre: ", fp_label),
ftext(erg$chiffre, fp_normal),
ftext(" Ausfülldatum: ", fp_label),
ftext(erg$datum_str, fp_normal)
))
for (w in erg$warnungen) {
doc = body_add_fpar(doc, fpar(ftext(w, fp_text(font.size = 10, italic = TRUE, color = "#BF360C"))))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Summenscores", fp_abschnitt)))
for (i in seq_len(nrow(erg$skalen))) {
sk = erg$skalen[i, ]
if (is.na(sk$summe)) {
doc = body_add_fpar(doc, fpar(
ftext(paste0(sk$name, ": "), fp_label),
ftext("nicht auswertbar (fehlende Werte)", fp_text(font.size = 11, italic = TRUE, color = "#777777"))
))
} else {
klasse_farbe = if (identical(sk$klasse, "unsicher")) "#B71C1C" else "#2E7D32"
doc = body_add_fpar(doc, fpar(
ftext(paste0(sk$name, ": "), fp_label),
ftext(paste0(sk$summe, " / ", sk$max, " (Cutoff: ", sk$cutoff, ") "), fp_normal),
ftext(sk$label, fp_text(bold = TRUE, font.size = 11, color = klasse_farbe))
))
}
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Einzelitems", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(ftext("In der Kindheit", fp_gruppe)))
for (r in which(erg$items$skala == "kindheit")) {
it = erg$items[r, ]
farben = if (is.na(it$wert)) list(bg = "#EEEEEE", text = "#777777")
else if (it$wert == 1) list(bg = "#FFCDD2", text = "#B71C1C")
else list(bg = "#E0E0E0", text = "#616161")
doc = body_add_fpar(doc, fpar(
ftext(paste0(it$nr, ". ", it$text, " "), fp_normal),
ftext(paste0(" ", it$wert_text, " "),
fp_text(color = farben$text, bold = TRUE, shading.color = farben$bg, font.size = 10))
))
}
doc = body_add_fpar(doc, fpar(ftext("Heute", fp_gruppe)))
for (r in which(erg$items$skala == "heute")) {
it = erg$items[r, ]
farben = if (is.na(it$wert)) list(bg = "#EEEEEE", text = "#777777")
else if (it$wert == 1) list(bg = "#FFCDD2", text = "#B71C1C")
else list(bg = "#E0E0E0", text = "#616161")
doc = body_add_fpar(doc, fpar(
ftext(paste0(it$nr, ". ", it$text, " "), fp_normal),
ftext(paste0(" ", it$wert_text, " "),
fp_text(color = farben$text, bold = TRUE, shading.color = farben$bg, font.size = 10))
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Freitextangaben", fp_abschnitt)))
for (feld in VDS20BU_FREITEXT_FELDER) {
frage = VDS20BU_FREITEXT_FRAGEN[[feld]]
antwort = erg$freitexte[[feld]]
doc = body_add_fpar(doc, fpar(ftext(frage, fp_label)))
if (is.na(antwort) || nchar(trimws(antwort)) == 0) {
doc = body_add_fpar(doc, fpar(ftext("(keine Angabe)",
fp_text(font.size = 10, italic = TRUE, color = "#999999"))))
} else {
doc = body_add_fpar(doc, fpar(ftext(antwort, fp_normal)))
}
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(VDS20BU_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", 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)))
}
ok_download = tryCatch({
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = conditionMessage(e)))
if (!ok_download$ok) {
return(list(typ = "skript_fehler",
meldung = paste0("Fehler im Download-Skript: ", ok_download$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. Gesucht ausgehend vom Pseudonym-Skript-Ordner ",
"bis zu 5 Ebenen nach oben."
)))
}
alter_wd = getwd()
on.exit(setwd(alter_wd), add = TRUE)
setwd(db_ordner)
ok_pseudonym = tryCatch({
source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = conditionMessage(e)))
if (!ok_pseudonym$ok) {
return(list(typ = "skript_fehler",
meldung = paste0("Fehler im Pseudonym-Skript: ", ok_pseudonym$msg)))
}
if (!exists("daten_vds20bu", envir = .GlobalEnv)) {
return(list(typ = "skript_fehler",
meldung = "Objekt 'daten_vds20bu' wurde nach dem Sourcen des Download-Skripts nicht gefunden."))
}
if (!exists("pseudo", envir = .GlobalEnv)) {
return(list(typ = "skript_fehler",
meldung = "Objekt 'pseudo' wurde nach dem Sourcen des Pseudonym-Skripts nicht gefunden."))
}
daten = get("daten_vds20bu", envir = .GlobalEnv)
pseudo_df = get("pseudo", envir = .GlobalEnv)
session_spalte = vds20bu_finde_session_spalte(daten)
if (is.na(session_spalte)) {
return(list(typ = "skript_fehler", meldung = paste0(
"In daten_vds20bu wurde keine Spalte fuer die Sitzungskennung (Session-ID) gefunden."
)))
}
fehlende_item_spalten = VDS20BU_ITEMS$feld[!(VDS20BU_ITEMS$feld %in% names(daten))]
if (length(fehlende_item_spalten) > 0) {
return(list(typ = "skript_fehler", meldung = paste0(
"Folgende erwarteten Item-Spalten fehlen in daten_vds20bu: ",
paste(fehlende_item_spalten, collapse = ", ")
)))
}
warnungen = c()
pseudonym_wert = trimws(input$pseudonym)
# Wenn ein Pseudonym eingegeben wurde: Chiffre daraus zurueckerhalten, damit
# Kopfzeile/Dateiname auch bei reiner Pseudonym-Eingabe korrekt sind.
if (nchar(pseudonym_wert) > 0) {
pw_treffer = pseudo_df[pseudo_df$pseudonym == pseudonym_wert, ]
if (nrow(pw_treffer) == 0) {
return(list(typ = "pseudonym_unbekannt", meldung = paste0(
"Pseudonym '", pseudonym_wert, "' wurde in der Pseudonym-Datenbank nicht gefunden."
)))
}
chiffre = toupper(trimws(pw_treffer$chiffre[1]))
}
treffer_ps = pseudo_df[pseudo_df$chiffre == chiffre, ]
if (nrow(treffer_ps) == 0) {
return(list(typ = "chiffre_unbekannt", meldung = paste0(
"Chiffre '", chiffre, "' wurde in der Pseudonym-Datenbank nicht gefunden."
)))
}
alle_session_ids = unique(treffer_ps$pseudonym)
# Eindeutigkeits-Override: explizit eingegebenes Pseudonym hat immer Vorrang.
if (nchar(pseudonym_wert) > 0) {
alle_session_ids = pseudonym_wert
}
treffer_dat = daten[daten[[session_spalte]] %in% alle_session_ids, ]
if (nrow(treffer_dat) == 0) {
return(list(typ = "kein_datensatz", meldung = paste0(
"Kein VDS20-BU-Datensatz für Chiffre '", chiffre, "' gefunden. ",
"(", length(alle_session_ids), " Sitzungskennung(en) geprüft)"
)))
}
if (nrow(treffer_dat) > 1) {
n = nrow(treffer_dat)
spalte_datum = vds20bu_finde_datumsspalte(daten, treffer_dat[1, , drop = FALSE])
if (!is.na(spalte_datum)) {
reihenfolge = order(
vapply(seq_len(n), function(i) {
d = vds20bu_parse_datum(treffer_dat[[spalte_datum]][i])
if (is.null(d)) -Inf else as.numeric(d)
}, numeric(1)),
decreasing = TRUE
)
treffer_dat = treffer_dat[reihenfolge, , drop = FALSE]
}
datum_neu = if (!is.na(spalte_datum)) {
d = vds20bu_parse_datum(treffer_dat[[spalte_datum]][1])
if (!is.null(d)) format(d, "%d.%m.%Y %H:%M") else "unbekanntes Datum"
} else "unbekanntes Datum"
warnungen = c(warnungen, paste0(
"Mehrere Ausfüllungen gefunden (", n, " Einträge). Angezeigt wird die neueste vom ",
datum_neu, "."
))
treffer_dat = treffer_dat[1, , drop = FALSE]
}
zeile = treffer_dat[1, , drop = FALSE]
spalte_datum = vds20bu_finde_datumsspalte(daten, zeile)
datum_geparst = if (!is.na(spalte_datum)) vds20bu_parse_datum(zeile[[spalte_datum]][1]) else NULL
datum_fallback = is.null(datum_geparst)
datum_str = if (!datum_fallback) format(datum_geparst, "%d.%m.%Y") else format(Sys.Date(), "%d.%m.%Y")
datum_dateikennung = if (!datum_fallback) format(datum_geparst, "%Y%m%d") else format(Sys.Date(), "%Y%m%d")
if (datum_fallback) {
warnungen = c(warnungen,
"Ausfülldatum nicht in den Daten gefunden, Erstellungsdatum verwendet.")
}
items = VDS20BU_ITEMS
items$wert = vapply(items$feld, function(f) zu_binaer(zeile[[f]][1]), integer(1))
items$wert_text = ifelse(is.na(items$wert), "k. A.",
ifelse(items$wert == 1, "angekreuzt", "nicht angekreuzt"))
n_unklar = sum(is.na(items$wert))
if (n_unklar > 0) {
warnungen = c(warnungen, paste0(
n_unklar, " Item(s) mit nicht interpretierbarem Rohwert - als fehlend gewertet, ",
"nicht als 0 geraten. Betroffene Item(s): ",
paste(items$nr[is.na(items$wert)], collapse = ", "), "."
))
}
summe_gesamt = vds20bu_teilsumme(items$wert)
summe_kindheit = vds20bu_teilsumme(items$wert[items$skala == "kindheit"])
summe_heute = vds20bu_teilsumme(items$wert[items$skala == "heute"])
skalen = VDS20BU_SKALEN
skalen$summe = c(summe_gesamt, summe_kindheit, summe_heute)
klass_liste = lapply(seq_len(nrow(skalen)), function(i) {
vds20bu_klassifiziere(skalen$summe[i], skalen$cutoff[i])
})
skalen$klasse = vapply(klass_liste, function(k) if (is.na(k$klasse)) NA_character_ else k$klasse, character(1))
skalen$label = vapply(klass_liste, function(k) k$label, character(1))
freitexte = list()
for (feld in VDS20BU_FREITEXT_FELDER) {
freitexte[[feld]] = if (feld %in% names(daten)) as.character(zeile[[feld]][1]) else NA_character_
}
list(
typ = "ok",
chiffre = chiffre,
datum_str = datum_str,
warnungen = warnungen,
items = items,
skalen = skalen,
freitexte = freitexte,
datum_dateikennung = datum_dateikennung
)
})
output$fehler_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (identical(d$typ, "ok")) return(NULL)
txt = switch(d$typ,
"leere_eingabe" = d$meldung,
"format_fehler" = paste0(
"Ungültige Chiffre '", d$chiffre, "'. Erwartet: ein Großbuchstabe + 6 Ziffern (z.B. P000123)."
),
d$meldung
)
div(class = "alert-fehler", txt)
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!identical(d$typ, "ok") || length(d$warnungen) == 0) return(NULL)
div(lapply(d$warnungen, function(w) div(class = "alert-warnung", w)))
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!identical(d$typ, "ok")) return(NULL)
skalen_ui = lapply(seq_len(nrow(d$skalen)), function(i) {
sk = d$skalen[i, ]
if (is.na(sk$summe)) {
div(class = "skala-kachel",
div(class = "skala-name", sk$name),
span(class = "skala-badge skala-badge-na", "nicht auswertbar"),
div(class = "skala-cutoff-text", "fehlende Werte in dieser Skala")
)
} else {
badge_klasse = if (identical(sk$klasse, "unsicher")) "skala-badge-unsicher" else "skala-badge-unauffaellig"
fuell_klasse = if (identical(sk$klasse, "unsicher")) "skala-balken-fuellung-unsicher" else "skala-balken-fuellung-unauffaellig"
pct_score = max(0, min(100, sk$summe / sk$max * 100))
pct_cutoff = max(0, min(100, sk$cutoff / sk$max * 100))
div(class = "skala-kachel",
div(class = "skala-name", sk$name,
span(class = paste0("skala-badge ", badge_klasse), sk$label)
),
span(class = "skala-score", sk$summe),
span(class = "skala-max", paste0("/ ", sk$max)),
div(class = "skala-balken",
div(class = paste0("skala-balken-fuellung ", fuell_klasse),
style = paste0("width:", pct_score, "%;")),
div(class = "skala-balken-cutoff-marker",
style = paste0("left:", pct_cutoff, "%;"))
),
div(class = "skala-cutoff-text", paste0("Cutoff: ", sk$cutoff))
)
}
})
item_zeile_ui = function(it) {
badge_klasse = if (is.na(it$wert)) "check-badge-na"
else if (it$wert == 1) "check-badge-ja" else "check-badge-nein"
div(class = "item-zeile",
div(class = "item-nr", paste0(it$nr, ".")),
div(class = "item-text", it$text),
span(class = paste0("check-badge ", badge_klasse), it$wert_text)
)
}
items_kindheit = d$items[d$items$skala == "kindheit", ]
items_heute = d$items[d$items$skala == "heute", ]
freitext_ui = lapply(VDS20BU_FREITEXT_FELDER, function(feld) {
antwort = d$freitexte[[feld]]
leer = is.na(antwort) || nchar(trimws(antwort)) == 0
div(class = "freitext-block",
div(class = "freitext-frage", VDS20BU_FREITEXT_FRAGEN[[feld]]),
if (leer)
div(class = "freitext-antwort freitext-antwort-leer", "(keine Angabe)")
else
div(class = "freitext-antwort", antwort)
)
})
tagList(
div(class = "abschnitt-karte",
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfülldatum: "), d$datum_str
)
),
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Summenscores"),
div(class = "skalen-reihe", skalen_ui)
),
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Einzelitems"),
div(class = "item-gruppe-titel", "In der Kindheit"),
div(lapply(seq_len(nrow(items_kindheit)), function(r) item_zeile_ui(items_kindheit[r, ]))),
div(class = "item-gruppe-titel", "Heute"),
div(lapply(seq_len(nrow(items_heute)), function(r) item_zeile_ui(items_heute[r, ])))
),
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Freitextangaben"),
freitext_ui
),
div(class = "abschnitt-karte",
div(class = "disclaimer-zeile", VDS20BU_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 = if (is.list(d) && identical(d$typ, "ok") && !is.null(d$datum_dateikennung))
d$datum_dateikennung
else
format(Sys.Date(), "%Y%m%d")
paste0("VDS20BU_", chiffre_esc, "_", datum, ".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_vds20bu_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)