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

744 lines
27 KiB
R
Raw 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 ####
AKZENT_FARBE = "#8B2635"
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_fas_pv.R"
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R"
FASPV_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Es liegen keine publizierten klinischen Cutoffs fuer ",
"die FAS-PV 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."
)
FASPV_GAUGE_HINWEIS = paste0(
"Es liegen keine publizierten klinischen Cutoffs fuer die FAS-PV vor. Der ",
"markierte Bereich zeigt Mittelwert ± Standardabweichung einer kleinen ",
"Validierungsstichprobe (N=61) zur groben Einordnung, keine diagnostische Schwelle."
)
# Referenzwerte Wu et al. (2016), N=61 - explizit KEINE Cutoffs.
FASPV_REF_MEAN = 14.34
FASPV_REF_SD = 12.87
FASPV_REF_RANGE = c(0, 53)
FASPV_SCORE_MAX = 76
FASPV_REF_ALPHA = 0.88
# Zuordnung Label-Text -> Stufe (0-4) fuer Teil II. Nie hartkodierte Rohwerte
# verwenden - die formr-Kodierung (0- oder 1-basiert) ist fuer diese
# Installation nicht verifiziert.
FASPV_STUFEN_MAP = c(
"nie" = 0L,
"1 Tag" = 1L,
"2-3 Tage" = 2L,
"4-6 Tage" = 3L,
"Jeden Tag" = 4L
)
# Verlauf gruen -> dunkelrot entspricht den 5 Antwortstufen 0-4 der FAS-PV.
FASPV_BADGE_FARBEN = c(
"0" = "#4CAF50",
"1" = "#C0CA33",
"2" = "#F48FB1",
"3" = "#EF5350",
"4" = "#4A0000"
)
FASPV_BADGE_TEXT_FARBEN = c(
"0" = "white",
"1" = "#333333",
"2" = "#333333",
"3" = "white",
"4" = "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 ####
# Entfernt formr-Markdown-Sternchen und Nummerierungsartefakte aus Label-Texten
# (Fragetexte kommen ausschliesslich aus dem label-Attribut, nie hartkodiert im Code).
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)
# formr escaped den Punkt der Nummerierung oft als "14\." statt "14.",
# damit Markdown daraus keine Aufzaehlung macht - Backslash optional mitfangen.
txt = sub("^\\d+\\\\?[.)]\\s*", "", txt)
txt = sub("^\\\\?\\.\\s*", "", txt)
trimws(txt)
}
# Liefert den Label-Text (Original-Antworttext) fuer einen Rohwert, gelesen
# aus dem labels-Attribut der ORIGINAL-Spalte (vor Subsetting).
faspv_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(trimws(names(lbl_attr)[pos[1]]))
}
NA_character_
}
# Stufenableitung (0-4) fuer Teil II: Rohwert -> Label-Text (aus labels-Attribut
# der Original-Spalte) -> Stufe (ueber FASPV_STUFEN_MAP per exaktem Textabgleich).
# Niemals aus dem Rohwert selbst ableiten. Bei unbekanntem Label-Text wird eine
# Warnung zurueckgegeben statt stillschweigend NA/0 zu setzen.
faspv_teil2_item = function(original_col, wert) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) {
return(list(stufe = NA_integer_, anker = NA_character_, warnung = NULL))
}
lbl_attr = attr(original_col, "labels")
if (is.null(lbl_attr) || length(lbl_attr) == 0) {
return(list(
stufe = NA_integer_, anker = NA_character_,
warnung = "Keine labels im Spaltenattribut gefunden - Stufe nicht ableitbar."
))
}
pos = which(as.vector(lbl_attr) == as.numeric(wert[1]))
if (length(pos) == 0) {
return(list(
stufe = NA_integer_, anker = NA_character_,
warnung = paste0("Rohwert '", wert[1], "' nicht im labels-Attribut gefunden.")
))
}
anker = trimws(names(lbl_attr)[pos[1]])
if (!anker %in% names(FASPV_STUFEN_MAP)) {
return(list(
stufe = NA_integer_, anker = anker,
warnung = paste0(
"Unerwarteter Label-Text '", anker, "' - Stufenzuordnung nicht sicher ",
"(erwartet: ", paste(names(FASPV_STUFEN_MAP), collapse = ", "), ")."
)
))
}
list(stufe = as.integer(FASPV_STUFEN_MAP[[anker]]), anker = anker, warnung = NULL)
}
# ANNAHME: Kodierung des check-Typs in dieser formr-Installation ist nicht
# verifiziert. Ein Wert gilt als "angekreuzt", wenn er nicht NA und nicht
# 0/FALSE/"0" ist. Bei erstem echten Testlauf zu verifizieren.
faspv_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))
if (is.character(w)) return(!(trimws(w) %in% c("", "0", "FALSE")))
isTRUE(as.numeric(w) != 0)
}
make_gauge_faspv = function(score) {
ref_lo = max(0, FASPV_REF_MEAN - FASPV_REF_SD)
ref_hi = FASPV_REF_MEAN + FASPV_REF_SD
ref_label = paste0(
"Vergleichsstichprobe (Wu et al., 2016, N=61) - kein klinischer Cutoff ",
"(M=", FASPV_REF_MEAN, ", SD=", FASPV_REF_SD, ")"
)
ggplot() +
geom_rect(aes(xmin = 0, xmax = FASPV_SCORE_MAX, ymin = 0, ymax = 1),
fill = "#F5F5F5", color = NA) +
geom_rect(aes(xmin = ref_lo, xmax = ref_hi, ymin = 0, ymax = 1),
fill = "#F5E6E8", color = NA) +
geom_rect(aes(xmin = 0, xmax = FASPV_SCORE_MAX, ymin = 0, ymax = 1),
fill = NA, color = "#9E9E9E", linewidth = 0.6) +
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, " / ", FASPV_SCORE_MAX)),
fill = AKZENT_FARBE, color = "white", fontface = "bold",
linewidth = 0, size = 4) +
annotate("text", x = (ref_lo + ref_hi) / 2, y = -0.55,
label = ref_label, color = "#6d1e29", size = 3, hjust = 0.5) +
scale_x_continuous(limits = c(0, FASPV_SCORE_MAX),
breaks = c(0, 19, 38, 57, FASPV_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-PV Gesamtscore (0-", FASPV_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: 240px; }
.teil1-hinweis {
font-size: 0.85em; color: #777; font-style: italic; margin-bottom: 12px;
}
.teil1-liste { margin: 4px 0 14px 0; padding-left: 20px; color: #333; font-size: 0.92em; }
.teil1-leer { color: #999; font-style: italic; font-size: 0.92em; }
.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: 26px; 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: #C0CA33; color: #333333; }
.stufe-badge-2 { background: #F48FB1; color: #333333; }
.stufe-badge-3 { background: #EF5350; color: white; }
.stufe-badge-4 { background: #4A0000; color: white; }
.score-zahl { font-size: 2.2rem; font-weight: 800; color: #8B2635; }
.cutoff-info { font-size: 0.88em; color: #555; margin-top: 4px; line-height: 1.5; }
"
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-PV Family Accommodation Scale (Patientenversion)"),
tags$p("Wu, M. S. et al. 2016 | Erfassung von Familienakkommodation bei Zwangsstoerungen")
),
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_faspv_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_score = fp_text(bold = TRUE, font.size = 14, color = AKZENT_FARBE)
fp_disclaimer = fp_text(font.size = 9, italic = TRUE, color = "#777777")
fp_hinweis = fp_text(font.size = 9, italic = TRUE, color = "#777777")
doc = body_add_fpar(doc, fpar(ftext("FAS-PV 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$ausfuelldatum, fp_normal)
))
if (!is.na(erg$geschlecht_text) || !is.na(erg$beziehung_text)) {
beziehung_anzeige = if (is.na(erg$beziehung_text)) "k. A." else {
if (!is.null(erg$beziehung_andere) && nchar(erg$beziehung_andere) > 0)
paste0(erg$beziehung_text, " (", erg$beziehung_andere, ")")
else erg$beziehung_text
}
doc = body_add_fpar(doc, fpar(
ftext("Geschlecht: ", fp_label),
ftext(if (is.na(erg$geschlecht_text)) "k. A." else erg$geschlecht_text, fp_normal),
ftext(" Beziehung: ", fp_label),
ftext(beziehung_anzeige, fp_normal)
))
}
if (!is.null(erg$warnung_mehrfach)) {
doc = body_add_fpar(doc, fpar(
ftext(erg$warnung_mehrfach, fp_text(font.size = 10, italic = TRUE, color = "#BF360C"))
))
}
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, " / ", FASPV_SCORE_MAX), fp_score)
))
doc = body_add_fpar(doc, fpar(ftext(paste0(
"Vergleichsstichprobe (Wu et al., 2016, N=61): M=", FASPV_REF_MEAN,
", SD=", FASPV_REF_SD, ", beobachteter Range ", FASPV_REF_RANGE[1], "-",
FASPV_REF_RANGE[2], " (Cronbachs Alpha=", FASPV_REF_ALPHA, "). ",
"Es liegen keine publizierten klinischen Cutoffs vor, dies dient nur der ",
"groben Einordnung."
), fp_hinweis)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(
"Teil I Berichtete Zwangssymptome (unverrechnet)", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(ftext(
"Diese Angaben fliessen nicht in den Gesamtscore ein.",
fp_text(font.size = 9, italic = TRUE, color = "#777777"))))
doc = body_add_fpar(doc, fpar(ftext("Zwangsgedanken: ", fp_label)))
zg_txt = if (nrow(erg$teil1_zg[erg$teil1_zg$angekreuzt, ]) == 0) "Keine Angabe" else
paste(erg$teil1_zg$text[erg$teil1_zg$angekreuzt], collapse = "; ")
doc = body_add_fpar(doc, fpar(ftext(zg_txt, fp_normal)))
doc = body_add_fpar(doc, fpar(ftext("Zwangshandlungen: ", fp_label)))
zh_txt = if (nrow(erg$teil1_zh[erg$teil1_zh$angekreuzt, ]) == 0) "Keine Angabe" else
paste(erg$teil1_zh$text[erg$teil1_zh$angekreuzt], collapse = "; ")
doc = body_add_fpar(doc, fpar(ftext(zh_txt, fp_normal)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Teil II Einzelitems", 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 "k. A."
fp_badge = fp_text(
color = FASPV_BADGE_TEXT_FARBEN[[sk]], bold = TRUE,
shading.color = FASPV_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(FASPV_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 = "pfad_fehler",
meldung = paste0("Download-Skript nicht gefunden unter:\n", PFAD_DOWNLOAD_SKRIPT)))
}
if (!file.exists(PFAD_PSEUDONYM_SKRIPT)) {
return(list(typ = "pfad_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_faspv", envir = .GlobalEnv)) {
return(list(typ = "objekt_fehler",
meldung = "Objekt 'daten_faspv' wurde nach dem Sourcen des Download-Skripts nicht gefunden."))
}
if (!exists("pseudo", envir = .GlobalEnv)) {
return(list(typ = "objekt_fehler",
meldung = "Objekt 'pseudo' wurde nach dem Sourcen des Pseudonym-Skripts nicht gefunden."))
}
daten = get("daten_faspv", 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-PV-Daten vor."))
}
n_treffer = nrow(treffer_daten)
if (n_treffer > 1) {
treffer_daten = treffer_daten[order(treffer_daten$created, decreasing = TRUE), ]
}
zeile = treffer_daten[1, , drop = FALSE]
# ANNAHME: 'created' ist das (Erst-)Ausfuelldatum; Format bei echten Daten
# verifizieren (siehe str(daten_faspv$created)), Fallback bei Parsing-Fehler.
ausfuelldatum = tryCatch(
format(as.POSIXct(zeile[["created"]][1]), "%d.%m.%Y"),
error = function(e) format(Sys.Date(), "%d.%m.%Y")
)
warnung_mehrfach = NULL
if (n_treffer > 1) {
warnung_mehrfach = paste0(
"Mehrere Ausfüllungen gefunden, es wird die neueste vom ",
ausfuelldatum, " angezeigt."
)
}
geschlecht_text = faspv_get_label_text(daten[["fas_geschlecht"]], zeile[["fas_geschlecht"]])
beziehung_text = faspv_get_label_text(daten[["fas_beziehung"]], zeile[["fas_beziehung"]])
beziehung_andere = NULL
if (!is.na(beziehung_text) && tolower(beziehung_text) == "andere/r") {
roh = zeile[["fas_beziehung_andere_text"]][1]
if (!is.null(roh) && !is.na(roh) && nchar(trimws(as.character(roh))) > 0) {
beziehung_andere = trimws(as.character(roh))
}
}
# Teil I: unverrechnete Symptom-Checkliste, nur Kontextanzeige.
zg_vars = paste0("fas_t1_zg_", sprintf("%02d", 1:8))
zh_vars = paste0("fas_t1_zh_", sprintf("%02d", 1:7))
teil1_zg = data.frame(
var = zg_vars,
text = sapply(zg_vars, function(v) clean_item_label(attr(daten[[v]], "label"))),
angekreuzt = sapply(zg_vars, function(v) faspv_ist_angekreuzt(zeile[[v]])),
stringsAsFactors = FALSE
)
teil1_zh = data.frame(
var = zh_vars,
text = sapply(zh_vars, function(v) clean_item_label(attr(daten[[v]], "label"))),
angekreuzt = sapply(zh_vars, function(v) faspv_ist_angekreuzt(zeile[[v]])),
stringsAsFactors = FALSE
)
# Teil II: 19 Items, Stufen 0-4 aus dem labels-Attribut abgeleitet, Summe = Gesamtscore.
ii_vars = paste0("fas_ii_", sprintf("%02d", 1:19))
items = data.frame(nr = 1:19, var = ii_vars, stringsAsFactors = FALSE)
items$text = sapply(ii_vars, function(v) clean_item_label(attr(daten[[v]], "label")))
items$stufe = NA_integer_
items$anker = NA_character_
label_warnungen = character(0)
for (i in seq_len(nrow(items))) {
v = items$var[i]
erg_item = faspv_teil2_item(daten[[v]], zeile[[v]])
items$stufe[i] = erg_item$stufe
items$anker[i] = erg_item$anker
if (!is.null(erg_item$warnung)) {
label_warnungen = c(label_warnungen, paste0(v, ": ", erg_item$warnung))
}
}
label_warnungen = unique(label_warnungen)
# 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), ]
gesamtscore = sum(items$stufe, na.rm = TRUE)
list(
typ = "ok",
chiffre = chiffre,
ausfuelldatum = ausfuelldatum,
warnung_mehrfach = warnung_mehrfach,
label_warnungen = if (length(label_warnungen) > 0) label_warnungen else NULL,
geschlecht_text = geschlecht_text,
beziehung_text = beziehung_text,
beziehung_andere = beziehung_andere,
teil1_zg = teil1_zg,
teil1_zh = teil1_zh,
items = items,
gesamtscore = gesamtscore
)
})
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") return(NULL)
meldungen = c(d$warnung_mehrfach, d$label_warnungen)
if (length(meldungen) == 0) return(NULL)
tagList(lapply(meldungen, function(m) div(class = "alert-warnung", m)))
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (d$typ != "ok") return(NULL)
beziehung_anzeige = if (is.na(d$beziehung_text)) "k. A." else {
if (!is.null(d$beziehung_andere) && nchar(d$beziehung_andere) > 0)
paste0(d$beziehung_text, " (", d$beziehung_andere, ")")
else d$beziehung_text
}
zg_angekreuzt = d$teil1_zg[d$teil1_zg$angekreuzt, ]
zh_angekreuzt = d$teil1_zh[d$teil1_zh$angekreuzt, ]
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 "k. A."
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-PV Auswertung"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfülldatum: "), d$ausfuelldatum
),
div(class = "kontext-zeile",
div(class = "kontext-label", "Geschlecht (Angehörige/r):"),
div(if (is.na(d$geschlecht_text)) "k. A." else d$geschlecht_text)
),
div(class = "kontext-zeile",
div(class = "kontext-label", "Beziehung zur Patientin/zum Patienten:"),
div(beziehung_anzeige)
),
tags$hr(),
tags$h5("Gesamtscore (Teil II)"),
fluidRow(
column(3,
div(
div(class = "score-zahl", d$gesamtscore),
div(paste0("Gesamtscore (0-", FASPV_SCORE_MAX, ")"), style = "color:#555;")
)
),
column(9, plotOutput("gauge_plot", height = "160px"))
),
div(class = "cutoff-info", FASPV_GAUGE_HINWEIS),
tags$hr(),
div(class = "abschnitt-titel", "Berichtete Zwangssymptome (Teil I, unverrechnet)"),
div(class = "teil1-hinweis", "Diese Angaben fliessen nicht in den Gesamtscore ein."),
fluidRow(
column(6,
tags$h5("Zwangsgedanken"),
if (nrow(zg_angekreuzt) == 0)
div(class = "teil1-leer", "Keine Angabe")
else
tags$ul(class = "teil1-liste", lapply(zg_angekreuzt$text, tags$li))
),
column(6,
tags$h5("Zwangshandlungen"),
if (nrow(zh_angekreuzt) == 0)
div(class = "teil1-leer", "Keine Angabe")
else
tags$ul(class = "teil1-liste", lapply(zh_angekreuzt$text, tags$li))
)
),
tags$hr(),
tags$h5("Teil II Einzelitems"),
div(items_ui)
)
})
output$gauge_plot = renderPlot({
req(input$btn_suchen)
d = ergebnis_r()
req(identical(d$typ, "ok"))
make_gauge_faspv(d$gesamtscore)
}, bg = "transparent")
output$download_word = downloadHandler(
filename = function() {
erg = tryCatch(ergebnis_r(), error = function(e) NULL)
ok = is.list(erg) && identical(erg$typ, "ok")
chiffre_esc = if (ok) erg$chiffre else "export"
ausfuelldatum_fn = if (ok && !is.null(erg$ausfuelldatum)) {
tryCatch(
format(as.Date(erg$ausfuelldatum, "%d.%m.%Y"), "%Y%m%d"),
error = function(e) format(Sys.Date(), "%Y%m%d")
)
} else {
format(Sys.Date(), "%Y%m%d")
}
paste0("FASPV_", chiffre_esc, "_", ausfuelldatum_fn, ".docx")
},
content = function(file) {
erg = tryCatch(ergebnis_r(), error = function(e) NULL)
daten_ok = is.list(erg) && identical(erg$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_faspv_docx(erg),
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)