feature: HL7 FHIR R4 Integration

This commit is contained in:
voltsrage
2026-06-27 22:23:45 +08:00
parent 756cff332c
commit 5646dfddb4
27 changed files with 2658 additions and 16 deletions
@@ -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>
+4
View File
@@ -16,6 +16,10 @@ export default defineConfig({
target: 'http://localhost:5217',
changeOrigin: true,
},
'/fhir': {
target: 'http://localhost:5217',
changeOrigin: true,
},
},
},
})