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

747 lines
27 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 ####
library(shiny)
library(dplyr)
library(ggplot2)
library(haven)
library(officer)
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_fas-sr.R"
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R"
AKZENT_FARBE = "#8B2635"
# Referenzwerte Wu et al., 2016, N=61 - explizit KEINE klinischen Cutoffs.
FASSR_REF_MEAN = 16.19
FASSR_REF_SD = 12.87
FASSR_REF_RANGE = c(0, 67)
FASSR_SCORE_MAX = 76
FASSR_REF_ALPHA = 0.90
FASSR_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Es liegen keine publizierten klinischen Cutoffs fuer ",
"die FAS-SR vor; der dargestellte Vergleichsbereich stammt aus einer kleinen ",
"Validierungsstichprobe (Wu et al., 2016, N=61) und dient nur der groben ",
"Einordnung. Die Interpretation obliegt der behandelnden Person."
)
FASSR_REFERENZ_HINWEIS = paste0(
"Es liegen keine publizierten klinischen Cutoffs für die FAS-SR vor. Der markierte ",
"Bereich zeigt Mittelwert ± Standardabweichung einer kleinen Validierungsstichprobe ",
"(N=61) zur groben Einordnung, keine diagnostische Schwelle."
)
# Verlauf gruen -> dunkelrot entspricht den 5 Antwortstufen 0-4 (nie ... Jeden Tag).
FASSR_BADGE_FARBEN = c(
"0" = "#4CAF50",
"1" = "#AED581",
"2" = "#F48FB1",
"3" = "#EF5350",
"4" = "#B71C1C"
)
FASSR_BADGE_TEXT_FARBEN = c(
"0" = "white",
"1" = "#333333",
"2" = "#333333",
"3" = "white",
"4" = "white"
)
FASSR_STUFEN_MAP = c(
"nie" = 0L,
"1 Tag" = 1L,
"2-3 Tage" = 2L,
"4-6 Tage" = 3L,
"Jeden Tag" = 4L
)
FASSR_STUFEN_TEXTE = c("nie", "1 Tag", "2-3 Tage", "4-6 Tage", "Jeden Tag")
# Fallback-Kategorienliste Teil I, nur falls label-Attribut fehlt.
# ANNAHME: Reihenfolge entspricht fas_t1_zg_01..08 bzw. fas_t1_zh_01..07,
# mit echten Testdaten zu verifizieren.
FASSR_T1_ZG_FALLBACK = c(
"Zwangsgedanken zu Gefährdung",
"Zwangsgedanken zu Kontaminierung",
"Sexuelle Zwangsgedanken",
"Zwangsgedanken zu Horten/Sammeln",
"Religiöse Zwangsgedanken",
"Zwangsgedanken zu Symmetrie/Genauigkeit",
"Körperliche Zwangsgedanken",
"Verschiedene Zwangsgedanken"
)
FASSR_T1_ZH_FALLBACK = c(
"Wasch-/Reinigungszwänge",
"Kontrollzwänge",
"Wiederholungsrituale",
"Zählzwänge",
"Ordnungszwänge",
"Hort-/Sammelzwänge",
"Verschiedene Zwangshandlungen"
)
# 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 ####
# Entfernt formr-Markdown-Sternchen und Nummerierungsartefakte aus label-Texten.
clean_item_label = function(text) {
if (is.null(text) || length(text) == 0 || is.na(text[1])) return(NA_character_)
txt = gsub("\\*", "", as.character(text[1]))
txt = trimws(txt)
txt = sub("^\\d+[.)]\\s*", "", txt)
txt = sub("^\\.\\s*", "", txt)
trimws(txt)
}
# Stufe (0-4) fuer Teil II AUSSCHLIESSLICH ueber das labels-Attribut der Original-
# spalte ableiten: Rohwert -> Label-Text -> Stufe (via FASSR_STUFEN_MAP).
# Bei fehlenden/abweichenden Labels wird gewarnt statt stillschweigend NA/0 zu setzen.
fassr_get_level = function(original_col, wert, item_name = "") {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_integer_)
lbl_attr = attr(original_col, "labels")
if (is.null(lbl_attr) || length(lbl_attr) == 0) {
warning(paste0("FAS-SR: Kein labels-Attribut fuer '", item_name,
"' - Stufe kann nicht sicher abgeleitet werden."))
return(NA_integer_)
}
pos = which(as.vector(lbl_attr) == as.numeric(wert[1]))
if (length(pos) == 0) {
warning(paste0("FAS-SR: Rohwert von '", item_name,
"' nicht im labels-Attribut gefunden."))
return(NA_integer_)
}
label_text = names(lbl_attr)[pos[1]]
if (!label_text %in% names(FASSR_STUFEN_MAP)) {
warning(paste0("FAS-SR: Unerwarteter Label-Text '", label_text, "' bei '",
item_name, "' - Stufenzuordnung nicht sicher."))
return(NA_integer_)
}
as.integer(FASSR_STUFEN_MAP[[label_text]])
}
# Ankertext (Original-Antworttext) aus dem labels-Attribut, sonst Fallback auf
# die feste FAS-SR-Antwortskala anhand der bereits abgeleiteten Stufe.
fassr_get_anker = function(original_col, wert, stufe) {
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(wert[1]))
if (length(pos) > 0) return(names(lbl_attr)[pos[1]])
}
if (!is.na(stufe) && stufe >= 0L && stufe <= 4L) return(FASSR_STUFEN_TEXTE[stufe + 1L])
NA_character_
}
# Label-Text (z.B. fuer fas_geschlecht, fas_beziehung) ueber das labels-Attribut.
fassr_get_label_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(wert[1]))
if (length(pos) > 0) return(names(lbl_attr)[pos[1]])
}
NA_character_
}
# ANNAHME: Teil-I-"check"-Items gelten als angekreuzt, wenn ihr Wert weder NA
# noch 0/FALSE/"0" ist. Mit echten formr-Testdaten zu verifizieren.
fassr_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))
wc = trimws(as.character(w))
if (wc %in% c("0", "FALSE", "false", "")) return(FALSE)
TRUE
}
make_gauge_fassr = function(score) {
ref_lower = max(0, FASSR_REF_MEAN - FASSR_REF_SD)
ref_upper = min(FASSR_SCORE_MAX, FASSR_REF_MEAN + FASSR_REF_SD)
ggplot() +
geom_rect(aes(xmin = 0, xmax = FASSR_SCORE_MAX, ymin = 0, ymax = 1),
fill = "#F5F5F5", color = NA) +
geom_rect(aes(xmin = ref_lower, xmax = ref_upper, ymin = 0, ymax = 1),
fill = "#E3E8F5", color = NA) +
geom_rect(aes(xmin = 0, xmax = FASSR_SCORE_MAX, ymin = 0, ymax = 1),
fill = NA, color = "#9E9E9E", linewidth = 0.6) +
geom_vline(xintercept = FASSR_REF_MEAN, color = "#5C6BC0",
linetype = "dashed", linewidth = 0.8) +
geom_segment(aes(x = score, xend = score, y = -0.25, yend = 1.25),
color = AKZENT_FARBE, linewidth = 2.5) +
geom_label(aes(x = score, y = 1.6, label = paste0("Score: ", score)),
fill = AKZENT_FARBE, color = "white", fontface = "bold",
linewidth = 0, size = 4) +
annotate("text", x = (ref_lower + ref_upper) / 2, y = -0.55,
label = "Vergleichsstichprobe (Wu et al., 2016, N=61) - kein klinischer Cutoff",
color = "#5C6BC0", size = 3, hjust = 0.5) +
scale_x_continuous(limits = c(-5, FASSR_SCORE_MAX + 5),
breaks = c(0, 20, 40, 60, FASSR_SCORE_MAX)) +
scale_y_continuous(limits = c(-0.8, 2.0)) +
theme_minimal(base_size = 12) +
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(),
plot.margin = margin(t = 5, r = 10, b = 5, l = 10)
) +
labs(x = paste0("FAS-SR Gesamtscore (0-", FASSR_SCORE_MAX, ")"), y = NULL)
}
# 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; }
#download_word {
background: #8B2635; color: white; border: none;
font-weight: 600; padding: 8px 20px; border-radius: 4px;
}
#download_word:hover { background: #6d1e29; color: white; }
.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; }
.kontext-zeile {
display: flex; gap: 8px; align-items: baseline;
padding: 4px 0; color: #444; font-size: 0.93em;
}
.kontext-label { font-weight: 600; color: #333; min-width: 260px; }
.referenz-hinweis {
font-size: 0.85em; color: #666; font-style: italic;
margin-top: 6px; padding: 0 4px;
}
.teil1-nicht-scoregebend {
font-size: 0.82em; color: #888; font-style: italic; margin-bottom: 10px;
}
.teil1-liste { margin: 0; padding-left: 18px; color: #333; font-size: 0.93em; }
.teil1-liste li { padding: 2px 0; }
.teil1-keine-angabe { color: #999; font-style: italic; font-size: 0.93em; }
.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: 30px; 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: #AED581; color: #333333; }
.stufe-badge-2 { background: #F48FB1; color: #333333; }
.stufe-badge-3 { background: #EF5350; color: white; }
.stufe-badge-4 { background: #B71C1C; color: white; }
.score-zahl { font-size: 2.2rem; font-weight: 800; color: #8B2635; }
.cutoff-info { font-size: 0.88em; color: #555; margin-top: 4px; }
"
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("FAS-SR Family Accommodation Scale (Angehörigenversion, Self-Report)"),
tags$p("Wu et al., 2016 | Fremdbericht des Familienmitglieds zur eigenen Anpassung an die Zwangssymptome der/des Patient:in")
),
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_fassr_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_score = fp_text(bold = TRUE, font.size = 12, color = AKZENT_FARBE)
doc = body_add_fpar(doc, fpar(ftext("FAS-SR Auswertung", fp_titel)))
doc = body_add_fpar(doc, fpar(
ftext("Chiffre: ", fp_label), ftext(erg$chiffre, fp_normal),
ftext(" Ausfülldatum: ", fp_label), ftext(erg$datum_str, fp_normal)
))
if (!is.null(erg$warnung)) {
doc = body_add_fpar(doc, fpar(
ftext(erg$warnung, fp_text(font.size = 10, italic = TRUE, color = "#BF360C"))
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Gesamtscore (Teil II)", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(
ftext(paste0(erg$gesamt, " / ", FASSR_SCORE_MAX), fp_score)
))
doc = body_add_fpar(doc, fpar(
ftext(paste0(
"Vergleichsstichprobe (Wu et al., 2016, N=61): M = ", FASSR_REF_MEAN,
", SD = ", FASSR_REF_SD, " - kein klinischer Cutoff."
), fp_text(font.size = 9, italic = TRUE, color = "#666666"))
))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(
"Vom Familienmitglied berichtete Zwangssymptome der/des Patient:in (Teil I, unverrechnet)",
fp_abschnitt)))
doc = body_add_fpar(doc, fpar(
ftext("Zwangsgedanken: ", fp_label),
ftext(if (length(erg$t1_zg_angekreuzt) == 0) "Keine Angabe" else
paste(erg$t1_zg_angekreuzt, collapse = "; "), fp_normal)
))
doc = body_add_fpar(doc, fpar(
ftext("Zwangshandlungen: ", fp_label),
ftext(if (length(erg$t1_zh_angekreuzt) == 0) "Keine Angabe" else
paste(erg$t1_zh_angekreuzt, collapse = "; "), fp_normal)
))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("FAS-SR Einzelitems (Teil II)", fp_abschnitt)))
for (i in seq_len(nrow(erg$items))) {
it = erg$items[i, ]
sk = if (!is.na(it$stufe) && it$stufe >= 0 && it$stufe <= 4) as.character(it$stufe) else "0"
anker_txt = if (!is.na(it$anker)) it$anker else FASSR_STUFEN_TEXTE[as.integer(sk) + 1L]
fp_badge = fp_text(
color = FASSR_BADGE_TEXT_FARBEN[[sk]], bold = TRUE,
shading.color = FASSR_BADGE_FARBEN[[sk]], font.size = 10
)
doc = body_add_fpar(doc, fpar(
ftext(paste0(it$nr, ". ", it$text, " "), fp_normal),
ftext(paste0(" ", anker_txt, " "), fp_badge)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(FASSR_DISCLAIMER, fp_disclaimer)))
doc
}
# Server ####
server = function(input, output, session) {
# --- pseudonym-support-injection v1 ---
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_roh = input$chiffre
if (is.null(chiffre_roh) || (nchar(trimws(input$pseudonym)) == 0 && nchar(trimws(chiffre_roh)) == 0)) {
return(list(typ = "leere_eingabe",
meldung = "Bitte Chiffre eingeben."))
}
chiffre = toupper(trimws(input$chiffre))
if (!(nchar(trimws(input$pseudonym)) > 0 || grepl("^[A-Z][0-9]{6}$", chiffre))) {
return(list(typ = "format_fehler", chiffre = chiffre,
meldung = "Chiffre-Format ungültig (erwartet: Buchstabe + 6 Ziffern, z. B. P000123)."))
}
if (!file.exists(PFAD_DOWNLOAD_SKRIPT)) {
return(list(typ = "skript_fehler",
meldung = paste0("Download-Skript nicht gefunden unter:\n", PFAD_DOWNLOAD_SKRIPT)))
}
if (!file.exists(PFAD_PSEUDONYM_SKRIPT)) {
return(list(typ = "skript_fehler",
meldung = paste0("Pseudonym-Skript nicht gefunden unter:\n", PFAD_PSEUDONYM_SKRIPT)))
}
ok_download = tryCatch({
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok_download$ok) {
return(list(typ = "skript_fehler",
meldung = paste0("Fehler im Download-Skript: ", ok_download$msg)))
}
db_ordner = local({
ordner = dirname(normalizePath(PFAD_PSEUDONYM_SKRIPT, mustWork = FALSE))
gefunden = NULL
for (i in 1:5) {
if (file.exists(file.path(ordner, "pseudonyme.db"))) { gefunden = ordner; break }
elternteil = dirname(ordner)
if (elternteil == ordner) break
ordner = elternteil
}
gefunden
})
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_pseudo = tryCatch({
source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
if (nchar(trimws(input$pseudonym)) > 0) {
.pw_wert = trimws(input$pseudonym)
.pw_tab = get("pseudo", envir = .GlobalEnv)
.pw_treffer = .pw_tab[.pw_tab$pseudonym == .pw_wert, ]
if (nrow(.pw_treffer) > 0) chiffre = toupper(trimws(.pw_treffer$chiffre[1]))
}
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok_pseudo$ok) {
return(list(typ = "skript_fehler",
meldung = paste0("Fehler im Pseudonym-Skript: ", ok_pseudo$msg)))
}
if (!exists("daten_fassr", envir = .GlobalEnv)) {
return(list(typ = "skript_fehler",
meldung = "Objekt 'daten_fassr' wurde nach dem Sourcen des Download-Skripts nicht gefunden."))
}
if (!exists("pseudo", envir = .GlobalEnv)) {
return(list(typ = "skript_fehler",
meldung = "Objekt 'pseudo' wurde nach dem Sourcen des Pseudonym-Skripts nicht gefunden."))
}
daten = get("daten_fassr", envir = .GlobalEnv)
pseudo_df = get("pseudo", envir = .GlobalEnv)
treffer_pseudo = pseudo_df[pseudo_df$chiffre == chiffre, ]
if (nrow(treffer_pseudo) == 0) {
return(list(typ = "chiffre_unbekannt",
meldung = "Keine Zuordnung für diese Chiffre gefunden."))
}
alle_pseudonyme = unique(treffer_pseudo$pseudonym)
if (nchar(trimws(input$pseudonym)) > 0) alle_pseudonyme = trimws(input$pseudonym)
treffer_daten = daten[daten$session %in% alle_pseudonyme, ]
if (nrow(treffer_daten) == 0) {
return(list(typ = "kein_datensatz",
meldung = "Für dieses Pseudonym liegen keine FAS-SR-Daten vor."))
}
warnung = NULL
if (nrow(treffer_daten) > 1) {
treffer_daten = treffer_daten[order(treffer_daten$created, decreasing = TRUE), ]
datum_neu = tryCatch(
format(as.POSIXct(treffer_daten$created[1]), "%d.%m.%Y"),
error = function(e) "unbekanntes Datum"
)
warnung = paste0(
"Mehrere Ausfüllungen gefunden, es wird die neueste vom ", datum_neu, " angezeigt."
)
treffer_daten = treffer_daten[1, , drop = FALSE]
}
zeile = treffer_daten[1, , drop = FALSE]
# Ausfuelldatum aus 'created'; Fallback auf Sys.Date(), falls Spalte fehlt
# oder nicht parsebar ist. Datumsformat in daten_fassr$created beim Testen
# mit echten Daten verifizieren.
datum_str = tryCatch(
format(as.POSIXct(zeile[["created"]][1]), "%d.%m.%Y"),
error = function(e) format(Sys.Date(), "%d.%m.%Y")
)
geschlecht_text = fassr_get_label_text(daten[["fas_geschlecht"]], zeile[["fas_geschlecht"]])
beziehung_text = fassr_get_label_text(daten[["fas_beziehung"]], zeile[["fas_beziehung"]])
beziehung_andere = if (!is.na(beziehung_text) && beziehung_text == "andere/r") {
wert = zeile[["fas_beziehung_andere_text"]][1]
if (is.null(wert) || is.na(wert) || trimws(as.character(wert)) == "") NA_character_
else trimws(as.character(wert))
} else NA_character_
# Teil I - unverrechnete Symptom-Checkliste (nur Kontext, nicht scoregebend).
zg_vars = paste0("fas_t1_zg_", sprintf("%02d", 1:8))
zh_vars = paste0("fas_t1_zh_", sprintf("%02d", 1:7))
zg_labels = sapply(seq_along(zg_vars), function(i) {
lbl = clean_item_label(attr(daten[[zg_vars[i]]], "label"))
if (is.na(lbl) || nchar(lbl) == 0) FASSR_T1_ZG_FALLBACK[i] else lbl
})
zh_labels = sapply(seq_along(zh_vars), function(i) {
lbl = clean_item_label(attr(daten[[zh_vars[i]]], "label"))
if (is.na(lbl) || nchar(lbl) == 0) FASSR_T1_ZH_FALLBACK[i] else lbl
})
zg_angekreuzt = sapply(zg_vars, function(v) fassr_ist_angekreuzt(zeile[[v]]))
zh_angekreuzt = sapply(zh_vars, function(v) fassr_ist_angekreuzt(zeile[[v]]))
t1_zg_angekreuzt = zg_labels[zg_angekreuzt]
t1_zh_angekreuzt = zh_labels[zh_angekreuzt]
# Teil II - Gesamtscore-relevante Itemliste.
ii_vars = paste0("fas_ii_", sprintf("%02d", 1:19))
items = data.frame(
nr = 1:19,
var = ii_vars,
text = sapply(ii_vars, function(v) {
txt = clean_item_label(attr(daten[[v]], "label"))
if (is.na(txt) || nchar(txt) == 0) paste0("Item ", which(ii_vars == v)) else txt
}),
stringsAsFactors = FALSE
)
items$stufe = NA_integer_
items$anker = NA_character_
for (i in seq_len(nrow(items))) {
var = items$var[i]
items$stufe[i] = fassr_get_level(daten[[var]], zeile[[var]], item_name = var)
items$anker[i] = fassr_get_anker(daten[[var]], zeile[[var]], items$stufe[i])
}
# Anzeigereihenfolge nach Stufenwert absteigend (staerkste Belastung zuerst),
# unbeantwortete Items (Stufe NA) ans Ende; die Original-Itemnummer (nr) bleibt
# am Item erhalten, nur die Reihenfolge in der Liste aendert sich.
items = items[order(-items$stufe, items$nr, na.last = TRUE), ]
gesamt = sum(items$stufe, na.rm = TRUE)
list(
typ = "ok",
chiffre = chiffre,
datum_str = datum_str,
warnung = warnung,
geschlecht_text = geschlecht_text,
beziehung_text = beziehung_text,
beziehung_andere = beziehung_andere,
t1_zg_angekreuzt = t1_zg_angekreuzt,
t1_zh_angekreuzt = t1_zh_angekreuzt,
items = items,
gesamt = gesamt
)
})
output$fehler_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (d$typ != "ok") div(class = "alert-fehler", d$meldung) else NULL
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (d$typ != "ok" || is.null(d$warnung)) return(NULL)
div(class = "alert-warnung", d$warnung)
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (d$typ != "ok") return(NULL)
items_ui = lapply(seq_len(nrow(d$items)), function(i) {
it = d$items[i, ]
sk = if (!is.na(it$stufe) && it$stufe >= 0 && it$stufe <= 4) as.character(it$stufe) else "0"
anker_txt = if (!is.na(it$anker)) it$anker else FASSR_STUFEN_TEXTE[as.integer(sk) + 1L]
div(class = "item-zeile",
div(class = "item-nr", paste0(it$nr, ".")),
div(class = "item-text", it$text),
span(class = paste0("stufe-badge stufe-badge-", sk), anker_txt)
)
})
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "FAS-SR Auswertung"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfülldatum: "), d$datum_str
),
div(class = "kontext-zeile",
div(class = "kontext-label", "Geschlecht des ausfüllenden Familienmitglieds:"),
div(if (is.na(d$geschlecht_text)) "Keine Angabe" else d$geschlecht_text)
),
div(class = "kontext-zeile",
div(class = "kontext-label", "Beziehung des ausfüllenden Familienmitglieds zur/zum Patient:in:"),
div(
if (is.na(d$beziehung_text)) "Keine Angabe" else d$beziehung_text,
if (!is.na(d$beziehung_andere)) paste0(" (", d$beziehung_andere, ")") else ""
)
),
tags$hr(),
tags$h5("Gesamtscore (Teil II)"),
fluidRow(
column(3,
div(
div(class = "score-zahl", d$gesamt),
div(paste0("Gesamtscore (0", FASSR_SCORE_MAX, ")"), style = "color:#555;")
)
),
column(9, plotOutput("gauge_plot", height = "160px"))
),
div(class = "referenz-hinweis", FASSR_REFERENZ_HINWEIS),
tags$hr(),
div(class = "abschnitt-titel",
"Vom Familienmitglied berichtete Zwangssymptome der/des Patient:in (Teil I, unverrechnet)"),
div(class = "teil1-nicht-scoregebend", "Nicht scoregebend, dient nur der Kontextinformation."),
fluidRow(
column(6,
tags$h5("Zwangsgedanken"),
if (length(d$t1_zg_angekreuzt) == 0)
div(class = "teil1-keine-angabe", "Keine Angabe")
else
tags$ul(class = "teil1-liste",
lapply(d$t1_zg_angekreuzt, function(x) tags$li(x))
)
),
column(6,
tags$h5("Zwangshandlungen"),
if (length(d$t1_zh_angekreuzt) == 0)
div(class = "teil1-keine-angabe", "Keine Angabe")
else
tags$ul(class = "teil1-liste",
lapply(d$t1_zh_angekreuzt, function(x) tags$li(x))
)
)
),
tags$hr(),
tags$h5("FAS-SR Einzelitems (Teil II)"),
div(items_ui)
)
})
output$gauge_plot = renderPlot({
req(input$btn_suchen)
d = ergebnis_r()
req(identical(d$typ, "ok"))
make_gauge_fassr(d$gesamt)
}, bg = "transparent")
output$download_word = downloadHandler(
filename = function() {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
chiffre_esc = if (is.list(d) && identical(d$typ, "ok")) d$chiffre else "export"
datum = if (is.list(d) && identical(d$typ, "ok") && !is.null(d$datum_str))
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("FASSR_", chiffre_esc, "_", datum, ".docx")
},
content = function(file) {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
daten_ok = is.list(d) && identical(d$typ, "ok")
if (!daten_ok) {
doc = read_docx()
doc = body_add_par(doc,
"Kein Datensatz geladen. Bitte zuerst Chiffre eingeben und 'Auswerten' klicken.",
style = "Normal")
print(doc, target = file)
return()
}
doc = tryCatch(
erstelle_fassr_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)