make addition ui fixes for cramped features
CI / backend (push) Successful in 6m6s
CI / frontend (push) Failing after 1m8s

This commit is contained in:
2026-08-12 06:11:30 +08:00
parent 4c5aafe591
commit 53338906aa
18 changed files with 668 additions and 317 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

@@ -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()
})
})
+16
View File
@@ -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') {