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

730 lines
26 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)
# Infrastruktur ####
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_cuditr.R"
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R"
AKZENT_FARBE = "#8B2635"
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)
CUDITR_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 Score-Stufen 0-4 der Items cuditr01-08.
CUDITR_BADGE_FARBEN = c(
"0" = "#4CAF50",
"1" = "#8BC34A",
"2" = "#FFC107",
"3" = "#FF7043",
"4" = "#B71C1C"
)
CUDITR_BADGE_TEXT_FARBEN = c(
"0" = "white",
"1" = "#333333",
"2" = "#333333",
"3" = "white",
"4" = "white"
)
CUDITR_KLASS_WORD_FARBEN = list(
"niedrig" = list(bg = "#E8F5E9", text = "#2E7D32"),
"mittel" = list(bg = "#FFF3E0", text = "#E65100"),
"hoch" = list(bg = "#FFEBEE", text = "#B71C1C")
)
# Ergebnistypen, die als harter Fehler (alert-fehler) angezeigt werden.
# "kein_konsum" und "unvollstaendig" sind KEINE technischen Fehler, sondern
# gueltige inhaltliche Zustaende, siehe warnung_ui.
CUDITR_FEHLER_TYPEN = c(
"leere_eingabe", "format_fehler", "pfad_fehler", "skript_fehler",
"objekt_fehler", "chiffre_nicht_gefunden", "keine_session_daten",
"unbekannte_antwortstufe"
)
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: 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: #8BC34A; color: #333333; }
.stufe-badge-2 { background: #FFC107; color: #333333; }
.stufe-badge-3 { background: #FF7043; color: white; }
.stufe-badge-4 { background: #B71C1C; color: white; }
.stufe-badge-na { background: #BDBDBD; color: white; }
.score-zahl { font-size: 2.2rem; font-weight: 800; color: #8B2635; }
.cutoff-info { font-size: 0.88em; color: #555; margin-top: 4px; }
.klass-box {
border-radius: 6px; padding: 14px 18px; margin: 12px 0;
border-left: 5px solid;
}
.klass-titel { font-weight: 700; font-size: 1.05rem; margin-bottom: 6px; }
.klass-hinweis { font-size: 0.93em; line-height: 1.55; }
.klass-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;
}
.klass-niedrig { background: #E8F5E9; border-color: #A5D6A7; color: #2E7D32; }
.klass-mittel { background: #FFF3E0; border-color: #FFCC80; color: #E65100; }
.klass-hoch { background: #FFEBEE; border-color: #EF9A9A; color: #B71C1C; }
"
app_css = gsub("#8B2635", AKZENT_FARBE, app_css, fixed = TRUE)
# Helper ####
# 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])))
}
# labels-Attribut der ORIGINAL-Spalte (vor Subsetting) lesen, damit die
# Zuordnung Rohwert -> Anker-Text immer aus den Daten selbst stammt und
# nicht ueber die numerische Positionskodierung erraten wird.
cuditr_get_label_text = function(original_col, wert) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_character_)
lbl = attr(original_col, "labels")
if (!is.null(lbl) && length(lbl) > 0) {
pos = which(as.numeric(lbl) == as.numeric(wert[1]))
if (length(pos) > 0) return(names(lbl)[pos[1]])
}
NA_character_
}
CUDITR_KLASSIFIKATION = list(
niedrig = list(
titel = "Unauffaellig",
text = paste0(
"Score liegt unterhalb der Schwelle fuer riskanten Konsum (0 bis 7 Punkte). ",
"Kein Hinweis auf riskanten Cannabiskonsum nach CUDIT-R."
)
),
mittel = list(
titel = "Hinweis auf riskanten Konsum",
text = paste0(
"Score von 8 bis 11 Punkten deutet auf einen riskanten Cannabiskonsum ",
"(hazardous cannabis use) hin."
)
),
hoch = list(
titel = "Hinweis auf moegliche Konsumstoerung",
text = paste0(
"Score von 12 oder mehr Punkten deutet auf eine moegliche Konsumstoerung ",
"(possible cannabis use disorder) hin. Weitere klinische Abklaerung ist indiziert."
)
)
)
cuditr_klassifiziere = function(score) {
if (is.na(score)) return(NA_character_)
if (score <= 7) return("niedrig")
if (score <= 11) return("mittel")
"hoch"
}
make_cuditr_gauge = function(score) {
zone_df = data.frame(
xmin = c(-0.5, 7.5, 11.5),
xmax = c(7.5, 11.5, 32.5),
fill = c("#E8F5E9", "#FFF3E0", "#FFEBEE"),
stringsAsFactors = FALSE
)
ggplot() +
geom_rect(data = zone_df,
aes(xmin = xmin, xmax = xmax, ymin = 0, ymax = 1, fill = fill),
color = NA) +
scale_fill_identity() +
geom_rect(aes(xmin = -0.5, xmax = 32.5, ymin = 0, ymax = 1),
fill = NA, color = "#9E9E9E", linewidth = 0.6) +
geom_vline(xintercept = c(7.5, 11.5), color = "#9E9E9E",
linetype = "dashed", linewidth = 0.5) +
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 = 3.5, y = -0.55, label = "0-7", color = "#2E7D32", size = 3.2) +
annotate("text", x = 9.5, y = -0.55, label = "8-11", color = "#E65100", size = 3.2) +
annotate("text", x = 22, y = -0.55, label = "12-32", color = "#B71C1C", size = 3.2) +
scale_x_continuous(limits = c(-2, 34), breaks = c(0, 7, 8, 11, 12, 32)) +
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 = "CUDIT-R Summenscore (0-32)", y = NULL)
}
# Datenaufbereitung ####
CUDITR_ITEM_VARS = paste0("cuditr", sprintf("%02d", 1:8))
CUDITR_SCORE_TABELLEN = list(
cuditr01 = c(
"Nie" = 0,
"Einmal im Monat oder seltener" = 1,
"Zwei- bis viermal im Monat" = 2,
"Zwei- bis dreimal die Woche" = 3,
"Viermal die Woche oder öfter" = 4
),
cuditr02 = c(
"Weniger als eine Stunde" = 0,
"Ein bis zwei Stunden" = 1,
"Drei bis vier Stunden" = 2,
"Fünf bis sechs Stunden" = 3,
"Sieben Stunden oder mehr" = 4
),
cuditr03 = c(
"Nie" = 0,
"Seltener als einmal im Monat" = 1,
"Jeden Monat" = 2,
"Jede Woche" = 3,
"Jeden Tag oder fast jeden Tag" = 4
),
cuditr08 = c(
"Nein" = 0,
"Ja, aber nicht während der letzten 6 Monate" = 2,
"Ja, während der letzten 6 Monate" = 4
)
)
# cuditr04 bis cuditr07 haben dieselben 5 Antwortstufen wie cuditr03.
for (var in paste0("cuditr", sprintf("%02d", 4:7))) {
CUDITR_SCORE_TABELLEN[[var]] = CUDITR_SCORE_TABELLEN[["cuditr03"]]
}
# UI ####
ui = fluidPage(
tags$head(
tags$meta(charset = "UTF-8"),
tags$style(HTML(app_css))
),
div(class = "app-header",
tags$h2("CUDIT-R Cannabis Use Disorders Identification Test Revised"),
tags$p("Adamson et al., 2010 | Einzelauswertung")
),
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_cuditr_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")
doc = body_add_fpar(doc, fpar(
ftext("CUDIT-R Cannabis Use Disorders Identification Test Revised", fp_titel)
))
doc = body_add_fpar(doc, fpar(
ftext("Chiffre: ", fp_label), ftext(erg$chiffre, fp_normal),
ftext(" Ausfuelldatum: ", fp_label), ftext(erg$ausfuelldatum, 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")
if (erg$typ == "kein_konsum") {
doc = body_add_fpar(doc, fpar(ftext("Hinweis", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(ftext(erg$meldung, fp_normal)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(CUDITR_DISCLAIMER, fp_disclaimer)))
return(doc)
}
klass_farbe = CUDITR_KLASS_WORD_FARBEN[[erg$klasse]]
fp_klass_titel = fp_text(bold = TRUE, font.size = 12,
color = klass_farbe$text, shading.color = klass_farbe$bg)
fp_klass_text = fp_text(font.size = 11,
color = klass_farbe$text, shading.color = klass_farbe$bg)
doc = body_add_fpar(doc, fpar(ftext("Auswertung", fp_abschnitt)))
doc = body_add_fpar(doc, fpar(
ftext("Summenscore: ", fp_label),
ftext(paste0(erg$summenscore, " / 32"),
fp_text(bold = TRUE, font.size = 12, color = klass_farbe$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(erg$klasse_titel, fp_klass_titel)))
doc = body_add_fpar(doc, fpar(ftext(erg$klasse_text, fp_klass_text)))
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("CUDIT-R Einzelitems", fp_abschnitt)))
for (it in erg$items) {
stufe_key = if (!is.na(it$score) && it$score %in% 0:4) as.character(it$score) else "na"
badge_farbe = if (stufe_key == "na") "#BDBDBD" else CUDITR_BADGE_FARBEN[[stufe_key]]
badge_text_farbe = if (stufe_key == "na") "white" else CUDITR_BADGE_TEXT_FARBEN[[stufe_key]]
fp_badge = fp_text(color = badge_text_farbe, bold = TRUE,
shading.color = badge_farbe, font.size = 10)
anker_txt = if (!is.na(it$text)) it$text else "k. A."
item_txt = if (!is.na(it$item_text)) it$item_text else paste0("Item ", it$nr)
doc = body_add_fpar(doc, fpar(
ftext(paste0(it$nr, ". ", item_txt, " "), fp_normal),
ftext(paste0(" ", anker_txt, " "), fp_badge)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(CUDITR_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(typ = "leere_eingabe",
meldung = "Bitte eine Patientenchiffre eingeben."))
if (!(nchar(trimws(input$pseudonym)) > 0 || grepl("^[A-Z][0-9]{6}$", chiffre))) {
return(list(typ = "format_fehler", chiffre = chiffre, meldung = paste0(
"Ungueltige Chiffre '", chiffre, "'. ",
"Erwartet: ein Grossbuchstabe gefolgt von 6 Ziffern (z.B. P000123)."
)))
}
if (!file.exists(PFAD_DOWNLOAD_SKRIPT))
return(list(typ = "pfad_fehler", chiffre = chiffre, meldung = paste0(
"Download-Skript nicht gefunden:\n", PFAD_DOWNLOAD_SKRIPT)))
if (!file.exists(PFAD_PSEUDONYM_SKRIPT))
return(list(typ = "pfad_fehler", chiffre = chiffre, 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", chiffre = chiffre,
meldung = paste0("Fehler im Download-Skript: ", ok$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)
ok = 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$ok)
return(list(typ = "skript_fehler", chiffre = chiffre,
meldung = paste0("Fehler im Pseudonym-Skript: ", ok$msg)))
if (!exists("daten_cuditr", envir = .GlobalEnv))
return(list(typ = "objekt_fehler", chiffre = chiffre, meldung = paste0(
"Objekt 'daten_cuditr' nach dem Sourcen nicht gefunden. ",
"Bitte Download-Skript pruefen."
)))
if (!exists("pseudo", envir = .GlobalEnv))
return(list(typ = "objekt_fehler", chiffre = chiffre, meldung = paste0(
"Objekt 'pseudo' nach dem Sourcen nicht gefunden. ",
"Bitte Pseudonym-Skript pruefen."
)))
daten_cuditr = get("daten_cuditr", envir = .GlobalEnv)
pseudo_df = get("pseudo", envir = .GlobalEnv)
treffer_ps = pseudo_df[pseudo_df$chiffre == chiffre, ]
if (nrow(treffer_ps) == 0)
return(list(typ = "chiffre_nicht_gefunden", chiffre = chiffre, 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)
# Session-ID-Spalte gemaess Konvention der anderen Apps "session" -
# beim ersten Testlauf gegen names(daten_cuditr) verifizieren, nicht geraten.
treffer_dat = daten_cuditr[daten_cuditr$session %in% alle_session_ids, ]
if (nrow(treffer_dat) == 0)
return(list(typ = "keine_session_daten", chiffre = chiffre, meldung = paste0(
"Kein CUDIT-R-Datensatz fuer Chiffre '", chiffre, "' gefunden. ",
"(", length(alle_session_ids), " Pseudonym(e) geprueft)"
)))
info_mehrere = NULL
if (nrow(treffer_dat) > 1) {
n = nrow(treffer_dat)
# Zeitstempel-Spalte gemaess Konvention der anderen Apps "created" -
# beim ersten Testlauf gegen names(daten_cuditr) verifizieren, nicht geraten.
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")
)
vorfrage_wert = zeile[["cuditr00"]][1]
if (is.null(vorfrage_wert) || is.na(vorfrage_wert)) {
return(list(
typ = "unvollstaendig", chiffre = chiffre, ausfuelldatum = datum_str,
info_mehrere = info_mehrere,
meldung = "Datensatz unvollstaendig, Vorfrage nicht beantwortet."
))
}
vorfrage_text = cuditr_get_label_text(daten_cuditr[["cuditr00"]], vorfrage_wert)
if (is.na(vorfrage_text))
return(list(
typ = "unbekannte_antwortstufe", chiffre = chiffre, ausfuelldatum = datum_str,
info_mehrere = info_mehrere,
meldung = paste0(
"Unbekannte Antwortstufe bei der Vorfrage (cuditr00), Rohwert: ",
vorfrage_wert, "."
)
))
if (vorfrage_text == "Nein") {
return(list(
typ = "kein_konsum", chiffre = chiffre, ausfuelldatum = datum_str,
info_mehrere = info_mehrere,
meldung = paste0(
"Patient/in berichtet keinen Cannabiskonsum in den letzten 6 Monaten, ",
"keine CUDIT-R-Auswertung moeglich."
)
))
}
if (vorfrage_text != "Ja")
return(list(
typ = "unbekannte_antwortstufe", chiffre = chiffre, ausfuelldatum = datum_str,
info_mehrere = info_mehrere,
meldung = paste0(
"Unerwartete Antwort bei der Vorfrage (cuditr00): '", vorfrage_text, "'."
)
))
items_erg = lapply(seq_along(CUDITR_ITEM_VARS), function(i) {
var = CUDITR_ITEM_VARS[i]
original_col = daten_cuditr[[var]]
text = cuditr_get_label_text(original_col, zeile[[var]])
item_text = clean_item_label(attr(original_col, "label"))
tabelle = CUDITR_SCORE_TABELLEN[[var]]
unbekannt = !is.na(text) && !(text %in% names(tabelle))
score = if (is.na(text) || unbekannt) NA_integer_ else as.integer(tabelle[[text]])
list(nr = i, var = var, item_text = item_text, text = text,
score = score, unbekannt = unbekannt)
})
unbekannte_items = Filter(function(it) it$unbekannt, items_erg)
if (length(unbekannte_items) > 0) {
it = unbekannte_items[[1]]
return(list(
typ = "unbekannte_antwortstufe", chiffre = chiffre, ausfuelldatum = datum_str,
info_mehrere = info_mehrere,
meldung = paste0(
"Unbekannte Antwortstufe bei Item '", it$var, "': '", it$text, "'. ",
"Bitte Score-Lookup-Tabelle mit der finalen xlsx abgleichen."
)
))
}
scores = sapply(items_erg, function(it) it$score)
summenscore = sum(scores, na.rm = TRUE)
klasse = cuditr_klassifiziere(summenscore)
kdef = CUDITR_KLASSIFIKATION[[klasse]]
list(
typ = "ergebnis",
chiffre = chiffre,
ausfuelldatum = datum_str,
info_mehrere = info_mehrere,
summenscore = summenscore,
klasse = klasse,
klasse_titel = kdef$titel,
klasse_text = kdef$text,
items = items_erg
)
})
output$fehler_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (d$typ %in% CUDITR_FEHLER_TYPEN) div(class = "alert-fehler", d$meldung) else NULL
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (d$typ %in% CUDITR_FEHLER_TYPEN) return(NULL)
tagList(
if (!is.null(d$info_mehrere)) div(class = "alert-warnung", d$info_mehrere),
if (d$typ %in% c("kein_konsum", "unvollstaendig")) div(class = "alert-warnung", d$meldung)
)
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (d$typ != "ergebnis") return(NULL)
items_ui = lapply(d$items, function(it) {
stufe_key = if (!is.na(it$score) && it$score %in% 0:4) as.character(it$score) else "na"
anker_txt = if (!is.na(it$text)) it$text else "k. A."
item_txt = if (!is.na(it$item_text)) it$item_text else paste0("Item ", it$nr)
div(class = "item-zeile",
div(class = "item-nr", paste0(it$nr, ".")),
div(class = "item-text", item_txt),
span(class = paste0("stufe-badge stufe-badge-", stufe_key), anker_txt)
)
})
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "CUDIT-R Cannabis Use Disorders Identification Test Revised"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfuelldatum: "), d$ausfuelldatum
),
tags$hr(),
fluidRow(
column(3,
div(
div(class = "score-zahl", d$summenscore),
div("Summenscore (0-32)", style = "color:#555;"),
div(class = "cutoff-info",
tags$span(
style = paste0("color:", CUDITR_KLASS_WORD_FARBEN[[d$klasse]]$text,
"; font-weight:600;"),
d$klasse_titel
)
)
)
),
column(9, plotOutput("gauge_plot", height = "160px"))
),
tags$hr(),
tags$h5("Klassifikation"),
div(class = paste0("klass-box klass-", d$klasse),
div(class = "klass-titel", d$klasse_titel),
div(class = "klass-hinweis", d$klasse_text),
div(class = "klass-disclaimer", CUDITR_DISCLAIMER)
),
tags$hr(),
tags$h5("CUDIT-R Einzelitems"),
div(items_ui)
)
})
output$gauge_plot = renderPlot({
req(input$btn_suchen)
d = ergebnis_r()
req(d$typ == "ergebnis")
make_cuditr_gauge(d$summenscore)
}, bg = "transparent")
output$download_word = downloadHandler(
filename = function() {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
daten_ok = is.list(d) && d$typ %in% c("ergebnis", "kein_konsum")
chiffre_esc = if (daten_ok && nchar(d$chiffre) > 0)
gsub("[^A-Za-z0-9]", "", d$chiffre) else "export"
ausfuelldatum_fn = if (daten_ok && !is.null(d$ausfuelldatum))
tryCatch(
format(as.Date(d$ausfuelldatum, "%d.%m.%Y"), "%Y%m%d"),
error = function(e) format(Sys.Date(), "%Y%m%d")
)
else
format(Sys.Date(), "%Y%m%d")
paste0("CUDITR_", chiffre_esc, "_", ausfuelldatum_fn, ".docx")
},
content = function(file) {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
daten_ok = is.list(d) && d$typ %in% c("ergebnis", "kein_konsum")
if (!daten_ok) {
doc = read_docx()
doc = body_add_par(doc,
"Kein auswertbarer Datensatz geladen. Bitte zuerst Chiffre eingeben und 'Auswerten' klicken.",
style = "Normal")
print(doc, target = file)
return()
}
doc = tryCatch(
erstelle_cuditr_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)