# Präambel #### AKZENT_FARBE = "#8B2635" app_css = " .alert-fehler { background-color: #f8d7da; color: #58151c; border: 1px solid #8B2635; border-radius: 4px; padding: 12px 16px; margin-bottom: 12px; } .alert-warnung { background-color: #fff3cd; color: #664d03; border: 1px solid #d9a441; border-radius: 4px; padding: 12px 16px; margin-bottom: 12px; } .abschnitt-karte { background-color: #ffffff; border: 1px solid #e0e0e0; border-radius: 6px; padding: 18px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } .abschnitt-titel { color: #8B2635; font-size: 20px; font-weight: 700; margin-bottom: 14px; } .abschnitt-titel:not(:first-child) { margin-top: 22px; } .app-titel { color: #8B2635; font-size: 26px; font-weight: 700; margin: 4px 0 20px 0; } .btn-laden { background-color: #8B2635; color: #ffffff; border: none; margin-top: 8px; margin-bottom: 8px; } .btn-laden:hover { background-color: #6e1e2a; color: #ffffff; } .item-zeile { display: flex; align-items: center; gap: 14px; padding: 10px 16px; border-top: 1px solid #f0f0f0; } .item-zeile:hover { background-color: #faf7f2; } .item-nr { min-width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 50%; background-color: #f2e4e6; color: #8B2635; font-weight: 700; font-size: 13px; flex-shrink: 0; } .item-text { flex: 1 1 auto; font-size: 14px; } .input-panel { flex: 0 0 auto; } .input-panel .shiny-options-group { display: flex; gap: 16px; flex-wrap: wrap; margin: 0; } .input-panel .radio-inline { margin: 0; padding: 4px 12px; border: 1px solid #d8d8d8; border-radius: 14px; font-size: 12px; font-weight: 400; cursor: pointer; background-color: #fafafa; } .input-panel .radio-inline:hover { border-color: #8B2635; } .input-panel .radio-inline input[type=\"radio\"] { margin-right: 4px; } .bereich-panel { border: 1px solid #e5e5e5; border-radius: 6px; margin-bottom: 12px; } .bereich-titel { cursor: pointer; padding: 12px 16px; background-color: #faf5f6; border-radius: 6px 6px 0 0; list-style: none; } .bereich-titel::-webkit-details-marker { display: none; } .bereich-titel h2 { display: inline-block; color: #8B2635; font-size: 17px; font-weight: 700; margin: 0; } .bereich-hinweis { display: block; font-size: 12px; font-weight: 400; color: #888888; margin-top: 2px; } input[type=\"radio\"], input[type=\"checkbox\"] { accent-color: #8B2635; } .syndrom-liste .shiny-input-container { width: 100%; max-width: 100%; } .syndrom-liste .shiny-options-group { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 6px 24px; } .syndrom-liste .checkbox { margin: 0; } .vorbemerkungen-text { background-color: #fdf6f2; border-left: 3px solid #8B2635; padding: 10px 14px; margin: 10px 16px; font-size: 13px; line-height: 1.5; color: #444444; white-space: pre-line; } .erhebungscode-badge { font-size: 10px; font-weight: 400; color: #999999; margin-left: 4px; } .tooltip-ziel { position: relative; cursor: help; border-bottom: 1px dotted #999999; } .tooltip-inhalt { display: none; position: absolute; z-index: 100; left: 0; top: 100%; margin-top: 6px; width: 360px; max-width: 80vw; background-color: #ffffff; border: 1px solid #8B2635; border-radius: 6px; padding: 10px 14px; font-size: 12px; font-weight: 400; line-height: 1.5; color: #333333; white-space: normal; text-align: left; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18); } .tooltip-ziel:hover .tooltip-inhalt, .tooltip-ziel:focus .tooltip-inhalt, .tooltip-ziel:focus-within .tooltip-inhalt { display: block; } .tooltip-titel { display: block; color: #8B2635; font-weight: 700; margin-top: 8px; } .tooltip-titel:first-child { margin-top: 0; } .tooltip-inhalt p { margin: 2px 0 0 0; } .tooltip-inhalt ul { margin: 2px 0 0 0; padding-left: 16px; } " app_css = gsub("#8B2635", AKZENT_FARBE, app_css, fixed = TRUE) # Infrastruktur #### library(shiny) library(jsonlite) library(stringr) library(officer) APP_VERZEICHNIS = normalizePath(getwd()) STUFE_AUSWAHL = c( "nicht vorhanden" = "0", "leicht ausgeprägt" = "1", "mittel ausgeprägt" = "2", "schwer ausgeprägt" = "3", "keine Aussage" = "keine_aussage" ) STUFE_STANDARDWERT = "0" GESCHLECHT_AUSWAHL = c( "männlich" = "maennlich", "weiblich" = "weiblich" ) GESCHLECHT_STANDARDWERT = "maennlich" # Helper #### lade_amdp_datei = function(pfad) { ergebnis = tryCatch({ daten = jsonlite::fromJSON(pfad, simplifyVector = FALSE) list(erfolg = TRUE, daten = daten, fehler = NULL) }, error = function(e) { list(erfolg = FALSE, daten = NULL, fehler = conditionMessage(e)) }) return(ergebnis) } stufe_bezeichnung = function(wert) { namen = names(STUFE_AUSWAHL) treffer = namen[STUFE_AUSWAHL == wert] if (length(treffer) == 0) { return(wert) } return(treffer[1]) } passe_text_an_geschlecht_an = function(text, geschlecht) { if (!identical(geschlecht, "weiblich")) { return(text) } text = gsub("\\bDer Patient\\b", "Die Patientin", text) text = gsub("\\bder Patient\\b", "die Patientin", text) text = gsub("\\bden Patienten\\b", "die Patientin", text) text = gsub("\\bvom Patienten\\b", "von der Patientin", text) text = gsub("\\bdem Patienten\\b", "der Patientin", text) text = gsub("\\bdes Patienten\\b", "der Patientin", text) text = gsub("\\bPatienten\\b", "Patientin", text) text = gsub("\\bPatient\\b", "Patientin", text) text = gsub("\\bseine\\b", "ihre", text) text = gsub("\\bseiner\\b", "ihrer", text) text = gsub("\\bseinem\\b", "ihrem", text) text = gsub("\\bseines\\b", "ihres", text) text = gsub("\\bseinen\\b", "ihren", text) return(text) } sammle_item_werte = function(input, items_data) { werte = sapply(items_data, function(item) { wert = input[[paste0("item_", item$id)]] if (is.null(wert)) STUFE_STANDARDWERT else wert }) names(werte) = sapply(items_data, function(item) item$id) return(werte) } sammle_fussnoten_werte = function(input, items_data) { werte = list() for (item in items_data) { if (!is.null(item$fussnoten)) { for (idx in seq_along(item$fussnoten)) { eingabe_id = paste0("fussnote_", item$id, "_", idx) wert = input[[eingabe_id]] werte[[eingabe_id]] = if (is.null(wert)) FALSE else wert } } } return(werte) } ermittle_codierungsvorschlaege = function(text, items_data) { ergebnis = data.frame( item_id = character(), item_name = character(), begriff = character(), vorschlag_stufe = character(), stringsAsFactors = FALSE ) if (is.null(text) || stringr::str_trim(text) == "") { return(ergebnis) } for (item in items_data) { for (synonym in item$synonyme) { if (stringr::str_detect(text, stringr::fixed(synonym, ignore_case = TRUE))) { ergebnis = rbind(ergebnis, data.frame( item_id = item$id, item_name = item$name, begriff = synonym, vorschlag_stufe = "1", stringsAsFactors = FALSE )) break } } } return(ergebnis) } baue_befundtext = function(item_werte, fussnoten_werte, unauffaellige_einblenden, items_data, merkmalsbereiche_data, geschlecht) { absaetze = character(0) unbeurteilte_namen = character(0) for (bereich in merkmalsbereiche_data) { items_im_bereich = Filter(function(x) identical(x$merkmalsbereich, bereich$id), items_data) saetze = character(0) for (item in items_im_bereich) { wert = item_werte[[item$id]] if (is.null(wert)) { wert = STUFE_STANDARDWERT } ist_keine_aussage = identical(wert, "keine_aussage") if (ist_keine_aussage) { unbeurteilte_namen = c(unbeurteilte_namen, item$name) } zeige_stufensatz = !ist_keine_aussage && !(identical(wert, "0") && !isTRUE(unauffaellige_einblenden)) if (zeige_stufensatz) { satz = item$stufen_text[[wert]] if (!is.null(satz)) { saetze = c(saetze, satz) } } if (!is.null(item$fussnoten)) { for (idx in seq_along(item$fussnoten)) { eingabe_id = paste0("fussnote_", item$id, "_", idx) if (isTRUE(fussnoten_werte[[eingabe_id]])) { fussnote = item$fussnoten[[idx]] saetze = c(saetze, paste0(fussnote$bezeichnung, " vorhanden.")) } } } } if (length(saetze) > 0) { absatz = paste(saetze, collapse = " ") absaetze = c(absaetze, absatz) } } if (length(unbeurteilte_namen) > 0) { hinweis = paste0("Nicht beurteilt: ", paste(unbeurteilte_namen, collapse = ", "), ".") absaetze = c(absaetze, hinweis) } if (length(absaetze) == 0) { return("Keine Befunde erfasst.") } text = paste(absaetze, collapse = "\n\n") text = passe_text_an_geschlecht_an(text, geschlecht) return(text) } # Datenaufbereitung #### merkmalsbereiche_ergebnis = lade_amdp_datei(file.path(APP_VERZEICHNIS, "merkmalsbereiche.json")) items_ergebnis = lade_amdp_datei(file.path(APP_VERZEICHNIS, "items.json")) syndrome_ergebnis = lade_amdp_datei(file.path(APP_VERZEICHNIS, "syndrome.json")) erlaeuterungen_ergebnis = lade_amdp_datei(file.path(APP_VERZEICHNIS, "erlaeuterungen.json")) daten_geladen = merkmalsbereiche_ergebnis$erfolg && items_ergebnis$erfolg && syndrome_ergebnis$erfolg && erlaeuterungen_ergebnis$erfolg ladefehler_meldung = paste( Filter(Negate(is.null), list(merkmalsbereiche_ergebnis$fehler, items_ergebnis$fehler, syndrome_ergebnis$fehler, erlaeuterungen_ergebnis$fehler)), collapse = " / " ) if (daten_geladen) { merkmalsbereiche_data = merkmalsbereiche_ergebnis$daten merkmalsbereiche_data = merkmalsbereiche_data[order(sapply(merkmalsbereiche_data, function(b) b$reihenfolge))] items_data = items_ergebnis$daten item_ids = sapply(items_data, function(x) x$id) item_praefixe = gsub("[0-9]+", "", item_ids) item_nummern = as.integer(gsub("\\D+", "", item_ids)) items_data = items_data[order(item_praefixe, item_nummern)] syndrome_data = syndrome_ergebnis$daten syndrome_by_id = setNames(syndrome_data, sapply(syndrome_data, function(s) s$id)) items_by_bereich = split(items_data, sapply(items_data, function(x) x$merkmalsbereich)) erlaeuterungen_data = erlaeuterungen_ergebnis$daten erlaeuterungen_by_id = setNames(erlaeuterungen_data, sapply(erlaeuterungen_data, function(x) x$item_id)) } # UI #### if (!daten_geladen) { ui = fluidPage( tags$head(tags$style(HTML(app_css))), div(class = "alert-fehler", paste("Fehler beim Laden der AMDP-Daten:", ladefehler_meldung)) ) } else { item_tooltip_inhalt_ui = function(item) { erl = erlaeuterungen_by_id[[item$id]] if (is.null(erl)) { return(NULL) } definition_ui = if (nzchar(erl$definition)) { tagList(tags$span(class = "tooltip-titel", "Definition"), tags$p(erl$definition)) } else { NULL } erlaeuterung_ui = if (nzchar(erl$erlaeuterung)) { tagList(tags$span(class = "tooltip-titel", "Erläuterung"), tags$p(erl$erlaeuterung)) } else { NULL } beispiele_ui = if (length(erl$beispiele) > 0) { tagList( tags$span(class = "tooltip-titel", "Beispiele"), tags$ul(lapply(erl$beispiele, function(b) tags$li(paste0("„", b, "“")))) ) } else { NULL } return(div(class = "tooltip-inhalt", definition_ui, erlaeuterung_ui, beispiele_ui)) } item_zeile_ui = function(item) { tooltip_inhalt = item_tooltip_inhalt_ui(item) name_ui = if (!is.null(tooltip_inhalt)) { span(class = "tooltip-ziel", tabindex = "0", item$name, tooltip_inhalt) } else { span(item$name) } zeile = div(class = "item-zeile", span(class = "item-nr", item$id), span(class = "item-text", name_ui, tags$span(class = "erhebungscode-badge", paste0("(", item$erhebungscode, ")"))), div(class = "input-panel", radioButtons(paste0("item_", item$id), label = NULL, choices = STUFE_AUSWAHL, selected = STUFE_STANDARDWERT, inline = TRUE) ) ) if (is.null(item$fussnoten)) { return(zeile) } fussnoten_ui = lapply(seq_along(item$fussnoten), function(idx) { fussnote = item$fussnoten[[idx]] checkboxInput(paste0("fussnote_", item$id, "_", idx), label = fussnote$bezeichnung, value = FALSE) }) return(tagList(zeile, div(style = "margin-left: 48px;", fussnoten_ui))) } merkmalsbereich_panels = lapply(merkmalsbereiche_data, function(bereich) { items_im_bereich = items_by_bereich[[bereich$id]] vorbemerkungen_ui = if (!is.null(bereich$vorbemerkungen) && nzchar(bereich$vorbemerkungen)) { div(class = "vorbemerkungen-text", bereich$vorbemerkungen) } else { NULL } tags$details(class = "bereich-panel", tags$summary(class = "bereich-titel", tags$h2(bereich$name), tags$span(class = "bereich-hinweis", "Zum Aufklappen anklicken") ), vorbemerkungen_ui, lapply(items_im_bereich, item_zeile_ui) ) }) syndrom_auswahl_choices = setNames( sapply(syndrome_data, function(s) s$id), sapply(syndrome_data, function(s) s$name) ) ui = fluidPage( tags$head(tags$style(HTML(app_css))), div(class = "app-titel", "AMDP-Befundgenerator"), div(class = "abschnitt-karte", tags$h2(class = "abschnitt-titel", "Einstellungen"), radioButtons("geschlecht", label = "Geschlecht", choices = GESCHLECHT_AUSWAHL, selected = GESCHLECHT_STANDARDWERT, inline = TRUE), actionButton("zuruecksetzen", "Formular zurücksetzen", class = "btn-laden"), tags$h2(class = "abschnitt-titel", "Syndrom-Schnellauswahl"), div(class = "syndrom-liste", checkboxGroupInput("syndrom_auswahl", label = NULL, choices = syndrom_auswahl_choices) ), actionButton("syndrom_uebernehmen", "Übernehmen", class = "btn-laden") ), div(class = "abschnitt-karte", div(class = "abschnitt-titel", "Item-Erfassung"), merkmalsbereich_panels ), div(class = "abschnitt-karte", div(class = "abschnitt-titel", "Freitext-Befund"), textAreaInput("freitext_befund", label = NULL, rows = 6, width = "100%"), actionButton("freitext_ermitteln", "Codierungsvorschlag ermitteln", class = "btn-laden"), uiOutput("freitext_vorschlaege_ui") ), div(class = "abschnitt-karte", div(class = "abschnitt-titel", "Befundtext"), checkboxInput("unauffaellige_einblenden", "Unauffällige Befunde einblenden", value = FALSE), actionButton("text_generieren", "Fließtext generieren", class = "btn-laden"), textAreaInput("befundtext", label = NULL, rows = 15, width = "100%"), downloadButton("download_docx", "Als Word-Dokument exportieren") ) ) } # Word-Export #### erstelle_amdp_befund_docx = function(befundtext, datum) { dokument = officer::read_docx() titel_format = officer::fp_text(color = AKZENT_FARBE, bold = TRUE, font.size = 18) titel_absatz = officer::fpar(officer::ftext(paste0("AMDP-Befund vom ", datum), prop = titel_format)) dokument = officer::body_add_fpar(dokument, titel_absatz) dokument = officer::body_add_par(dokument, "", style = "Normal") absaetze = strsplit(befundtext, "\n\n", fixed = TRUE)[[1]] for (absatz in absaetze) { dokument = officer::body_add_par(dokument, absatz, style = "Normal") } dokument = officer::body_add_par(dokument, "", style = "Normal") disclaimer_text = "Dieser Text ist ein Hilfsmittel zur Befunddokumentation und ersetzt keine eigenständige klinische Beurteilung. Automatisch bzw. heuristisch ermittelte Codierungsvorschläge wurden vor Übernahme fachlich geprüft." dokument = officer::body_add_par(dokument, disclaimer_text, style = "Normal") return(dokument) } # Server #### if (!daten_geladen) { server = function(input, output, session) {} } else { server = function(input, output, session) { observeEvent(input$syndrom_uebernehmen, { for (syndrom_id in input$syndrom_auswahl) { syndrom = syndrome_by_id[[syndrom_id]] for (eintrag in syndrom$items) { updateRadioButtons(session, paste0("item_", eintrag$item_id), selected = as.character(eintrag$stufe)) } } }) vorschlaege = reactiveVal(data.frame( item_id = character(), item_name = character(), begriff = character(), vorschlag_stufe = character(), stringsAsFactors = FALSE )) observeEvent(input$zuruecksetzen, { for (item in items_data) { updateRadioButtons(session, paste0("item_", item$id), selected = STUFE_STANDARDWERT) if (!is.null(item$fussnoten)) { for (idx in seq_along(item$fussnoten)) { updateCheckboxInput(session, paste0("fussnote_", item$id, "_", idx), value = FALSE) } } } updateCheckboxGroupInput(session, "syndrom_auswahl", selected = character(0)) updateRadioButtons(session, "geschlecht", selected = GESCHLECHT_STANDARDWERT) updateCheckboxInput(session, "unauffaellige_einblenden", value = FALSE) updateTextAreaInput(session, "freitext_befund", value = "") updateTextAreaInput(session, "befundtext", value = "") vorschlaege(vorschlaege()[0, ]) }) observeEvent(input$freitext_ermitteln, { treffer = ermittle_codierungsvorschlaege(input$freitext_befund, items_data) vorschlaege(treffer) }) output$freitext_vorschlaege_ui = renderUI({ treffer = vorschlaege() if (nrow(treffer) == 0) { return(div(class = "alert-warnung", "Keine Treffer im Freitext gefunden.")) } zeilen = lapply(seq_len(nrow(treffer)), function(i) { zeile = treffer[i, ] div(class = "item-zeile", span(class = "item-nr", zeile$item_id), span(class = "item-text", paste0( zeile$item_name, " — Treffer: \"", zeile$begriff, "\", Vorschlag: ", stufe_bezeichnung(zeile$vorschlag_stufe) )), actionButton(paste0("apply_", zeile$item_id), "Übernehmen", class = "btn-laden") ) }) tagList( tagList(zeilen), actionButton("freitext_alle_uebernehmen", "Alle Vorschläge übernehmen", class = "btn-laden") ) }) observeEvent(input$freitext_alle_uebernehmen, { treffer = vorschlaege() for (i in seq_len(nrow(treffer))) { updateRadioButtons(session, paste0("item_", treffer$item_id[i]), selected = treffer$vorschlag_stufe[i]) } vorschlaege(treffer[0, ]) }) for (aktuelles_item in items_data) { local({ item_fuer_beobachter = aktuelles_item observeEvent(input[[paste0("apply_", item_fuer_beobachter$id)]], { treffer = vorschlaege() zeile = treffer[treffer$item_id == item_fuer_beobachter$id, ] if (nrow(zeile) > 0) { updateRadioButtons(session, paste0("item_", item_fuer_beobachter$id), selected = zeile$vorschlag_stufe[1]) vorschlaege(treffer[treffer$item_id != item_fuer_beobachter$id, ]) } }, ignoreInit = TRUE, ignoreNULL = TRUE) }) } aktualisiere_befundtext = function() { item_werte = sammle_item_werte(input, items_data) fussnoten_werte = sammle_fussnoten_werte(input, items_data) text = baue_befundtext(item_werte, fussnoten_werte, input$unauffaellige_einblenden, items_data, merkmalsbereiche_data, input$geschlecht) updateTextAreaInput(session, "befundtext", value = text) } observeEvent(input$text_generieren, { aktualisiere_befundtext() }) observeEvent(input$unauffaellige_einblenden, { aktualisiere_befundtext() }, ignoreInit = TRUE) observeEvent(input$geschlecht, { aktualisiere_befundtext() }, ignoreInit = TRUE) output$download_docx = downloadHandler( filename = function() { paste0("AMDP-Befund_", format(Sys.time(), "%Y%m%d_%H%M"), ".docx") }, content = function(datei) { dokument = erstelle_amdp_befund_docx(input$befundtext, format(Sys.Date(), "%d.%m.%Y")) print(dokument, target = datei) } ) } } # Start #### shiny::shinyApp(ui = ui, server = server)