make addition ui fixes for cramped features
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import AuditTrailPanel from '@/components/AuditTrailPanel.vue'
|
||||
import { useBatchStore } from '@/stores/batches'
|
||||
import type { BatchEventResponse } from '@/types'
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
del: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
uploadFile: vi.fn(),
|
||||
}))
|
||||
|
||||
function makeEvent(overrides: Partial<BatchEventResponse> = {}): BatchEventResponse {
|
||||
return {
|
||||
id: 'evt-1',
|
||||
batchId: 'b1',
|
||||
eventType: 'STATUS_CHANGED',
|
||||
actorUserId: 'u1',
|
||||
actorUsername: 'entry1',
|
||||
actorFullName: 'Entry Clerk 1',
|
||||
occurredAt: '2026-06-27T10:00:00Z',
|
||||
metadataJson: JSON.stringify({ previousStatus: 'DRAFT', newStatus: 'PENDING_VERIFICATION' }),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AuditTrailPanel', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('shows empty state when opened with no events', async () => {
|
||||
const store = useBatchStore()
|
||||
store.events = []
|
||||
store.eventsLoading = false
|
||||
store.eventsError = null
|
||||
|
||||
vi.spyOn(store, 'fetchEvents').mockResolvedValue()
|
||||
|
||||
const wrapper = mount(AuditTrailPanel, { props: { batchId: 'b1' } })
|
||||
const details = wrapper.find('details')
|
||||
details.element.open = true
|
||||
await details.trigger('toggle')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('No audit events for this batch yet.')
|
||||
})
|
||||
|
||||
it('renders events as a vertical timeline', async () => {
|
||||
const store = useBatchStore()
|
||||
store.events = [
|
||||
makeEvent(),
|
||||
makeEvent({
|
||||
id: 'evt-2',
|
||||
eventType: 'VERIFIED',
|
||||
actorFullName: 'Verifier One',
|
||||
metadataJson: null,
|
||||
}),
|
||||
]
|
||||
store.eventsLoading = false
|
||||
store.eventsHasMore = false
|
||||
|
||||
vi.spyOn(store, 'fetchEvents').mockResolvedValue()
|
||||
|
||||
const wrapper = mount(AuditTrailPanel, { props: { batchId: 'b1' } })
|
||||
const details = wrapper.find('details')
|
||||
details.element.open = true
|
||||
await details.trigger('toggle')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="audit-trail-timeline"]').exists()).toBe(true)
|
||||
expect(wrapper.find('table').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('Status Changed')
|
||||
expect(wrapper.text()).toContain('Entry Clerk 1')
|
||||
expect(wrapper.text()).toContain('DRAFT → PENDING_VERIFICATION')
|
||||
expect(wrapper.text()).toContain('Verified')
|
||||
expect(wrapper.text()).toContain('Verifier One')
|
||||
})
|
||||
|
||||
it('shows load more when hasMore is true', async () => {
|
||||
const store = useBatchStore()
|
||||
store.events = [makeEvent()]
|
||||
store.eventsHasMore = true
|
||||
store.eventsNextCursor = 'cursor-1'
|
||||
|
||||
const fetchSpy = vi.spyOn(store, 'fetchEvents').mockResolvedValue()
|
||||
|
||||
const wrapper = mount(AuditTrailPanel, { props: { batchId: 'b1' } })
|
||||
const details = wrapper.find('details')
|
||||
details.element.open = true
|
||||
await details.trigger('toggle')
|
||||
await flushPromises()
|
||||
|
||||
const loadMore = wrapper.find('[data-testid="audit-trail-load-more"]')
|
||||
expect(loadMore.exists()).toBe(true)
|
||||
await loadMore.trigger('click')
|
||||
expect(fetchSpy).toHaveBeenCalledWith('b1', 'cursor-1')
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,7 @@ function makeObservation(overrides: Partial<DraftObservation> = {}): DraftObserv
|
||||
}
|
||||
|
||||
describe('ObservationRow', () => {
|
||||
it('renders observation code options', () => {
|
||||
it('renders observation code options in editable mode', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation() },
|
||||
})
|
||||
@@ -42,15 +42,23 @@ describe('ObservationRow', () => {
|
||||
expect(unitInput.element.value).toBe('°F')
|
||||
})
|
||||
|
||||
it('emits update event on code change', async () => {
|
||||
it('emits update event on code change and auto-fills unit', async () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation() },
|
||||
props: { observation: makeObservation({ unit: '' }) },
|
||||
})
|
||||
const select = wrapper.find('select')
|
||||
await select.setValue('TEMP_C')
|
||||
|
||||
expect(wrapper.emitted('update')).toBeTruthy()
|
||||
expect(wrapper.emitted('update')![0]).toEqual(['observationCode', 'TEMP_C'])
|
||||
expect(wrapper.emitted('update')![1]).toEqual(['unit', '°C'])
|
||||
})
|
||||
|
||||
it('hides delete button when canDelete is false', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation(), canDelete: false },
|
||||
})
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('emits update event on value change', async () => {
|
||||
@@ -86,15 +94,15 @@ describe('ObservationRow', () => {
|
||||
expect(wrapper.emitted('delete')![0]).toEqual(['obs-42'])
|
||||
})
|
||||
|
||||
it('disables inputs in readonly mode', () => {
|
||||
it('renders a scannable summary card in readonly mode', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation(), readonly: true },
|
||||
props: { observation: makeObservation({ value: 88, unit: 'bpm' }), readonly: true },
|
||||
})
|
||||
const select = wrapper.find('select')
|
||||
expect(select.element.disabled).toBe(true)
|
||||
|
||||
const numberInput = wrapper.find<HTMLInputElement>('input[type="number"]')
|
||||
expect(numberInput.element.disabled).toBe(true)
|
||||
expect(wrapper.find('select').exists()).toBe(false)
|
||||
expect(wrapper.find('input[type="number"]').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('Heart Rate')
|
||||
expect(wrapper.text()).toContain('88')
|
||||
expect(wrapper.text()).toContain('bpm')
|
||||
})
|
||||
|
||||
it('hides delete button in readonly mode', () => {
|
||||
@@ -150,7 +158,7 @@ describe('ObservationRow', () => {
|
||||
|
||||
it('hides verification checkbox when showVerified is false', () => {
|
||||
const wrapper = mount(ObservationRow, {
|
||||
props: { observation: makeObservation(), showVerified: false },
|
||||
props: { observation: makeObservation(), readonly: true, showVerified: false },
|
||||
})
|
||||
const checkbox = wrapper.find('input[type="checkbox"]')
|
||||
expect(checkbox.exists()).toBe(false)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import VerificationFieldCard from '@/components/VerificationFieldCard.vue'
|
||||
|
||||
describe('VerificationFieldCard', () => {
|
||||
it('renders soft label, bold value, and OK chip', () => {
|
||||
const wrapper = mount(VerificationFieldCard, {
|
||||
props: {
|
||||
label: 'Full Name',
|
||||
value: 'Jane Doe',
|
||||
checked: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Full Name')
|
||||
expect(wrapper.text()).toContain('Jane Doe')
|
||||
expect(wrapper.text()).toContain('OK')
|
||||
expect(wrapper.find('input[type="checkbox"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows (empty) when value is blank', () => {
|
||||
const wrapper = mount(VerificationFieldCard, {
|
||||
props: {
|
||||
label: 'MRN',
|
||||
value: '',
|
||||
checked: false,
|
||||
},
|
||||
})
|
||||
expect(wrapper.text()).toContain('(empty)')
|
||||
})
|
||||
|
||||
it('emits toggle when OK checkbox changes', async () => {
|
||||
const wrapper = mount(VerificationFieldCard, {
|
||||
props: {
|
||||
label: 'Sex',
|
||||
value: 'F',
|
||||
checked: false,
|
||||
},
|
||||
})
|
||||
|
||||
await wrapper.find('input[type="checkbox"]').setValue(true)
|
||||
expect(wrapper.emitted('toggle')?.[0]).toEqual([true])
|
||||
})
|
||||
})
|
||||
@@ -171,6 +171,10 @@ describe('VerificationForm', () => {
|
||||
expect(wrapper.text()).toContain('Jane Doe')
|
||||
expect(wrapper.text()).toContain('1990-05-15')
|
||||
|
||||
const cards = wrapper.findAll('[data-testid="verification-field-card"]')
|
||||
expect(cards.length).toBeGreaterThan(0)
|
||||
expect(wrapper.text()).toContain('OK')
|
||||
|
||||
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||
expect(checkboxes.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import WorkstationQueueRail from '@/components/WorkstationQueueRail.vue'
|
||||
import type { BatchDetailResponse } from '@/types'
|
||||
import { fieldRequirementsForBatchType } from '@/__tests__/helpers/fieldRequirements'
|
||||
|
||||
function makeBatch(overrides: Partial<BatchDetailResponse> = {}): BatchDetailResponse {
|
||||
const batchType = overrides.batchType ?? 'VITALS'
|
||||
return {
|
||||
id: 'abcdef12-3456-7890-abcd-ef1234567890',
|
||||
status: 'PENDING_ENTRY',
|
||||
batchType,
|
||||
track: 'TRACK_A',
|
||||
fieldRequirements: fieldRequirementsForBatchType(batchType),
|
||||
patientId: 'p1',
|
||||
documentRef: 'docs/scan.pdf',
|
||||
documentUrl: null,
|
||||
enableRetroactiveAlerts: false,
|
||||
enteredByUserId: null,
|
||||
verifiedByUserId: null,
|
||||
approvedByUserId: null,
|
||||
rejectionReason: null,
|
||||
promotedAt: null,
|
||||
promotionEncounterId: null,
|
||||
supersedesBatchId: null,
|
||||
clinicianAttestation: false,
|
||||
isCorrection: false,
|
||||
supersession: null,
|
||||
createdAt: '2026-06-27T10:00:00Z',
|
||||
updatedAt: '2026-06-27T10:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('WorkstationQueueRail', () => {
|
||||
it('shows batch type as title with status badge and truncated id as meta', () => {
|
||||
const wrapper = mount(WorkstationQueueRail, {
|
||||
props: {
|
||||
batches: [makeBatch({ batchType: 'ADMISSION', status: 'PENDING_ENTRY' })],
|
||||
selectedId: 'abcdef12-3456-7890-abcd-ef1234567890',
|
||||
},
|
||||
})
|
||||
|
||||
const item = wrapper.find('button[aria-current="true"]')
|
||||
expect(item.exists()).toBe(true)
|
||||
const text = item.text()
|
||||
expect(text).toContain('Admission')
|
||||
expect(text).toContain('Pending Entry')
|
||||
expect(text).toContain('abcdef12…')
|
||||
|
||||
const html = item.html()
|
||||
const typeIdx = html.indexOf('Admission')
|
||||
const badgeIdx = html.indexOf('Pending Entry')
|
||||
const idIdx = html.indexOf('abcdef12…')
|
||||
expect(typeIdx).toBeGreaterThan(-1)
|
||||
expect(badgeIdx).toBeGreaterThan(typeIdx)
|
||||
expect(idIdx).toBeGreaterThan(badgeIdx)
|
||||
})
|
||||
|
||||
it('emits select when a queue item is clicked', async () => {
|
||||
const batch = makeBatch()
|
||||
const wrapper = mount(WorkstationQueueRail, {
|
||||
props: { batches: [batch] },
|
||||
})
|
||||
|
||||
await wrapper.findAll('button')[0].trigger('click')
|
||||
expect(wrapper.emitted('select')?.[0]).toEqual([batch.id])
|
||||
})
|
||||
|
||||
it('emits back from Full queue action', async () => {
|
||||
const wrapper = mount(WorkstationQueueRail, {
|
||||
props: { batches: [] },
|
||||
})
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
expect(wrapper.emitted('back')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -107,6 +107,22 @@
|
||||
.workstation-field-label {
|
||||
@apply flex items-center gap-1.5 text-xs text-ink-secondary mb-1;
|
||||
}
|
||||
/* Observation cards — scannable list (design tips: hero value + timeline meta) */
|
||||
.observation-card {
|
||||
@apply rounded-card border border-line bg-surface p-3 shadow-card;
|
||||
}
|
||||
.observation-card--readonly {
|
||||
@apply flex gap-2.5 bg-canvas p-2.5 shadow-none;
|
||||
}
|
||||
.observation-card--verified {
|
||||
@apply border-clinical-safe/40 bg-clinical-safe-bg/40;
|
||||
}
|
||||
.observation-card__rail {
|
||||
@apply flex w-3 shrink-0 flex-col items-center pt-1.5;
|
||||
}
|
||||
.observation-card__dot {
|
||||
@apply h-2.5 w-2.5 rounded-full bg-primary-600 ring-4 ring-primary-50;
|
||||
}
|
||||
/* Design-doc §34 evidence hierarchy */
|
||||
.evidence-level {
|
||||
@apply flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-ink-secondary mb-2 shrink-0;
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
<legend class="workstation-form-legend">
|
||||
Observations ({{ draft?.observations?.length ?? 0 }})
|
||||
</legend>
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-3">
|
||||
<ObservationRow
|
||||
v-for="obs in (draft?.observations ?? [])"
|
||||
:key="obs.id"
|
||||
|
||||
@@ -30,45 +30,47 @@
|
||||
<p v-else-if="!loading && events.length === 0" class="text-sm text-ink-secondary">
|
||||
No audit events for this batch yet.
|
||||
</p>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full min-w-[520px] text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line text-left text-ink-secondary">
|
||||
<th class="py-2 px-2 font-medium w-[28%]">Timestamp</th>
|
||||
<th class="py-2 px-2 font-medium">Event</th>
|
||||
<th class="py-2 px-2 font-medium w-[22%]">Actor</th>
|
||||
<th class="py-2 px-2 font-medium w-[22%]">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="event in events"
|
||||
:key="event.id"
|
||||
class="border-b border-line last:border-0 align-top"
|
||||
<ol v-else class="space-y-0" data-testid="audit-trail-timeline">
|
||||
<li
|
||||
v-for="event in events"
|
||||
:key="event.id"
|
||||
class="flex gap-3"
|
||||
>
|
||||
<div class="w-[5.5rem] shrink-0 pt-0.5 text-right">
|
||||
<time
|
||||
class="block text-[11px] leading-snug text-ink-secondary tabular-nums"
|
||||
:datetime="event.occurredAt"
|
||||
>
|
||||
<td class="py-2 px-2 text-ink-secondary whitespace-nowrap">
|
||||
{{ formatDateTime(event.occurredAt) }}
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<span class="font-medium text-ink-strong">
|
||||
{{ formatEventType(event.eventType) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 px-2 text-ink">
|
||||
{{ event.actorFullName || event.actorUsername || '—' }}
|
||||
</td>
|
||||
<td class="py-2 px-2 text-ink-secondary text-xs">
|
||||
{{ summarizePayload(event.metadataJson) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{ formatDateTime(event.occurredAt) }}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<div class="observation-card__rail self-stretch" aria-hidden="true">
|
||||
<span class="observation-card__dot" />
|
||||
<span class="mt-1 w-px flex-1 bg-line" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 pb-4">
|
||||
<p class="text-sm font-bold leading-snug text-ink-strong">
|
||||
{{ formatEventType(event.eventType) }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs text-ink">
|
||||
{{ event.actorFullName || event.actorUsername || '—' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="summarizePayload(event.metadataJson) !== '—'"
|
||||
class="mt-1 text-xs text-ink-secondary"
|
||||
>
|
||||
{{ summarizePayload(event.metadataJson) }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<button
|
||||
v-if="hasMore"
|
||||
type="button"
|
||||
class="mt-3 text-sm font-medium text-primary-700 hover:text-primary-800 disabled:opacity-50"
|
||||
class="mt-1 text-sm font-medium text-primary-700 hover:text-primary-800 disabled:opacity-50"
|
||||
:disabled="loading"
|
||||
data-testid="audit-trail-load-more"
|
||||
@click="loadMore"
|
||||
@@ -121,13 +123,17 @@ async function loadMore() {
|
||||
|
||||
function formatEventType(type: string): string {
|
||||
return type
|
||||
.toLowerCase()
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleString()
|
||||
return new Date(iso).toLocaleString(undefined, {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@
|
||||
<!-- Observations section -->
|
||||
<fieldset class="workstation-form-section">
|
||||
<legend class="workstation-form-legend">Observations</legend>
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-3">
|
||||
<ObservationRow
|
||||
v-for="obs in observations"
|
||||
:key="obs.id"
|
||||
@@ -258,7 +258,7 @@
|
||||
@update="(field, value) => handleObsUpdate(obs.id, field, value)"
|
||||
@delete="handleObsDelete"
|
||||
/>
|
||||
<button type="button" class="btn-secondary text-sm py-1.5" @click="addObservation">
|
||||
<button type="button" class="btn-secondary text-sm py-1.5 w-full sm:w-auto" @click="addObservation">
|
||||
+ Add Observation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,117 +1,204 @@
|
||||
<template>
|
||||
<!-- Read-only: activity-list row — bold value, timestamp meta, optional OK -->
|
||||
<div
|
||||
class="flex flex-col xl:flex-row xl:items-start gap-3 p-3 rounded-input border border-line bg-canvas"
|
||||
v-if="readonly"
|
||||
class="observation-card observation-card--readonly"
|
||||
:class="{ 'observation-card--verified': showVerified && verified }"
|
||||
data-testid="observation-row"
|
||||
>
|
||||
<div class="flex-1 grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-3 min-w-0">
|
||||
<div>
|
||||
<label class="workstation-field-label">Code</label>
|
||||
<div class="observation-card__rail" aria-hidden="true">
|
||||
<span class="observation-card__dot" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
|
||||
{{ codeLabel }}
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 flex flex-wrap items-baseline gap-x-1.5 gap-y-0.5 tabular-nums"
|
||||
:class="valueInputClass || 'text-ink-strong'"
|
||||
>
|
||||
<span class="text-xl font-bold leading-tight tracking-tight">
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
<span
|
||||
v-if="observation.unit"
|
||||
class="text-sm font-medium text-ink-secondary"
|
||||
>
|
||||
{{ observation.unit }}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="recordedAtLabel"
|
||||
class="mt-1 text-[11px] leading-snug text-ink-secondary"
|
||||
>
|
||||
{{ recordedAtLabel }}
|
||||
</p>
|
||||
<p
|
||||
v-if="observation.note"
|
||||
class="mt-1 text-xs text-ink"
|
||||
>
|
||||
<span class="text-ink-secondary">Note:</span>
|
||||
{{ observation.note }}
|
||||
</p>
|
||||
<OcrConfidenceBadge
|
||||
v-if="ocrLabel"
|
||||
class="mt-1"
|
||||
:label="ocrLabel"
|
||||
:level="ocrLevel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="showVerified"
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-input border px-2 py-1 text-xs font-semibold cursor-pointer transition-colors"
|
||||
:class="verified
|
||||
? 'border-clinical-safe bg-clinical-safe-bg text-clinical-safe'
|
||||
: 'border-line bg-surface text-ink-secondary hover:border-primary-300'"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="w-3.5 h-3.5 text-clinical-safe rounded"
|
||||
:checked="verified"
|
||||
@change="$emit('verify', observation.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
OK
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editable: card with hero value, then secondary fields (not a 5-col crunch) -->
|
||||
<div
|
||||
v-else
|
||||
class="observation-card"
|
||||
data-testid="observation-row"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2 mb-2.5">
|
||||
<div class="min-w-0 flex-1">
|
||||
<label class="workstation-field-label">Observation</label>
|
||||
<select
|
||||
:value="observation.observationCode"
|
||||
@change="update('observationCode', ($event.target as HTMLSelectElement).value)"
|
||||
class="form-input text-sm py-1.5"
|
||||
:disabled="readonly"
|
||||
class="form-input text-sm font-semibold py-1.5"
|
||||
@change="onCodeChange(($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
<option value="HEART_RATE">Heart Rate</option>
|
||||
<option value="TEMP_C">Temperature (C)</option>
|
||||
<option value="BP_SYSTOLIC">BP Systolic</option>
|
||||
<option value="BP_DIASTOLIC">BP Diastolic</option>
|
||||
<option value="RESP_RATE">Respiratory Rate</option>
|
||||
<option value="SPO2">SpO2</option>
|
||||
<option value="POTASSIUM_MEQ_L">Potassium</option>
|
||||
<option value="GLUCOSE_MG_DL">Glucose</option>
|
||||
<option value="WBC_K_UL">WBC</option>
|
||||
<option value="LACTATE_MMOL_L">Lactate</option>
|
||||
<option value="">Select code…</option>
|
||||
<option
|
||||
v-for="opt in CODE_OPTIONS"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
class="shrink-0 mt-5 text-sm font-medium text-clinical-danger hover:text-clinical-critical"
|
||||
@click="$emit('delete', observation.id)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-2 mb-2.5">
|
||||
<div class="min-w-0 flex-[1.4]">
|
||||
<label class="workstation-field-label">
|
||||
Value
|
||||
<OcrConfidenceBadge :label="ocrLabel" :level="ocrLevel" />
|
||||
</label>
|
||||
<input
|
||||
:value="observation.value"
|
||||
@change="update('value', parseFloat(($event.target as HTMLInputElement).value))"
|
||||
type="number"
|
||||
step="0.01"
|
||||
:class="['form-input', 'text-sm', 'py-1.5', valueInputClass]"
|
||||
:disabled="readonly"
|
||||
:class="[
|
||||
'form-input text-xl font-bold tabular-nums py-1.5 leading-none',
|
||||
valueInputClass,
|
||||
]"
|
||||
@change="update('value', parseFloat(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="w-[4.75rem] shrink-0">
|
||||
<label class="workstation-field-label">Unit</label>
|
||||
<input
|
||||
:value="observation.unit"
|
||||
@change="update('unit', ($event.target as HTMLInputElement).value)"
|
||||
type="text"
|
||||
class="form-input text-sm py-1.5"
|
||||
:disabled="readonly"
|
||||
@change="update('unit', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<div>
|
||||
<label class="workstation-field-label">Recorded At</label>
|
||||
<label class="workstation-field-label">Recorded at</label>
|
||||
<input
|
||||
:value="observation.recordedAt?.substring(0, 16)"
|
||||
@change="update('recordedAt', ($event.target as HTMLInputElement).value)"
|
||||
type="datetime-local"
|
||||
class="form-input text-sm py-1.5"
|
||||
:disabled="readonly"
|
||||
@change="update('recordedAt', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="workstation-field-label">Note</label>
|
||||
<input
|
||||
:value="observation.note"
|
||||
@change="update('note', ($event.target as HTMLInputElement).value)"
|
||||
type="text"
|
||||
class="form-input text-sm py-1.5"
|
||||
placeholder="Optional"
|
||||
:disabled="readonly"
|
||||
@change="update('note', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showVerified"
|
||||
class="flex items-center shrink-0 xl:mt-7"
|
||||
>
|
||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="verified"
|
||||
@change="$emit('verify', observation.id, ($event.target as HTMLInputElement).checked)"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
/>
|
||||
<span class="text-xs text-ink-secondary">OK</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!readonly && !showVerified"
|
||||
type="button"
|
||||
@click="$emit('delete', observation.id)"
|
||||
class="self-start xl:mt-7 text-clinical-danger hover:text-clinical-critical text-sm font-medium"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DraftObservation } from '../types'
|
||||
import { computed } from 'vue'
|
||||
import type { OcrConfidenceLevel } from '../composables/useOcrFieldConfidence'
|
||||
import OcrConfidenceBadge from './OcrConfidenceBadge.vue'
|
||||
|
||||
defineProps<{
|
||||
observation: DraftObservation
|
||||
readonly?: boolean
|
||||
showVerified?: boolean
|
||||
verified?: boolean
|
||||
valueInputClass?: string
|
||||
ocrLabel?: string | null
|
||||
ocrLevel?: OcrConfidenceLevel | null
|
||||
}>()
|
||||
/** Minimal observation shape for editable/readonly cards (DraftObservation satisfies this). */
|
||||
export interface ObservationRowModel {
|
||||
id: string
|
||||
observationCode: string
|
||||
value: number | null
|
||||
unit: string | null
|
||||
recordedAt: string | null
|
||||
note: string | null
|
||||
}
|
||||
|
||||
const CODE_OPTIONS = [
|
||||
{ value: 'HEART_RATE', label: 'Heart Rate', unit: 'bpm' },
|
||||
{ value: 'TEMP_C', label: 'Temperature (C)', unit: '°C' },
|
||||
{ value: 'BP_SYSTOLIC', label: 'BP Systolic', unit: 'mmHg' },
|
||||
{ value: 'BP_DIASTOLIC', label: 'BP Diastolic', unit: 'mmHg' },
|
||||
{ value: 'RESP_RATE', label: 'Respiratory Rate', unit: 'breaths/min' },
|
||||
{ value: 'SPO2', label: 'SpO2', unit: '%' },
|
||||
{ value: 'POTASSIUM_MEQ_L', label: 'Potassium', unit: 'mEq/L' },
|
||||
{ value: 'GLUCOSE_MG_DL', label: 'Glucose', unit: 'mg/dL' },
|
||||
{ value: 'WBC_K_UL', label: 'WBC', unit: '×10³/µL' },
|
||||
{ value: 'LACTATE_MMOL_L', label: 'Lactate', unit: 'mmol/L' },
|
||||
] as const
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
observation: ObservationRowModel
|
||||
readonly?: boolean
|
||||
showVerified?: boolean
|
||||
verified?: boolean
|
||||
canDelete?: boolean
|
||||
valueInputClass?: string
|
||||
ocrLabel?: string | null
|
||||
ocrLevel?: OcrConfidenceLevel | null
|
||||
}>(),
|
||||
{
|
||||
canDelete: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update', field: string, value: unknown): void
|
||||
@@ -119,7 +206,38 @@ const emit = defineEmits<{
|
||||
(e: 'verify', obsId: string, passed: boolean): void
|
||||
}>()
|
||||
|
||||
const codeLabel = computed(() => {
|
||||
const code = props.observation.observationCode
|
||||
if (!code) return 'Unspecified observation'
|
||||
return CODE_OPTIONS.find((o) => o.value === code)?.label ?? code.replace(/_/g, ' ')
|
||||
})
|
||||
|
||||
const displayValue = computed(() => {
|
||||
const value = props.observation.value
|
||||
if (value == null || Number.isNaN(Number(value))) return '—'
|
||||
return String(value)
|
||||
})
|
||||
|
||||
const recordedAtLabel = computed(() => {
|
||||
const raw = props.observation.recordedAt
|
||||
if (!raw) return null
|
||||
const date = new Date(raw)
|
||||
if (Number.isNaN(date.getTime())) return raw
|
||||
return date.toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
})
|
||||
|
||||
function update(field: string, value: unknown) {
|
||||
emit('update', field, value)
|
||||
}
|
||||
|
||||
function onCodeChange(code: string) {
|
||||
emit('update', 'observationCode', code)
|
||||
const match = CODE_OPTIONS.find((o) => o.value === code)
|
||||
if (match?.unit) {
|
||||
emit('update', 'unit', match.unit)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div
|
||||
class="observation-card observation-card--readonly"
|
||||
:class="{ 'observation-card--verified': checked }"
|
||||
data-testid="verification-field-card"
|
||||
>
|
||||
<div class="observation-card__rail" aria-hidden="true">
|
||||
<span class="observation-card__dot" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
|
||||
{{ label }}
|
||||
</p>
|
||||
<OcrConfidenceBadge
|
||||
v-if="ocrLabel"
|
||||
:label="ocrLabel"
|
||||
:level="ocrLevel"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="mt-0.5 text-base font-bold leading-snug text-ink-strong break-words"
|
||||
:class="valueClass"
|
||||
>
|
||||
{{ displayValue }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-input border px-2 py-1 text-xs font-semibold cursor-pointer transition-colors"
|
||||
:class="checked
|
||||
? 'border-clinical-safe bg-clinical-safe-bg text-clinical-safe'
|
||||
: 'border-line bg-surface text-ink-secondary hover:border-primary-300'"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="w-3.5 h-3.5 text-clinical-safe rounded"
|
||||
:checked="checked"
|
||||
@change="$emit('toggle', ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
OK
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { OcrConfidenceLevel } from '../composables/useOcrFieldConfidence'
|
||||
import OcrConfidenceBadge from './OcrConfidenceBadge.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
value: string | null | undefined
|
||||
checked: boolean
|
||||
valueClass?: string
|
||||
ocrLabel?: string | null
|
||||
ocrLevel?: OcrConfidenceLevel | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'toggle', checked: boolean): void
|
||||
}>()
|
||||
|
||||
const displayValue = computed(() => {
|
||||
const raw = props.value
|
||||
if (raw == null || String(raw).trim() === '') return '(empty)'
|
||||
return String(raw)
|
||||
})
|
||||
</script>
|
||||
@@ -34,30 +34,20 @@
|
||||
/>
|
||||
|
||||
<!-- Patient review -->
|
||||
<fieldset class="workstation-form-section">
|
||||
<fieldset v-if="patientFields.length > 0" class="workstation-form-section">
|
||||
<legend class="workstation-form-legend">Patient Demographics</legend>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div v-for="field in patientFields" :key="field.path">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="fieldChecks[field.path]"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
@change="toggleCheck(field.path)"
|
||||
/>
|
||||
<label class="text-xs text-ink-secondary">{{ field.label }}</label>
|
||||
<OcrConfidenceBadge
|
||||
:label="fieldConfidenceLabel(field.path)"
|
||||
:level="fieldConfidenceLevel(field.path)"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm mt-1.5 pl-6 font-medium"
|
||||
:class="fieldConfidenceClass(field.path)"
|
||||
>
|
||||
{{ field.value || '(empty)' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2.5">
|
||||
<VerificationFieldCard
|
||||
v-for="field in patientFields"
|
||||
:key="field.path"
|
||||
:label="field.label"
|
||||
:value="field.value"
|
||||
:checked="fieldChecks[field.path] ?? false"
|
||||
:value-class="fieldConfidenceClass(field.path)"
|
||||
:ocr-label="fieldConfidenceLabel(field.path)"
|
||||
:ocr-level="fieldConfidenceLevel(field.path)"
|
||||
@toggle="(passed) => toggleCheck(field.path, passed)"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -65,27 +55,17 @@
|
||||
<fieldset v-if="allergyFields.length > 0" class="workstation-form-section">
|
||||
<legend class="workstation-form-legend">Allergies</legend>
|
||||
<div class="space-y-2.5">
|
||||
<div v-for="field in allergyFields" :key="field.path">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="fieldChecks[field.path]"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
@change="toggleCheck(field.path)"
|
||||
/>
|
||||
<label class="text-xs text-ink-secondary">{{ field.label }}</label>
|
||||
<OcrConfidenceBadge
|
||||
:label="fieldConfidenceLabel(field.path)"
|
||||
:level="fieldConfidenceLevel(field.path)"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm mt-1.5 pl-6 font-medium"
|
||||
:class="fieldConfidenceClass(field.path)"
|
||||
>
|
||||
{{ field.value || '(empty)' }}
|
||||
</p>
|
||||
</div>
|
||||
<VerificationFieldCard
|
||||
v-for="field in allergyFields"
|
||||
:key="field.path"
|
||||
:label="field.label"
|
||||
:value="field.value"
|
||||
:checked="fieldChecks[field.path] ?? false"
|
||||
:value-class="fieldConfidenceClass(field.path)"
|
||||
:ocr-label="fieldConfidenceLabel(field.path)"
|
||||
:ocr-level="fieldConfidenceLevel(field.path)"
|
||||
@toggle="(passed) => toggleCheck(field.path, passed)"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -93,27 +73,17 @@
|
||||
<fieldset v-if="medicationFields.length > 0" class="workstation-form-section">
|
||||
<legend class="workstation-form-legend">Medications</legend>
|
||||
<div class="space-y-2.5">
|
||||
<div v-for="field in medicationFields" :key="field.path">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="fieldChecks[field.path]"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
@change="toggleCheck(field.path)"
|
||||
/>
|
||||
<label class="text-xs text-ink-secondary">{{ field.label }}</label>
|
||||
<OcrConfidenceBadge
|
||||
:label="fieldConfidenceLabel(field.path)"
|
||||
:level="fieldConfidenceLevel(field.path)"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm mt-1.5 pl-6 font-medium"
|
||||
:class="fieldConfidenceClass(field.path)"
|
||||
>
|
||||
{{ field.value || '(empty)' }}
|
||||
</p>
|
||||
</div>
|
||||
<VerificationFieldCard
|
||||
v-for="field in medicationFields"
|
||||
:key="field.path"
|
||||
:label="field.label"
|
||||
:value="field.value"
|
||||
:checked="fieldChecks[field.path] ?? false"
|
||||
:value-class="fieldConfidenceClass(field.path)"
|
||||
:ocr-label="fieldConfidenceLabel(field.path)"
|
||||
:ocr-level="fieldConfidenceLevel(field.path)"
|
||||
@toggle="(passed) => toggleCheck(field.path, passed)"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -123,28 +93,18 @@
|
||||
class="workstation-form-section"
|
||||
>
|
||||
<legend class="workstation-form-legend">Encounter Context</legend>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div v-for="field in encounterFields" :key="field.path">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="fieldChecks[field.path]"
|
||||
class="w-4 h-4 text-clinical-safe rounded"
|
||||
@change="toggleCheck(field.path)"
|
||||
/>
|
||||
<label class="text-xs text-ink-secondary">{{ field.label }}</label>
|
||||
<OcrConfidenceBadge
|
||||
:label="fieldConfidenceLabel(field.path)"
|
||||
:level="fieldConfidenceLevel(field.path)"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm mt-1.5 pl-6 font-medium"
|
||||
:class="fieldConfidenceClass(field.path)"
|
||||
>
|
||||
{{ field.value || '(empty)' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2.5">
|
||||
<VerificationFieldCard
|
||||
v-for="field in encounterFields"
|
||||
:key="field.path"
|
||||
:label="field.label"
|
||||
:value="field.value"
|
||||
:checked="fieldChecks[field.path] ?? false"
|
||||
:value-class="fieldConfidenceClass(field.path)"
|
||||
:ocr-label="fieldConfidenceLabel(field.path)"
|
||||
:ocr-level="fieldConfidenceLevel(field.path)"
|
||||
@toggle="(passed) => toggleCheck(field.path, passed)"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -162,7 +122,7 @@
|
||||
>
|
||||
No observations recorded.
|
||||
</p>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-else class="space-y-3">
|
||||
<ObservationRow
|
||||
v-for="(obs, index) in observations"
|
||||
:key="obs.id"
|
||||
@@ -284,8 +244,8 @@ import { useBatchStore } from '../stores/batches'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import { useOcrFieldConfidence } from '../composables/useOcrFieldConfidence'
|
||||
import ObservationRow from '../components/ObservationRow.vue'
|
||||
import VerificationFieldCard from '../components/VerificationFieldCard.vue'
|
||||
import StatusBadge from '../components/StatusBadge.vue'
|
||||
import OcrConfidenceBadge from '../components/OcrConfidenceBadge.vue'
|
||||
import SeparationOfDutiesBanner from '../components/SeparationOfDutiesBanner.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import WorkstationActionBar from '../components/WorkstationActionBar.vue'
|
||||
@@ -405,6 +365,10 @@ watch(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
patientFields.value = []
|
||||
allergyFields.value = []
|
||||
medicationFields.value = []
|
||||
}
|
||||
|
||||
if (draft.encounter && showEncounterContext.value) {
|
||||
|
||||
@@ -35,15 +35,15 @@
|
||||
:aria-current="batch.id === selectedId ? 'true' : undefined"
|
||||
@click="$emit('select', batch.id)"
|
||||
>
|
||||
<div class="font-mono text-xs font-medium text-ink-strong">
|
||||
{{ batch.id.substring(0, 8) }}…
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-ink-secondary truncate">
|
||||
<div class="text-sm font-semibold text-ink-strong truncate">
|
||||
{{ formatBatchType(batch.batchType) }}
|
||||
</div>
|
||||
<div class="mt-1.5">
|
||||
<StatusBadge :status="batch.status" />
|
||||
</div>
|
||||
<div class="mt-1 font-mono text-[11px] text-ink-secondary truncate">
|
||||
{{ batch.id.substring(0, 8) }}…
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -84,6 +84,9 @@ defineEmits<{
|
||||
}>()
|
||||
|
||||
function formatBatchType(type: string): string {
|
||||
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
return type
|
||||
.toLowerCase()
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. Observations (dense table) -->
|
||||
<!-- 3. Observations -->
|
||||
<section class="card space-y-4">
|
||||
<header class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
@@ -113,12 +113,12 @@
|
||||
{{ mode === 'new' ? '3' : '2' }} · Observations
|
||||
</p>
|
||||
<h2 class="text-base font-semibold text-ink-strong mt-1">
|
||||
Vitals ({{ observations.length }})
|
||||
Vitals ({{ observations.length }}/10)
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-semibold text-primary-700 hover:text-primary-800"
|
||||
class="text-sm font-semibold text-primary-700 hover:text-primary-800 disabled:opacity-50"
|
||||
:disabled="observations.length >= 10"
|
||||
@click="addObservation"
|
||||
>
|
||||
@@ -126,84 +126,15 @@
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="overflow-x-auto -mx-1">
|
||||
<table class="w-full min-w-[720px] text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-line text-left text-ink-secondary">
|
||||
<th class="py-2 px-2 font-medium w-[18%]">Time</th>
|
||||
<th class="py-2 px-2 font-medium w-[20%]">Type</th>
|
||||
<th class="py-2 px-2 font-medium w-[12%]">Value</th>
|
||||
<th class="py-2 px-2 font-medium w-[12%]">Unit</th>
|
||||
<th class="py-2 px-2 font-medium">Notes</th>
|
||||
<th class="py-2 px-2 font-medium w-[5rem]"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(obs, idx) in observations"
|
||||
:key="idx"
|
||||
class="border-b border-line align-top"
|
||||
>
|
||||
<td class="py-2 px-2">
|
||||
<input
|
||||
v-model="obs.recordedAt"
|
||||
type="datetime-local"
|
||||
class="form-input text-sm py-1.5"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<select
|
||||
v-model="obs.observationCode"
|
||||
class="form-input text-sm py-1.5"
|
||||
@change="autoFillUnit(obs)"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
<option
|
||||
v-for="code in vitalCodes"
|
||||
:key="code.value"
|
||||
:value="code.value"
|
||||
>
|
||||
{{ code.label }}
|
||||
</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<input
|
||||
v-model.number="obs.value"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="form-input text-sm py-1.5"
|
||||
inputmode="decimal"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<input
|
||||
v-model="obs.unit"
|
||||
type="text"
|
||||
class="form-input text-sm py-1.5"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2">
|
||||
<input
|
||||
v-model="obs.note"
|
||||
type="text"
|
||||
class="form-input text-sm py-1.5"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</td>
|
||||
<td class="py-2 px-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-clinical-danger hover:underline py-1.5"
|
||||
:disabled="observations.length <= 1"
|
||||
@click="removeObservation(idx)"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="space-y-3">
|
||||
<ObservationRow
|
||||
v-for="obs in observations"
|
||||
:key="obs.id"
|
||||
:observation="obs"
|
||||
:can-delete="observations.length > 1"
|
||||
@update="(field, value) => handleObsUpdate(obs.id, field, value)"
|
||||
@delete="removeObservationById"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="observations.length >= 10" class="text-xs text-ink-disabled">
|
||||
Maximum 10 observations per submission.
|
||||
@@ -345,6 +276,8 @@ import { useToast } from '../composables/useToast'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import PatientSearch from '../components/PatientSearch.vue'
|
||||
import InlineError from '../components/InlineError.vue'
|
||||
import ObservationRow from '../components/ObservationRow.vue'
|
||||
import type { ObservationRowModel } from '../components/ObservationRow.vue'
|
||||
import type { LiveCaptureObservationInput } from '../types'
|
||||
|
||||
const store = useLiveCaptureStore()
|
||||
@@ -385,30 +318,24 @@ const departments = [
|
||||
{ value: 'Anesthesiology', label: 'Anesthesiology' },
|
||||
]
|
||||
|
||||
const vitalCodes = [
|
||||
{ value: 'HEART_RATE', label: 'Heart Rate', unit: 'bpm' },
|
||||
{ value: 'TEMP_C', label: 'Temperature', unit: '°C' },
|
||||
{ value: 'BP_SYSTOLIC', label: 'BP Systolic', unit: 'mmHg' },
|
||||
{ value: 'BP_DIASTOLIC', label: 'BP Diastolic', unit: 'mmHg' },
|
||||
{ value: 'RESP_RATE', label: 'Respiratory Rate', unit: 'breaths/min' },
|
||||
{ value: 'SPO2', label: 'SpO2', unit: '%' },
|
||||
{ value: 'POTASSIUM_MEQ_L', label: 'Potassium', unit: 'mEq/L' },
|
||||
{ value: 'GLUCOSE_MG_DL', label: 'Glucose', unit: 'mg/dL' },
|
||||
{ value: 'WBC_K_UL', label: 'WBC', unit: '×10³/µL' },
|
||||
{ value: 'LACTATE_MMOL_L', label: 'Lactate', unit: 'mmol/L' },
|
||||
]
|
||||
|
||||
function nowLocal(): string {
|
||||
const d = new Date()
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset())
|
||||
return d.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function makeObservation(): LiveCaptureObservationInput {
|
||||
return { observationCode: '', value: null, unit: '', recordedAt: nowLocal(), note: '' }
|
||||
function makeObservation(): ObservationRowModel {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
observationCode: '',
|
||||
value: null,
|
||||
unit: '',
|
||||
recordedAt: nowLocal(),
|
||||
note: '',
|
||||
}
|
||||
}
|
||||
|
||||
const observations = ref<LiveCaptureObservationInput[]>([makeObservation()])
|
||||
const observations = ref<ObservationRowModel[]>([makeObservation()])
|
||||
|
||||
function addObservation() {
|
||||
if (observations.value.length < 10) {
|
||||
@@ -416,20 +343,29 @@ function addObservation() {
|
||||
}
|
||||
}
|
||||
|
||||
function removeObservation(idx: number) {
|
||||
if (observations.value.length > 1) {
|
||||
observations.value.splice(idx, 1)
|
||||
}
|
||||
function removeObservationById(id: string) {
|
||||
if (observations.value.length <= 1) return
|
||||
observations.value = observations.value.filter((o) => o.id !== id)
|
||||
}
|
||||
|
||||
function autoFillUnit(obs: LiveCaptureObservationInput) {
|
||||
const match = vitalCodes.find(c => c.value === obs.observationCode)
|
||||
if (match) obs.unit = match.unit
|
||||
function handleObsUpdate(id: string, field: string, value: unknown) {
|
||||
const obs = observations.value.find((o) => o.id === id)
|
||||
if (!obs) return
|
||||
;(obs as Record<string, unknown>)[field] = value
|
||||
}
|
||||
|
||||
function toSubmitPayload(): LiveCaptureObservationInput[] {
|
||||
return observations.value.map(({ observationCode, value, unit, recordedAt, note }) => ({
|
||||
observationCode,
|
||||
value,
|
||||
unit: unit ?? '',
|
||||
recordedAt: new Date(recordedAt ?? nowLocal()).toISOString(),
|
||||
note: note ?? '',
|
||||
}))
|
||||
}
|
||||
|
||||
function formatCode(code: string): string {
|
||||
const match = vitalCodes.find(c => c.value === code)
|
||||
return match?.label ?? code
|
||||
return code.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
@@ -448,10 +384,7 @@ async function submit() {
|
||||
errorMessage.value = ''
|
||||
store.reset()
|
||||
|
||||
const obs = observations.value.map(o => ({
|
||||
...o,
|
||||
recordedAt: new Date(o.recordedAt).toISOString(),
|
||||
}))
|
||||
const obs = toSubmitPayload()
|
||||
|
||||
try {
|
||||
if (mode.value === 'new') {
|
||||
|
||||
Reference in New Issue
Block a user