Refactor frontend into routed views with shared triage session state

This commit is contained in:
Dualmind-Assistant
2026-04-21 12:57:31 +00:00
parent 40efec19d7
commit fea8c105ab
10 changed files with 469 additions and 317 deletions
+2 -314
View File
@@ -1,131 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import LanguageSelect from './components/LanguageSelect.vue'
import SymptomSelector from './components/SymptomSelector.vue'
import PainSlider from './components/PainSlider.vue'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000'
interface QuestionOption {
value: string
label: string
}
interface Question {
code: string
type: string
title: string
options?: QuestionOption[]
min?: number
max?: number
}
interface MtsPreparation {
session_id: string
proposed_presenting_flowchart?: string | null
red_flag_indicators: string[]
suggested_priority_level?: string | null
}
const language = ref<'de' | 'en' | 'ar'>('de')
const questions = ref<Question[]>([])
const isLoadingConfig = ref(false)
const loadError = ref<string | null>(null)
const chiefComplaint = ref<string | null>(null)
const pain = ref<number>(0)
const isSubmitting = ref(false)
const submitError = ref<string | null>(null)
const result = ref<MtsPreparation | null>(null)
const chiefComplaintQuestion = computed(() =>
questions.value.find((q) => q.code === 'chief_complaint') ?? null,
)
const painQuestion = computed(() =>
questions.value.find((q) => q.code === 'pain_intensity') ?? null,
)
const priorityClass = computed(() => {
const level = result.value?.suggested_priority_level
if (!level) return ''
if (level === 'RED_OR_ORANGE') return 'priority-high'
return 'priority-default'
})
const priorityLabel = computed(() => {
const level = result.value?.suggested_priority_level
if (!level) return ''
if (level === 'RED_OR_ORANGE') return 'Hohe Dringlichkeit'
return 'Priorität'
})
async function loadQuestions() {
isLoadingConfig.value = true
loadError.value = null
questions.value = []
chiefComplaint.value = null
pain.value = 0
result.value = null
try {
const res = await fetch(`${API_BASE_URL}/questions/${language.value}`)
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = (await res.json()) as { language: string; questions: Question[] }
questions.value = data.questions
} catch (err) {
console.error('Failed to load questions', err)
loadError.value = 'Konfiguration konnte nicht geladen werden. Bitte prüfen, ob der Server läuft.'
} finally {
isLoadingConfig.value = false
}
}
watch(
() => language.value,
() => {
void loadQuestions()
},
{ immediate: true },
)
async function startSession() {
if (!chiefComplaint.value) return
isSubmitting.value = true
submitError.value = null
result.value = null
const payload = {
language: language.value,
answers: [
{ question_code: 'chief_complaint', value: chiefComplaint.value },
{ question_code: 'pain_intensity', value: pain.value },
],
}
try {
const res = await fetch(`${API_BASE_URL}/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
result.value = (await res.json()) as MtsPreparation
} catch (err) {
console.error('Failed to start session', err)
submitError.value = 'Sitzung konnte nicht angelegt werden.'
} finally {
isSubmitting.value = false
}
}
import { RouterView } from 'vue-router'
</script>
<template>
@@ -139,74 +13,7 @@ async function startSession() {
</div>
</header>
<section class="block">
<LanguageSelect v-model="language" />
</section>
<section v-if="isLoadingConfig" class="status">
Konfiguration wird geladen ...
</section>
<section v-if="loadError" class="status error">
{{ loadError }}
</section>
<template v-if="!isLoadingConfig && !loadError">
<section v-if="chiefComplaintQuestion" class="block card">
<SymptomSelector
v-model="chiefComplaint"
:title="chiefComplaintQuestion.title"
:options="chiefComplaintQuestion.options ?? []"
/>
</section>
<section v-if="chiefComplaint && painQuestion" class="block card">
<PainSlider
v-model="pain"
:title="painQuestion.title"
:min="painQuestion.min ?? 0"
:max="painQuestion.max ?? 10"
/>
</section>
<section v-if="chiefComplaint" class="actions">
<button class="primary" type="button" :disabled="isSubmitting" @click="startSession">
<span v-if="!isSubmitting">Weiter</span>
<span v-else>Wird gesendet ...</span>
</button>
</section>
<section v-if="submitError" class="status error">
{{ submitError }}
</section>
<section v-if="result" class="result block" :class="priorityClass">
<div class="result-header">
<div>
<h2>Vorbereitung für MTS</h2>
<p class="result-subtitle">Automatisierte Einschätzung anhand der Antworten</p>
</div>
<p v-if="priorityLabel" class="priority-pill">
{{ priorityLabel }}
</p>
</div>
<div class="result-body">
<p class="result-line">
<span class="label">Sitzungs-ID</span>
<span class="value">{{ result.session_id }}</span>
</p>
<p v-if="result.proposed_presenting_flowchart" class="result-line">
<span class="label">Flussdiagramm</span>
<span class="value">{{ result.proposed_presenting_flowchart }}</span>
</p>
<p v-if="result.red_flag_indicators.length" class="result-line">
<span class="label">Red Flags</span>
<span class="value">{{ result.red_flag_indicators.join(', ') }}</span>
</p>
</div>
</section>
</template>
<RouterView />
</section>
</main>
</template>
@@ -263,123 +70,4 @@ h1 {
font-size: 0.85rem;
color: #6b7280;
}
.block {
margin-top: 1.4rem;
}
.card {
padding: 1.1rem 1rem 1rem;
border-radius: 20px;
background: #f9fafb;
}
.actions {
margin-top: 1.9rem;
}
button.primary {
width: 100%;
padding: 0.9rem 1rem;
font-size: 1.05rem;
font-weight: 600;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #007aff, #0b84ff);
color: #ffffff;
box-shadow: 0 10px 20px rgba(0, 122, 255, 0.35);
cursor: pointer;
transition: transform 0.08s ease-out, box-shadow 0.08s ease-out, opacity 0.15s ease-out;
}
button.primary:hover:enabled {
transform: translateY(-1px);
box-shadow: 0 14px 26px rgba(0, 122, 255, 0.45);
}
button.primary:active:enabled {
transform: translateY(0);
box-shadow: 0 6px 14px rgba(0, 122, 255, 0.3);
}
button.primary:disabled {
opacity: 0.6;
cursor: default;
box-shadow: none;
}
.status {
margin-top: 1rem;
font-size: 0.9rem;
color: #4b5563;
}
.status.error {
color: #b91c1c;
}
.result {
margin-top: 1.8rem;
padding: 1.2rem 1rem 1rem;
border-radius: 20px;
background: #f9fafb;
border-left: 4px solid #e5e7eb;
}
.result.priority-high {
background: #fef2f2;
border-left-color: #ff3b30;
}
.result.priority-default {
background: #eff6ff;
border-left-color: #3b82f6;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.result h2 {
font-size: 1.05rem;
margin-bottom: 0.15rem;
}
.result-subtitle {
font-size: 0.8rem;
color: #6b7280;
}
.priority-pill {
padding: 0.3rem 0.7rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
background: #fee2e2;
color: #b91c1c;
}
.result-body {
margin-top: 0.25rem;
}
.result-line {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 0.9rem;
margin-top: 0.25rem;
}
.result-line .label {
color: #6b7280;
}
.result-line .value {
font-weight: 500;
}
</style>
@@ -0,0 +1,134 @@
import { computed, reactive, ref } from 'vue'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000'
export interface QuestionOption {
value: string
label: string
}
export interface Question {
code: string
type: string
title: string
options?: QuestionOption[]
min?: number
max?: number
}
export interface MtsPreparation {
session_id: string
proposed_presenting_flowchart?: string | null
red_flag_indicators: string[]
suggested_priority_level?: string | null
}
const language = ref<'de' | 'en' | 'ar'>('de')
const questions = reactive<Question[]>([])
const isLoadingConfig = ref(false)
const loadError = ref<string | null>(null)
const chiefComplaint = ref<string | null>(null)
const pain = ref<number>(0)
const isSubmitting = ref(false)
const submitError = ref<string | null>(null)
const result = ref<MtsPreparation | null>(null)
const chiefComplaintQuestion = computed(() =>
questions.find((q) => q.code === 'chief_complaint') ?? null,
)
const painQuestion = computed(() => questions.find((q) => q.code === 'pain_intensity') ?? null)
const priorityClass = computed(() => {
const level = result.value?.suggested_priority_level
if (!level) return ''
if (level === 'RED_OR_ORANGE') return 'priority-high'
return 'priority-default'
})
const priorityLabel = computed(() => {
const level = result.value?.suggested_priority_level
if (!level) return ''
if (level === 'RED_OR_ORANGE') return 'Hohe Dringlichkeit'
return 'Priorität'
})
async function loadQuestions() {
isLoadingConfig.value = true
loadError.value = null
questions.splice(0, questions.length)
chiefComplaint.value = null
pain.value = 0
result.value = null
try {
const res = await fetch(`${API_BASE_URL}/questions/${language.value}`)
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = (await res.json()) as { language: string; questions: Question[] }
questions.push(...data.questions)
} catch (err) {
console.error('Failed to load questions', err)
loadError.value = 'Konfiguration konnte nicht geladen werden. Bitte prüfen, ob der Server läuft.'
} finally {
isLoadingConfig.value = false
}
}
async function startSession() {
if (!chiefComplaint.value) return
isSubmitting.value = true
submitError.value = null
result.value = null
const payload = {
language: language.value,
answers: [
{ question_code: 'chief_complaint', value: chiefComplaint.value },
{ question_code: 'pain_intensity', value: pain.value },
],
}
try {
const res = await fetch(`${API_BASE_URL}/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
result.value = (await res.json()) as MtsPreparation
} catch (err) {
console.error('Failed to start session', err)
submitError.value = 'Sitzung konnte nicht angelegt werden.'
} finally {
isSubmitting.value = false
}
}
export function useTriageSession() {
return {
language,
questions,
isLoadingConfig,
loadError,
chiefComplaint,
pain,
isSubmitting,
submitError,
result,
chiefComplaintQuestion,
painQuestion,
priorityClass,
priorityLabel,
loadQuestions,
startSession,
}
}
+4 -1
View File
@@ -1,4 +1,7 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App).mount('#app')
const app = createApp(App)
app.use(router)
app.mount('#app')
+18
View File
@@ -0,0 +1,18 @@
import { createRouter, createWebHistory } from 'vue-router'
import LanguageView from '../views/LanguageView.vue'
import ComplaintView from '../views/ComplaintView.vue'
import PainView from '../views/PainView.vue'
import SummaryView from '../views/SummaryView.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: '/language' },
{ path: '/language', component: LanguageView },
{ path: '/complaint', component: ComplaintView },
{ path: '/pain', component: PainView },
{ path: '/summary', component: SummaryView },
],
})
export default router
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import SymptomSelector from '../components/SymptomSelector.vue'
import { useTriageSession } from '../composables/useTriageSession'
const router = useRouter()
const { chiefComplaint, chiefComplaintQuestion, questions } = useTriageSession()
const hasConfig = computed(() => questions.length > 0)
onMounted(() => {
if (!hasConfig.value) {
router.replace('/language')
}
})
function goNext() {
if (chiefComplaint.value) {
router.push('/pain')
}
}
</script>
<template>
<section v-if="chiefComplaintQuestion">
<SymptomSelector
v-model="chiefComplaint"
:title="chiefComplaintQuestion.title"
:options="chiefComplaintQuestion.options ?? []"
/>
<div class="actions">
<button class="primary" type="button" :disabled="!chiefComplaint" @click="goNext">
Weiter
</button>
</div>
</section>
<p v-else class="status">Keine Konfiguration für Beschwerden gefunden.</p>
</template>
<style scoped>
.actions {
margin-top: 1.5rem;
}
.status {
margin-top: 1rem;
font-size: 0.9rem;
color: #4b5563;
}
</style>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { watch } from 'vue'
import LanguageSelect from '../components/LanguageSelect.vue'
import { useTriageSession } from '../composables/useTriageSession'
import { useRouter } from 'vue-router'
const router = useRouter()
const { language, isLoadingConfig, loadError, loadQuestions } = useTriageSession()
watch(
() => language.value,
() => {
void loadQuestions()
},
{ immediate: true },
)
function goNext() {
if (!isLoadingConfig.value && !loadError.value) {
router.push('/complaint')
}
}
</script>
<template>
<section>
<LanguageSelect v-model="language" />
<p v-if="isLoadingConfig" class="status">Konfiguration wird geladen ...</p>
<p v-if="loadError" class="status error">{{ loadError }}</p>
<div class="actions">
<button class="primary" type="button" :disabled="!!loadError || isLoadingConfig" @click="goNext">
Weiter
</button>
</div>
</section>
</template>
<style scoped>
.status {
margin-top: 1rem;
font-size: 0.9rem;
color: #4b5563;
}
.status.error {
color: #b91c1c;
}
.actions {
margin-top: 1.5rem;
}
</style>
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import PainSlider from '../components/PainSlider.vue'
import { useTriageSession } from '../composables/useTriageSession'
const router = useRouter()
const { pain, painQuestion, chiefComplaint, startSession, isSubmitting, submitError } = useTriageSession()
const isReady = computed(() => !!chiefComplaint.value && !!painQuestion.value)
onMounted(() => {
if (!chiefComplaint.value) {
router.replace('/complaint')
}
})
async function goNext() {
await startSession()
router.push('/summary')
}
</script>
<template>
<section v-if="painQuestion">
<PainSlider
v-model="pain"
:title="painQuestion.title"
:min="painQuestion.min ?? 0"
:max="painQuestion.max ?? 10"
/>
<p v-if="submitError" class="status error">{{ submitError }}</p>
<div class="actions">
<button class="primary" type="button" :disabled="!isReady || isSubmitting" @click="goNext">
<span v-if="!isSubmitting">Weiter</span>
<span v-else>Wird gesendet ...</span>
</button>
</div>
</section>
<p v-else class="status">Keine Konfiguration für Schmerz-Frage gefunden.</p>
</template>
<style scoped>
.actions {
margin-top: 1.5rem;
}
.status {
margin-top: 1rem;
font-size: 0.9rem;
color: #4b5563;
}
.status.error {
color: #b91c1c;
}
</style>
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTriageSession } from '../composables/useTriageSession'
const router = useRouter()
const { result, priorityClass, priorityLabel } = useTriageSession()
const hasResult = computed(() => !!result.value)
onMounted(() => {
if (!hasResult.value) {
router.replace('/language')
}
})
</script>
<template>
<section v-if="hasResult" class="result" :class="priorityClass">
<div class="result-header">
<div>
<h2>Vorbereitung für MTS</h2>
<p class="result-subtitle">Automatisierte Einschätzung anhand der Antworten</p>
</div>
<p v-if="priorityLabel" class="priority-pill">
{{ priorityLabel }}
</p>
</div>
<div class="result-body">
<p class="result-line">
<span class="label">Sitzungs-ID</span>
<span class="value">{{ result!.session_id }}</span>
</p>
<p v-if="result!.proposed_presenting_flowchart" class="result-line">
<span class="label">Flussdiagramm</span>
<span class="value">{{ result!.proposed_presenting_flowchart }}</span>
</p>
<p v-if="result!.red_flag_indicators.length" class="result-line">
<span class="label">Red Flags</span>
<span class="value">{{ result!.red_flag_indicators.join(', ') }}</span>
</p>
</div>
<div class="actions">
<button class="primary" type="button" @click="router.push('/language')">
Neue Erfassung starten
</button>
</div>
</section>
<p v-else class="status">Kein Ergebnis vorhanden.</p>
</template>
<style scoped>
.result {
padding: 1.2rem 1rem 1rem;
border-radius: 20px;
background: #f9fafb;
border-left: 4px solid #e5e7eb;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.result h2 {
font-size: 1.05rem;
margin-bottom: 0.15rem;
}
.result-subtitle {
font-size: 0.8rem;
color: #6b7280;
}
.priority-pill {
padding: 0.3rem 0.7rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
background: #fee2e2;
color: #b91c1c;
}
.result-body {
margin-top: 0.25rem;
}
.result-line {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 0.9rem;
margin-top: 0.25rem;
}
.result-line .label {
color: #6b7280;
}
.result-line .value {
font-weight: 500;
}
.status {
margin-top: 1rem;
font-size: 0.9rem;
color: #4b5563;
}
.actions {
margin-top: 1.5rem;
}
</style>