Initial commit

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

777
VDS27/app.R Normal file
View file

@ -0,0 +1,777 @@
# Präambel ####
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_vds27.R" # liefert: daten_vds27
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R" # liefert: pseudo
AKZENT_FARBE = "#8B2635"
VDS27_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel für klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die Interpretation obliegt der behandelnden Person."
)
VDS27_LANGFORM_HINWEIS = paste0(
"Dieser Fragebogen erfasst die 21 Kernitems der Bedürfnisdiagnostik. Für eine vertiefte ",
"Exploration einzelner Bedürfnisbereiche (eigener Umgang, Reaktion der Bezugspersonen, ",
"Therapieziel) verweisen wir auf die ausführliche Papier-Langform."
)
# Farbverlauf gruen -> dunkelrot ueber die 6 Antwortstufen 0-5, wie in der
# Referenzimplementierung pg13r/app.R (dort 5 Stufen 0-4).
VDS27_BADGE_FARBEN = c(
"0" = "#4CAF50",
"1" = "#8BC34A",
"2" = "#FFB74D",
"3" = "#EF5350",
"4" = "#B71C1C",
"5" = "#4A0000"
)
VDS27_BADGE_TEXT_FARBEN = c(
"0" = "white",
"1" = "#333333",
"2" = "#333333",
"3" = "white",
"4" = "white",
"5" = "white"
)
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 ####
# Score (0-5) je Item wird IMMER aus dem Klartext des Labels abgeleitet, nie aus
# dem rohen numerischen Code selbst - fuer den formr-Itemtyp "mc" ist das
# Exportformat in dieser Installation NICHT verifiziert (siehe Spezifikation).
# Deckt defensiv drei moegliche Faelle ab, der Reihe nach geprueft:
# (a) haven-labelled: Code + labels-Attribut, Klartext (z.B. "3 = deutlich")
# steht in den NAMEN von labels - fuehrende Ziffer daraus lesen
# (b) Klartext bereits direkt als String im Rohwert, z.B. "3 = deutlich"
# (c) UNGEPRUEFTER Fall: Rohwert ist bereits die nackte Zahl 0-5 ohne Label
extrahiere_score = function(spalte_voll, wert_roh) {
if (is.null(wert_roh) || length(wert_roh) == 0 || is.na(wert_roh[1])) return(NA_real_)
wert_roh = wert_roh[1]
labels_attr = attr(spalte_voll, "labels")
ist_labelled = haven::is.labelled(spalte_voll) || !is.null(labels_attr)
if (ist_labelled) {
if (!is.null(labels_attr) && length(labels_attr) > 0) {
treffer = which(as.numeric(labels_attr) == as.numeric(unclass(wert_roh)))
if (length(treffer) > 0) {
label_text = names(labels_attr)[treffer[1]]
ziffer = sub("^\\s*(\\d+).*", "\\1", trimws(label_text))
if (grepl("^\\d+$", ziffer)) return(as.numeric(ziffer))
}
}
return(NA_real_)
}
if (is.character(wert_roh) && grepl("^\\s*\\d+\\s*=", wert_roh)) {
ziffer = sub("^\\s*(\\d+).*", "\\1", trimws(wert_roh))
return(as.numeric(ziffer))
}
# Fall (c), UNGEPRUEFT: Rohwert evtl. bereits die nackte Zahl 0-5.
wert_num = suppressWarnings(as.numeric(wert_roh))
if (!is.na(wert_num) && wert_num >= 0 && wert_num <= 5) return(wert_num)
NA_real_
}
# Entfernt escapte Backslashes vor Satzzeichen in Item-/Choicetexten (Artefakt
# aus der xlsx-Formularerstellung, z.B. "1\\. Willkommensein" -> "1. Willkommensein",
# bestaetigt im ersten produktiven Testlauf: sowohl bei den Itemtexten selbst
# als auch bei den Rangfolge-Choicetexten vorhanden). fixed = TRUE, da ein
# woertlicher Backslash entfernt werden soll, kein Regex-Sonderzeichen.
vds27_text_bereinigen = function(x) {
if (is.na(x)) return(NA_character_)
gsub("\\", "", x, fixed = TRUE)
}
# Itemtext aus dem formr "label"-Attribut der Spalte (Fragetext) gelesen, nicht
# statisch im App-Code hinterlegt - fuer VDS27 liegen keine verifizierten
# Itemtexte vor, die als Konstante uebernommen werden koennten. Wird durch
# vds27_text_bereinigen() von den escapten Backslashes befreit (siehe oben).
vds27_item_label = function(spalte, fallback) {
lbl = attr(spalte, "label")
if (is.null(lbl) || length(lbl) == 0 || is.na(lbl[1]) || trimws(lbl[1]) == "") return(fallback)
vds27_text_bereinigen(trimws(as.character(lbl[1])))
}
# Klartext eines select_one-Rangfolgefeldes. Deckt beide plausiblen
# Exportrichtungen ab: numerischer/interner Code mit Klartext in den NAMEN von
# labels, oder ein Zeichenkettencode, dessen Klartext im WERT von labels
# steht. Nicht zuordenbare Rohwerte werden als NA behandelt, nicht geraten.
vds27_wahl_text = function(spalte_voll, wert_roh) {
if (is.null(wert_roh) || length(wert_roh) == 0 || is.na(wert_roh[1])) return(NA_character_)
wert_roh = wert_roh[1]
lab = attr(spalte_voll, "labels")
klartext = NA_character_
if (!is.null(lab) && length(lab) > 0) {
wert_chr = trimws(as.character(unclass(wert_roh)))
if (is.character(lab) && !is.null(names(lab)) && wert_chr %in% names(lab)) {
klartext = unname(lab[[wert_chr]])
} else {
pos = which(as.character(unclass(as.vector(lab))) == wert_chr)
if (length(pos) > 0) klartext = names(lab)[pos[1]]
}
} else if (is.character(wert_roh)) {
klartext = wert_roh
} else {
klartext = as.character(wert_roh)
}
if (is.na(klartext) || trimws(klartext) == "") return(NA_character_)
vds27_text_bereinigen(trimws(klartext))
}
# Inline-CSS fuer den Item-Score-Badge, Farbe nach VDS27_BADGE_FARBEN (siehe
# Praeambel). Score wird auf 0-5 begrenzt/gerundet, falls er aus irgendeinem
# Grund ausserhalb der erwarteten Skala liegt. Fehlende Werte (NA) erhalten
# ein neutrales Grau statt einer Stufenfarbe.
vds27_badge_style = function(score) {
if (is.na(score)) return("background-color:#E0E0E0; color:#555555;")
k = as.character(max(0L, min(5L, as.integer(round(score)))))
paste0("background-color:", VDS27_BADGE_FARBEN[[k]], "; color:", VDS27_BADGE_TEXT_FARBEN[[k]], ";")
}
# Faktor-Mittelwert (0-5), reine Mittelwertbildung ohne Summenscore, Umpolung
# oder Gewichtung. NA-Items werden aus Summe UND Anzahl-Divisor ausgeschlossen,
# statt still mit einem verfaelschten Mittelwert weiterzurechnen.
vds27_faktor_auswerten = function(werte) {
vorhanden = !is.na(werte)
n_vorhanden = sum(vorhanden)
n_gesamt = length(werte)
rohsumme = if (n_vorhanden > 0) sum(werte[vorhanden]) else NA_real_
mittelwert = if (n_vorhanden > 0) rohsumme / n_vorhanden else NA_real_
list(rohsumme = rohsumme, mittelwert = mittelwert, n_vorhanden = n_vorhanden, n_gesamt = n_gesamt)
}
# Profilwert (0-3-Skala) nur fuer die Diagrammachse - reine Darstellungskonvention
# analog zur VDS24-App, keine im VDS27-Originaldokument enthaltene Formel.
vds27_profilwert = function(mittelwert) mittelwert * 3 / 5
# Profildiagramm: 6 Faktoren, Skala 0-3 (Profilwert), Beschriftung zeigt
# zusaetzlich den rohen Mittelwert (0-5). Kein Cutoff, keine Farbzonen - fuer
# dieses Instrument liegen keine Normwerte vor.
vds27_profil_plot = function(profil_df) {
profil_df$faktor = factor(profil_df$faktor, levels = rev(FAKTOR_REIHENFOLGE))
profil_df$y_balken = ifelse(is.na(profil_df$profilwert), 0, profil_df$profilwert)
profil_df$beschriftung = ifelse(
is.na(profil_df$mittelwert), "k. A.",
paste0(format(round(profil_df$mittelwert, 1), nsmall = 1), " / 5")
)
ggplot(profil_df, aes(x = faktor, y = y_balken)) +
geom_col(fill = AKZENT_FARBE, width = 0.6) +
geom_text(aes(label = beschriftung), hjust = -0.12, size = 4, color = "#333333") +
coord_flip(clip = "off") +
scale_y_continuous(limits = c(0, 3.6), breaks = 0:3) +
labs(x = NULL, y = "Profilwert (03), entspricht Faktor-Mittelwert × 3/5") +
theme_minimal(base_size = 12) +
theme(
panel.grid.minor = element_blank(),
axis.text.y = element_text(face = "bold", color = "#333333"),
plot.margin = margin(t = 5, r = 40, b = 5, l = 5)
)
}
# Datenaufbereitung ####
FAKTOR_REIHENFOLGE = c("BINDUNG", "SELBSTWERT", "AUTONOMIE", "ORIENTIERUNG", "IDENTITÄT", "HOMÖOSTASE")
VDS27_ITEMS = data.frame(
nr = c(as.character(1:14), paste0("H", 1:7)),
feld = c(paste0("vds27_", sprintf("%02d", 1:14)), paste0("vds27_h", 1:7)),
faktor = c(rep("BINDUNG", 4), rep("SELBSTWERT", 3), rep("AUTONOMIE", 2),
rep("ORIENTIERUNG", 3), rep("IDENTITÄT", 2), rep("HOMÖOSTASE", 7)),
stringsAsFactors = FALSE
)
# Kontrollsumme: 4 + 3 + 2 + 3 + 2 + 7 = 21 Items. Bei Abweichung sofort
# abbrechen statt still mit einer falschen Tabelle weiterzurechnen (Schutz vor
# Copy-Paste-Fehlern in der Struktur oben).
.vds27_kontrollsumme = nrow(VDS27_ITEMS)
if (.vds27_kontrollsumme != 21) {
stop("VDS27: Item-Faktor-Tabelle hat ", .vds27_kontrollsumme, " Zeilen, erwartet 21.")
}
.vds27_faktor_counts = table(factor(VDS27_ITEMS$faktor, levels = FAKTOR_REIHENFOLGE))
.vds27_counts_erwartet = setNames(c(4, 3, 2, 3, 2, 7), FAKTOR_REIHENFOLGE)
if (!identical(as.integer(.vds27_faktor_counts[FAKTOR_REIHENFOLGE]), as.integer(.vds27_counts_erwartet))) {
stop("VDS27: Itemanzahl je Faktor weicht von der Spezifikation ab. Bitte VDS27_ITEMS prüfen.")
}
# Rangfolge-Felder (formr-Typ select_one), rein deskriptiv - fliessen NICHT in
# die Faktor-Mittelwerte ein.
VDS27_RANG_FELDER = list(
list(titel = "Block A 1. Priorität (Bindung/Selbstwert)",
feld = "vds27_rang_a1_wahl"),
list(titel = "Block A 2. Priorität (Bindung/Selbstwert)",
feld = "vds27_rang_a2_wahl"),
list(titel = "Block B 1. Priorität (Autonomie/Orientierung/Identität)",
feld = "vds27_rang_b1_wahl"),
list(titel = "Block B 2. Priorität (Autonomie/Orientierung/Identität)",
feld = "vds27_rang_b2_wahl"),
list(titel = "Gesamt Block A+B (Items 114) wichtigstes Bedürfnis",
feld = "vds27_rang_b_gesamt_wahl"),
list(titel = "Block C 1. Priorität (Homöostase)",
feld = "vds27_rang_c1_wahl"),
list(titel = "Block C 2. Priorität (Homöostase)",
feld = "vds27_rang_c2_wahl"),
list(titel = "Gesamt (Items 114 und H1H7) wichtigstes Bedürfnis",
feld = "vds27_rang_c_gesamt_wahl")
)
# 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: 30px; flex-shrink: 0; }
.item-text { flex: 1; color: #333; font-size: 0.92em; }
.item-wert {
border-radius: 4px;
padding: 2px 9px; font-weight: 700; font-size: 0.82em;
white-space: nowrap; display: inline-block; flex-shrink: 0;
}
.faktor-tabelle { width: 100%; border-collapse: collapse; margin-top: 10px; }
.faktor-tabelle th, .faktor-tabelle td {
text-align: left; padding: 6px 10px; border-bottom: 1px solid #eee; font-size: 0.93em;
}
.faktor-tabelle th { color: #8B2635; border-bottom: 2px solid #8B2635; }
.freitext-block { margin-bottom: 12px; }
.freitext-block:last-child { margin-bottom: 0; }
.freitext-frage { font-weight: 600; color: #8B2635; font-size: 0.93em; margin-bottom: 3px; }
.freitext-antwort { color: #333; font-size: 0.93em; }
.hinweis-langform {
background: #F5F5F5; border-left: 5px solid #8B2635; border-radius: 4px;
padding: 12px 16px; margin-bottom: 16px; color: #444; font-size: 0.9em; line-height: 1.5;
}
.hinweis-rang {
font-size: 0.82em; color: #777; font-style: italic;
margin-bottom: 12px; border-bottom: 1px dashed #ddd; padding-bottom: 8px;
}
.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("VDS27 Zentrale Bedürfnisse / Grundbedürfnisse"),
tags$p("21 Ratingitems, 6 Bedürfnisbereiche, kein Summenscore, keine Normwerte")
),
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_vds27_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")
# Score-Badge im gleichen Gruen->Dunkelrot-Verlauf wie in der UI (siehe
# vds27_badge_style), analog zur Referenzimplementierung pg13r/app.R.
vds27_fp_badge = function(score) {
if (is.na(score)) return(fp_text(color = "#555555", bold = TRUE, shading.color = "#E0E0E0", font.size = 10))
k = as.character(max(0L, min(5L, as.integer(round(score)))))
fp_text(color = VDS27_BADGE_TEXT_FARBEN[[k]], bold = TRUE,
shading.color = VDS27_BADGE_FARBEN[[k]], font.size = 10)
}
doc = body_add_fpar(doc, fpar(ftext("VDS27 Zentrale Bedürfnisse / Grundbedürfnisse", 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_text(font.size = 10, italic = TRUE, color = "#555555"))
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Profil der 6 Bedürfnisbereiche", fp_abschnitt)))
profil_img = tempfile(fileext = ".png")
ggsave(profil_img, plot = vds27_profil_plot(erg$profil_df), width = 7, height = 4, dpi = 150, bg = "white")
doc = body_add_img(doc, src = profil_img, width = 6, height = 3.4)
file.remove(profil_img)
doc = body_add_par(doc, "", style = "Normal")
faktor_tabelle_df = data.frame(
Faktor = sapply(erg$faktor_ergebnisse, function(fe) fe$faktor),
Itemanzahl = sapply(erg$faktor_ergebnisse, function(fe) fe$n_gesamt),
`Mittelwert (0-5)` = sapply(erg$faktor_ergebnisse, function(fe) if (is.na(fe$mittelwert)) "" else format(round(fe$mittelwert, 2), nsmall = 2)),
`Profilwert (0-3)` = sapply(erg$faktor_ergebnisse, function(fe) if (is.na(fe$mittelwert)) "" else format(round(vds27_profilwert(fe$mittelwert), 2), nsmall = 2)),
check.names = FALSE, stringsAsFactors = FALSE
)
doc = body_add_table(doc, faktor_tabelle_df)
doc = body_add_par(doc, "", style = "Normal")
for (fe in erg$faktor_ergebnisse) {
doc = body_add_fpar(doc, fpar(ftext(fe$faktor, fp_abschnitt)))
if (!is.na(fe$mittelwert) && fe$n_vorhanden < fe$n_gesamt) {
doc = body_add_fpar(doc, fpar(ftext(
paste0("Faktor-Score basiert auf ", fe$n_vorhanden, " von ", fe$n_gesamt, " Items."),
fp_text(font.size = 9.5, italic = TRUE, color = "#BF360C")
)))
}
for (r in seq_len(nrow(fe$items))) {
zeile = fe$items[r, ]
wert_txt = if (is.na(zeile$score)) "k. A." else paste0(zeile$score, " / 5")
doc = body_add_fpar(doc, fpar(
ftext(paste0(zeile$nr, ". ", zeile$itemtext, " "), fp_normal),
ftext(paste0(" ", wert_txt, " "), vds27_fp_badge(zeile$score))
))
}
doc = body_add_par(doc, "", style = "Normal")
}
doc = body_add_break(doc)
doc = body_add_fpar(doc, fpar(ftext("Rangfolge (deskriptiv)", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(ftext(
"Durch die Patientin/den Patienten angegeben, rein deskriptiv, fließt nicht in die Faktor-Mittelwerte ein.",
fp_text(font.size = 9.5, italic = TRUE, color = "#777777")
)))
doc = body_add_par(doc, "", style = "Normal")
for (rg in erg$rang_ergebnisse) {
doc = body_add_fpar(doc, fpar(
ftext(paste0(rg$titel, ": "), fp_label),
ftext(if (is.na(rg$wert)) "keine Angabe" else rg$wert, fp_normal)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Hinweis zur Langform", fp_label)))
doc = body_add_fpar(doc, fpar(ftext(VDS27_LANGFORM_HINWEIS, fp_normal)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(VDS27_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_vds27", envir = .GlobalEnv) || !exists("pseudo", envir = .GlobalEnv)) {
return(list(typ = "daten_fehlen"))
}
daten = get("daten_vds27", envir = .GlobalEnv)
pseudo_df = get("pseudo", envir = .GlobalEnv)
# Bei Bedarf zur Diagnose aktivieren (zeigt das tatsaechliche Exportformat
# der mc-Item-Spalten in der R-Konsole):
# print(str(daten$vds27_01))
if (!("session" %in% names(daten))) {
return(list(typ = "daten_fehlen",
meldung = "Erwartete Spalte 'session' nicht in 'daten_vds27' 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_vds27 finden
treffer_daten = daten[daten$session %in% alle_session_ids, ]
if (nrow(treffer_daten) == 0) return(list(typ = "keine_daten", 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 = tryCatch(
format(as.POSIXct(zeile[["created"]][1]), "%d.%m.%Y"),
error = function(e) format(Sys.Date(), "%d.%m.%Y")
)
# Schritt 7: Faktor-Auswertung
faktor_ergebnisse = lapply(FAKTOR_REIHENFOLGE, function(fname) {
idx = which(VDS27_ITEMS$faktor == fname)
items_df = do.call(rbind, lapply(idx, function(i) {
feld = VDS27_ITEMS$feld[i]
spalte_voll = daten[[feld]]
wert_roh = if (feld %in% names(zeile)) zeile[[feld]][1] else NA
score = if (!is.null(spalte_voll)) extrahiere_score(spalte_voll, wert_roh) else NA_real_
itemtext = if (!is.null(spalte_voll))
vds27_item_label(spalte_voll, paste0("Item ", VDS27_ITEMS$nr[i]))
else
paste0("Item ", VDS27_ITEMS$nr[i])
data.frame(nr = VDS27_ITEMS$nr[i], feld = feld, itemtext = itemtext,
score = score, stringsAsFactors = FALSE)
}))
fs = vds27_faktor_auswerten(items_df$score)
c(list(faktor = fname, items = items_df), fs)
})
names(faktor_ergebnisse) = FAKTOR_REIHENFOLGE
profil_df = data.frame(
faktor = FAKTOR_REIHENFOLGE,
mittelwert = sapply(faktor_ergebnisse, function(fe) fe$mittelwert),
stringsAsFactors = FALSE
)
profil_df$profilwert = vds27_profilwert(profil_df$mittelwert)
# Schritt 8: Rangfolge-Felder (deskriptiv)
rang_ergebnisse = lapply(VDS27_RANG_FELDER, function(rf) {
spalte_voll = daten[[rf$feld]]
wert_roh = if (rf$feld %in% names(zeile)) zeile[[rf$feld]][1] else NA
wert_text = if (!is.null(spalte_voll)) vds27_wahl_text(spalte_voll, wert_roh) else NA_character_
list(titel = rf$titel, wert = wert_text)
})
list(
typ = "erfolg",
chiffre = chiffre,
ausfuelldatum = ausfuelldatum,
mehrfach_warnung = mehrfach_warnung,
faktor_ergebnisse = faktor_ergebnisse,
profil_df = profil_df,
rang_ergebnisse = rang_ergebnisse
)
})
vds27_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_vds27' oder 'pseudo'.",
"chiffre_nicht_gefunden" = paste0("Chiffre '", d$chiffre, "' wurde in der Pseudonym-Datenbank nicht gefunden."),
"keine_daten" = paste0("Kein VDS27-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", vds27_fehlermeldung(d))
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis()
if (d$typ != "erfolg") return(NULL)
if (!is.null(d$mehrfach_warnung)) div(class = "alert-warnung", d$mehrfach_warnung)
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis()
if (d$typ != "erfolg") return(NULL)
faktor_tabelle_zeilen = lapply(d$faktor_ergebnisse, function(fe) {
tags$tr(
tags$td(fe$faktor),
tags$td(fe$n_gesamt),
tags$td(if (is.na(fe$mittelwert)) "" else format(round(fe$mittelwert, 2), nsmall = 2)),
tags$td(if (is.na(fe$mittelwert)) "" else format(round(vds27_profilwert(fe$mittelwert), 2), nsmall = 2))
)
})
faktor_tabelle = tags$table(class = "faktor-tabelle",
tags$thead(tags$tr(
tags$th("Faktor"), tags$th("Itemanzahl"),
tags$th("Mittelwert (05)"), tags$th("Profilwert (03)")
)),
tags$tbody(faktor_tabelle_zeilen)
)
item_karten = lapply(d$faktor_ergebnisse, function(fe) {
items_ui = lapply(seq_len(nrow(fe$items)), function(r) {
zeile = fe$items[r, ]
div(class = "item-zeile",
div(class = "item-nr", paste0(zeile$nr, ".")),
div(class = "item-text", zeile$itemtext),
span(class = "item-wert", style = vds27_badge_style(zeile$score),
if (is.na(zeile$score)) "k. A." else paste0(zeile$score, " / 5"))
)
})
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", fe$faktor),
if (!is.na(fe$mittelwert) && fe$n_vorhanden < fe$n_gesamt)
div(class = "alert-warnung",
paste0("Unvollständig ausgefüllt: Faktor-Score basiert auf ", fe$n_vorhanden,
" von ", fe$n_gesamt, " Items.")),
div(items_ui)
)
})
rang_ui = lapply(d$rang_ergebnisse, function(rg) {
div(class = "freitext-block",
div(class = "freitext-frage", rg$titel),
div(class = "freitext-antwort", if (is.na(rg$wert)) "keine Angabe" else rg$wert)
)
})
rang_karte = div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Rangfolge (deskriptiv)"),
div(class = "hinweis-rang",
"Durch die Patientin/den Patienten angegeben. Rein deskriptiv, fließt nicht in die Faktor-Mittelwerte ein."),
rang_ui
)
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
)
),
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Profil der 6 Bedürfnisbereiche"),
plotOutput("profil_plot", height = "340px"),
tags$hr(),
faktor_tabelle
),
item_karten,
rang_karte,
div(class = "hinweis-langform", VDS27_LANGFORM_HINWEIS),
div(class = "disclaimer-zeile", VDS27_DISCLAIMER)
)
})
output$profil_plot = renderPlot({
req(input$btn_suchen)
d = ergebnis()
req(d$typ == "erfolg")
vds27_profil_plot(d$profil_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"
ausfuelldatum_fn = if (erfolgreich) {
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("VDS27_", chiffre_esc, "_", ausfuelldatum_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_vds27_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)