Add modular flow step for path-specific triage questions
This commit is contained in:
@@ -49,3 +49,16 @@ Die Datei `mts-config/flowcharts.json` enthält aktuell folgende abgebildete Pr
|
|||||||
- BEHAVING_STRANGELY → chief_complaint `behaving_strangely` / `psychiatric`
|
- BEHAVING_STRANGELY → chief_complaint `behaving_strangely` / `psychiatric`
|
||||||
|
|
||||||
Diese Konfiguration bildet die Zuordnung von Chief-Complaint-Codes zu den entsprechenden MTS-Präsentations-Flowcharts ab. Im Backend (FastAPI) ist bereits eine erste datengetriebene Logik für alle 10 Flowcharts umgesetzt: Das API ordnet `chief_complaint` automatisch dem passenden MTS-Flowchart zu, erkennt erste Red Flags wie `breathlessness`, `severe_pain` und `moderate_pain` und schlägt darauf basierend eine grobe Prioritätsstufe vor. Zusätzlich enthalten `questions.de.json` und `questions.en.json` bereits eine Ja/Nein-Frage zu starker Luftnot als ersten expliziten Red-Flag-Discriminator im Fragenstrom.
|
Diese Konfiguration bildet die Zuordnung von Chief-Complaint-Codes zu den entsprechenden MTS-Präsentations-Flowcharts ab. Im Backend (FastAPI) ist bereits eine erste datengetriebene Logik für alle 10 Flowcharts umgesetzt: Das API ordnet `chief_complaint` automatisch dem passenden MTS-Flowchart zu, erkennt erste Red Flags wie `breathlessness`, `severe_pain` und `moderate_pain` und schlägt darauf basierend eine grobe Prioritätsstufe vor. Zusätzlich enthalten `questions.de.json` und `questions.en.json` bereits eine Ja/Nein-Frage zu starker Luftnot als ersten expliziten Red-Flag-Discriminator im Fragenstrom.
|
||||||
|
|
||||||
|
|
||||||
|
## Aktueller App-Flow
|
||||||
|
|
||||||
|
Die Frontend-App bleibt modular als Vue-3-SPA aufgebaut und wurde um einen zusätzlichen Schritt `FlowView` erweitert. Der Ablauf ist aktuell:
|
||||||
|
|
||||||
|
- `LanguageView` – Sprache wählen
|
||||||
|
- `ComplaintView` – Hauptbeschwerde auswählen
|
||||||
|
- `FlowView` – pfadspezifische Zusatzfragen / Red-Flag-Fragen (derzeit als modulare Boolean-Komponenten)
|
||||||
|
- `PainView` – Schmerzintensität erfassen
|
||||||
|
- `SummaryView` – vorgeschlagenes MTS-Flowchart, Red Flags und Prioritätsstufe anzeigen
|
||||||
|
|
||||||
|
Die Zusatzfragen werden aus `mts-config/questions.<lang>.json` geladen. Damit kann die App schrittweise in Richtung mehrerer MTS-spezifischer Entscheidungsbäume erweitert werden, ohne eine monolithische Einzeldatei zu erzeugen.
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const modelValue = defineModel<boolean | null>()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
trueLabel?: string
|
||||||
|
falseLabel?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function select(value: boolean) {
|
||||||
|
modelValue.value = value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="boolean-section" aria-labelledby="boolean-title" role="radiogroup">
|
||||||
|
<h2 id="boolean-title">{{ props.title }}</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="choice-btn"
|
||||||
|
:class="{ active: modelValue === true }"
|
||||||
|
role="radio"
|
||||||
|
:aria-checked="modelValue === true"
|
||||||
|
@click="select(true)"
|
||||||
|
>
|
||||||
|
{{ props.trueLabel ?? 'Ja' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="choice-btn"
|
||||||
|
:class="{ active: modelValue === false }"
|
||||||
|
role="radio"
|
||||||
|
:aria-checked="modelValue === false"
|
||||||
|
@click="select(false)"
|
||||||
|
>
|
||||||
|
{{ props.falseLabel ?? 'Nein' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-btn {
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
background: #f9fafb;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #111827;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease-out, border-color 0.12s ease-out, box-shadow 0.12s ease-out,
|
||||||
|
transform 0.08s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-btn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-btn.active {
|
||||||
|
background: #e5f0ff;
|
||||||
|
border-color: #2563eb;
|
||||||
|
color: #0b1f4a;
|
||||||
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-btn:focus-visible {
|
||||||
|
outline: 3px solid #2563eb;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -29,23 +29,38 @@ const questions = reactive<Question[]>([])
|
|||||||
const isLoadingConfig = ref(false)
|
const isLoadingConfig = ref(false)
|
||||||
const loadError = ref<string | null>(null)
|
const loadError = ref<string | null>(null)
|
||||||
|
|
||||||
const chiefComplaint = ref<string | null>(null)
|
const answers = reactive<Record<string, string | number | boolean | null>>({
|
||||||
const pain = ref<number>(0)
|
chief_complaint: null,
|
||||||
|
breathlessness: null,
|
||||||
|
pain_intensity: 0,
|
||||||
|
})
|
||||||
|
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const submitError = ref<string | null>(null)
|
const submitError = ref<string | null>(null)
|
||||||
const result = ref<MtsPreparation | null>(null)
|
const result = ref<MtsPreparation | null>(null)
|
||||||
|
|
||||||
const chiefComplaintQuestion = computed(() =>
|
const orderedQuestions = computed(() => questions)
|
||||||
questions.find((q) => q.code === 'chief_complaint') ?? null,
|
const chiefComplaintQuestion = computed(() => questions.find((q) => q.code === 'chief_complaint') ?? null)
|
||||||
)
|
const flowQuestions = computed(() => questions.filter((q) => q.code !== 'chief_complaint' && q.code !== 'pain_intensity'))
|
||||||
|
|
||||||
const painQuestion = computed(() => questions.find((q) => q.code === 'pain_intensity') ?? null)
|
const painQuestion = computed(() => questions.find((q) => q.code === 'pain_intensity') ?? null)
|
||||||
|
const chiefComplaint = computed({
|
||||||
|
get: () => (typeof answers.chief_complaint === 'string' ? answers.chief_complaint : null),
|
||||||
|
set: (value: string | null) => {
|
||||||
|
answers.chief_complaint = value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const pain = computed({
|
||||||
|
get: () => (typeof answers.pain_intensity === 'number' ? answers.pain_intensity : 0),
|
||||||
|
set: (value: number) => {
|
||||||
|
answers.pain_intensity = value
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const priorityClass = computed(() => {
|
const priorityClass = computed(() => {
|
||||||
const level = result.value?.suggested_priority_level
|
const level = result.value?.suggested_priority_level
|
||||||
if (!level) return ''
|
if (!level) return ''
|
||||||
if (level === 'RED_OR_ORANGE') return 'priority-high'
|
if (level === 'RED_OR_ORANGE') return 'priority-high'
|
||||||
|
if (level === 'YELLOW_OR_ORANGE') return 'priority-medium'
|
||||||
return 'priority-default'
|
return 'priority-default'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -53,15 +68,29 @@ const priorityLabel = computed(() => {
|
|||||||
const level = result.value?.suggested_priority_level
|
const level = result.value?.suggested_priority_level
|
||||||
if (!level) return ''
|
if (!level) return ''
|
||||||
if (level === 'RED_OR_ORANGE') return 'Hohe Dringlichkeit'
|
if (level === 'RED_OR_ORANGE') return 'Hohe Dringlichkeit'
|
||||||
|
if (level === 'YELLOW_OR_ORANGE') return 'Erhöhte Dringlichkeit'
|
||||||
return 'Priorität'
|
return 'Priorität'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function resetAnswers() {
|
||||||
|
answers.chief_complaint = null
|
||||||
|
answers.breathlessness = null
|
||||||
|
answers.pain_intensity = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAnswer(questionCode: string, value: string | number | boolean | null) {
|
||||||
|
answers[questionCode] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnswer(questionCode: string) {
|
||||||
|
return answers[questionCode] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
async function loadQuestions() {
|
async function loadQuestions() {
|
||||||
isLoadingConfig.value = true
|
isLoadingConfig.value = true
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
questions.splice(0, questions.length)
|
questions.splice(0, questions.length)
|
||||||
chiefComplaint.value = null
|
resetAnswers()
|
||||||
pain.value = 0
|
|
||||||
result.value = null
|
result.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -88,10 +117,9 @@ async function startSession() {
|
|||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
language: language.value,
|
language: language.value,
|
||||||
answers: [
|
answers: orderedQuestions.value
|
||||||
{ question_code: 'chief_complaint', value: chiefComplaint.value },
|
.filter((q) => answers[q.code] !== null && answers[q.code] !== undefined)
|
||||||
{ question_code: 'pain_intensity', value: pain.value },
|
.map((q) => ({ question_code: q.code, value: answers[q.code] })),
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -118,10 +146,13 @@ export function useTriageSession() {
|
|||||||
return {
|
return {
|
||||||
language,
|
language,
|
||||||
questions,
|
questions,
|
||||||
|
orderedQuestions,
|
||||||
isLoadingConfig,
|
isLoadingConfig,
|
||||||
loadError,
|
loadError,
|
||||||
|
answers,
|
||||||
chiefComplaint,
|
chiefComplaint,
|
||||||
pain,
|
pain,
|
||||||
|
flowQuestions,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
submitError,
|
submitError,
|
||||||
result,
|
result,
|
||||||
@@ -129,7 +160,10 @@ export function useTriageSession() {
|
|||||||
painQuestion,
|
painQuestion,
|
||||||
priorityClass,
|
priorityClass,
|
||||||
priorityLabel,
|
priorityLabel,
|
||||||
|
setAnswer,
|
||||||
|
getAnswer,
|
||||||
loadQuestions,
|
loadQuestions,
|
||||||
startSession,
|
startSession,
|
||||||
|
resetAnswers,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import LanguageView from '../views/LanguageView.vue'
|
import LanguageView from '../views/LanguageView.vue'
|
||||||
import ComplaintView from '../views/ComplaintView.vue'
|
import ComplaintView from '../views/ComplaintView.vue'
|
||||||
|
import FlowView from '../views/FlowView.vue'
|
||||||
import PainView from '../views/PainView.vue'
|
import PainView from '../views/PainView.vue'
|
||||||
import SummaryView from '../views/SummaryView.vue'
|
import SummaryView from '../views/SummaryView.vue'
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ const router = createRouter({
|
|||||||
{ path: '/', redirect: '/language' },
|
{ path: '/', redirect: '/language' },
|
||||||
{ path: '/language', component: LanguageView },
|
{ path: '/language', component: LanguageView },
|
||||||
{ path: '/complaint', component: ComplaintView },
|
{ path: '/complaint', component: ComplaintView },
|
||||||
|
{ path: '/flow', component: FlowView },
|
||||||
{ path: '/pain', component: PainView },
|
{ path: '/pain', component: PainView },
|
||||||
{ path: '/summary', component: SummaryView },
|
{ path: '/summary', component: SummaryView },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
function goNext() {
|
function goNext() {
|
||||||
if (chiefComplaint.value) {
|
if (chiefComplaint.value) {
|
||||||
router.push('/pain')
|
router.push('/flow')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import BooleanChoice from '../components/BooleanChoice.vue'
|
||||||
|
import { useTriageSession } from '../composables/useTriageSession'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { chiefComplaint, flowQuestions, getAnswer, setAnswer } = useTriageSession()
|
||||||
|
|
||||||
|
const isReady = computed(() =>
|
||||||
|
flowQuestions.value.every((q) => getAnswer(q.code) !== null && getAnswer(q.code) !== undefined),
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!chiefComplaint.value) {
|
||||||
|
router.replace('/complaint')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function goNext() {
|
||||||
|
if (isReady.value) {
|
||||||
|
router.push('/pain')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section>
|
||||||
|
<div class="stack" v-if="flowQuestions.length > 0">
|
||||||
|
<template v-for="question in flowQuestions" :key="question.code">
|
||||||
|
<BooleanChoice
|
||||||
|
v-if="question.type === 'boolean'"
|
||||||
|
:title="question.title"
|
||||||
|
:model-value="(getAnswer(question.code) as boolean | null)"
|
||||||
|
@update:model-value="(value) => setAnswer(question.code, value)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="status">Für diesen Pfad sind noch keine Zusatzfragen konfiguriert.</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="primary" type="button" :disabled="!isReady && flowQuestions.length > 0" @click="goNext">
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,7 +11,7 @@ const isReady = computed(() => !!chiefComplaint.value && !!painQuestion.value)
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!chiefComplaint.value) {
|
if (!chiefComplaint.value) {
|
||||||
router.replace('/complaint')
|
router.replace('/flow')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user