Initial commit
This commit is contained in:
commit
3cba772836
1341 changed files with 532924 additions and 0 deletions
777
VDS27/app.R
Normal file
777
VDS27/app.R
Normal 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 (0–3), 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 1–14) – 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 1–14 und H1–H7) – 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 (0–5)"), tags$th("Profilwert (0–3)")
|
||||
)),
|
||||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue