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

694 lines
24 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_oci4.R"
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R"
OCI4_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die genannten Cutoffs sind Screening-Hinweise aus der Testquelle, ",
"keine Ausschluss- oder Einschlusskriterien. Die Interpretation obliegt der behandelnden Person."
)
# Reihenfolge = Anzeige-/Auswertungsreihenfolge der vier Items.
OCI4_ITEMS = data.frame(
var = c("oci4_1_ordnen", "oci4_2_kontrollieren", "oci4_3_waschen", "oci4_4_zwangsgedanken"),
subskala = c("Ordnen", "Kontrollieren", "Waschen", "Zwangsgedanken"),
itemtext_fallback = c(
"Es stoert mich, wenn Gegenstaende nicht richtig angeordnet sind.",
"Ich kontrolliere immer wieder Tueren, Fenster, Schubladen usw.",
"Manchmal muss ich mich waschen oder reinigen, einfach weil ich mich kontaminiert/beschmutzt fuehle.",
"Ich habe oft schlechte Gedanken und habe Schwierigkeiten, sie loszuwerden."
),
stringsAsFactors = FALSE
)
# Verlauf gruen -> dunkelrot entspricht den 5 Antwortstufen 0-4.
OCI4_BADGE_FARBEN = c(
"0" = "#4CAF50",
"1" = "#AED581",
"2" = "#FFB300",
"3" = "#EF5350",
"4" = "#B71C1C"
)
OCI4_BADGE_TEXT_FARBEN = c(
"0" = "white",
"1" = "#333333",
"2" = "#333333",
"3" = "white",
"4" = "white"
)
# Word-Export: Hintergrund-/Textfarbe fuer die Klassifikationsbox je Zone.
OCI4_ZONE_WORD_FARBEN = list(
gruen = list(bg = "#E8F5E9", text = "#2E7D32"),
orange = list(bg = "#FFF3E0", text = "#E65100"),
dunkelrot = list(bg = "#FFEBEE", text = "#B71C1C"),
na = list(bg = "#F5F5F5", text = "#424242")
)
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 ####
# labels-Attribut der ORIGINAL-Spalte (vor Subsetting/Filtern) lesen, damit
# die Zuordnung Rohwert -> Stufe (0-4) immer aus den Daten selbst stammt,
# nicht aus einer hartkodierten Annahme "Rohwert minus 1".
oci4_get_level = function(original_col, wert) {
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) {
lbl_sortiert = sort(as.vector(lbl_attr))
pos = which(lbl_sortiert == as.numeric(wert[1]))
if (length(pos) > 0) return(as.integer(pos[1]) - 1L)
}
# Fallback bei fehlendem labels-Attribut: 1-basierte Kodierung angenommen.
as.integer(as.numeric(wert[1])) - 1L
}
oci4_get_anker = 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]])
}
stufe = max(0L, min(4L, as.integer(as.numeric(wert[1])) - 1L))
c("Ueberhaupt nicht", "Ein wenig", "Maessig", "Stark", "Extrem")[stufe + 1L]
}
# Entfernt formr-Nummerierungsartefakte am Anfang des Itemtexts
# (z.B. "1. " oder "01) "), die manchmal im label-Attribut erscheinen.
clean_item_label = function(text) {
if (is.null(text) || length(text) == 0 || is.na(text[1])) return(NA_character_)
sub("^\\d+[.)\\s]\\s*", "", trimws(as.character(text[1])))
}
# Cutoffs (woertlich aus der Testquelle):
# >= 4: Hinweis auf Zwangsstoerung vs. nicht-klinische Personen.
# >= 6: Hinweis auf Zwangsstoerung auch vs. angstbezogene Stoerungen.
klassifiziere_oci4 = function(score) {
if (is.na(score)) {
return(list(
titel = "Nicht berechenbar",
hinweis = "Der Summenscore konnte nicht berechnet werden.",
farbe = "#888888",
zone = "na"
))
}
if (score < 4) {
return(list(
titel = "Unauffaellig",
hinweis = paste0(
"Gesamtscore ", score, " / 16 liegt unter dem Cutoff von 4. ",
"Kein Hinweis auf eine Zwangsstoerung im Vergleich zu nicht-klinischen Personen."
),
farbe = "#2E7D32",
zone = "gruen"
))
}
if (score < 6) {
return(list(
titel = "Cutoff (vs. nicht-klinisch) ueberschritten",
hinweis = paste0(
"Gesamtscore ", score, " / 16: Cutoff >= 4 erreicht. ",
"Hinweis auf eine Zwangsstoerung im Vergleich zu nicht-klinischen Personen. ",
"Der Cutoff >= 6 (Abgrenzung zu angstbezogenen Stoerungen) ist noch nicht erreicht."
),
farbe = "#E65100",
zone = "orange"
))
}
list(
titel = "Beide Cutoffs ueberschritten",
hinweis = paste0(
"Gesamtscore ", score, " / 16: Cutoff >= 6 erreicht. ",
"Hinweis auf eine Zwangsstoerung sowohl im Vergleich zu nicht-klinischen Personen ",
"als auch im Vergleich zu angstbezogenen Stoerungen."
),
farbe = "#B71C1C",
zone = "dunkelrot"
)
}
erstelle_gauge_oci4 = function(score) {
zonen = data.frame(
xmin = c(0, 4, 6),
xmax = c(4, 6, 16),
zone = c("Unauffaellig (0-3)", "vs. nicht-klinisch (4-5)", "vs. angstbezogen (6-16)"),
stringsAsFactors = FALSE
)
zonen$zone = factor(zonen$zone, levels = zonen$zone)
zonen_farben = c(
"Unauffaellig (0-3)" = "#C8E6C9",
"vs. nicht-klinisch (4-5)" = "#FFE0B2",
"vs. angstbezogen (6-16)" = "#FFCDD2"
)
zone_mitte = c(2, 5, 11)
zone_labels = c("Unauffaellig\n0-3", "Cutoff >= 4\n4-5", "Cutoff >= 6\n6-16")
p = ggplot() +
geom_rect(data = zonen,
aes(xmin = xmin, xmax = xmax, ymin = 0, ymax = 1, fill = zone),
colour = "white", linewidth = 1) +
scale_fill_manual(values = zonen_farben, guide = "none") +
annotate("text", x = zone_mitte, y = 0.5, label = zone_labels,
size = 2.9, colour = "#444444", fontface = "bold", lineheight = 0.9) +
scale_x_continuous(limits = c(0, 16), expand = c(0, 0),
breaks = c(0, 4, 6, 16)) +
scale_y_continuous(limits = c(-0.25, 1.35), expand = c(0, 0)) +
labs(x = "OCI-4 Summenscore (0-16)", y = NULL) +
theme_minimal(base_size = 11) +
theme(
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
panel.grid = element_blank(),
plot.background = element_rect(fill = "white", colour = NA),
panel.background = element_rect(fill = "white", colour = NA),
axis.line.x = element_line(colour = "#cccccc", linewidth = 0.5),
axis.text.x = element_text(colour = "#666666", size = 9)
)
if (!is.na(score)) {
p = p +
geom_segment(aes(x = score, xend = score, y = -0.1, yend = 1.1),
colour = AKZENT_FARBE, linewidth = 2.5, lineend = "round") +
annotate("text", x = score, y = 1.26,
label = as.character(score),
colour = AKZENT_FARBE, fontface = "bold", size = 4.2)
}
p
}
# 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; }
.diagnose-box {
border-radius: 6px; padding: 14px 18px; margin: 12px 0;
border-left: 5px solid;
}
.diagnose-titel { font-weight: 700; font-size: 1.05rem; margin-bottom: 6px; }
.diagnose-hinweis { font-size: 0.93em; line-height: 1.55; }
.diagnose-disclaimer {
font-size: 0.82em; color: #777; font-style: italic;
margin-top: 10px; border-top: 1px solid rgba(0,0,0,.1); padding-top: 8px;
}
.oci4-zone-gruen { background: #E8F5E9; border-color: #A5D6A7; color: #2E7D32; }
.oci4-zone-orange { background: #FFF3E0; border-color: #FFCC80; color: #E65100; }
.oci4-zone-dunkelrot { background: #FFEBEE; border-color: #EF9A9A; color: #B71C1C; }
.oci4-zone-na { background: #F5F5F5; border-color: #9E9E9E; color: #424242; }
.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-subskala { font-weight: 600; color: #555; min-width: 130px; 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: #FFB300; 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("OCI-4 Obsessive Compulsive Inventory (4 Items)"),
tags$p("Mueller, Jelinek, Fink-Lamotte, Scheunemann, McKay, Abramowitz, Abramovitch & Cludius, 2024")
),
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)",
style = paste0(
"background:", AKZENT_FARBE, "; color:white; border:none;",
" font-weight:600; padding:8px 20px; border-radius:4px;"
)
)
)
),
uiOutput("fehler_ui"),
uiOutput("warnung_ui"),
uiOutput("ergebnis_ui")
)
)
# Word-Export ####
erstelle_oci4_docx = function(d) {
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")
zone_farbe = OCI4_ZONE_WORD_FARBEN[[d$klassifikation$zone]]
fp_kat_titel = fp_text(bold = TRUE, font.size = 12,
color = zone_farbe$text, shading.color = zone_farbe$bg)
fp_kat_text = fp_text(font.size = 11,
color = zone_farbe$text, shading.color = zone_farbe$bg)
doc = body_add_fpar(doc, fpar(ftext("OCI-4 - Einzelauswertung", fp_titel)))
doc = body_add_fpar(doc, fpar(
ftext("Chiffre: ", fp_label),
ftext(d$chiffre, fp_normal),
ftext(" Datum: ", fp_label),
ftext(d$datum_str, fp_normal)
))
if (!is.null(d$info_mehrere)) {
doc = body_add_fpar(doc, fpar(
ftext(d$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("Auswertung", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(
ftext("Summenscore: ", fp_label),
ftext(paste0(d$summenscore, " / 16"), fp_kat_text)
))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Klassifikation", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(ftext(d$klassifikation$titel, fp_kat_titel)))
doc = body_add_fpar(doc, fpar(ftext(d$klassifikation$hinweis, fp_kat_text)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("OCI-4 Einzelitems", fp_abschnitt)))
for (i in seq_len(4)) {
stufe = d$stufen[i]
anker = d$anker_texte[i]
stufe_key = if (!is.na(stufe) && stufe >= 0L && stufe <= 4L)
as.character(stufe) else "0"
anker_txt = if (!is.na(anker)) anker else
c("Ueberhaupt nicht", "Ein wenig", "Maessig", "Stark", "Extrem")[as.integer(stufe_key) + 1L]
item_txt = if (!is.na(d$item_texte[i])) d$item_texte[i] else OCI4_ITEMS$itemtext_fallback[i]
fp_badge = fp_text(
color = OCI4_BADGE_TEXT_FARBEN[[stufe_key]],
bold = TRUE,
shading.color = OCI4_BADGE_FARBEN[[stufe_key]],
font.size = 10
)
doc = body_add_fpar(doc, fpar(
ftext(paste0(OCI4_ITEMS$subskala[i], ": "), fp_label),
ftext(paste0(item_txt, " "), fp_normal),
ftext(paste0(" ", anker_txt, " "), fp_badge)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(OCI4_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 = toupper(trimws(input$chiffre))
if ((nchar(trimws(input$pseudonym)) == 0 && nchar(chiffre) == 0))
return(list(error = "Bitte eine Patientenchiffre eingeben."))
if (!(nchar(trimws(input$pseudonym)) > 0 || grepl("^[A-Z][0-9]{6}$", chiffre)))
return(list(error = paste0(
"Ungueltige Chiffre. Erwartet: ein Grossbuchstabe + 6 Ziffern (z.B. P000123).")))
if (!file.exists(PFAD_DOWNLOAD_SKRIPT))
return(list(error = paste0(
"Download-Skript nicht gefunden:\n", PFAD_DOWNLOAD_SKRIPT)))
if (!file.exists(PFAD_PSEUDONYM_SKRIPT))
return(list(error = paste0(
"Pseudonym-Skript nicht gefunden:\n", PFAD_PSEUDONYM_SKRIPT)))
res_dl = tryCatch(
{ source(PFAD_DOWNLOAD_SKRIPT, local = FALSE); list(ok = TRUE) },
error = function(e) list(ok = FALSE, msg = e$message)
)
if (!res_dl$ok)
return(list(error = paste0("Fehler im Download-Skript: ", res_dl$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))
setwd(wd_ziel)
on.exit(setwd(alter_wd), add = TRUE)
res_ps = 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 (!res_ps$ok)
return(list(error = paste0("Fehler im Pseudonym-Skript: ", res_ps$msg)))
if (!exists("daten_oci4", envir = .GlobalEnv))
return(list(error = paste0(
"Objekt 'daten_oci4' nach dem Sourcen nicht gefunden. ",
"Bitte Download-Skript pruefen.")))
if (!exists("pseudo", envir = .GlobalEnv))
return(list(error = paste0(
"Objekt 'pseudo' nach dem Sourcen nicht gefunden. ",
"Bitte Pseudonym-Skript pruefen.")))
daten = get("daten_oci4", envir = .GlobalEnv)
pseudo_df = get("pseudo", envir = .GlobalEnv)
treffer_ps = pseudo_df[pseudo_df$chiffre == chiffre, ]
if (nrow(treffer_ps) == 0)
return(list(error = paste0(
"Chiffre '", chiffre, "' wurde in der Pseudonym-Datenbank nicht gefunden.")))
alle_pseudonyme = unique(treffer_ps$pseudonym)
if (nchar(trimws(input$pseudonym)) > 0) alle_pseudonyme = trimws(input$pseudonym)
treffer_dat = daten[daten$session %in% alle_pseudonyme, ]
if (nrow(treffer_dat) == 0)
return(list(error = paste0(
"Kein OCI-4-Datensatz fuer Chiffre '", chiffre, "' gefunden. ",
"(", length(alle_pseudonyme), " Pseudonym(e) geprueft)")))
# Zeitstempelspalte fuer die Auswahl bei Mehrfachausfuellungen: 'created'
# (formr-Session-Erstellungszeitpunkt). Beim ersten echten Testlauf mit
# str(daten_oci4) gegenpruefen, ob 'created' oder 'ended' die passende
# Spalte ist - siehe Hinweis im Build-Prompt (Abschnitt 2/8/9).
info_mehrere = NULL
if (nrow(treffer_dat) > 1) {
n = nrow(treffer_dat)
treffer_dat = treffer_dat[order(treffer_dat$created, decreasing = TRUE), ]
datum_neu = tryCatch(
format(as.POSIXct(treffer_dat$created[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[["created"]][1]), "%d.%m.%Y"),
error = function(e) format(Sys.Date(), "%d.%m.%Y")
)
item_texte = sapply(seq_len(4), function(i) {
var = OCI4_ITEMS$var[i]
txt = clean_item_label(attr(daten[[var]], "label"))
if (is.na(txt)) OCI4_ITEMS$itemtext_fallback[i] else txt
})
stufen = sapply(seq_len(4), function(i) {
var = OCI4_ITEMS$var[i]
oci4_get_level(daten[[var]], zeile[[var]])
})
anker_texte = sapply(seq_len(4), function(i) {
var = OCI4_ITEMS$var[i]
oci4_get_anker(daten[[var]], zeile[[var]])
})
summenscore = if (any(is.na(stufen))) NA_integer_ else sum(stufen)
klassifikation = klassifiziere_oci4(summenscore)
list(
chiffre = chiffre,
datum_str = datum_str,
info_mehrere = info_mehrere,
summenscore = summenscore,
stufen = stufen,
anker_texte = anker_texte,
item_texte = item_texte,
klassifikation = klassifikation,
error = NULL
)
})
output$fehler_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error)) div(class = "alert-fehler", d$error)
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error) || is.null(d$info_mehrere)) return(NULL)
div(class = "alert-warnung", d$info_mehrere)
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error)) return(NULL)
klass = d$klassifikation
items_ui = lapply(seq_len(4), function(i) {
stufe = d$stufen[i]
anker = d$anker_texte[i]
sk = if (!is.na(stufe) && stufe >= 0L && stufe <= 4L)
as.character(stufe) else "0"
anker_txt = if (!is.na(anker)) anker else
c("Ueberhaupt nicht", "Ein wenig", "Maessig", "Stark", "Extrem")[as.integer(sk) + 1L]
item_txt = if (!is.na(d$item_texte[i])) d$item_texte[i] else OCI4_ITEMS$itemtext_fallback[i]
div(class = "item-zeile",
div(class = "item-subskala", OCI4_ITEMS$subskala[i]),
div(class = "item-text", item_txt),
span(class = paste0("stufe-badge stufe-badge-", sk),
paste0(stufe, " - ", anker_txt))
)
})
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "OCI-4"),
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$summenscore),
div("Summenscore (0-16)", style = "color:#555;"),
div(class = "cutoff-info",
tags$span(style = paste0("color:", klass$farbe, "; font-weight:600;"),
klass$titel)
)
)
),
column(9, plotOutput("gauge_plot", height = "160px"))
),
tags$hr(),
tags$h5("Klassifikation"),
div(class = paste0("diagnose-box oci4-zone-", klass$zone),
div(class = "diagnose-titel", klass$titel),
div(class = "diagnose-hinweis", klass$hinweis),
div(class = "diagnose-disclaimer", OCI4_DISCLAIMER)
),
tags$hr(),
tags$h5("OCI-4 Einzelitems"),
div(items_ui)
)
})
output$gauge_plot = renderPlot({
req(input$btn_suchen)
d = ergebnis_r()
req(is.null(d$error))
erstelle_gauge_oci4(d$summenscore)
}, bg = "transparent")
output$download_word = downloadHandler(
filename = function() {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
chiffre = if (is.list(d) && is.null(d$error) && nchar(d$chiffre) > 0)
d$chiffre else "export"
# Datum aus dem Ausfuelldatum der Daten (d$datum_str), nicht Sys.Date() -
# Fallback auf Sys.Date() nur, falls d$datum_str nicht parsebar ist.
datum = if (is.list(d) && is.null(d$error) && !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("OCI4_", chiffre, "_", datum, ".docx")
},
content = function(file) {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
daten_ok = is.list(d) && is.null(d$error)
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_oci4_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)