diff --git a/docs/designs/tips/#uiux #uxdesign #productdesign #designthinking #figma _ Dharmik Rathod.jpeg b/docs/designs/tips/#uiux #uxdesign #productdesign #designthinking #figma _ Dharmik Rathod.jpeg new file mode 100644 index 0000000..7c62adf Binary files /dev/null and b/docs/designs/tips/#uiux #uxdesign #productdesign #designthinking #figma _ Dharmik Rathod.jpeg differ diff --git a/docs/designs/tips/LMS Design System UI Kit _ Modern Education App & Dashboard Design Inspiration.jpeg b/docs/designs/tips/LMS Design System UI Kit _ Modern Education App & Dashboard Design Inspiration.jpeg new file mode 100644 index 0000000..dad9068 Binary files /dev/null and b/docs/designs/tips/LMS Design System UI Kit _ Modern Education App & Dashboard Design Inspiration.jpeg differ diff --git a/docs/designs/tips/Logistics & Package Tracking App.jpeg b/docs/designs/tips/Logistics & Package Tracking App.jpeg new file mode 100644 index 0000000..5904083 Binary files /dev/null and b/docs/designs/tips/Logistics & Package Tracking App.jpeg differ diff --git a/docs/designs/tips/Mobile User Journey Mapping _ App UI UX Design Tips.jpeg b/docs/designs/tips/Mobile User Journey Mapping _ App UI UX Design Tips.jpeg new file mode 100644 index 0000000..7ce475a Binary files /dev/null and b/docs/designs/tips/Mobile User Journey Mapping _ App UI UX Design Tips.jpeg differ diff --git a/vigilcare-records-web/src/__tests__/components/AuditTrailPanel.test.ts b/vigilcare-records-web/src/__tests__/components/AuditTrailPanel.test.ts new file mode 100644 index 0000000..bf175e4 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/components/AuditTrailPanel.test.ts @@ -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 { + 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') + }) +}) diff --git a/vigilcare-records-web/src/__tests__/components/ObservationRow.test.ts b/vigilcare-records-web/src/__tests__/components/ObservationRow.test.ts index 70d08d5..99744ad 100644 --- a/vigilcare-records-web/src/__tests__/components/ObservationRow.test.ts +++ b/vigilcare-records-web/src/__tests__/components/ObservationRow.test.ts @@ -17,7 +17,7 @@ function makeObservation(overrides: Partial = {}): 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('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) diff --git a/vigilcare-records-web/src/__tests__/components/VerificationFieldCard.test.ts b/vigilcare-records-web/src/__tests__/components/VerificationFieldCard.test.ts new file mode 100644 index 0000000..c6943d8 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/components/VerificationFieldCard.test.ts @@ -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]) + }) +}) diff --git a/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts b/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts index 4672522..16e9fba 100644 --- a/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts +++ b/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts @@ -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) }) diff --git a/vigilcare-records-web/src/__tests__/components/WorkstationQueueRail.test.ts b/vigilcare-records-web/src/__tests__/components/WorkstationQueueRail.test.ts new file mode 100644 index 0000000..815ec7a --- /dev/null +++ b/vigilcare-records-web/src/__tests__/components/WorkstationQueueRail.test.ts @@ -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 { + 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() + }) +}) diff --git a/vigilcare-records-web/src/assets/main.css b/vigilcare-records-web/src/assets/main.css index 09efd55..d245508 100644 --- a/vigilcare-records-web/src/assets/main.css +++ b/vigilcare-records-web/src/assets/main.css @@ -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; diff --git a/vigilcare-records-web/src/components/ApprovalForm.vue b/vigilcare-records-web/src/components/ApprovalForm.vue index 937ff65..4c122df 100644 --- a/vigilcare-records-web/src/components/ApprovalForm.vue +++ b/vigilcare-records-web/src/components/ApprovalForm.vue @@ -111,7 +111,7 @@ Observations ({{ draft?.observations?.length ?? 0 }}) -
+
No audit events for this batch yet.

-
- - - - - - - - - - - +
  • +
    +
  • - - - - - -
    TimestampEventActorDetails
    - {{ formatDateTime(event.occurredAt) }} - - - {{ formatEventType(event.eventType) }} - - - {{ event.actorFullName || event.actorUsername || '—' }} - - {{ summarizePayload(event.metadataJson) }} -
    -
    + {{ formatDateTime(event.occurredAt) }} + +
    + + + +
    +

    + {{ formatEventType(event.eventType) }} +

    +

    + {{ event.actorFullName || event.actorUsername || '—' }} +

    +

    + {{ summarizePayload(event.metadataJson) }} +

    +
    + +
    diff --git a/vigilcare-records-web/src/components/ObservationRow.vue b/vigilcare-records-web/src/components/ObservationRow.vue index fef20e3..c78ba3a 100644 --- a/vigilcare-records-web/src/components/ObservationRow.vue +++ b/vigilcare-records-web/src/components/ObservationRow.vue @@ -1,117 +1,204 @@ diff --git a/vigilcare-records-web/src/components/VerificationFieldCard.vue b/vigilcare-records-web/src/components/VerificationFieldCard.vue new file mode 100644 index 0000000..68e023c --- /dev/null +++ b/vigilcare-records-web/src/components/VerificationFieldCard.vue @@ -0,0 +1,74 @@ + + + diff --git a/vigilcare-records-web/src/components/VerificationForm.vue b/vigilcare-records-web/src/components/VerificationForm.vue index 1b58f9a..4b249a7 100644 --- a/vigilcare-records-web/src/components/VerificationForm.vue +++ b/vigilcare-records-web/src/components/VerificationForm.vue @@ -34,30 +34,20 @@ /> -
    +
    Patient Demographics -
    -
    -
    - - - -
    -

    - {{ field.value || '(empty)' }} -

    -
    +
    +
    @@ -65,27 +55,17 @@
    Allergies
    -
    -
    - - - -
    -

    - {{ field.value || '(empty)' }} -

    -
    +
    @@ -93,27 +73,17 @@
    Medications
    -
    -
    - - - -
    -

    - {{ field.value || '(empty)' }} -

    -
    +
    @@ -123,28 +93,18 @@ class="workstation-form-section" > Encounter Context -
    -
    -
    - - - -
    -

    - {{ field.value || '(empty)' }} -

    -
    +
    +
    @@ -162,7 +122,7 @@ > No observations recorded.

    -
    +
    -
    - {{ batch.id.substring(0, 8) }}… -
    -
    +
    {{ formatBatchType(batch.batchType) }}
    +
    + {{ batch.id.substring(0, 8) }}… +
    @@ -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()) } diff --git a/vigilcare-records-web/src/views/LiveCaptureView.vue b/vigilcare-records-web/src/views/LiveCaptureView.vue index 9a35b26..cd18e08 100644 --- a/vigilcare-records-web/src/views/LiveCaptureView.vue +++ b/vigilcare-records-web/src/views/LiveCaptureView.vue @@ -105,7 +105,7 @@
    - +
    @@ -113,12 +113,12 @@ {{ mode === 'new' ? '3' : '2' }} · Observations

    - Vitals ({{ observations.length }}) + Vitals ({{ observations.length }}/10)

    -
    - - - - - - - - - - - - - - - - - - - - - -
    TimeTypeValueUnitNotes
    - - - - - - - - - - - -
    +
    +

    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([makeObservation()]) +const observations = ref([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)[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') {