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

794 lines
28 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_pssi.R"
PFAD_PSEUDONYM_SKRIPT = "../get_pseudo.R"
PFAD_NORMTABELLEN = "normtabellen"
PSSI_DISCLAIMER = paste0(
"Diese Auswertung ist ein Hilfsmittel fuer klinisches Fachpersonal und ersetzt ",
"keine klinische Diagnose. Die Interpretation obliegt der behandelnden Person. ",
"Es wird kein klinischer Cutoff-Wert angewendet; Prozentrang und T-Wert werden ",
"neutral berichtet und muessen fachlich eingeordnet werden."
)
library(shiny)
library(dplyr)
library(ggplot2)
library(haven)
library(officer)
library(DBI)
library(RSQLite)
# 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)
PFAD_NORMTABELLEN = normalizePath(absPath(PFAD_NORMTABELLEN), mustWork = FALSE)
# Helper ####
pssi_antwortkategorien = c(
"trifft gar nicht zu", "trifft etwas zu",
"trifft ueberwiegend zu", "trifft ausgesprochen zu"
)
pssi_normalisiere_kat = function(text) {
x = tolower(trimws(as.character(text)))
x = gsub("ü", "ue", x, fixed = TRUE)
x
}
pssi_antwortkategorien_norm = pssi_normalisiere_kat(pssi_antwortkategorien)
# Extrahiert die Stufe 0-3 aus einer PSSI-Item-Spalte. Nutzt bei
# haven-labelled Spalten IMMER das labels-Attribut (Antworttext -> Code),
# nie den rohen numerischen Code direkt, da dessen Kodierung variieren kann.
stufe_aus_item = function(spalte, item_name = "") {
if (length(spalte) == 0 || is.na(spalte[1])) return(NA_integer_)
if (haven::is.labelled(spalte)) {
lbl_attr = attr(spalte, "labels")
wert = as.numeric(spalte[1])
if (is.null(lbl_attr) || length(lbl_attr) == 0 || is.na(wert)) {
stop(paste0("Unerwartetes Antwortformat bei PSSI-Item ", item_name,
": labelled-Spalte ohne labels-Attribut."))
}
pos = which(as.vector(lbl_attr) == wert)
if (length(pos) == 0) {
stop(paste0("Unerwartetes Antwortformat bei PSSI-Item ", item_name,
": Code ", wert, " nicht in labels-Attribut gefunden."))
}
txt_norm = pssi_normalisiere_kat(names(lbl_attr)[pos[1]])
stufe = match(txt_norm, pssi_antwortkategorien_norm) - 1L
if (is.na(stufe)) {
stop(paste0("Unerwartetes Antwortformat bei PSSI-Item ", item_name,
": Antworttext '", names(lbl_attr)[pos[1]],
"' passt zu keiner der vier bekannten Kategorien."))
}
return(as.integer(stufe))
}
txt_norm = pssi_normalisiere_kat(spalte[1])
stufe = match(txt_norm, pssi_antwortkategorien_norm) - 1L
if (is.na(stufe)) {
stop(paste0("Unerwartetes Antwortformat bei PSSI-Item ", item_name,
": Wert '", spalte[1], "' passt zu keiner der vier bekannten Kategorien."))
}
as.integer(stufe)
}
pssi_pr_t_lookup = function(normtabelle, skala, rohwert) {
zeile = normtabelle[normtabelle$rohwert == rohwert, , drop = FALSE]
if (nrow(zeile) == 0) return(list(pr = NA_real_, t = NA_real_))
pr_col = paste0(skala, "_PR")
t_col = paste0(skala, "_T")
list(
pr = suppressWarnings(as.numeric(zeile[[pr_col]][1])),
t = suppressWarnings(as.numeric(zeile[[t_col]][1]))
)
}
pssi_vorschlag_normtabelle = function(alter, geschlecht) {
if (is.na(alter) || alter < 14 || alter > 82) return(NULL)
if (is.na(geschlecht) || !(geschlecht %in% c("weiblich", "maennlich"))) return(NULL)
if (alter >= 14 && alter <= 17) return("B4")
altersgruppe = if (alter <= 25) "18_25"
else if (alter <= 45) "26_45"
else if (alter <= 55) "46_55"
else "56_82"
schluessel = list(
"18_25" = c(weiblich = "B10", maennlich = "B9"),
"26_45" = c(weiblich = "B12", maennlich = "B11"),
"46_55" = c(weiblich = "B14", maennlich = "B13"),
"56_82" = c(weiblich = "B16", maennlich = "B15")
)
unname(schluessel[[altersgruppe]][geschlecht])
}
pssi_profil_plot = function(profil_df) {
df = profil_df
df$fehlend = is.na(df$t)
df_plot = df
df_plot$t_plot = ifelse(df_plot$fehlend, NA, df_plot$t)
ggplot(df_plot, aes(x = skala, y = t_plot, group = 1)) +
geom_hline(yintercept = 50, color = "#777777", linetype = "dashed", linewidth = 0.6) +
annotate("text", x = levels(df_plot$skala)[1], y = 52,
label = "Populationsmittelwert (T=50) - statistische Konvention, kein Cutoff",
hjust = 0, size = 3, color = "#555555") +
geom_line(color = AKZENT_FARBE, linewidth = 0.9, na.rm = TRUE) +
geom_point(data = df_plot[!df_plot$fehlend, ], aes(x = skala, y = t_plot),
color = AKZENT_FARBE, size = 2.6) +
geom_point(data = df_plot[df_plot$fehlend, ], aes(x = skala, y = 50),
shape = 21, size = 3, color = AKZENT_FARBE, fill = "white", stroke = 1.1) +
scale_y_continuous(limits = c(10, 90), breaks = seq(10, 90, 10)) +
labs(x = NULL, y = "T-Wert",
caption = "Offener Kreis = kein T-Wert ausgewiesen (Boden-/Deckeneffekt der Normstichprobe)") +
theme_minimal(base_size = 11) +
theme(
panel.grid.minor = element_blank(),
axis.text.x = element_text(angle = 0),
plot.caption = element_text(size = 8, color = "#777777", hjust = 0)
)
}
# Datenaufbereitung ####
pssi_skala_reihenfolge_items = c("PN","SZ","ST","BL","HI","NA","SU","AB","ZW","NT","DP","SL","RH","AS")
pssi_item_map = data.frame(
item = 1:140,
skala = rep(pssi_skala_reihenfolge_items, times = 10),
umgepolt = (1:140) %in% c(15, 39, 43, 44, 49, 67, 71, 72, 86, 91, 99, 104, 105, 109, 137),
stringsAsFactors = FALSE
)
{
n_je_skala = table(pssi_item_map$skala)
if (!all(n_je_skala == 10) || length(n_je_skala) != 14) {
stop("Datenintegritaetsfehler: pssi_item_map weist nicht jeder der 14 Skalen genau 10 Items zu.")
}
if (length(unique(pssi_item_map$item)) != 140 || anyNA(pssi_item_map$skala)) {
stop("Datenintegritaetsfehler: nicht alle 140 PSSI-Items sind genau einer Skala zugeordnet.")
}
}
pssi_skalennamen = c(
AS = "Selbstbestimmt (antisoziale PS)",
PN = "Eigenwillig (paranoide PS)",
SZ = "Zurueckhaltend (schizoide PS)",
SU = "Selbstkritisch (selbstunsichere PS)",
ZW = "Sorgfaeltig (zwanghafte PS)",
ST = "Ahnungsvoll (schizotypische PS)",
RH = "Optimistisch (rhapsodisch, kein DSM-Bezug)",
"NA" = "Ehrgeizig (narzisstische PS)",
NT = "Kritisch (negativistische/passiv-aggressive PS)",
AB = "Loyal (abhaengige PS)",
BL = "Spontan (Borderline-PS)",
HI = "Liebenswuerdig (histrionische PS)",
DP = "Still/passiv (depressive PS, kein DSM-Achse-II-Bezug)",
SL = "Hilfsbereit/altruistisch (selbstlose PS, kein DSM-Bezug)"
)
pssi_skala_anzeige_reihenfolge = c("AS","PN","SZ","SU","ZW","ST","RH","NA","NT","AB","BL","HI","DP","SL")
pssi_normtabellen_meta = data.frame(
key = paste0("B", 1:16),
datei = c(
"B1_gesamtstichprobe.csv", "B2_maenner_gesamt.csv", "B3_frauen_gesamt.csv",
"B4_14_17_jahre.csv", "B5_18_25_jahre.csv", "B6_26_45_jahre.csv",
"B7_46_55_jahre.csv", "B8_56_82_jahre.csv", "B9_18_25_maenner.csv",
"B10_18_25_frauen.csv", "B11_26_45_maenner.csv", "B12_26_45_frauen.csv",
"B13_46_55_maenner.csv", "B14_46_55_frauen.csv", "B15_56_82_maenner.csv",
"B16_56_82_frauen.csv"
),
beschreibung = c(
"Gesamtstichprobe, alle Erwachsenen", "alle erwachsenen Maenner", "alle erwachsenen Frauen",
"14-17 Jahre (beide Geschlechter)", "18-25 Jahre (beide Geschlechter)", "26-45 Jahre (beide Geschlechter)",
"46-55 Jahre (beide Geschlechter)", "56-82 Jahre (beide Geschlechter)", "18-25 Jahre, maennlich",
"18-25 Jahre, weiblich", "26-45 Jahre, maennlich", "26-45 Jahre, weiblich",
"46-55 Jahre, maennlich", "46-55 Jahre, weiblich", "56-82 Jahre, maennlich",
"56-82 Jahre, weiblich"
),
n = c(1903, 1037, 866, 40, 658, 852, 256, 137, 327, 331, 456, 396, 183, 73, 71, 66),
stringsAsFactors = FALSE
)
pssi_normtabellen_meta$label = paste0(
pssi_normtabellen_meta$key, " ", pssi_normtabellen_meta$beschreibung,
" (N=", pssi_normtabellen_meta$n, ")"
)
pssi_normtabellen = list()
for (i in seq_len(nrow(pssi_normtabellen_meta))) {
key = pssi_normtabellen_meta$key[i]
datei = pssi_normtabellen_meta$datei[i]
pfad = file.path(PFAD_NORMTABELLEN, datei)
if (!file.exists(pfad)) {
stop(paste0("Normtabelle nicht gefunden: ", pfad))
}
tab = tryCatch(
read.csv(pfad, na.strings = character(0), stringsAsFactors = FALSE),
error = function(e) stop(paste0("Fehler beim Einlesen von '", datei, "': ", e$message))
)
if (!("rohwert" %in% names(tab))) {
stop(paste0("Normtabelle '", datei, "' hat keine Spalte 'rohwert'."))
}
if (!setequal(sort(tab$rohwert), 0:30)) {
stop(paste0("Normtabelle '", datei, "' enthaelt nicht lueckenlos die Rohwerte 0-30."))
}
for (sk in pssi_skala_reihenfolge_items) {
pr_col = paste0(sk, "_PR")
t_col = paste0(sk, "_T")
if (!(pr_col %in% names(tab))) {
stop(paste0("Normtabelle '", datei, "' hat keine Spalte '", pr_col, "'."))
}
if (!(t_col %in% names(tab))) {
stop(paste0("Normtabelle '", datei, "' hat keine Spalte '", t_col, "'."))
}
}
pssi_normtabellen[[key]] = tab
}
# UI ####
app_css = "
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f5f5f5; }
.container-fluid { max-width: 1100px; }
.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; }
.normtabelle-info { margin-bottom: 14px; color: #444; font-size: 0.92em; }
.disclaimer-block {
font-size: 0.82em; color: #777; font-style: italic;
margin-top: 10px; border-top: 1px solid rgba(0,0,0,.1); padding-top: 8px;
}
.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; }
table.pssi-tabelle { width: 100%; border-collapse: collapse; font-size: 0.92em; }
table.pssi-tabelle th {
text-align: left; border-bottom: 2px solid #8B2635; padding: 6px 8px; color: #8B2635;
}
table.pssi-tabelle td { padding: 6px 8px; border-bottom: 1px solid #eee; }
table.pssi-tabelle td.zahl { text-align: right; }
"
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("PSSI Persoenlichkeits-Stil- und Stoerungs-Inventar"),
tags$p("Einzelfall-Auswertung mit Normtabellen (Prozentrang, T-Wert)")
),
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_normtabelle_ui"),
uiOutput("warnung_mehrfach_ui"),
uiOutput("normtabelle_auswahl_ui"),
uiOutput("ergebnis_ui")
)
)
# Word-Export ####
erstelle_pssi_docx = function(erg, normtabelle_info) {
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("PSSI - Einzelauswertung", fp_titel)))
doc = body_add_fpar(doc, fpar(
ftext("Chiffre: ", fp_label),
ftext(erg$chiffre, fp_normal),
ftext(" Ausfuelldatum: ", fp_label),
ftext(erg$ausfuelldatum_anzeige, fp_normal)
))
doc = body_add_fpar(doc, fpar(
ftext("Normtabelle: ", fp_label),
ftext(normtabelle_info, 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")
doc = body_add_fpar(doc, fpar(ftext("Skalenwerte", fp_abschnitt)))
for (i in seq_len(nrow(erg$profil))) {
zeile = erg$profil[i, ]
pr_txt = if (is.na(zeile$pr)) "k. A." else sprintf("%.1f", zeile$pr)
t_txt = if (is.na(zeile$t)) "kein T-Wert ausgewiesen (Boden-/Deckeneffekt)" else sprintf("%.0f", zeile$t)
doc = body_add_fpar(doc, fpar(
ftext(sprintf("%-4s ", zeile$skala), fp_text(bold = TRUE, font.size = 10, font.family = "Courier New")),
ftext(sprintf("%-55s ", substr(zeile$skala_name, 1, 55)), fp_normal),
ftext(sprintf("Rohwert: %2d PR: %-6s T: %s", zeile$rohwert, pr_txt, t_txt), fp_normal)
))
}
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext("Profildiagramm", fp_abschnitt)))
tmp_png = tempfile(fileext = ".png")
ggplot2::ggsave(tmp_png, pssi_profil_plot(erg$profil), width = 8, height = 4, dpi = 150, bg = "white")
doc = body_add_img(doc, src = tmp_png, width = 6.2, height = 3.1)
if (file.exists(tmp_png)) unlink(tmp_png)
doc = body_add_par(doc, "", style = "Normal")
doc = body_add_fpar(doc, fpar(ftext(PSSI_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)))
}
})
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 = "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)))
}
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(error = paste0("Fehler im Download-Skript: ", 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(error = paste0(
"pseudonyme.db nicht gefunden (bis 5 Ebenen oberhalb von ",
dirname(normalizePath(PFAD_PSEUDONYM_SKRIPT, mustWork = FALSE)), " gesucht)."
)))
}
alter_wd = getwd()
setwd(db_ordner)
on.exit(setwd(alter_wd), add = TRUE)
ok_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 (!ok_ps$ok) {
return(list(error = paste0("Fehler im Pseudonym-Skript: ", ok_ps$msg)))
}
if (!exists("daten_pssi", envir = .GlobalEnv)) {
return(list(error = "Objekt 'daten_pssi' nach dem Sourcen nicht gefunden. Bitte Download-Skript pruefen."))
}
if (!exists("pseudo", envir = .GlobalEnv)) {
return(list(error = "Objekt 'pseudo' nach dem Sourcen nicht gefunden. Bitte Pseudonym-Skript pruefen."))
}
daten_pssi = get("daten_pssi", envir = .GlobalEnv)
pseudo = get("pseudo", envir = .GlobalEnv)
treffer_ps = pseudo[toupper(trimws(as.character(pseudo$chiffre))) == chiffre, ]
if (nrow(treffer_ps) == 0) {
return(list(error = paste0("Chiffre '", chiffre, "' wurde in der Pseudonym-Datenbank nicht gefunden.")))
}
alle_session_ids = unique(as.character(treffer_ps$pseudonym))
if (nchar(trimws(input$pseudonym)) > 0) alle_session_ids = trimws(input$pseudonym)
treffer_dat = daten_pssi[as.character(daten_pssi$session) %in% alle_session_ids, , drop = FALSE]
if (nrow(treffer_dat) == 0) {
return(list(error = paste0(
"Kein PSSI-Datensatz fuer Chiffre '", chiffre, "' gefunden. (",
length(alle_session_ids), " Pseudonym(e) geprueft)"
)))
}
info_mehrere = NULL
if (nrow(treffer_dat) > 1) {
n = nrow(treffer_dat)
idx_neu = which.max(as.POSIXct(treffer_dat$created))
datum_neu = tryCatch(
format(as.POSIXct(treffer_dat$created[idx_neu]), "%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[idx_neu, , drop = FALSE]
}
zeile = treffer_dat[1, , drop = FALSE]
ausfuelldatum_raw = zeile[["created"]][1]
ausfuelldatum_anzeige = tryCatch(
format(as.POSIXct(ausfuelldatum_raw), "%d.%m.%Y"),
error = function(e) "unbekannt"
)
alter_roh = zeile[["pssi_alter"]][1]
alter_num = suppressWarnings(as.numeric(trimws(as.character(alter_roh))))
alter_gueltig = !is.na(alter_num) && alter_num >= 10 && alter_num <= 110
if (!alter_gueltig) alter_num = NA_real_
geschlecht_roh = zeile[["pssi_geschlecht"]][1]
geschlecht_txt = tryCatch({
spalte_orig = daten_pssi[["pssi_geschlecht"]]
if (haven::is.labelled(spalte_orig)) {
lbl_attr = attr(spalte_orig, "labels")
pos = which(as.vector(lbl_attr) == as.numeric(geschlecht_roh))
if (length(pos) > 0) trimws(names(lbl_attr)[pos[1]]) else NA_character_
} else {
trimws(as.character(geschlecht_roh))
}
}, error = function(e) NA_character_)
geschlecht = if (!is.na(geschlecht_txt) && grepl("^weiblich$", geschlecht_txt, ignore.case = TRUE)) {
"weiblich"
} else if (!is.na(geschlecht_txt) && grepl("^m(a|ä)nnlich$", geschlecht_txt, ignore.case = TRUE)) {
"maennlich"
} else {
NA_character_
}
stufen = sapply(1:140, function(i) {
var = sprintf("pssi_%03d", i)
if (!(var %in% names(daten_pssi))) {
stop(paste0("Item-Spalte '", var, "' fehlt in daten_pssi."))
}
stufe_aus_item(zeile[[var]], var)
})
rohwerte = sapply(pssi_skala_anzeige_reihenfolge, function(sk) {
idx = pssi_item_map$item[pssi_item_map$skala == sk]
umgepolt = pssi_item_map$umgepolt[pssi_item_map$skala == sk]
werte = stufen[idx]
werte_final = ifelse(umgepolt, 3 - werte, werte)
sum(werte_final)
})
names(rohwerte) = pssi_skala_anzeige_reihenfolge
normtabelle_vorschlag = pssi_vorschlag_normtabelle(alter_num, geschlecht)
kein_vorschlag = is.null(normtabelle_vorschlag)
list(
chiffre = chiffre,
ausfuelldatum_anzeige = ausfuelldatum_anzeige,
info_mehrere = info_mehrere,
alter_num = alter_num,
geschlecht = geschlecht,
rohwerte = rohwerte,
normtabelle_vorschlag = normtabelle_vorschlag,
kein_vorschlag = kein_vorschlag,
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_mehrfach_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$warnung_normtabelle_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error) || !isTRUE(d$kein_vorschlag)) return(NULL)
div(class = "alert-warnung",
"Kein automatischer Normtabellen-Vorschlag moeglich (Alter und/oder Geschlecht ",
"nicht eindeutig auswertbar). Bitte Normtabelle manuell pruefen und auswaehlen."
)
})
output$normtabelle_auswahl_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error)) return(NULL)
default_key = if (isTRUE(d$kein_vorschlag)) "B1" else d$normtabelle_vorschlag
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "Normtabelle"),
selectInput("normtabelle_wahl", label = "Verwendete Normtabelle",
choices = setNames(pssi_normtabellen_meta$key, pssi_normtabellen_meta$label),
selected = default_key, width = "100%"),
uiOutput("normtabelle_info_ui")
)
})
output$normtabelle_info_ui = renderUI({
req(input$normtabelle_wahl)
meta = pssi_normtabellen_meta[pssi_normtabellen_meta$key == input$normtabelle_wahl, ]
if (nrow(meta) == 0) return(NULL)
div(class = "normtabelle-info",
tags$strong("Aktuell verwendet: "), meta$label[1]
)
})
profil_r = reactive({
req(input$btn_suchen, input$normtabelle_wahl)
d = ergebnis_r()
if (!is.null(d$error)) return(NULL)
tab = pssi_normtabellen[[input$normtabelle_wahl]]
zeilen = lapply(pssi_skala_anzeige_reihenfolge, function(sk) {
rw = d$rohwerte[[sk]]
lk = pssi_pr_t_lookup(tab, sk, rw)
data.frame(
skala = sk,
skala_name = unname(pssi_skalennamen[sk]),
rohwert = rw,
pr = lk$pr,
t = lk$t,
stringsAsFactors = FALSE
)
})
df = do.call(rbind, zeilen)
df$skala = factor(df$skala, levels = pssi_skala_anzeige_reihenfolge)
df
})
output$ergebnis_ui = renderUI({
req(input$btn_suchen)
d = ergebnis_r()
if (!is.null(d$error)) return(NULL)
profil = profil_r()
if (is.null(profil)) return(NULL)
tabellen_zeilen = lapply(seq_len(nrow(profil)), function(i) {
z = profil[i, ]
t_anzeige = if (is.na(z$t)) {
tags$span(style = "color:#999; font-style:italic;",
"kein T-Wert ausgewiesen (Boden-/Deckeneffekt)")
} else {
sprintf("%.0f", z$t)
}
pr_anzeige = if (is.na(z$pr)) "k. A." else sprintf("%.1f", z$pr)
tags$tr(
tags$td(paste0(z$skala, " ", z$skala_name)),
tags$td(class = "zahl", z$rohwert),
tags$td(class = "zahl", pr_anzeige),
tags$td(class = "zahl", t_anzeige)
)
})
div(class = "abschnitt-karte",
div(class = "abschnitt-titel", "PSSI-Auswertung"),
div(class = "meta-block",
tags$strong("Chiffre: "), d$chiffre,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Ausfuelldatum: "), d$ausfuelldatum_anzeige,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Alter: "), if (is.na(d$alter_num)) "nicht auswertbar" else d$alter_num,
tags$span(" | ", style = "color:#ccc;"),
tags$strong("Geschlecht: "), if (is.na(d$geschlecht)) "nicht zuordenbar" else d$geschlecht
),
tags$hr(),
tags$h5("Skalenwerte"),
tags$table(class = "pssi-tabelle",
tags$thead(
tags$tr(
tags$th("Skala"), tags$th("Rohwert (0-30)"),
tags$th("Prozentrang"), tags$th("T-Wert")
)
),
tags$tbody(tabellen_zeilen)
),
tags$hr(),
tags$h5("Profildiagramm"),
plotOutput("profil_plot", height = "380px"),
div(class = "disclaimer-block", PSSI_DISCLAIMER)
)
})
output$profil_plot = renderPlot({
profil = profil_r()
req(profil)
pssi_profil_plot(profil)
})
output$download_word = downloadHandler(
filename = function() {
d = tryCatch(ergebnis_r(), error = function(e) NULL)
chiffre_esc = if (is.list(d) && is.null(d$error) && nchar(d$chiffre) > 0) {
gsub("[^A-Za-z0-9_-]", "_", d$chiffre)
} else {
"export"
}
ausfuelldatum_fn = tryCatch(
format(as.Date(d$ausfuelldatum_anzeige, "%d.%m.%Y"), "%Y%m%d"),
error = function(e) format(Sys.Date(), "%Y%m%d")
)
if (is.na(ausfuelldatum_fn) || length(ausfuelldatum_fn) == 0) {
ausfuelldatum_fn = format(Sys.Date(), "%Y%m%d")
}
paste0("PSSI_", chiffre_esc, "_", ausfuelldatum_fn, ".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()
}
profil = profil_r()
meta = pssi_normtabellen_meta[pssi_normtabellen_meta$key == input$normtabelle_wahl, ]
normtabelle_info = if (nrow(meta) > 0) meta$label[1] else input$normtabelle_wahl
erg = list(
chiffre = d$chiffre,
ausfuelldatum_anzeige = d$ausfuelldatum_anzeige,
info_mehrere = d$info_mehrere,
profil = profil
)
doc = tryCatch(
erstelle_pssi_docx(erg, normtabelle_info),
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)