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

777 lines
30 KiB
R
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# Präambel ####
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_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)