feature: HL7 FHIR R4 Integration
This commit is contained in:
@@ -139,6 +139,24 @@ describe('router navigation guard', () => {
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('allows ADMINISTRATOR access to fhir-explorer route', () => {
|
||||
const { runGuard } = authenticatedGuard('ADMINISTRATOR')
|
||||
const { next } = runGuard(buildRoute('/fhir-explorer', {
|
||||
requiresAuth: true,
|
||||
roles: ['ADMINISTRATOR'],
|
||||
}))
|
||||
expect(next).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('redirects DATA_ENTRY_CLERK from fhir-explorer to /entry', () => {
|
||||
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||
const { next } = runGuard(buildRoute('/fhir-explorer', {
|
||||
requiresAuth: true,
|
||||
roles: ['ADMINISTRATOR'],
|
||||
}))
|
||||
expect(next).toHaveBeenCalledWith('/entry')
|
||||
})
|
||||
|
||||
it('redirects DATA_ENTRY_CLERK from cover sheets to /entry', () => {
|
||||
const { runGuard } = authenticatedGuard('DATA_ENTRY_CLERK')
|
||||
const { next } = runGuard(buildRoute('/cover-sheets', {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import FhirExplorerView from '@/views/FhirExplorerView.vue'
|
||||
|
||||
vi.mock('@/api/fhirClient', () => ({
|
||||
fhirGet: vi.fn(),
|
||||
openFhirJsonInNewTab: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/AppHeader.vue', () => ({
|
||||
default: { template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/PatientSearch.vue', () => ({
|
||||
default: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<input data-testid="patient-search" @input="$emit(\'update:modelValue\', \'patient-1\')" />',
|
||||
},
|
||||
}))
|
||||
|
||||
import { fhirGet, openFhirJsonInNewTab } from '@/api/fhirClient'
|
||||
|
||||
const mockedFhirGet = vi.mocked(fhirGet)
|
||||
const mockedOpenTab = vi.mocked(openFhirJsonInNewTab)
|
||||
|
||||
const patientBundle = {
|
||||
resourceType: 'Bundle',
|
||||
type: 'searchset',
|
||||
total: 1,
|
||||
entry: [
|
||||
{
|
||||
resource: {
|
||||
resourceType: 'Patient',
|
||||
id: 'patient-1',
|
||||
name: [{ text: 'MARIA SANTOS' }],
|
||||
identifier: [{ value: 'VCR-000001' }],
|
||||
birthDate: '1978-03-15',
|
||||
gender: 'female',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const everythingBundle = {
|
||||
resourceType: 'Bundle',
|
||||
type: 'searchset',
|
||||
total: 3,
|
||||
entry: [
|
||||
{ resource: { resourceType: 'Patient', id: 'patient-1', name: [{ text: 'MARIA SANTOS' }] } },
|
||||
{ resource: { resourceType: 'Encounter', id: 'enc-1', status: 'in-progress', period: { start: '2026-06-22' } } },
|
||||
{
|
||||
resource: {
|
||||
resourceType: 'Observation',
|
||||
id: 'obs-1',
|
||||
code: { coding: [{ code: '8867-4', display: 'Heart rate' }] },
|
||||
valueQuantity: { value: 88, unit: 'bpm' },
|
||||
effectiveDateTime: '2026-06-22T10:00:00Z',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockedFhirGet.mockResolvedValue(patientBundle)
|
||||
})
|
||||
|
||||
describe('FhirExplorerView', () => {
|
||||
it('renders resource browser and patient search fields by default', () => {
|
||||
const wrapper = mount(FhirExplorerView)
|
||||
|
||||
expect(wrapper.text()).toContain('Resource Browser')
|
||||
expect(wrapper.text()).toContain('Patient $everything')
|
||||
expect(wrapper.find('select').element).toBeTruthy()
|
||||
expect(wrapper.text()).toContain('MRN (identifier)')
|
||||
})
|
||||
|
||||
it('searches FHIR Patient resources and shows results table', async () => {
|
||||
const wrapper = mount(FhirExplorerView)
|
||||
|
||||
await wrapper.find('input[placeholder="e.g. Santos"]').setValue('Santos')
|
||||
await wrapper.find('button.btn-primary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockedFhirGet).toHaveBeenCalledWith('/Patient', {
|
||||
name: 'Santos',
|
||||
birthdate: undefined,
|
||||
identifier: undefined,
|
||||
_count: 20,
|
||||
})
|
||||
expect(wrapper.text()).toContain('MARIA SANTOS')
|
||||
expect(wrapper.text()).toContain('VCR-000001')
|
||||
})
|
||||
|
||||
it('shows formatted JSON when a result row is clicked', async () => {
|
||||
const wrapper = mount(FhirExplorerView)
|
||||
|
||||
await wrapper.find('button.btn-primary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('tbody tr').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('"resourceType": "Patient"')
|
||||
expect(wrapper.text()).toContain('"id": "patient-1"')
|
||||
})
|
||||
|
||||
it('loads Patient $everything bundle grouped by resource type', async () => {
|
||||
mockedFhirGet.mockResolvedValue(everythingBundle)
|
||||
|
||||
const wrapper = mount(FhirExplorerView)
|
||||
|
||||
await wrapper.find('[data-testid="patient-search"]').trigger('input')
|
||||
await flushPromises()
|
||||
|
||||
const loadButton = wrapper.findAll('button.btn-primary').find((b) =>
|
||||
b.text().includes('Load All Data')
|
||||
)
|
||||
expect(loadButton).toBeDefined()
|
||||
await loadButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockedFhirGet).toHaveBeenCalledWith('/Patient/patient-1/$everything')
|
||||
expect(wrapper.text()).toContain('Encounters (1)')
|
||||
expect(wrapper.text()).toContain('Observations (1)')
|
||||
expect(wrapper.text()).toContain('Heart rate')
|
||||
})
|
||||
|
||||
it('opens metadata CapabilityStatement in a new tab', async () => {
|
||||
mockedFhirGet.mockResolvedValueOnce({ resourceType: 'CapabilityStatement' })
|
||||
|
||||
const wrapper = mount(FhirExplorerView)
|
||||
await wrapper.find('button.btn-secondary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockedFhirGet).toHaveBeenCalledWith('/metadata')
|
||||
expect(mockedOpenTab).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import axios, { type AxiosInstance } from 'axios'
|
||||
|
||||
export interface FhirBundleLink {
|
||||
relation: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface FhirBundleEntry {
|
||||
fullUrl?: string
|
||||
resource?: FhirResource
|
||||
search?: { mode?: string }
|
||||
}
|
||||
|
||||
export interface FhirBundle {
|
||||
resourceType: 'Bundle'
|
||||
type?: string
|
||||
total?: number
|
||||
entry?: FhirBundleEntry[]
|
||||
link?: FhirBundleLink[]
|
||||
}
|
||||
|
||||
export type FhirResource = Record<string, unknown> & {
|
||||
resourceType: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
const fhirClient: AxiosInstance = axios.create({
|
||||
baseURL: '/fhir',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
Accept: 'application/fhir+json',
|
||||
},
|
||||
})
|
||||
|
||||
fhirClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('vigilcare_token')
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export async function fhirGet<T = unknown>(
|
||||
path: string,
|
||||
params?: Record<string, string | number | undefined>
|
||||
): Promise<T> {
|
||||
const cleaned: Record<string, string | number> = {}
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== '') {
|
||||
cleaned[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fhirClient.get<T>(path, { params: cleaned })
|
||||
return response.data
|
||||
}
|
||||
|
||||
export function openFhirJsonInNewTab(data: unknown, filename = 'fhir-resource.json'): void {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: 'application/fhir+json',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const tab = window.open(url, '_blank')
|
||||
if (!tab) {
|
||||
URL.revokeObjectURL(url)
|
||||
return
|
||||
}
|
||||
tab.document.title = filename
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
<router-link v-if="auth.canLiveCapture" to="/live-capture" class="nav-link">Live Capture</router-link>
|
||||
<router-link to="/patients" class="nav-link">History</router-link>
|
||||
<router-link v-if="auth.canSupervise" to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||
<router-link v-if="auth.canSupervise" to="/fhir-explorer" class="nav-link">FHIR Explorer</router-link>
|
||||
</nav>
|
||||
<slot name="subtitle" />
|
||||
</div>
|
||||
|
||||
@@ -96,6 +96,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/QueueDashboardView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: '/fhir-explorer',
|
||||
name: 'FhirExplorer',
|
||||
component: () => import('../views/FhirExplorerView.vue'),
|
||||
meta: { requiresAuth: true, roles: ['ADMINISTRATOR'] },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/login',
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<AppHeader title="FHIR Explorer" />
|
||||
|
||||
<div class="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto flex-1 w-full space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-2xl font-bold">FHIR R4 Explorer</h1>
|
||||
<button type="button" class="btn-secondary text-sm" @click="openMetadata">
|
||||
Open CapabilityStatement (/fhir/metadata)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Resource browser -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-semibold mb-4">Resource Browser</h2>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Resource Type</label>
|
||||
<select v-model="resourceType" class="form-input" @change="clearResults">
|
||||
<option value="Patient">Patient</option>
|
||||
<option value="Encounter">Encounter</option>
|
||||
<option value="Observation">Observation</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<template v-if="resourceType === 'Patient'">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Name</label>
|
||||
<input v-model="patientSearch.name" type="text" class="form-input" placeholder="e.g. Santos" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Birth Date</label>
|
||||
<input v-model="patientSearch.birthdate" type="date" class="form-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">MRN (identifier)</label>
|
||||
<input v-model="patientSearch.identifier" type="text" class="form-input" placeholder="VCR-000001" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="resourceType === 'Encounter'">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Patient ID</label>
|
||||
<input v-model="encounterSearch.patient" type="text" class="form-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Status</label>
|
||||
<select v-model="encounterSearch.status" class="form-input">
|
||||
<option value="">Any</option>
|
||||
<option value="in-progress">in-progress</option>
|
||||
<option value="finished">finished</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Date</label>
|
||||
<input v-model="encounterSearch.date" type="date" class="form-input" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Patient ID</label>
|
||||
<input v-model="observationSearch.patient" type="text" class="form-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">LOINC Code</label>
|
||||
<input v-model="observationSearch.code" type="text" class="form-input" placeholder="8867-4" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Category</label>
|
||||
<select v-model="observationSearch.category" class="form-input">
|
||||
<option value="">Any</option>
|
||||
<option value="vital-signs">vital-signs</option>
|
||||
<option value="laboratory">laboratory</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Date (FHIR prefix)</label>
|
||||
<input
|
||||
v-model="observationSearch.date"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="ge2026-06-20"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-primary" :disabled="searching" @click="runSearch">
|
||||
{{ searching ? 'Searching...' : 'Search' }}
|
||||
</button>
|
||||
|
||||
<p v-if="searchError" class="text-clinical-danger text-sm mt-3">{{ searchError }}</p>
|
||||
|
||||
<div v-if="searchResults.length > 0" class="mt-6 overflow-x-auto">
|
||||
<p class="text-sm text-gray-600 mb-2">
|
||||
{{ searchTotal }} result(s)
|
||||
</p>
|
||||
<table class="min-w-full text-sm border border-gray-200 rounded-md overflow-hidden">
|
||||
<thead class="bg-gray-50 text-left">
|
||||
<tr>
|
||||
<th class="px-3 py-2">ID</th>
|
||||
<th v-if="resourceType === 'Patient'" class="px-3 py-2">Name</th>
|
||||
<th v-if="resourceType === 'Patient'" class="px-3 py-2">MRN</th>
|
||||
<th v-if="resourceType === 'Patient'" class="px-3 py-2">DOB</th>
|
||||
<th v-if="resourceType === 'Encounter'" class="px-3 py-2">Status</th>
|
||||
<th v-if="resourceType === 'Encounter'" class="px-3 py-2">Patient</th>
|
||||
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Code</th>
|
||||
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Value</th>
|
||||
<th v-if="resourceType === 'Observation'" class="px-3 py-2">Recorded</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="entry in searchResults"
|
||||
:key="String(entry.resource?.id)"
|
||||
class="border-t border-gray-100 hover:bg-primary-50 cursor-pointer"
|
||||
:class="{ 'bg-primary-50': selectedResource?.id === entry.resource?.id }"
|
||||
@click="selectResource(entry.resource)"
|
||||
>
|
||||
<td class="px-3 py-2 font-mono text-xs">{{ entry.resource?.id }}</td>
|
||||
<td v-if="resourceType === 'Patient'" class="px-3 py-2">
|
||||
{{ patientDisplayName(entry.resource) }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Patient'" class="px-3 py-2">
|
||||
{{ patientMrn(entry.resource) }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Patient'" class="px-3 py-2">
|
||||
{{ entry.resource?.birthDate ?? '—' }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Encounter'" class="px-3 py-2">
|
||||
{{ entry.resource?.status ?? '—' }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Encounter'" class="px-3 py-2">
|
||||
{{ subjectReference(entry.resource) }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Observation'" class="px-3 py-2">
|
||||
{{ observationCodeDisplay(entry.resource) }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Observation'" class="px-3 py-2">
|
||||
{{ observationValueDisplay(entry.resource) }}
|
||||
</td>
|
||||
<td v-if="resourceType === 'Observation'" class="px-3 py-2">
|
||||
{{ observationEffective(entry.resource) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedResource" class="mt-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">Resource JSON</h3>
|
||||
<pre class="bg-gray-900 text-green-100 text-xs p-4 rounded-md overflow-x-auto max-h-96">{{ formattedSelectedResource }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Patient $everything -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-semibold mb-4">Patient $everything</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Load all FHIR resources for a patient in one Bundle.
|
||||
</p>
|
||||
|
||||
<div class="max-w-md mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Patient</label>
|
||||
<PatientSearch v-model="everythingPatientId" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
:disabled="!everythingPatientId || everythingLoading"
|
||||
@click="loadEverything"
|
||||
>
|
||||
{{ everythingLoading ? 'Loading...' : 'Load All Data' }}
|
||||
</button>
|
||||
|
||||
<p v-if="everythingError" class="text-clinical-danger text-sm mt-3">{{ everythingError }}</p>
|
||||
|
||||
<div v-if="everythingBundle" class="mt-6 space-y-6">
|
||||
<div v-if="everythingPatient" class="card bg-primary-50 border border-primary-100">
|
||||
<h3 class="font-semibold mb-2">Patient</h3>
|
||||
<p class="text-sm"><span class="text-gray-500">Name:</span> {{ patientDisplayName(everythingPatient) }}</p>
|
||||
<p class="text-sm"><span class="text-gray-500">MRN:</span> {{ patientMrn(everythingPatient) }}</p>
|
||||
<p class="text-sm"><span class="text-gray-500">DOB:</span> {{ everythingPatient.birthDate ?? '—' }}</p>
|
||||
<p class="text-sm"><span class="text-gray-500">Gender:</span> {{ everythingPatient.gender ?? '—' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="everythingEncounters.length > 0">
|
||||
<h3 class="font-semibold mb-2">Encounters ({{ everythingEncounters.length }})</h3>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="enc in everythingEncounters"
|
||||
:key="String(enc.id)"
|
||||
class="text-sm border border-gray-200 rounded-md px-3 py-2"
|
||||
>
|
||||
<span class="font-mono text-xs text-gray-500">{{ enc.id }}</span>
|
||||
— {{ enc.status }}
|
||||
<span v-if="enc.period?.start"> · {{ enc.period.start }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="everythingObservations.length > 0">
|
||||
<h3 class="font-semibold mb-2">Observations ({{ everythingObservations.length }})</h3>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="obs in everythingObservations"
|
||||
:key="String(obs.id)"
|
||||
class="text-sm border border-gray-200 rounded-md px-3 py-2 flex flex-wrap gap-x-3"
|
||||
>
|
||||
<span class="font-mono text-xs text-gray-500">{{ obs.id }}</span>
|
||||
<span>{{ observationCodeDisplay(obs) }}</span>
|
||||
<span>{{ observationValueDisplay(obs) }}</span>
|
||||
<span class="text-gray-500">{{ observationEffective(obs) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import PatientSearch from '../components/PatientSearch.vue'
|
||||
import {
|
||||
fhirGet,
|
||||
openFhirJsonInNewTab,
|
||||
type FhirBundle,
|
||||
type FhirBundleEntry,
|
||||
type FhirResource,
|
||||
} from '../api/fhirClient'
|
||||
|
||||
type ResourceType = 'Patient' | 'Encounter' | 'Observation'
|
||||
|
||||
const resourceType = ref<ResourceType>('Patient')
|
||||
const searching = ref(false)
|
||||
const searchError = ref('')
|
||||
const searchResults = ref<FhirBundleEntry[]>([])
|
||||
const searchTotal = ref(0)
|
||||
const selectedResource = ref<FhirResource | null>(null)
|
||||
|
||||
const patientSearch = reactive({ name: '', birthdate: '', identifier: '' })
|
||||
const encounterSearch = reactive({ patient: '', status: '', date: '' })
|
||||
const observationSearch = reactive({ patient: '', code: '', category: '', date: '' })
|
||||
|
||||
const everythingPatientId = ref<string | undefined>()
|
||||
const everythingLoading = ref(false)
|
||||
const everythingError = ref('')
|
||||
const everythingBundle = ref<FhirBundle | null>(null)
|
||||
|
||||
const formattedSelectedResource = computed(() =>
|
||||
selectedResource.value ? JSON.stringify(selectedResource.value, null, 2) : ''
|
||||
)
|
||||
|
||||
const everythingPatient = computed(() =>
|
||||
everythingBundle.value?.entry
|
||||
?.map((e) => e.resource)
|
||||
.find((r) => r?.resourceType === 'Patient') as FhirPatientResource | undefined
|
||||
)
|
||||
|
||||
const everythingEncounters = computed(() =>
|
||||
(everythingBundle.value?.entry ?? [])
|
||||
.map((e) => e.resource)
|
||||
.filter((r): r is FhirEncounterResource => r?.resourceType === 'Encounter')
|
||||
)
|
||||
|
||||
const everythingObservations = computed(() =>
|
||||
(everythingBundle.value?.entry ?? [])
|
||||
.map((e) => e.resource)
|
||||
.filter((r): r is FhirObservationResource => r?.resourceType === 'Observation')
|
||||
)
|
||||
|
||||
type FhirPatientResource = FhirResource & {
|
||||
name?: { text?: string; family?: string }[]
|
||||
identifier?: { value?: string }[]
|
||||
birthDate?: string
|
||||
gender?: string
|
||||
}
|
||||
|
||||
type FhirEncounterResource = FhirResource & {
|
||||
status?: string
|
||||
subject?: { reference?: string }
|
||||
period?: { start?: string }
|
||||
}
|
||||
|
||||
type FhirObservationResource = FhirResource & {
|
||||
code?: { coding?: { code?: string; display?: string }[]; text?: string }
|
||||
valueQuantity?: { value?: number; unit?: string }
|
||||
effectiveDateTime?: string
|
||||
effective?: string
|
||||
}
|
||||
|
||||
function clearResults(): void {
|
||||
searchResults.value = []
|
||||
searchTotal.value = 0
|
||||
selectedResource.value = null
|
||||
searchError.value = ''
|
||||
}
|
||||
|
||||
function selectResource(resource: FhirResource | undefined): void {
|
||||
selectedResource.value = resource ?? null
|
||||
}
|
||||
|
||||
function patientDisplayName(resource: FhirResource | undefined): string {
|
||||
if (!resource) return '—'
|
||||
const patient = resource as FhirPatientResource
|
||||
const name = patient.name?.[0]
|
||||
return name?.text ?? name?.family ?? '—'
|
||||
}
|
||||
|
||||
function patientMrn(resource: FhirResource | undefined): string {
|
||||
if (!resource) return '—'
|
||||
const patient = resource as FhirPatientResource
|
||||
return patient.identifier?.[0]?.value ?? '—'
|
||||
}
|
||||
|
||||
function subjectReference(resource: FhirResource | undefined): string {
|
||||
if (!resource) return '—'
|
||||
return (resource as FhirEncounterResource).subject?.reference ?? '—'
|
||||
}
|
||||
|
||||
function observationCodeDisplay(resource: FhirResource | undefined): string {
|
||||
if (!resource) return '—'
|
||||
const obs = resource as FhirObservationResource
|
||||
const coding = obs.code?.coding?.[0]
|
||||
return coding?.display ?? coding?.code ?? obs.code?.text ?? '—'
|
||||
}
|
||||
|
||||
function observationValueDisplay(resource: FhirResource | undefined): string {
|
||||
if (!resource) return '—'
|
||||
const qty = (resource as FhirObservationResource).valueQuantity
|
||||
if (!qty) return '—'
|
||||
return `${qty.value ?? '—'} ${qty.unit ?? ''}`.trim()
|
||||
}
|
||||
|
||||
function observationEffective(resource: FhirResource | undefined): string {
|
||||
if (!resource) return '—'
|
||||
const obs = resource as FhirObservationResource
|
||||
return obs.effectiveDateTime ?? obs.effective ?? '—'
|
||||
}
|
||||
|
||||
function buildSearchParams(): Record<string, string | number | undefined> {
|
||||
if (resourceType.value === 'Patient') {
|
||||
return {
|
||||
name: patientSearch.name || undefined,
|
||||
birthdate: patientSearch.birthdate || undefined,
|
||||
identifier: patientSearch.identifier || undefined,
|
||||
_count: 20,
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceType.value === 'Encounter') {
|
||||
return {
|
||||
patient: encounterSearch.patient || undefined,
|
||||
status: encounterSearch.status || undefined,
|
||||
date: encounterSearch.date || undefined,
|
||||
_count: 20,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
patient: observationSearch.patient || undefined,
|
||||
code: observationSearch.code || undefined,
|
||||
category: observationSearch.category || undefined,
|
||||
date: observationSearch.date || undefined,
|
||||
_count: 50,
|
||||
}
|
||||
}
|
||||
|
||||
async function runSearch(): Promise<void> {
|
||||
searching.value = true
|
||||
searchError.value = ''
|
||||
selectedResource.value = null
|
||||
|
||||
try {
|
||||
const bundle = await fhirGet<FhirBundle>(`/${resourceType.value}`, buildSearchParams())
|
||||
searchResults.value = bundle.entry ?? []
|
||||
searchTotal.value = bundle.total ?? searchResults.value.length
|
||||
} catch (err: unknown) {
|
||||
searchResults.value = []
|
||||
searchTotal.value = 0
|
||||
searchError.value = err instanceof Error ? err.message : 'FHIR search failed'
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEverything(): Promise<void> {
|
||||
if (!everythingPatientId.value) return
|
||||
|
||||
everythingLoading.value = true
|
||||
everythingError.value = ''
|
||||
everythingBundle.value = null
|
||||
|
||||
try {
|
||||
everythingBundle.value = await fhirGet<FhirBundle>(
|
||||
`/Patient/${everythingPatientId.value}/$everything`
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
everythingError.value = err instanceof Error ? err.message : 'Failed to load patient Bundle'
|
||||
} finally {
|
||||
everythingLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openMetadata(): Promise<void> {
|
||||
try {
|
||||
const metadata = await fhirGet('/metadata')
|
||||
openFhirJsonInNewTab(metadata, 'capability-statement.json')
|
||||
} catch (err: unknown) {
|
||||
searchError.value = err instanceof Error ? err.message : 'Failed to load metadata'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -16,6 +16,10 @@ export default defineConfig({
|
||||
target: 'http://localhost:5217',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/fhir': {
|
||||
target: 'http://localhost:5217',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user