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

701 lines
25 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 ####
PFAD_DOWNLOAD_SKRIPT = "../API/get_data_smi.R" # liefert: daten_smi
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R" # liefert: pseudo
AKZENT_FARBE = "#8B2635"
SMI_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Der SMI-1 liefert deskriptive Modus-Mittelwerte ohne ",
"normierte Cutoffs; die Interpretation obliegt der behandelnden Person."
)
# Farbverlauf gruen -> dunkelrot fuer die Antwortstufen 1-6, analog zum
# Badge-Schema in pg13r/app.R (dort 0-4). Rein visuelle Kodierung der
# Rohantwort ("wie oft"), keine Klassifikation/kein Cutoff des Modus selbst.
SMI_BADGE_FARBEN = c(
"1" = "#4CAF50",
"2" = "#9CCC65",
"3" = "#FFCA28",
"4" = "#FF7043",
"5" = "#E53935",
"6" = "#7B0000"
)
SMI_BADGE_TEXT_FARBEN = c(
"1" = "white",
"2" = "#333333",
"3" = "#333333",
"4" = "white",
"5" = "white",
"6" = "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 ####
# Recodierung der SMI-Antwortwerte (1-6). Exportformat noch nicht mit echten
# formr-Daten verifiziert, siehe Doku Abschnitt G: unklar, ob formr fuer
# mc-Felder im regulaeren CSV-Export den Antworttext oder den 1-basierten
# Choice-Index liefert. Diese Funktion deckt deshalb robust BEIDE moeglichen
# Faelle ab, statt eine Variante zu unterstellen. Ist nach dieser Logik kein
# gueltiger Wert bestimmbar, wird NA geliefert (nicht 0, nicht stillschweigend
# verworfen).
smi_text_zu_wert = c(
"Nie oder fast nie" = 1,
"Selten" = 2,
"Gelegentlich" = 3,
"Öfters" = 4,
"Meistens" = 5,
"Immer" = 6
)
smi_recode_wert = function(wert) {
if (is.null(wert) || length(wert) == 0 || is.na(wert[1])) return(NA_real_)
roh = wert[1]
if (is.character(roh) || is.factor(roh)) {
roh_text = trimws(as.character(roh))
if (roh_text %in% names(smi_text_zu_wert)) return(unname(smi_text_zu_wert[[roh_text]]))
roh_num = suppressWarnings(as.numeric(roh_text))
if (!is.na(roh_num) && roh_num >= 1 && roh_num <= 6) return(roh_num)
return(NA_real_)
}
roh_num = suppressWarnings(as.numeric(roh))
if (!is.na(roh_num) && roh_num >= 1 && roh_num <= 6) return(roh_num)
NA_real_
}
# Item-Spalten eines Modus per Regex auf die Spaltennamen von daten_smi
# selektieren. Die Item-zu-Modus-Zuordnung steckt bereits im Spaltennamen
# (Suffix nach dem letzten Unterstrich = Modus-Kuerzel) und wird NICHT
# zusaetzlich hartkodiert.
smi_modus_spalten = function(daten, code) {
muster = paste0("^smi_[0-9]{3}_", code, "$")
sort(grep(muster, names(daten), value = TRUE))
}
# Itemnummer und Itemtext stammen ausschliesslich aus dem label-Attribut der
# ORIGINAL-Spalte in daten_smi (haven-Label), nie aus dem Spaltennamen und nie
# von einer bereits gefilterten/subgesetteten Zeile.
smi_item_info = function(daten, spaltenname) {
label_roh = attr(daten[[spaltenname]], "label")
if (is.null(label_roh) || length(label_roh) == 0 || is.na(label_roh[1])) {
return(list(nr = NA_integer_, text = spaltenname))
}
label_clean = trimws(gsub("\\*\\*", "", as.character(label_roh[1])))
treffer = regmatches(label_clean, regexpr("^[0-9]+\\.", label_clean))
if (length(treffer) > 0 && nchar(treffer) > 0) {
nr = as.integer(sub("\\.$", "", treffer))
text = trimws(sub("^[0-9]+\\.\\s*", "", label_clean))
} else {
nr = NA_integer_
text = label_clean
}
list(nr = nr, text = text)
}
# Modus-Score = arithmetisches Mittel der vorhandenen (rekodierten) Items
# (na.rm = TRUE). Die Missing-Value-Regel ist in der Quelle nicht dokumentiert;
# fehlt fuer einen Modus mindestens ein Item, wird das als sichtbarer
# Warnhinweis zurueckgegeben statt stillschweigend ignoriert. Items werden in
# Bloecke sortiert: > 3 (absteigend, bei Gleichstand aufsteigend nach
# Itemnummer), <= 3 (gleiche Sortierung), sowie NA ("nicht beantwortet").
smi_modus_auswertung = function(daten, zeile, modus_zeile) {
code = modus_zeile$code
spalten = smi_modus_spalten(daten, code)
items = lapply(spalten, function(sp) {
info = smi_item_info(daten, sp)
wert = smi_recode_wert(zeile[[sp]])
list(nr = info$nr, text = info$text, wert = wert)
})
werte = sapply(items, function(it) it$wert)
n_gesamt = length(items)
n_vorhanden = sum(!is.na(werte))
mittelwert = if (n_vorhanden > 0) mean(werte, na.rm = TRUE) else NA_real_
sortiere_absteigend = function(it_liste) {
if (length(it_liste) == 0) return(it_liste)
nrn = sapply(it_liste, function(it) it$nr)
wn = sapply(it_liste, function(it) it$wert)
it_liste[order(-wn, nrn)]
}
block_a = sortiere_absteigend(Filter(function(it) !is.na(it$wert) && it$wert > 3, items))
block_b = sortiere_absteigend(Filter(function(it) !is.na(it$wert) && it$wert <= 3, items))
block_c = Filter(function(it) is.na(it$wert), items)
if (length(block_c) > 0) {
nrn_c = sapply(block_c, function(it) it$nr)
block_c = block_c[order(nrn_c)]
}
list(
code = code,
name = modus_zeile$name,
kategorie = modus_zeile$kategorie,
mittelwert = mittelwert,
n_vorhanden = n_vorhanden,
n_gesamt = n_gesamt,
hat_warnung = n_vorhanden < n_gesamt,
block_a = block_a,
block_b = block_b,
block_c = block_c
)
}
# Horizontaler Balken mit allen 14 Modus-Mittelwerten, x-Achse fix 1-6 (keine
# Klassifikationszonen, da kein Cutoff existiert). Kategorie-Gruppierung per
# Facet, in derselben Reihenfolge wie die Textdarstellung.
make_smi_balken_plot = function(modus_ergebnisse) {
df = data.frame(
name = sapply(modus_ergebnisse, function(e) e$name),
kategorie = sapply(modus_ergebnisse, function(e) e$kategorie),
mittelwert = sapply(modus_ergebnisse, function(e) e$mittelwert),
stringsAsFactors = FALSE
)
df$name = factor(df$name, levels = rev(df$name))
df$kategorie = factor(df$kategorie, levels = kategorie_reihenfolge)
ggplot(df, aes(x = name, y = mittelwert)) +
geom_col(fill = AKZENT_FARBE, width = 0.65, na.rm = TRUE) +
geom_text(aes(label = ifelse(is.na(mittelwert), "", sprintf("%.2f", mittelwert))),
hjust = -0.15, size = 3.4, color = "#333333", na.rm = TRUE) +
coord_flip(ylim = c(1, 6)) +
scale_y_continuous(breaks = 1:6) +
facet_grid(kategorie ~ ., scales = "free_y", space = "free_y") +
theme_minimal(base_size = 12) +
theme(
strip.text.y = element_text(angle = 0, face = "bold", color = AKZENT_FARBE),
strip.background = element_rect(fill = "#F5F5F5", color = NA),
panel.grid.minor = element_blank(),
axis.title.y = element_blank(),
plot.margin = margin(t = 10, r = 20, b = 5, l = 5)
) +
labs(y = "Modus-Mittelwert (1-6)")
}
# Datenaufbereitung ####
smi_modi = data.frame(
code = c("vk","ak","wk","ik","uk","gk","uw","db","ds","ns","sa","se","fe","ge"),
name = c(
"Verletzter Kindmodus", "Ärgerlicher Kindmodus", "Wütender Kindmodus",
"Impulsiver Kindmodus", "Undisziplinierter Kindmodus", "Glücklicher Kindmodus",
"Unterwerfungsmodus", "Distanzierter Beschützermodus", "Distanzierte Selbsttröstung",
"Narzisstische Selbstüberhöhung", "Schikane und Angriff",
"Strafender Elternmodus", "Fordernder Elternmodus", "Gesunder Erwachsenenmodus"
),
kategorie = c(
"Kindmodi","Kindmodi","Kindmodi","Kindmodi","Kindmodi","Kindmodi",
"Bewältigungsmodi","Bewältigungsmodi","Bewältigungsmodi","Bewältigungsmodi","Bewältigungsmodi",
"Elternmodi","Elternmodi",
"Gesunder Erwachsener"
),
stringsAsFactors = FALSE
)
kategorie_reihenfolge = c("Kindmodi", "Bewältigungsmodi", "Elternmodi", "Gesunder Erwachsener")
# Diese Spalten/Felder sind keine SMI-Items und werden bei der Auswertung
# ignoriert, falls in daten_smi vorhanden.
SMI_NICHT_ITEM_SPALTEN = c("smi_intro", "smi_submit_030", "smi_submit_060",
"smi_submit_090", "smi_submit_118")
# 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; }
.modus-kopf {
display: flex; align-items: baseline; gap: 10px; margin: 16px 0 6px;
}
.modus-kopf h5 { margin: 0; color: #333; font-size: 1.2rem; font-weight: 700; }
.modus-mittelwert { font-weight: 700; color: #8B2635; font-size: 1.1rem; }
.trenner-zeile {
font-style: italic; color: #777; font-size: 0.85em; margin: 10px 0 4px;
}
.details-block { margin: 10px 0; }
.details-block summary {
cursor: pointer; font-weight: 600; color: #8B2635; padding: 6px 0; outline: none;
font-size: 0.88rem;
}
.item-zeile {
display: flex; align-items: flex-start; gap: 10px;
padding: 7px 0; border-bottom: 1px solid #F0F0F0;
}
.item-zeile:last-child { border-bottom: none; }
.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;
background: #ECEFF1; color: #37474F;
}
"
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("SMI-1 Schema Mode Inventory"),
tags$p("118 Items, 14 Schemamodi rein deskriptive Auswertung ohne Normwerte oder Cutoffs")
),
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_smi_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_warnung = fp_text(italic = TRUE, font.size = 10, color = "#BF360C")
fp_trenner = fp_text(italic = TRUE, font.size = 10, color = "#777777")
fp_disclaimer = fp_text(font.size = 9, italic = TRUE, color = "#777777")
fp_badge = function(wert) {
wert_key = as.character(wert)
bg = if (wert_key %in% names(SMI_BADGE_FARBEN)) SMI_BADGE_FARBEN[[wert_key]] else "#ECEFF1"
fg = if (wert_key %in% names(SMI_BADGE_TEXT_FARBEN)) SMI_BADGE_TEXT_FARBEN[[wert_key]] else "#37474F"
fp_text(bold = TRUE, font.size = 9.5, color = fg, shading.color = bg)
}
doc = body_add_fpar(doc, fpar(ftext("SMI-1 Schema Mode Inventory", fp_titel)))
meta_teile = list(
ftext("Chiffre: ", fp_label), ftext(erg$chiffre, fp_normal),
ftext(" Ausfülldatum: ", fp_label), ftext(format(erg$ausfuelldatum, "%d.%m.%Y"), fp_normal)
)
if (!is.null(erg$pseudonym_verwendet) && nchar(erg$pseudonym_verwendet) > 0) {
meta_teile = c(meta_teile, list(
ftext(" Pseudonym: ", fp_label), ftext(erg$pseudonym_verwendet, fp_normal)
))
}
doc = body_add_fpar(doc, do.call(fpar, meta_teile))
if (!is.null(erg$mehrfach_warnung)) {
doc = body_add_fpar(doc, fpar(ftext(erg$mehrfach_warnung, fp_warnung)))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_gg(doc, make_smi_balken_plot(erg$modus_ergebnisse), width = 6.3, height = 6.3)
doc = body_add_par(doc, "", style = "Normal")
for (kat in kategorie_reihenfolge) {
doc = body_add_fpar(doc, fpar(ftext(kat, fp_abschnitt)))
modi_kat = Filter(function(m) m$kategorie == kat, erg$modus_ergebnisse)
for (m in modi_kat) {
mw_text = if (is.na(m$mittelwert)) "k. A." else sprintf("%.2f", m$mittelwert)
doc = body_add_fpar(doc, fpar(
ftext(paste0(m$name, ": "), fp_label),
ftext(paste0(mw_text, " (Range 1-6)"), fp_normal)
))
if (m$hat_warnung) {
doc = body_add_fpar(doc, fpar(ftext(
paste0("Basiert auf ", m$n_vorhanden, " von ", m$n_gesamt,
" Items, mindestens ein Item fehlt."), fp_warnung)))
}
for (it in m$block_a) {
doc = body_add_fpar(doc, fpar(
ftext(paste0(it$nr, ". ", it$text, " "), fp_normal),
ftext(paste0(" ", it$wert, " "), fp_badge(it$wert))
))
}
if (length(m$block_b) > 0) {
doc = body_add_fpar(doc, fpar(ftext("Items ≤ 3:", fp_trenner)))
for (it in m$block_b) {
doc = body_add_fpar(doc, fpar(
ftext(paste0(it$nr, ". ", it$text, " "), fp_normal),
ftext(paste0(" ", it$wert, " "), fp_badge(it$wert))
))
}
}
if (length(m$block_c) > 0) {
doc = body_add_fpar(doc, fpar(ftext("Nicht beantwortet:", fp_trenner)))
for (it in m$block_c) {
doc = body_add_fpar(doc, fpar(ftext(paste0(it$nr, ". ", it$text), fp_normal)))
}
}
doc = body_add_par(doc, "", style = "Normal")
}
}
doc = body_add_fpar(doc, fpar(ftext(SMI_DISCLAIMER, fp_disclaimer)))
doc
}
# Server ####
server = function(input, output, session) {
observe({
query = parseQueryString(session$clientData$url_search)
if (!is.null(query$pseudonym) && nchar(trimws(query$pseudonym)) > 0) {
updateTextInput(session, "pseudonym", value = trimws(query$pseudonym))
}
})
observe({
query = parseQueryString(session$clientData$url_search)
if (!is.null(query$chiffre) && nchar(trimws(query$chiffre)) > 0) {
updateTextInput(session, "chiffre", value = toupper(trimws(query$chiffre)))
}
})
# Skripte werden NICHT beim App-Start gesourct, nur beim Klick.
ergebnis = eventReactive(input$btn_suchen, {
chiffre = toupper(trimws(input$chiffre))
if (nchar(trimws(input$pseudonym)) == 0 && nchar(chiffre) == 0) {
return(list(typ = "leere_eingabe", meldung = "Bitte Chiffre oder Pseudonym eingeben."))
}
if (nchar(trimws(input$pseudonym)) == 0 && !grepl("^[A-Z][0-9]{6}$", chiffre)) {
return(list(typ = "format_fehler", chiffre = chiffre))
}
if (!file.exists(PFAD_DOWNLOAD_SKRIPT)) {
return(list(typ = "skript_fehler",
meldung = paste0("Download-Skript nicht gefunden:\n", PFAD_DOWNLOAD_SKRIPT)))
}
if (!file.exists(PFAD_PSEUDONYM_SKRIPT)) {
return(list(typ = "skript_fehler",
meldung = paste0("Pseudonym-Skript nicht gefunden:\n", PFAD_PSEUDONYM_SKRIPT)))
}
ok_dl = tryCatch({
source(PFAD_DOWNLOAD_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok_dl$ok) return(list(typ = "skript_fehler", meldung = ok_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
})
if (is.null(db_ordner)) return(list(typ = "db_nicht_gefunden"))
alter_wd = getwd()
on.exit(setwd(alter_wd), add = TRUE)
setwd(db_ordner)
ok_ps = tryCatch({
source(PFAD_PSEUDONYM_SKRIPT, local = FALSE)
list(ok = TRUE)
}, error = function(e) list(ok = FALSE, msg = e$message))
if (!ok_ps$ok) return(list(typ = "skript_fehler", meldung = ok_ps$msg))
if (!exists("daten_smi", envir = .GlobalEnv) || !exists("pseudo", envir = .GlobalEnv)) {
return(list(typ = "daten_fehlen"))
}
daten = get("daten_smi", envir = .GlobalEnv)
pseudo_df = get("pseudo", envir = .GlobalEnv)
if (!("session" %in% names(daten))) {
return(list(typ = "daten_fehlen",
meldung = "Erwartete Spalte 'session' nicht in 'daten_smi' gefunden."))
}
if (nchar(trimws(input$pseudonym)) > 0) {
pw_treffer = pseudo_df[pseudo_df$pseudonym == trimws(input$pseudonym), ]
if (nrow(pw_treffer) > 0) chiffre = toupper(trimws(pw_treffer$chiffre[1]))
}
treffer_ps = pseudo_df[toupper(trimws(pseudo_df$chiffre)) == chiffre, ]
if (nrow(treffer_ps) == 0) return(list(typ = "chiffre_nicht_gefunden", chiffre = chiffre))
alle_session_ids = unique(treffer_ps$pseudonym)
if (nchar(trimws(input$pseudonym)) > 0) alle_session_ids = trimws(input$pseudonym)
treffer_daten = daten[daten$session %in% alle_session_ids, ]
if (nrow(treffer_daten) == 0) return(list(typ = "kein_treffer", chiffre = chiffre))
mehrfach_warnung = NULL
if (nrow(treffer_daten) > 1) {
n = nrow(treffer_daten)
if ("created" %in% names(treffer_daten)) {
treffer_daten = treffer_daten[order(treffer_daten$created, decreasing = TRUE), ]
}
treffer_daten = treffer_daten[1, , drop = FALSE]
mehrfach_warnung = paste0(
"Mehrere Ausfüllungen gefunden (", n, " Einträge) — es wird die neueste angezeigt."
)
}
zeile = treffer_daten[1, , drop = FALSE]
ausfuelldatum = tryCatch({
if ("created" %in% names(zeile)) {
as.Date(as.POSIXct(as.character(zeile[["created"]][1])))
} else {
Sys.Date()
}
}, error = function(e) Sys.Date())
daten_items = daten[, !(names(daten) %in% SMI_NICHT_ITEM_SPALTEN), drop = FALSE]
modus_ergebnisse = lapply(seq_len(nrow(smi_modi)), function(i) {
smi_modus_auswertung(daten_items, zeile, smi_modi[i, ])
})
list(
typ = "erfolg",
chiffre = chiffre,
pseudonym_verwendet = trimws(input$pseudonym),
ausfuelldatum = ausfuelldatum,
mehrfach_warnung = mehrfach_warnung,
modus_ergebnisse = modus_ergebnisse
)
})
smi_fehlermeldung = function(d) {
switch(d$typ,
"leere_eingabe" = d$meldung,
"format_fehler" = paste0("Ungültige Chiffre '", d$chiffre, "'. Erwartet: ein Großbuchstabe + 6 Ziffern (z.B. P000123)."),
"skript_fehler" = paste0("Fehler beim Sourcen eines externen Skripts: ", d$meldung),
"db_nicht_gefunden" = "Die Datei 'pseudonyme.db' konnte in den übergeordneten Verzeichnissen nicht gefunden werden.",
"daten_fehlen" = if (!is.null(d$meldung)) d$meldung else "Nach dem Sourcen der Skripte fehlen die erwarteten Objekte 'daten_smi' oder 'pseudo'.",
"chiffre_nicht_gefunden" = paste0("Chiffre '", d$chiffre, "' wurde in der Pseudonym-Datenbank nicht gefunden."),
"kein_treffer" = paste0("Kein SMI-1-Datensatz für Chiffre '", d$chiffre, "' gefunden."),
"Unbekannter Fehler."
)
}
output$fehler_ui = renderUI({
req(input$btn_suchen)
d = ergebnis()
if (d$typ != "erfolg") div(class = "alert-fehler", smi_fehlermeldung(d))
})
output$warnung_ui = renderUI({
req(input$btn_suchen)
d = ergebnis()
if (d$typ != "erfolg" || is.null(d$mehrfach_warnung)) return(NULL)
div(class = "alert-warnung", d$mehrfach_warnung)
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis()
if (d$typ != "erfolg") return(NULL)
render_item_zeile = function(it, zeige_wert = TRUE) {
wert_key = as.character(it$wert)
bg = if (wert_key %in% names(SMI_BADGE_FARBEN)) SMI_BADGE_FARBEN[[wert_key]] else "#ECEFF1"
fg = if (wert_key %in% names(SMI_BADGE_TEXT_FARBEN)) SMI_BADGE_TEXT_FARBEN[[wert_key]] else "#37474F"
div(class = "item-zeile",
div(class = "item-nr", paste0(it$nr, ".")),
div(class = "item-text", it$text),
if (zeige_wert) span(class = "stufe-badge",
style = paste0("background:", bg, "; color:", fg, ";"), it$wert)
)
}
modus_karten = lapply(kategorie_reihenfolge, function(kat) {
modi_kat = Filter(function(m) m$kategorie == kat, d$modus_ergebnisse)
modi_ui = lapply(modi_kat, function(m) {
mw_text = if (is.na(m$mittelwert)) "k. A." else sprintf("%.2f", m$mittelwert)
tagList(
div(class = "modus-kopf",
tags$h5(m$name),
span(class = "modus-mittelwert", paste0(mw_text, " / 6"))
),
if (m$hat_warnung) div(class = "alert-warnung",
paste0("Basiert auf ", m$n_vorhanden, " von ", m$n_gesamt,
" Items, mindestens ein Item fehlt.")),
if (length(m$block_a) > 0) div(lapply(m$block_a, render_item_zeile)),
# Items <= 3 sind standardmaessig eingeklappt, per Klick aufklappbar -
# der Modus-Mittelwert oben bleibt davon unberuehrt, da er weiterhin
# aus allen Items berechnet wird.
if (length(m$block_b) > 0) tags$details(class = "details-block",
tags$summary(paste0("Items ≤ 3 anzeigen (", length(m$block_b), ")")),
div(lapply(m$block_b, render_item_zeile))
),
if (length(m$block_c) > 0) tagList(
div(class = "trenner-zeile", "Nicht beantwortet:"),
div(lapply(m$block_c, function(it) render_item_zeile(it, zeige_wert = FALSE)))
)
)
})
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", kat),
modi_ui
)
})
tagList(
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "SMI-1 Übersicht"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfülldatum: "), format(d$ausfuelldatum, "%d.%m.%Y")
),
plotOutput("balken_plot", height = "440px")
),
modus_karten
)
})
output$balken_plot = renderPlot({
req(input$btn_suchen)
d = ergebnis()
req(d$typ == "erfolg")
make_smi_balken_plot(d$modus_ergebnisse)
}, bg = "transparent")
output$download_word = downloadHandler(
filename = function() {
d = tryCatch(ergebnis(), error = function(e) NULL)
erfolgreich = is.list(d) && identical(d$typ, "erfolg")
chiffre_esc = if (erfolgreich && nchar(d$chiffre) > 0) gsub("[^A-Za-z0-9_-]", "_", d$chiffre) else "export"
ausfuelldatum_fn = if (erfolgreich) {
tryCatch(format(as.Date(d$ausfuelldatum), "%Y%m%d"),
error = function(e) format(Sys.Date(), "%Y%m%d"))
} else {
format(Sys.Date(), "%Y%m%d")
}
paste0("SMI_", chiffre_esc, "_", ausfuelldatum_fn, ".docx")
},
content = function(file) {
d = tryCatch(ergebnis(), error = function(e) NULL)
erfolgreich = is.list(d) && identical(d$typ, "erfolg")
if (!erfolgreich) {
doc = read_docx()
doc = body_add_par(doc,
"Kein Datensatz geladen. Bitte zuerst Chiffre oder Pseudonym eingeben und 'Auswerten' klicken.",
style = "Normal")
print(doc, target = file)
return()
}
doc = tryCatch(
erstelle_smi_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)