Initial commit
This commit is contained in:
commit
3cba772836
1341 changed files with 532924 additions and 0 deletions
873
PAS/app.R
Normal file
873
PAS/app.R
Normal file
|
|
@ -0,0 +1,873 @@
|
|||
# Praeambel ####
|
||||
|
||||
AKZENT_FARBE = "#8B2635"
|
||||
|
||||
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_pas.R" # liefert beim Sourcen: daten_pas
|
||||
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R" # liefert beim Sourcen: pseudo
|
||||
|
||||
PAS_DISCLAIMER = paste0(
|
||||
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
|
||||
"keine klinische Diagnose. Die Interpretation obliegt der behandelnden Person."
|
||||
)
|
||||
|
||||
# Verlauf gruen -> dunkelrot entspricht den 5 Rohwert-Stufen 0-4 der Einzelitems.
|
||||
PAS_STUFE_FARBEN = c(
|
||||
"0" = "#4CAF50",
|
||||
"1" = "#F48FB1",
|
||||
"2" = "#EF5350",
|
||||
"3" = "#B71C1C",
|
||||
"4" = "#4A0000"
|
||||
)
|
||||
PAS_STUFE_TEXT_FARBEN = c(
|
||||
"0" = "white",
|
||||
"1" = "#333333",
|
||||
"2" = "white",
|
||||
"3" = "white",
|
||||
"4" = "white"
|
||||
)
|
||||
|
||||
# Angenommene Spaltennamen im formr-Export (Konvention dieser App-Serie).
|
||||
# Werden zur Laufzeit gegen die tatsaechlichen Spalten von daten_pas geprueft
|
||||
# (siehe pas_spalte_finden in # Helper ####) - falls abweichend, bricht die
|
||||
# Auswertung mit einer Fehlermeldung ab, die die tatsaechlichen Spaltennamen
|
||||
# auflistet, statt stillschweigend falsche Werte zu verwenden.
|
||||
PAS_SPALTE_SESSION_STANDARD = "session"
|
||||
PAS_SPALTE_SESSION_ALT = c("session_id", "id", "formr_session")
|
||||
PAS_SPALTE_DATUM_STANDARD = "created"
|
||||
PAS_SPALTE_DATUM_ALT = c("ausfuelldatum", "expired", "ended", "modified")
|
||||
|
||||
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)
|
||||
|
||||
# EXPLIZITER AUSSCHLUSS: Diese App enthaelt keine API-Keys und keinen direkten
|
||||
# Datenbankzugriff. Sie sourced ausschliesslich die beiden oben genannten externen
|
||||
# Skripte zur Laufzeit (beim Klick auf "Auswerten", nicht beim App-Start).
|
||||
|
||||
|
||||
# Helper ####
|
||||
|
||||
# Rohwert = Choice-Index - 1 (Range 0-4). Die Zuordnung Index -> Rohwert ist
|
||||
# laut Manual deterministisch, daher direkte numerische Konvertierung statt
|
||||
# Parsen des Label-Texts.
|
||||
pas_get_rohwert = function(wert) {
|
||||
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_real_)
|
||||
as.numeric(haven::zap_labels(wert[1])) - 1
|
||||
}
|
||||
|
||||
# formr exportiert Frage-/Choice-Texte teils Markdown-escaped (z.B. "01\. Flugzeug"
|
||||
# statt "01. Flugzeug", weil eine fuehrende "01." in Markdown sonst als
|
||||
# Listennummerierung interpretiert wuerde). Entfernt den Backslash vor
|
||||
# Satzzeichen, sonst nichts - kein Eingriff in den eigentlichen Textinhalt.
|
||||
pas_unescape_markdown = function(text) {
|
||||
if (is.null(text) || length(text) == 0 || is.na(text[1])) return(text)
|
||||
gsub("\\\\([[:punct:]])", "\\1", as.character(text[1]))
|
||||
}
|
||||
|
||||
# Entfernt eine fuehrende Item-Nummerierung (z.B. "A.1. " oder "01. ") aus dem
|
||||
# Fragetext, da diese bereits separat in der item-nr-Spalte angezeigt wird und
|
||||
# sonst doppelt erscheint.
|
||||
pas_strip_item_nr_praefix = function(text) {
|
||||
if (is.null(text) || length(text) == 0 || is.na(text[1])) return(text)
|
||||
sub("^\\s*[A-Za-z]?\\.?[0-9]{1,2}\\.\\s*", "", as.character(text[1]))
|
||||
}
|
||||
|
||||
# Liefert den Antworttext (aus dem labels-Attribut der ORIGINAL-Spalte) fuer
|
||||
# den tatsaechlich gewaehlten Choice-Index.
|
||||
pas_antwort_text = function(original_col, wert) {
|
||||
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_character_)
|
||||
lbl_attr = attr(original_col, "labels")
|
||||
if (!is.null(lbl_attr) && length(lbl_attr) > 0) {
|
||||
pos = which(as.vector(lbl_attr) == as.numeric(haven::zap_labels(wert[1])))
|
||||
if (length(pos) > 0) return(pas_unescape_markdown(names(lbl_attr)[pos[1]]))
|
||||
}
|
||||
NA_character_
|
||||
}
|
||||
|
||||
pas_item_label = function(original_col) {
|
||||
lbl = attr(original_col, "label")
|
||||
if (is.null(lbl) || length(lbl) == 0 || is.na(lbl[1])) return(NA_character_)
|
||||
trimws(pas_unescape_markdown(lbl[1]))
|
||||
}
|
||||
|
||||
# Robuste Erkennung von "angekreuzt" fuer pas_b2_01..23 (Typ `check`), da die
|
||||
# exakte formr-Exportkodierung in dieser Installation nicht verifiziert ist.
|
||||
# Erkennt sowohl logische (TRUE/FALSE) als auch numerische (1/0) Werte; NA gilt
|
||||
# als nicht angekreuzt.
|
||||
pas_ist_angekreuzt = function(wert) {
|
||||
if (is.null(wert) || length(wert) == 0) return(FALSE)
|
||||
w = wert[1]
|
||||
if (is.na(w)) return(FALSE)
|
||||
if (is.logical(w)) return(isTRUE(w))
|
||||
wn = suppressWarnings(as.numeric(haven::zap_labels(w)))
|
||||
if (is.na(wn)) return(FALSE)
|
||||
isTRUE(wn != 0)
|
||||
}
|
||||
|
||||
pas_freitext_ausgefuellt = function(txt) {
|
||||
if (is.null(txt) || length(txt) == 0) return(FALSE)
|
||||
t = txt[1]
|
||||
if (is.na(t)) return(FALSE)
|
||||
nchar(trimws(as.character(t))) > 0
|
||||
}
|
||||
|
||||
# Sucht die tatsaechliche Spalte fuer session-id/Datum in daten_pas: erst der
|
||||
# angenommene Standardname, dann bekannte Alternativen. Bricht mit einer
|
||||
# Fehlermeldung ab, die alle vorhandenen Spaltennamen auflistet, statt eine
|
||||
# falsche Spalte zu raten.
|
||||
pas_spalte_finden = function(df, standard, alternativen, beschreibung) {
|
||||
if (standard %in% names(df)) return(standard)
|
||||
for (alt in alternativen) {
|
||||
if (alt %in% names(df)) return(alt)
|
||||
}
|
||||
stop(paste0(
|
||||
"Spalte fuer '", beschreibung, "' nicht gefunden. Erwartet: '", standard,
|
||||
"' (oder Alternativen: ", paste(alternativen, collapse = ", "), "). ",
|
||||
"Tatsaechlich vorhandene Spalten in daten_pas: ", paste(names(df), collapse = ", ")
|
||||
))
|
||||
}
|
||||
|
||||
pas_b2_score_berechnen = function(anzahl) {
|
||||
treffer = PAS_B2_SCORE_TABELLE[
|
||||
anzahl >= PAS_B2_SCORE_TABELLE$anzahl_von & anzahl <= PAS_B2_SCORE_TABELLE$anzahl_bis, ]
|
||||
if (nrow(treffer) == 0) return(NA_integer_)
|
||||
as.integer(treffer$score[1])
|
||||
}
|
||||
|
||||
pas_klassifizieren = function(score) {
|
||||
if (is.na(score)) return(list(label = "k. A.", farbe = "#9E9E9E"))
|
||||
treffer = PAS_KLASSIFIKATION_TABELLE[
|
||||
score >= PAS_KLASSIFIKATION_TABELLE$von & score <= PAS_KLASSIFIKATION_TABELLE$bis, ]
|
||||
if (nrow(treffer) == 0) return(list(label = "k. A.", farbe = "#9E9E9E"))
|
||||
list(label = treffer$label[1], farbe = treffer$farbe[1])
|
||||
}
|
||||
|
||||
pas_u_anzeige = function(index) {
|
||||
if (is.na(index) || !(index %in% PAS_U_MAPPING$index)) {
|
||||
return(list(code = NA_integer_, text = NA_character_))
|
||||
}
|
||||
treffer = PAS_U_MAPPING[PAS_U_MAPPING$index == index, ]
|
||||
list(code = treffer$code[1], text = treffer$text[1])
|
||||
}
|
||||
|
||||
make_gauge_pas = function(score) {
|
||||
zonen = PAS_KLASSIFIKATION_TABELLE
|
||||
zonen$bis_plot = pmin(zonen$bis, 52)
|
||||
|
||||
p = ggplot()
|
||||
for (i in seq_len(nrow(zonen))) {
|
||||
p = p + geom_rect(
|
||||
aes(xmin = xmin, xmax = xmax, ymin = 0, ymax = 1),
|
||||
data = data.frame(xmin = zonen$von[i], xmax = zonen$bis_plot[i] + 1),
|
||||
fill = zonen$farbe[i], color = NA, alpha = 0.85
|
||||
)
|
||||
}
|
||||
|
||||
score_plot = if (is.na(score)) 0 else min(max(score, 0), 52)
|
||||
|
||||
p = p +
|
||||
geom_rect(aes(xmin = 0, xmax = 52, ymin = 0, ymax = 1),
|
||||
fill = NA, color = "#9E9E9E", linewidth = 0.6) +
|
||||
geom_segment(aes(x = score_plot, xend = score_plot, y = -0.25, yend = 1.25),
|
||||
color = AKZENT_FARBE, linewidth = 2.5) +
|
||||
geom_label(aes(x = score_plot, y = 1.6, label = paste0("Score: ", score)),
|
||||
fill = AKZENT_FARBE, color = "white", fontface = "bold",
|
||||
linewidth = 0, size = 4) +
|
||||
scale_x_continuous(limits = c(-2, 54), breaks = c(0, 8, 9, 18, 19, 28, 29, 39, 40, 52)) +
|
||||
scale_y_continuous(limits = c(-0.8, 2.0)) +
|
||||
theme_minimal(base_size = 11) +
|
||||
theme(
|
||||
axis.text.y = element_blank(),
|
||||
axis.ticks.y = element_blank(),
|
||||
panel.grid.major.y = element_blank(),
|
||||
panel.grid.minor = element_blank(),
|
||||
axis.title.y = element_blank(),
|
||||
axis.text.x = element_text(size = 8),
|
||||
plot.margin = margin(t = 5, r = 10, b = 5, l = 10)
|
||||
) +
|
||||
labs(x = "PAS Gesamtscore (0-52)", y = NULL)
|
||||
p
|
||||
}
|
||||
|
||||
make_subscore_plot = function(subscores) {
|
||||
df = data.frame(
|
||||
subskala = factor(names(subscores), levels = rev(names(subscores))),
|
||||
wert = as.numeric(subscores)
|
||||
)
|
||||
ggplot(df, aes(x = subskala, y = wert)) +
|
||||
geom_col(fill = "#78909C", width = 0.6) +
|
||||
geom_text(aes(label = sprintf("%.2f", wert)), hjust = -0.15, size = 3.5, color = "#333333") +
|
||||
coord_flip(clip = "off") +
|
||||
scale_y_continuous(limits = c(0, 4.6), breaks = 0:4) +
|
||||
theme_minimal(base_size = 11) +
|
||||
theme(
|
||||
panel.grid.major.y = element_blank(),
|
||||
panel.grid.minor = element_blank(),
|
||||
axis.title.y = element_blank(),
|
||||
plot.margin = margin(t = 5, r = 30, b = 5, l = 5)
|
||||
) +
|
||||
labs(y = "Mittelwert (0-4, deskriptiv, unklassifiziert)")
|
||||
}
|
||||
|
||||
|
||||
# Datenaufbereitung ####
|
||||
|
||||
# Item-zu-Subskala-Zuordnung: die 12 Einzelitems, die direkt in den Gesamtscore
|
||||
# eingehen (B.2-Score kommt gesondert hinzu, siehe unten).
|
||||
PAS_ITEM_SUBSKALA = c(
|
||||
pas_a1 = "Panikattacken",
|
||||
pas_a2 = "Panikattacken",
|
||||
pas_a3 = "Panikattacken",
|
||||
pas_b1 = "Agoraphobie/Vermeidung",
|
||||
pas_b3 = "Agoraphobie/Vermeidung",
|
||||
pas_c1 = "Antizipatorische Angst",
|
||||
pas_c2 = "Antizipatorische Angst",
|
||||
pas_d1 = "Einschraenkung",
|
||||
pas_d2 = "Einschraenkung",
|
||||
pas_d3 = "Einschraenkung",
|
||||
pas_e1 = "Gesundheitssorgen",
|
||||
pas_e2 = "Gesundheitssorgen"
|
||||
)
|
||||
|
||||
PAS_B2_CHECK_VARS = paste0("pas_b2_", sprintf("%02d", 1:23))
|
||||
PAS_B2_TEXT_VARS = c("pas_b2_24", "pas_b2_25", "pas_b2_26")
|
||||
|
||||
# B.2-Score-Tabelle (Manual PAS, S. 21-26): Anzahl angekreuzter/ausgefuellter
|
||||
# Situationen (0-26) -> B.2-Score (0-4). Exakt diese Stufeneinteilung
|
||||
# verwenden, nicht linear interpolieren.
|
||||
PAS_B2_SCORE_TABELLE = data.frame(
|
||||
anzahl_von = c(0L, 1L, 2L, 4L, 9L),
|
||||
anzahl_bis = c(0L, 1L, 3L, 8L, 26L),
|
||||
score = c(0L, 1L, 2L, 3L, 4L)
|
||||
)
|
||||
|
||||
# U-Item-Mapping (Manual PAS): Choice-Index -> Original-Manual-Code -> Anzeigetext.
|
||||
# pas_u geht NICHT in den Score ein, ausschliesslich deskriptiv.
|
||||
PAS_U_MAPPING = data.frame(
|
||||
index = 1:6,
|
||||
code = c(9, 0, 1, 2, 3, 4),
|
||||
text = c(
|
||||
"keine Panikattacken",
|
||||
"meistens unerwartet",
|
||||
"haeufiger unerwartet als erwartet",
|
||||
"teilweise unerwartet, teilweise erwartet",
|
||||
"haeufiger erwartet als unerwartet",
|
||||
"meistens erwartet"
|
||||
),
|
||||
stringsAsFactors = FALSE
|
||||
)
|
||||
|
||||
# Klassifikation NUR fuer den Gesamtscore (Selbstbeurteilungsversion).
|
||||
PAS_KLASSIFIKATION_TABELLE = data.frame(
|
||||
von = c(0, 9, 19, 29, 40),
|
||||
bis = c(8, 18, 28, 39, 52),
|
||||
label = c("grenzwertig bzw. Remission", "leicht", "mittel", "schwer", "sehr schwer"),
|
||||
farbe = c("#4CAF50", "#F48FB1", "#EF5350", "#B71C1C", "#4A0000"),
|
||||
stringsAsFactors = FALSE
|
||||
)
|
||||
|
||||
|
||||
# UI ####
|
||||
|
||||
app_css = "
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f5f5f5; }
|
||||
.app-header {
|
||||
background: #8B2635; color: white; padding: 18px 24px 14px;
|
||||
margin-bottom: 20px; border-radius: 0 0 6px 6px;
|
||||
}
|
||||
.app-header h2 { margin: 0; font-size: 1.5rem; font-weight: 600; }
|
||||
.app-header p { margin: 4px 0 0; opacity: 0.85; font-size: 0.9rem; }
|
||||
.input-panel {
|
||||
background: white; border-radius: 6px; padding: 16px 20px;
|
||||
margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.12);
|
||||
display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap;
|
||||
}
|
||||
.input-panel .form-group { margin-bottom: 0; }
|
||||
.input-panel label { font-weight: 600; color: #333; }
|
||||
.btn-laden {
|
||||
background: #8B2635 !important; color: white !important;
|
||||
border: none !important; border-radius: 4px !important;
|
||||
padding: 8px 20px !important; font-weight: 600 !important; cursor: pointer;
|
||||
}
|
||||
.btn-laden:hover { background: #6d1e29 !important; }
|
||||
.alert-fehler {
|
||||
background: #FFEBEE; border-left: 5px solid #C62828;
|
||||
padding: 12px 16px; border-radius: 4px; color: #B71C1C;
|
||||
margin-bottom: 12px; font-weight: 500;
|
||||
}
|
||||
.alert-warnung {
|
||||
background: #FFF3E0; border-left: 5px solid #E65100;
|
||||
padding: 10px 16px; border-radius: 4px; color: #BF360C;
|
||||
margin-bottom: 12px; font-size: 0.93em; font-weight: 500;
|
||||
}
|
||||
.abschnitt-karte {
|
||||
background: white; border-radius: 6px; padding: 20px 24px;
|
||||
margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.12);
|
||||
}
|
||||
.abschnitt-titel {
|
||||
color: #8B2635; font-size: 1.15rem; font-weight: 700;
|
||||
border-bottom: 2px solid #8B2635; padding-bottom: 8px; margin-bottom: 14px;
|
||||
}
|
||||
.meta-block { margin-bottom: 10px; color: #555; font-size: 0.95em; }
|
||||
.meta-block strong { color: #222; }
|
||||
.item-zeile {
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 7px 0; border-bottom: 1px solid #F0F0F0;
|
||||
}
|
||||
.item-nr { font-weight: 600; color: #8B2635; min-width: 34px; flex-shrink: 0; }
|
||||
.item-text { flex: 1; color: #333; font-size: 0.92em; }
|
||||
.stufe-badge {
|
||||
border-radius: 4px; padding: 2px 9px; font-weight: 700;
|
||||
font-size: 0.82em; white-space: nowrap; display: inline-block; flex-shrink: 0;
|
||||
}
|
||||
.stufe-badge-0 { background: #4CAF50; color: white; }
|
||||
.stufe-badge-1 { background: #F48FB1; color: #333333; }
|
||||
.stufe-badge-2 { background: #EF5350; color: white; }
|
||||
.stufe-badge-3 { background: #B71C1C; color: white; }
|
||||
.stufe-badge-4 { background: #4A0000; color: white; }
|
||||
.score-zahl { font-size: 2.2rem; font-weight: 800; color: #8B2635; }
|
||||
.hinweis-nicht-score {
|
||||
display: inline-block; margin-left: 8px; font-size: 0.78em; font-weight: 600;
|
||||
color: #E65100; background: #FFF3E0; border-radius: 3px; padding: 1px 7px;
|
||||
}
|
||||
.situations-liste { margin: 0; padding-left: 20px; color: #333; font-size: 0.92em; }
|
||||
.situations-liste li { padding: 3px 0; }
|
||||
"
|
||||
|
||||
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("PAS - Panik- und Agoraphobie-Skala"),
|
||||
tags$p("Selbstbeurteilungsversion")
|
||||
),
|
||||
|
||||
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_pas_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_klass = fp_text(bold = TRUE, font.size = 12, color = erg$klassifikation$farbe)
|
||||
|
||||
doc = body_add_fpar(doc, fpar(ftext("PAS - Panik- und Agoraphobie-Skala", fp_titel)))
|
||||
doc = body_add_fpar(doc, fpar(
|
||||
ftext("Chiffre: ", fp_label),
|
||||
ftext(erg$chiffre, fp_normal),
|
||||
ftext(" Ausfuelldatum: ", fp_label),
|
||||
ftext(erg$datum_str, fp_normal)
|
||||
))
|
||||
if (!is.null(erg$info_mehrere)) {
|
||||
doc = body_add_fpar(doc, fpar(
|
||||
ftext(erg$info_mehrere, fp_text(font.size = 10, italic = TRUE, color = "#555555"))
|
||||
))
|
||||
}
|
||||
doc = body_add_par(doc, "", style = "Normal")
|
||||
|
||||
doc = body_add_fpar(doc, fpar(ftext("Gesamtscore", fp_abschnitt)))
|
||||
doc = body_add_fpar(doc, fpar(
|
||||
ftext(paste0(erg$gesamtscore, " / 52 Klassifikation: ", erg$klassifikation$label), fp_klass)
|
||||
))
|
||||
doc = body_add_par(doc, "", style = "Normal")
|
||||
|
||||
doc = body_add_fpar(doc, fpar(ftext("Subskalen (deskriptiv, unklassifiziert)", fp_abschnitt)))
|
||||
for (nm in names(erg$subscores)) {
|
||||
doc = body_add_fpar(doc, fpar(
|
||||
ftext(paste0(nm, ": "), fp_label),
|
||||
ftext(sprintf("%.2f", erg$subscores[[nm]]), fp_normal)
|
||||
))
|
||||
}
|
||||
doc = body_add_par(doc, "", style = "Normal")
|
||||
|
||||
doc = body_add_fpar(doc, fpar(ftext("U - Art der Panikattacken (nicht im Gesamtscore enthalten)", fp_abschnitt)))
|
||||
doc = body_add_fpar(doc, fpar(
|
||||
ftext("Antwort: ", fp_label),
|
||||
ftext(if (is.na(erg$u_antwort)) "k. A." else erg$u_antwort, fp_normal)
|
||||
))
|
||||
doc = body_add_par(doc, "", style = "Normal")
|
||||
|
||||
doc = body_add_fpar(doc, fpar(ftext("B.2 - Angekreuzte Situationen", fp_abschnitt)))
|
||||
if (length(erg$b2_situationen) == 0) {
|
||||
doc = body_add_fpar(doc, fpar(ftext("Keine Situationen angekreuzt/ausgefuellt.", fp_normal)))
|
||||
} else {
|
||||
for (s in erg$b2_situationen) {
|
||||
doc = body_add_fpar(doc, fpar(ftext(paste0("- ", s), fp_normal)))
|
||||
}
|
||||
}
|
||||
doc = body_add_fpar(doc, fpar(
|
||||
ftext(paste0("Anzahl: ", erg$b2_anzahl, " -> B.2-Score: ", erg$b2_score), fp_normal)
|
||||
))
|
||||
doc = body_add_par(doc, "", style = "Normal")
|
||||
|
||||
doc = body_add_fpar(doc, fpar(ftext("Einzelitems", fp_abschnitt)))
|
||||
for (i in seq_along(erg$item_vars)) {
|
||||
rw = erg$item_rohwerte[i]
|
||||
sk = if (!is.na(rw) && rw >= 0 && rw <= 4) as.character(round(rw)) else "0"
|
||||
fp_badge = fp_text(
|
||||
bold = TRUE,
|
||||
font.size = 10,
|
||||
color = PAS_STUFE_TEXT_FARBEN[[sk]],
|
||||
shading.color = PAS_STUFE_FARBEN[[sk]]
|
||||
)
|
||||
doc = body_add_fpar(doc, fpar(
|
||||
ftext(paste0(erg$item_nr[i], ". ", erg$item_texte[i], " "), fp_normal),
|
||||
ftext(paste0(" ", erg$item_antworten[i], " (", erg$item_rohwerte[i], ") "), fp_badge)
|
||||
))
|
||||
}
|
||||
|
||||
doc = body_add_par(doc, "", style = "Normal")
|
||||
doc = body_add_fpar(doc, fpar(ftext(PAS_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 = "pfad_fehler", meldung = paste0(
|
||||
"Download-Skript nicht gefunden:\n", PFAD_DOWNLOAD_SKRIPT)))
|
||||
}
|
||||
if (!file.exists(PFAD_PSEUDONYM_SKRIPT)) {
|
||||
return(list(typ = "pfad_fehler", meldung = paste0(
|
||||
"Pseudonym-Skript nicht gefunden:\n", PFAD_PSEUDONYM_SKRIPT)))
|
||||
}
|
||||
|
||||
ok = tryCatch({
|
||||
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
|
||||
list(ok = TRUE)
|
||||
}, error = function(e) list(ok = FALSE, msg = e$message))
|
||||
if (!ok$ok) return(list(typ = "skript_fehler", meldung = ok$msg))
|
||||
|
||||
if (!exists("daten_pas", envir = .GlobalEnv)) {
|
||||
return(list(typ = "objekt_fehlt", meldung = paste0(
|
||||
"Objekt 'daten_pas' wurde nach dem Sourcen von PFAD_DOWNLOAD_SKRIPT nicht gefunden. ",
|
||||
"Bitte Download-Skript pruefen.")))
|
||||
}
|
||||
daten = get("daten_pas", envir = .GlobalEnv)
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
alter_wd = getwd()
|
||||
wd_ziel = if (!is.null(db_ordner)) db_ordner else
|
||||
dirname(normalizePath(PFAD_PSEUDONYM_SKRIPT, mustWork = FALSE))
|
||||
on.exit(setwd(alter_wd), add = TRUE)
|
||||
setwd(wd_ziel)
|
||||
|
||||
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("pseudo", envir = .GlobalEnv)) {
|
||||
return(list(typ = "objekt_fehlt", meldung = paste0(
|
||||
"Objekt 'pseudo' wurde nach dem Sourcen von PFAD_PSEUDONYM_SKRIPT nicht gefunden. ",
|
||||
"Bitte Pseudonym-Skript pruefen.")))
|
||||
}
|
||||
pseudo = get("pseudo", envir = .GlobalEnv)
|
||||
|
||||
# Chiffre-Rueckaufloesung aus Pseudonym, falls Pseudonym eingegeben wurde.
|
||||
if (nchar(trimws(input$pseudonym)) > 0) {
|
||||
pw_treffer = pseudo[pseudo$pseudonym == trimws(input$pseudonym), ]
|
||||
if (nrow(pw_treffer) == 0) {
|
||||
return(list(typ = "pseudonym_nicht_gefunden", meldung = paste0(
|
||||
"Pseudonym '", trimws(input$pseudonym), "' wurde in der Pseudonym-Datenbank nicht gefunden.")))
|
||||
}
|
||||
chiffre = toupper(trimws(pw_treffer$chiffre[1]))
|
||||
}
|
||||
|
||||
treffer_ps = pseudo[pseudo$chiffre == chiffre, ]
|
||||
if (nrow(treffer_ps) == 0) {
|
||||
return(list(typ = "chiffre_nicht_gefunden", meldung = paste0(
|
||||
"Chiffre '", chiffre, "' wurde in der Pseudonym-Datenbank nicht gefunden.")))
|
||||
}
|
||||
|
||||
alle_session_ids = unique(treffer_ps$pseudonym)
|
||||
if (nchar(trimws(input$pseudonym)) > 0) alle_session_ids = trimws(input$pseudonym)
|
||||
|
||||
spalten_ok = tryCatch({
|
||||
spalte_session = pas_spalte_finden(daten, PAS_SPALTE_SESSION_STANDARD,
|
||||
PAS_SPALTE_SESSION_ALT, "Session-ID")
|
||||
spalte_datum = pas_spalte_finden(daten, PAS_SPALTE_DATUM_STANDARD,
|
||||
PAS_SPALTE_DATUM_ALT, "Ausfuelldatum")
|
||||
list(ok = TRUE, session = spalte_session, datum = spalte_datum)
|
||||
}, error = function(e) list(ok = FALSE, msg = e$message))
|
||||
if (!spalten_ok$ok) return(list(typ = "spalten_fehler", meldung = spalten_ok$msg))
|
||||
|
||||
treffer_dat = daten[daten[[spalten_ok$session]] %in% alle_session_ids, ]
|
||||
if (nrow(treffer_dat) == 0) {
|
||||
return(list(typ = "keine_daten", meldung = paste0(
|
||||
"Kein PAS-Datensatz fuer Chiffre '", chiffre, "' gefunden. (",
|
||||
length(alle_session_ids), " Pseudonym(e) geprueft)")))
|
||||
}
|
||||
|
||||
info_mehrere = NULL
|
||||
if (nrow(treffer_dat) > 1) {
|
||||
n = nrow(treffer_dat)
|
||||
treffer_dat = treffer_dat[order(treffer_dat[[spalten_ok$datum]], decreasing = TRUE), ]
|
||||
datum_neu = tryCatch(
|
||||
format(as.POSIXct(treffer_dat[[spalten_ok$datum]][1]), "%d.%m.%Y %H:%M"),
|
||||
error = function(e) "unbekanntes Datum"
|
||||
)
|
||||
info_mehrere = paste0(
|
||||
"Mehrere Ausfuellungen gefunden (", n, " Eintraege). ",
|
||||
"Angezeigt wird die neueste vom ", datum_neu, "."
|
||||
)
|
||||
treffer_dat = treffer_dat[1, , drop = FALSE]
|
||||
}
|
||||
|
||||
zeile = treffer_dat[1, , drop = FALSE]
|
||||
datum_str = tryCatch(
|
||||
format(as.POSIXct(zeile[[spalten_ok$datum]][1]), "%d.%m.%Y"),
|
||||
error = function(e) format(Sys.Date(), "%d.%m.%Y")
|
||||
)
|
||||
|
||||
# --- Einzelitems (12 Stueck, gehen direkt in den Gesamtscore ein) ---
|
||||
item_vars = names(PAS_ITEM_SUBSKALA)
|
||||
item_nr = toupper(sub("^pas_", "", item_vars))
|
||||
item_texte = sapply(item_vars, function(v) {
|
||||
t = pas_item_label(daten[[v]])
|
||||
if (is.na(t)) paste0("Item ", toupper(sub("^pas_", "", v))) else pas_strip_item_nr_praefix(t)
|
||||
})
|
||||
item_rohwerte = sapply(item_vars, function(v) pas_get_rohwert(zeile[[v]]))
|
||||
item_antworten = sapply(item_vars, function(v) {
|
||||
a = pas_antwort_text(daten[[v]], zeile[[v]])
|
||||
if (is.na(a)) "k. A." else a
|
||||
})
|
||||
names(item_rohwerte) = item_vars
|
||||
|
||||
# --- pas_u (deskriptiv, kein Score-Beitrag) ---
|
||||
# Primaer das Label aus den Quelldaten; nur falls dieses fehlt, stiller
|
||||
# Rueckfall auf die Manual-Formulierung (PAS_U_MAPPING).
|
||||
u_antwort = pas_antwort_text(daten[["pas_u"]], zeile[["pas_u"]])
|
||||
if (is.na(u_antwort)) {
|
||||
u_rohindex = as.numeric(haven::zap_labels(zeile[["pas_u"]][1]))
|
||||
u_antwort = pas_u_anzeige(u_rohindex)$text
|
||||
}
|
||||
|
||||
# --- B.2 Situations-Checkliste ---
|
||||
b2_situationen = character(0)
|
||||
for (v in PAS_B2_CHECK_VARS) {
|
||||
if (v %in% names(zeile) && pas_ist_angekreuzt(zeile[[v]][1])) {
|
||||
lbl = pas_item_label(daten[[v]])
|
||||
b2_situationen = c(b2_situationen, if (is.na(lbl)) v else lbl)
|
||||
}
|
||||
}
|
||||
for (v in PAS_B2_TEXT_VARS) {
|
||||
if (v %in% names(zeile) && pas_freitext_ausgefuellt(zeile[[v]][1])) {
|
||||
lbl = pas_item_label(daten[[v]])
|
||||
praefix = if (is.na(lbl)) v else lbl
|
||||
b2_situationen = c(b2_situationen, paste0(praefix, ": ", trimws(as.character(zeile[[v]][1]))))
|
||||
}
|
||||
}
|
||||
b2_anzahl = length(b2_situationen)
|
||||
b2_score = pas_b2_score_berechnen(b2_anzahl)
|
||||
|
||||
# --- Gesamtscore und Subscores ---
|
||||
werte_13 = c(item_rohwerte, pas_b2_score = b2_score)
|
||||
gesamtscore = sum(werte_13)
|
||||
fehlende_items = names(werte_13)[is.na(werte_13)]
|
||||
|
||||
subscores = c(
|
||||
"Panikattacken" = mean(c(item_rohwerte[["pas_a1"]], item_rohwerte[["pas_a2"]], item_rohwerte[["pas_a3"]])),
|
||||
"Agoraphobie/Vermeidung" = mean(c(item_rohwerte[["pas_b1"]], b2_score, item_rohwerte[["pas_b3"]])),
|
||||
"Antizipatorische Angst" = mean(c(item_rohwerte[["pas_c1"]], item_rohwerte[["pas_c2"]])),
|
||||
"Einschraenkung" = mean(c(item_rohwerte[["pas_d1"]], item_rohwerte[["pas_d2"]], item_rohwerte[["pas_d3"]])),
|
||||
"Gesundheitssorgen" = mean(c(item_rohwerte[["pas_e1"]], item_rohwerte[["pas_e2"]]))
|
||||
)
|
||||
|
||||
klassifikation = pas_klassifizieren(gesamtscore)
|
||||
|
||||
list(
|
||||
typ = NULL,
|
||||
chiffre = chiffre,
|
||||
datum_str = datum_str,
|
||||
info_mehrere = info_mehrere,
|
||||
fehlende_items = fehlende_items,
|
||||
item_vars = item_vars,
|
||||
item_nr = item_nr,
|
||||
item_texte = item_texte,
|
||||
item_rohwerte = item_rohwerte,
|
||||
item_antworten = item_antworten,
|
||||
u_antwort = u_antwort,
|
||||
b2_situationen = b2_situationen,
|
||||
b2_anzahl = b2_anzahl,
|
||||
b2_score = b2_score,
|
||||
gesamtscore = gesamtscore,
|
||||
subscores = subscores,
|
||||
klassifikation = klassifikation
|
||||
)
|
||||
})
|
||||
|
||||
fehlermeldung_text = function(d) {
|
||||
switch(d$typ,
|
||||
leere_eingabe = d$meldung,
|
||||
format_fehler = paste0(
|
||||
"Ungueltiges Chiffre-Format: '", d$chiffre, "'. Erwartet: ein Grossbuchstabe + 6 Ziffern (z.B. P000123)."),
|
||||
pfad_fehler = d$meldung,
|
||||
skript_fehler = paste0("Fehler beim Ausfuehren eines externen Skripts: ", d$meldung),
|
||||
objekt_fehlt = d$meldung,
|
||||
pseudonym_nicht_gefunden = d$meldung,
|
||||
chiffre_nicht_gefunden = d$meldung,
|
||||
spalten_fehler = d$meldung,
|
||||
keine_daten = d$meldung,
|
||||
"Unbekannter Fehler."
|
||||
)
|
||||
}
|
||||
|
||||
output$fehler_ui = renderUI({
|
||||
req(input$btn_suchen)
|
||||
d = ergebnis_r()
|
||||
if (!is.null(d$typ)) div(class = "alert-fehler", fehlermeldung_text(d))
|
||||
})
|
||||
|
||||
output$warnung_ui = renderUI({
|
||||
req(input$btn_suchen)
|
||||
d = ergebnis_r()
|
||||
if (!is.null(d$typ)) return(NULL)
|
||||
warnungen = list()
|
||||
if (!is.null(d$info_mehrere)) warnungen = c(warnungen, d$info_mehrere)
|
||||
if (length(d$fehlende_items) > 0) {
|
||||
warnungen = c(warnungen, paste0(
|
||||
"Fehlende/nicht auswertbare Angaben bei: ", paste(d$fehlende_items, collapse = ", "),
|
||||
" - der Gesamtscore ist dadurch unvollstaendig."))
|
||||
}
|
||||
if (length(warnungen) == 0) return(NULL)
|
||||
tagList(lapply(warnungen, function(w) div(class = "alert-warnung", w)))
|
||||
})
|
||||
|
||||
output$ergebnis_ui = renderUI({
|
||||
req(input$btn_suchen)
|
||||
d = ergebnis_r()
|
||||
if (!is.null(d$typ)) return(NULL)
|
||||
|
||||
items_ui = lapply(seq_along(d$item_vars), function(i) {
|
||||
rw = d$item_rohwerte[i]
|
||||
sk = if (!is.na(rw) && rw >= 0 && rw <= 4) as.character(round(rw)) else "0"
|
||||
div(class = "item-zeile",
|
||||
div(class = "item-nr", paste0(d$item_nr[i], ".")),
|
||||
div(class = "item-text", d$item_texte[i]),
|
||||
span(class = paste0("stufe-badge stufe-badge-", sk),
|
||||
paste0(d$item_antworten[i], " (", d$item_rohwerte[i], ")"))
|
||||
)
|
||||
})
|
||||
|
||||
situationen_ui = if (length(d$b2_situationen) == 0) {
|
||||
tags$em("Keine Situationen angekreuzt/ausgefuellt.")
|
||||
} else {
|
||||
tags$ul(class = "situations-liste",
|
||||
lapply(d$b2_situationen, function(s) tags$li(s))
|
||||
)
|
||||
}
|
||||
|
||||
tagList(
|
||||
|
||||
div(class = "abschnitt-karte",
|
||||
div(class = "abschnitt-titel", "Gesamtscore"),
|
||||
div(class = "meta-block",
|
||||
tags$strong("Chiffre: "), d$chiffre,
|
||||
tags$span(" | ", style = "color:#ccc;"),
|
||||
tags$strong("Ausfuelldatum: "), d$datum_str
|
||||
),
|
||||
tags$hr(),
|
||||
fluidRow(
|
||||
column(3,
|
||||
div(
|
||||
div(class = "score-zahl", d$gesamtscore),
|
||||
div("Summenscore (0-52)", style = "color:#555;"),
|
||||
div(style = paste0("margin-top:6px; font-weight:600; color:", d$klassifikation$farbe, ";"),
|
||||
d$klassifikation$label)
|
||||
)
|
||||
),
|
||||
column(9, plotOutput("gauge_plot", height = "160px"))
|
||||
)
|
||||
),
|
||||
|
||||
div(class = "abschnitt-karte",
|
||||
div(class = "abschnitt-titel", "Subskalen"),
|
||||
tags$p(style = "color:#777; font-size:0.85em; font-style:italic; margin-top:-8px;",
|
||||
"Deskriptiv, unklassifiziert - keine Cutoffs vorhanden."),
|
||||
plotOutput("subscore_plot", height = "220px")
|
||||
),
|
||||
|
||||
div(class = "abschnitt-karte",
|
||||
div(class = "abschnitt-titel", "Einzelitems"),
|
||||
div(items_ui)
|
||||
),
|
||||
|
||||
div(class = "abschnitt-karte",
|
||||
div(class = "abschnitt-titel",
|
||||
"U - Art der Panikattacken",
|
||||
tags$span(class = "hinweis-nicht-score", "nicht im Gesamtscore enthalten")
|
||||
),
|
||||
div(class = "meta-block",
|
||||
tags$strong("Antwort: "),
|
||||
if (is.na(d$u_antwort)) "k. A." else d$u_antwort
|
||||
)
|
||||
),
|
||||
|
||||
div(class = "abschnitt-karte",
|
||||
div(class = "abschnitt-titel", "B.2 - Situations-Checkliste"),
|
||||
div(class = "meta-block",
|
||||
tags$strong("Anzahl: "), d$b2_anzahl,
|
||||
tags$span(" -> B.2-Score: ", style = "color:#555;"), d$b2_score
|
||||
),
|
||||
situationen_ui
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
output$gauge_plot = renderPlot({
|
||||
req(input$btn_suchen)
|
||||
d = ergebnis_r()
|
||||
req(is.null(d$typ))
|
||||
make_gauge_pas(d$gesamtscore)
|
||||
}, bg = "transparent")
|
||||
|
||||
output$subscore_plot = renderPlot({
|
||||
req(input$btn_suchen)
|
||||
d = ergebnis_r()
|
||||
req(is.null(d$typ))
|
||||
make_subscore_plot(d$subscores)
|
||||
}, bg = "transparent")
|
||||
|
||||
output$download_word = downloadHandler(
|
||||
filename = function() {
|
||||
d = tryCatch(ergebnis_r(), error = function(e) NULL)
|
||||
daten_ok = is.list(d) && is.null(d$typ)
|
||||
chiffre_esc = if (daten_ok && nchar(d$chiffre) > 0) d$chiffre else "export"
|
||||
ausfuelldatum_fn = if (daten_ok) {
|
||||
tryCatch(
|
||||
format(as.Date(d$datum_str, "%d.%m.%Y"), "%Y%m%d"),
|
||||
error = function(e) format(Sys.Date(), "%Y%m%d")
|
||||
)
|
||||
} else {
|
||||
format(Sys.Date(), "%Y%m%d")
|
||||
}
|
||||
paste0("PAS_", chiffre_esc, "_", ausfuelldatum_fn, ".docx")
|
||||
},
|
||||
content = function(file) {
|
||||
d = tryCatch(ergebnis_r(), error = function(e) NULL)
|
||||
daten_ok = is.list(d) && is.null(d$typ)
|
||||
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_pas_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