Add walkthrough
This commit is contained in:
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import HelpPanel from '@/components/HelpPanel.vue'
|
||||||
|
import TourHelpButton from '@/components/TourHelpButton.vue'
|
||||||
|
import { useHelpPanel } from '@/composables/useHelpPanel'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useTourStore } from '@/stores/tour'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
post: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/router', () => ({
|
||||||
|
default: {
|
||||||
|
push: vi.fn().mockResolvedValue(undefined),
|
||||||
|
currentRoute: { value: { path: '/entry' } },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRoute: () => ({ path: '/entry' }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
let wrappers: VueWrapper[] = []
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
wrappers = []
|
||||||
|
useHelpPanel().closePanel()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrappers.forEach((w) => w.unmount())
|
||||||
|
wrappers = []
|
||||||
|
useHelpPanel().closePanel()
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountPanel() {
|
||||||
|
const wrapper = mount(HelpPanel, { attachTo: document.body })
|
||||||
|
wrappers.push(wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TourHelpButton', () => {
|
||||||
|
it('opens the help panel when clicked', async () => {
|
||||||
|
const help = useHelpPanel()
|
||||||
|
const wrapper = mount(TourHelpButton)
|
||||||
|
wrappers.push(wrapper)
|
||||||
|
await wrapper.get('[data-testid="tour-help"]').trigger('click')
|
||||||
|
expect(help.open.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('remains enabled when role has no tour', () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.user = {
|
||||||
|
id: 'u1',
|
||||||
|
username: 'x',
|
||||||
|
fullName: 'X',
|
||||||
|
role: 'UNKNOWN',
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrapper = mount(TourHelpButton)
|
||||||
|
wrappers.push(wrapper)
|
||||||
|
expect(wrapper.get('[data-testid="tour-help"]').attributes('disabled')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('HelpPanel', () => {
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
mountPanel()
|
||||||
|
expect(document.querySelector('[data-testid="help-panel"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows page guide content when opened', async () => {
|
||||||
|
const help = useHelpPanel()
|
||||||
|
mountPanel()
|
||||||
|
help.openPanel()
|
||||||
|
await nextTick()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const panel = document.querySelector('[data-testid="help-panel"]')
|
||||||
|
expect(panel).not.toBeNull()
|
||||||
|
expect(panel?.textContent).toContain('Data Entry')
|
||||||
|
expect(panel?.textContent).toContain('Transcribe structured fields')
|
||||||
|
expect(panel?.textContent).toContain('Replay walkthrough')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes on Close button click', async () => {
|
||||||
|
const help = useHelpPanel()
|
||||||
|
mountPanel()
|
||||||
|
help.openPanel()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const done = document.querySelector('[data-testid="help-panel-done"]') as HTMLButtonElement
|
||||||
|
expect(done).not.toBeNull()
|
||||||
|
done.click()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(help.open.value).toBe(false)
|
||||||
|
expect(document.querySelector('[data-testid="help-panel"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Replay walkthrough closes panel and starts tour', async () => {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.setAttribute('data-tour', 'entry-header')
|
||||||
|
document.body.appendChild(el)
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.user = {
|
||||||
|
id: 'u1',
|
||||||
|
username: 'entry1',
|
||||||
|
fullName: 'Entry One',
|
||||||
|
role: 'DATA_ENTRY_CLERK',
|
||||||
|
}
|
||||||
|
|
||||||
|
const tour = useTourStore()
|
||||||
|
const help = useHelpPanel()
|
||||||
|
|
||||||
|
mountPanel()
|
||||||
|
help.openPanel()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const replay = document.querySelector('[data-testid="help-panel-replay"]') as HTMLButtonElement
|
||||||
|
expect(replay).not.toBeNull()
|
||||||
|
expect(replay.disabled).toBe(false)
|
||||||
|
replay.click()
|
||||||
|
await flushPromises()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(help.open.value).toBe(false)
|
||||||
|
expect(tour.active).toBe(true)
|
||||||
|
expect(tour.currentStep?.id).toBe('entry-job')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import TourOverlay from '@/components/TourOverlay.vue'
|
||||||
|
import { useTourStore } from '@/stores/tour'
|
||||||
|
|
||||||
|
vi.mock('@/router', () => ({
|
||||||
|
default: {
|
||||||
|
push: vi.fn().mockResolvedValue(undefined),
|
||||||
|
currentRoute: { value: { path: '/entry' } },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('TourOverlay', () => {
|
||||||
|
it('renders nothing when tour is inactive', () => {
|
||||||
|
mount(TourOverlay, { attachTo: document.body })
|
||||||
|
expect(document.querySelector('[data-testid="tour-overlay"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders title, body, Next and Skip when active', async () => {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.setAttribute('data-tour', 'entry-header')
|
||||||
|
document.body.appendChild(el)
|
||||||
|
|
||||||
|
const tour = useTourStore()
|
||||||
|
await tour.start('DATA_ENTRY_CLERK', 'u1')
|
||||||
|
|
||||||
|
mount(TourOverlay, { attachTo: document.body })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const overlay = document.querySelector('[data-testid="tour-overlay"]')
|
||||||
|
expect(overlay).not.toBeNull()
|
||||||
|
expect(overlay?.textContent).toContain('Your job: Data Entry')
|
||||||
|
expect(overlay?.textContent).toContain('Transcribe structured fields')
|
||||||
|
expect(document.querySelector('[data-testid="tour-next"]')).not.toBeNull()
|
||||||
|
expect(document.querySelector('[data-testid="tour-skip"]')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Skip closes the tour', async () => {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.setAttribute('data-tour', 'entry-header')
|
||||||
|
document.body.appendChild(el)
|
||||||
|
|
||||||
|
const tour = useTourStore()
|
||||||
|
await tour.start('DATA_ENTRY_CLERK', 'u1')
|
||||||
|
|
||||||
|
mount(TourOverlay, { attachTo: document.body })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const skip = document.querySelector('[data-testid="tour-skip"]') as HTMLButtonElement
|
||||||
|
skip.click()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(tour.active).toBe(false)
|
||||||
|
expect(document.querySelector('[data-testid="tour-overlay"]')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { useHelpPanel } from '@/composables/useHelpPanel'
|
||||||
|
|
||||||
|
describe('useHelpPanel', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const { closePanel } = useHelpPanel()
|
||||||
|
closePanel()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens, closes, and toggles shared state', () => {
|
||||||
|
const a = useHelpPanel()
|
||||||
|
const b = useHelpPanel()
|
||||||
|
|
||||||
|
expect(a.open.value).toBe(false)
|
||||||
|
a.openPanel()
|
||||||
|
expect(b.open.value).toBe(true)
|
||||||
|
|
||||||
|
a.closePanel()
|
||||||
|
expect(b.open.value).toBe(false)
|
||||||
|
|
||||||
|
a.togglePanel()
|
||||||
|
expect(a.open.value).toBe(true)
|
||||||
|
a.togglePanel()
|
||||||
|
expect(a.open.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { getPageGuide, fallbackGuide } from '@/help/pageGuides'
|
||||||
|
|
||||||
|
describe('getPageGuide', () => {
|
||||||
|
it.each([
|
||||||
|
['/intake', 'intake'],
|
||||||
|
['/cover-sheets', 'cover-sheets'],
|
||||||
|
['/entry', 'entry'],
|
||||||
|
['/entry/batch-uuid-123', 'entry'],
|
||||||
|
['/verification', 'verification'],
|
||||||
|
['/verification/abc', 'verification'],
|
||||||
|
['/approval', 'approval'],
|
||||||
|
['/approval/xyz', 'approval'],
|
||||||
|
['/live-capture', 'live-capture'],
|
||||||
|
['/dashboard', 'dashboard'],
|
||||||
|
['/users', 'users'],
|
||||||
|
['/fhir-explorer', 'fhir-explorer'],
|
||||||
|
['/patients', 'patients'],
|
||||||
|
['/patients/p1/history', 'patients'],
|
||||||
|
])('resolves %s to guide %s', (path, expectedId) => {
|
||||||
|
expect(getPageGuide(path).id).toBe(expectedId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns fallback for unknown paths', () => {
|
||||||
|
const guide = getPageGuide('/unknown-route')
|
||||||
|
expect(guide.id).toBe(fallbackGuide.id)
|
||||||
|
expect(guide.title).toBe(fallbackGuide.title)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes steps for entry and tips for verification', () => {
|
||||||
|
expect(getPageGuide('/entry').steps.length).toBeGreaterThanOrEqual(4)
|
||||||
|
expect(getPageGuide('/verification').tips?.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
|
import { useTourStore } from '@/stores/tour'
|
||||||
|
import { tourDefinitions } from '@/tours/definitions'
|
||||||
|
|
||||||
|
vi.mock('@/router', () => ({
|
||||||
|
default: {
|
||||||
|
push: vi.fn().mockResolvedValue(undefined),
|
||||||
|
currentRoute: { value: { path: '/entry' } },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
const mockedPush = vi.mocked(router.push)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
vi.clearAllMocks()
|
||||||
|
localStorage.clear()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
// Reset route path between tests
|
||||||
|
;(router.currentRoute as { value: { path: string } }).value = { path: '/entry' }
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function mountAnchor(tourId: string) {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.setAttribute('data-tour', tourId)
|
||||||
|
document.body.appendChild(el)
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useTourStore', () => {
|
||||||
|
it('hasCompleted is false until marked', () => {
|
||||||
|
const tour = useTourStore()
|
||||||
|
expect(tour.hasCompleted('u1', 'DATA_ENTRY_CLERK')).toBe(false)
|
||||||
|
tour.markCompleted('u1', 'DATA_ENTRY_CLERK')
|
||||||
|
expect(tour.hasCompleted('u1', 'DATA_ENTRY_CLERK')).toBe(true)
|
||||||
|
expect(localStorage.getItem('vigilcare_tour_u1_DATA_ENTRY_CLERK')).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keys completion by userId and role', () => {
|
||||||
|
const tour = useTourStore()
|
||||||
|
tour.markCompleted('u1', 'VERIFIER')
|
||||||
|
expect(tour.hasCompleted('u1', 'VERIFIER')).toBe(true)
|
||||||
|
expect(tour.hasCompleted('u1', 'DATA_ENTRY_CLERK')).toBe(false)
|
||||||
|
expect(tour.hasCompleted('u2', 'VERIFIER')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tryAutoStart starts when incomplete and skips when completed', async () => {
|
||||||
|
mountAnchor('entry-header')
|
||||||
|
const tour = useTourStore()
|
||||||
|
|
||||||
|
await tour.tryAutoStart('DATA_ENTRY_CLERK', 'u1')
|
||||||
|
expect(tour.active).toBe(true)
|
||||||
|
expect(tour.currentStep?.id).toBe('entry-job')
|
||||||
|
|
||||||
|
tour.complete()
|
||||||
|
expect(tour.active).toBe(false)
|
||||||
|
expect(tour.hasCompleted('u1', 'DATA_ENTRY_CLERK')).toBe(true)
|
||||||
|
|
||||||
|
await tour.tryAutoStart('DATA_ENTRY_CLERK', 'u1')
|
||||||
|
expect(tour.active).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skip dismisses and prevents auto-restart', async () => {
|
||||||
|
mountAnchor('entry-header')
|
||||||
|
const tour = useTourStore()
|
||||||
|
await tour.start('DATA_ENTRY_CLERK', 'u1')
|
||||||
|
expect(tour.active).toBe(true)
|
||||||
|
|
||||||
|
tour.skip()
|
||||||
|
expect(tour.active).toBe(false)
|
||||||
|
expect(tour.hasCompleted('u1', 'DATA_ENTRY_CLERK')).toBe(true)
|
||||||
|
|
||||||
|
await tour.tryAutoStart('DATA_ENTRY_CLERK', 'u1')
|
||||||
|
expect(tour.active).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('replay restarts even after completion', async () => {
|
||||||
|
mountAnchor('entry-header')
|
||||||
|
const tour = useTourStore()
|
||||||
|
tour.markCompleted('u1', 'DATA_ENTRY_CLERK')
|
||||||
|
|
||||||
|
await tour.replay('DATA_ENTRY_CLERK', 'u1')
|
||||||
|
expect(tour.active).toBe(true)
|
||||||
|
expect(tour.stepIndex).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips steps whose selector is missing', async () => {
|
||||||
|
// Only header and patient nav exist — queue / workstation steps skip
|
||||||
|
mountAnchor('entry-header')
|
||||||
|
mountAnchor('nav-patients')
|
||||||
|
const tour = useTourStore()
|
||||||
|
|
||||||
|
await tour.start('DATA_ENTRY_CLERK', 'u1')
|
||||||
|
expect(tour.active).toBe(true)
|
||||||
|
expect(tour.currentStep?.id).toBe('entry-job')
|
||||||
|
|
||||||
|
await tour.next()
|
||||||
|
// entry-queue missing → skips through to patient-history
|
||||||
|
expect(tour.currentStep?.id).toBe('patient-history')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pushes route for cover-sheets step when needed', async () => {
|
||||||
|
mountAnchor('intake-header')
|
||||||
|
mountAnchor('intake-cover-lookup')
|
||||||
|
mountAnchor('intake-upload')
|
||||||
|
mountAnchor('intake-metadata')
|
||||||
|
mountAnchor('intake-recent')
|
||||||
|
mountAnchor('cover-sheets-header')
|
||||||
|
mountAnchor('cover-sheets-generate')
|
||||||
|
mountAnchor('nav-patients')
|
||||||
|
;(router.currentRoute as { value: { path: string } }).value = { path: '/intake' }
|
||||||
|
|
||||||
|
const tour = useTourStore()
|
||||||
|
await tour.start('INTAKE_CLERK', 'u1')
|
||||||
|
expect(tour.currentStep?.id).toBe('intake-job')
|
||||||
|
|
||||||
|
// Advance until cover-sheets-nav
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await tour.next()
|
||||||
|
}
|
||||||
|
expect(mockedPush).toHaveBeenCalledWith('/cover-sheets')
|
||||||
|
expect(tour.currentStep?.id).toBe('cover-sheets-nav')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every role has a non-empty definition', () => {
|
||||||
|
const roles = [
|
||||||
|
'INTAKE_CLERK',
|
||||||
|
'DATA_ENTRY_CLERK',
|
||||||
|
'VERIFIER',
|
||||||
|
'CLINICAL_APPROVER',
|
||||||
|
'CLINICIAN',
|
||||||
|
'ADMINISTRATOR',
|
||||||
|
]
|
||||||
|
for (const role of roles) {
|
||||||
|
expect(tourDefinitions[role]?.steps.length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -20,6 +20,10 @@ vi.mock('@/components/AppHeader.vue', () => ({
|
|||||||
default: { template: '<div />' },
|
default: { template: '<div />' },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/TourHelpButton.vue', () => ({
|
||||||
|
default: { template: '<button type="button" data-testid="tour-help" />' },
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/PatientSearch.vue', () => ({
|
vi.mock('@/components/PatientSearch.vue', () => ({
|
||||||
default: {
|
default: {
|
||||||
props: ['modelValue'],
|
props: ['modelValue'],
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ vi.mock('@/components/AppHeader.vue', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/TourHelpButton.vue', () => ({
|
||||||
|
default: { template: '<button type="button" data-testid="tour-help" />' },
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/PatientSearch.vue', () => ({
|
vi.mock('@/components/PatientSearch.vue', () => ({
|
||||||
default: {
|
default: {
|
||||||
props: ['modelValue'],
|
props: ['modelValue'],
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ vi.mock('@/components/AppHeader.vue', () => ({
|
|||||||
default: { template: '<div />' },
|
default: { template: '<div />' },
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/TourHelpButton.vue', () => ({
|
||||||
|
default: { template: '<button type="button" data-testid="tour-help" />' },
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/PatientSearch.vue', () => ({
|
vi.mock('@/components/PatientSearch.vue', () => ({
|
||||||
default: {
|
default: {
|
||||||
props: ['modelValue'],
|
props: ['modelValue'],
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ vi.mock('@/components/AppHeader.vue', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/components/TourHelpButton.vue', () => ({
|
||||||
|
default: { template: '<button type="button" data-testid="tour-help" />' },
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/EmptyState.vue', () => ({
|
vi.mock('@/components/EmptyState.vue', () => ({
|
||||||
default: {
|
default: {
|
||||||
props: ['title', 'description'],
|
props: ['title', 'description'],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-header">
|
<div class="app-header" :data-tour="tourAnchor || undefined">
|
||||||
<div class="app-header-title min-w-0 flex-1">
|
<div class="app-header-title min-w-0 flex-1">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h1 class="text-lg sm:text-xl font-semibold text-ink-strong leading-tight">
|
<h1 class="text-lg sm:text-xl font-semibold text-ink-strong leading-tight">
|
||||||
@@ -25,7 +25,9 @@ withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
|
/** Stable walkthrough anchor, e.g. intake-header */
|
||||||
|
tourAnchor?: string
|
||||||
}>(),
|
}>(),
|
||||||
{ description: undefined },
|
{ description: undefined, tourAnchor: undefined },
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="flex-1 overflow-y-auto py-3 px-2 space-y-4">
|
<nav class="flex-1 overflow-y-auto py-3 px-2 space-y-4">
|
||||||
<div v-if="workspaceItems.length">
|
<div v-if="workspaceItems.length" data-tour="nav-workspace">
|
||||||
<p
|
<p
|
||||||
v-if="!sidebarCollapsed"
|
v-if="!sidebarCollapsed"
|
||||||
class="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/40"
|
class="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/40"
|
||||||
@@ -59,6 +59,7 @@
|
|||||||
:to="item.to"
|
:to="item.to"
|
||||||
:title="sidebarCollapsed ? item.label : undefined"
|
:title="sidebarCollapsed ? item.label : undefined"
|
||||||
:class="navItemClass(item.to)"
|
:class="navItemClass(item.to)"
|
||||||
|
:data-tour="item.tourAnchor"
|
||||||
@click="closeMobile"
|
@click="closeMobile"
|
||||||
>
|
>
|
||||||
<span class="shrink-0 w-5 h-5 flex items-center justify-center" aria-hidden="true">
|
<span class="shrink-0 w-5 h-5 flex items-center justify-center" aria-hidden="true">
|
||||||
@@ -70,7 +71,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="adminItems.length">
|
<div v-if="adminItems.length" data-tour="nav-admin">
|
||||||
<p
|
<p
|
||||||
v-if="!sidebarCollapsed"
|
v-if="!sidebarCollapsed"
|
||||||
class="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/40"
|
class="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/40"
|
||||||
@@ -83,6 +84,7 @@
|
|||||||
:to="item.to"
|
:to="item.to"
|
||||||
:title="sidebarCollapsed ? item.label : undefined"
|
:title="sidebarCollapsed ? item.label : undefined"
|
||||||
:class="navItemClass(item.to)"
|
:class="navItemClass(item.to)"
|
||||||
|
:data-tour="item.tourAnchor"
|
||||||
@click="closeMobile"
|
@click="closeMobile"
|
||||||
>
|
>
|
||||||
<span class="shrink-0 w-5 h-5 flex items-center justify-center" aria-hidden="true">
|
<span class="shrink-0 w-5 h-5 flex items-center justify-center" aria-hidden="true">
|
||||||
@@ -251,6 +253,7 @@ interface NavItem {
|
|||||||
to: string
|
to: string
|
||||||
icon: () => ReturnType<typeof h>
|
icon: () => ReturnType<typeof h>
|
||||||
show: boolean
|
show: boolean
|
||||||
|
tourAnchor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceItems = computed<NavItem[]>(() =>
|
const workspaceItems = computed<NavItem[]>(() =>
|
||||||
@@ -261,7 +264,13 @@ const workspaceItems = computed<NavItem[]>(() =>
|
|||||||
{ label: 'Verification', to: '/verification', icon: icons.verify, show: auth.canVerify },
|
{ label: 'Verification', to: '/verification', icon: icons.verify, show: auth.canVerify },
|
||||||
{ label: 'Clinical Approval', to: '/approval', icon: icons.approve, show: auth.canApprove },
|
{ label: 'Clinical Approval', to: '/approval', icon: icons.approve, show: auth.canApprove },
|
||||||
{ label: 'Live Capture', to: '/live-capture', icon: icons.live, show: auth.canLiveCapture },
|
{ label: 'Live Capture', to: '/live-capture', icon: icons.live, show: auth.canLiveCapture },
|
||||||
{ label: 'Patient History', to: '/patients', icon: icons.patients, show: auth.isAuthenticated },
|
{
|
||||||
|
label: 'Patient History',
|
||||||
|
to: '/patients',
|
||||||
|
icon: icons.patients,
|
||||||
|
show: auth.isAuthenticated,
|
||||||
|
tourAnchor: 'nav-patients',
|
||||||
|
},
|
||||||
].filter((item) => item.show),
|
].filter((item) => item.show),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<div
|
<div
|
||||||
class="h-full min-h-0 flex flex-col approval-frame"
|
class="h-full min-h-0 flex flex-col approval-frame"
|
||||||
data-testid="approval-form"
|
data-testid="approval-form"
|
||||||
|
data-tour="approval-form"
|
||||||
>
|
>
|
||||||
<div class="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
|
<div class="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
ref="root"
|
ref="root"
|
||||||
class="mt-2 group workstation-form-section !p-3"
|
class="mt-2 group workstation-form-section !p-3"
|
||||||
data-testid="audit-trail-panel"
|
data-testid="audit-trail-panel"
|
||||||
|
data-tour="audit-trail"
|
||||||
@toggle="onToggle"
|
@toggle="onToggle"
|
||||||
>
|
>
|
||||||
<summary
|
<summary
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full min-h-0 flex flex-col" data-testid="entry-form">
|
<div class="h-full min-h-0 flex flex-col" data-testid="entry-form" data-tour="entry-form">
|
||||||
<div class="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
|
<div class="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="fixed inset-0 z-50 flex justify-end"
|
||||||
|
data-testid="help-panel"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/50"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-testid="help-panel-backdrop"
|
||||||
|
@click="closePanel()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
class="relative z-10 flex h-full w-full max-w-md flex-col border-l border-line bg-surface shadow-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-labelledby="titleId"
|
||||||
|
data-testid="help-panel-drawer"
|
||||||
|
@keydown.esc.prevent="closePanel()"
|
||||||
|
>
|
||||||
|
<header class="flex items-start justify-between gap-3 border-b border-line px-5 py-4 shrink-0">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-ink-secondary">
|
||||||
|
Page instructions
|
||||||
|
</p>
|
||||||
|
<h2 :id="titleId" class="mt-1 text-lg font-semibold text-ink-strong">
|
||||||
|
{{ guide.title }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
ref="closeBtnRef"
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 rounded-input p-2 text-ink-secondary hover:bg-canvas hover:text-ink-strong"
|
||||||
|
aria-label="Close help"
|
||||||
|
data-testid="help-panel-close"
|
||||||
|
@click="closePanel()"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-5">
|
||||||
|
<p class="text-sm text-ink leading-relaxed">{{ guide.summary }}</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 class="text-sm font-semibold text-ink-strong mb-2">Steps</h3>
|
||||||
|
<ol class="list-decimal pl-5 space-y-2 text-sm text-ink leading-relaxed">
|
||||||
|
<li v-for="(step, index) in guide.steps" :key="index">{{ step }}</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="guide.tips?.length">
|
||||||
|
<h3 class="text-sm font-semibold text-ink-strong mb-2">Tips</h3>
|
||||||
|
<ul class="list-disc pl-5 space-y-2 text-sm text-ink-secondary leading-relaxed">
|
||||||
|
<li v-for="(tip, index) in guide.tips" :key="index">{{ tip }}</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer
|
||||||
|
class="flex flex-col-reverse gap-3 border-t border-line px-5 py-4 sm:flex-row sm:items-center sm:justify-between shrink-0"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
data-testid="help-panel-replay"
|
||||||
|
:disabled="!canReplay"
|
||||||
|
:title="replayTitle"
|
||||||
|
@click="onReplay"
|
||||||
|
>
|
||||||
|
Replay walkthrough
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-primary text-sm"
|
||||||
|
data-testid="help-panel-done"
|
||||||
|
@click="closePanel()"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, useId, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useTourStore } from '../stores/tour'
|
||||||
|
import { useHelpPanel } from '../composables/useHelpPanel'
|
||||||
|
import { getPageGuide } from '../help/pageGuides'
|
||||||
|
import { getTourForRole } from '../tours/definitions'
|
||||||
|
|
||||||
|
const { open, closePanel } = useHelpPanel()
|
||||||
|
const route = useRoute()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const tour = useTourStore()
|
||||||
|
|
||||||
|
const titleId = useId()
|
||||||
|
const closeBtnRef = ref<HTMLButtonElement | null>(null)
|
||||||
|
let focusRestore: HTMLElement | null = null
|
||||||
|
|
||||||
|
const guide = computed(() => getPageGuide(route.path))
|
||||||
|
|
||||||
|
const canReplay = computed(
|
||||||
|
() =>
|
||||||
|
!!auth.userRole &&
|
||||||
|
!!auth.userId &&
|
||||||
|
!!getTourForRole(auth.userRole) &&
|
||||||
|
!tour.active,
|
||||||
|
)
|
||||||
|
|
||||||
|
const replayTitle = computed(() => {
|
||||||
|
if (tour.active) return 'A walkthrough is already running'
|
||||||
|
if (!getTourForRole(auth.userRole)) return 'No walkthrough for this role'
|
||||||
|
return 'Start the guided walkthrough for your role'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onReplay() {
|
||||||
|
if (!canReplay.value) return
|
||||||
|
closePanel()
|
||||||
|
await tour.replay(auth.userRole, auth.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(open, async (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
focusRestore = document.activeElement as HTMLElement | null
|
||||||
|
await nextTick()
|
||||||
|
closeBtnRef.value?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (focusRestore) {
|
||||||
|
focusRestore.focus()
|
||||||
|
focusRestore = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
: 'border-primary-100 bg-primary-50'"
|
: 'border-primary-100 bg-primary-50'"
|
||||||
role="status"
|
role="status"
|
||||||
data-testid="sod-banner"
|
data-testid="sod-banner"
|
||||||
|
data-tour="sod-banner"
|
||||||
>
|
>
|
||||||
<template v-if="blocked">
|
<template v-if="blocked">
|
||||||
<p class="font-semibold text-clinical-danger">
|
<p class="font-semibold text-clinical-danger">
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary text-sm"
|
||||||
|
data-testid="tour-help"
|
||||||
|
title="Page instructions and walkthrough"
|
||||||
|
@click="togglePanel()"
|
||||||
|
>
|
||||||
|
Help
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useHelpPanel } from '../composables/useHelpPanel'
|
||||||
|
|
||||||
|
const { togglePanel } = useHelpPanel()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="tour.active && tour.currentStep"
|
||||||
|
class="fixed inset-0 z-[60]"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-labelledby="titleId"
|
||||||
|
:aria-describedby="bodyId"
|
||||||
|
data-testid="tour-overlay"
|
||||||
|
@keydown.esc.prevent="tour.skip()"
|
||||||
|
>
|
||||||
|
<!-- Dimmed backdrop with cutout -->
|
||||||
|
<svg class="absolute inset-0 h-full w-full pointer-events-none" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<mask :id="maskId">
|
||||||
|
<rect width="100%" height="100%" fill="white" />
|
||||||
|
<rect
|
||||||
|
v-if="highlight"
|
||||||
|
:x="highlight.x"
|
||||||
|
:y="highlight.y"
|
||||||
|
:width="highlight.width"
|
||||||
|
:height="highlight.height"
|
||||||
|
rx="8"
|
||||||
|
fill="black"
|
||||||
|
/>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<rect
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
fill="rgba(0,0,0,0.5)"
|
||||||
|
:mask="`url(#${maskId})`"
|
||||||
|
class="pointer-events-auto"
|
||||||
|
@click="tour.skip()"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Highlight ring -->
|
||||||
|
<div
|
||||||
|
v-if="highlight"
|
||||||
|
class="pointer-events-none absolute rounded-input ring-2 ring-primary-500 ring-offset-2 ring-offset-transparent transition-all duration-150"
|
||||||
|
:style="highlightStyle"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Popover -->
|
||||||
|
<div
|
||||||
|
ref="popoverRef"
|
||||||
|
class="absolute z-[61] w-[min(100vw-2rem,22rem)] rounded-card border border-line bg-surface p-5 shadow-dialog"
|
||||||
|
:style="popoverStyle"
|
||||||
|
data-testid="tour-popover"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-ink-secondary">
|
||||||
|
Step {{ tour.stepIndex + 1 }} of {{ tour.stepCount }}
|
||||||
|
</p>
|
||||||
|
<h3 :id="titleId" class="mt-1 text-lg font-semibold text-ink-strong">
|
||||||
|
{{ tour.currentStep.title }}
|
||||||
|
</h3>
|
||||||
|
<p :id="bodyId" class="mt-2 text-sm text-ink leading-relaxed">
|
||||||
|
{{ tour.currentStep.body }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-2 text-sm text-ink-secondary hover:text-ink-strong"
|
||||||
|
data-testid="tour-skip"
|
||||||
|
@click="tour.skip()"
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
v-if="!tour.isFirstStep"
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary"
|
||||||
|
data-testid="tour-prev"
|
||||||
|
:disabled="tour.preparing"
|
||||||
|
@click="tour.prev()"
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
ref="nextBtnRef"
|
||||||
|
type="button"
|
||||||
|
class="btn-primary"
|
||||||
|
data-testid="tour-next"
|
||||||
|
:disabled="tour.preparing"
|
||||||
|
@click="tour.next()"
|
||||||
|
>
|
||||||
|
{{ tour.isLastStep ? 'Done' : 'Next' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, useId, watch } from 'vue'
|
||||||
|
import { useTourStore } from '../stores/tour'
|
||||||
|
import type { TourPlacement } from '../tours/types'
|
||||||
|
|
||||||
|
const tour = useTourStore()
|
||||||
|
const titleId = useId()
|
||||||
|
const bodyId = useId()
|
||||||
|
const maskId = useId()
|
||||||
|
|
||||||
|
const nextBtnRef = ref<HTMLButtonElement | null>(null)
|
||||||
|
const popoverRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
interface Rect {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const highlight = ref<Rect | null>(null)
|
||||||
|
const popoverPos = ref({ top: 16, left: 16 })
|
||||||
|
|
||||||
|
const PAD = 8
|
||||||
|
const POPOVER_GAP = 12
|
||||||
|
|
||||||
|
const highlightStyle = computed(() => {
|
||||||
|
if (!highlight.value) return {}
|
||||||
|
return {
|
||||||
|
top: `${highlight.value.y}px`,
|
||||||
|
left: `${highlight.value.x}px`,
|
||||||
|
width: `${highlight.value.width}px`,
|
||||||
|
height: `${highlight.value.height}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const popoverStyle = computed(() => ({
|
||||||
|
top: `${popoverPos.value.top}px`,
|
||||||
|
left: `${popoverPos.value.left}px`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function measureTarget(selector: string): Rect | null {
|
||||||
|
const el = document.querySelector(selector)
|
||||||
|
if (!el) return null
|
||||||
|
const r = el.getBoundingClientRect()
|
||||||
|
if (r.width === 0 && r.height === 0) return null
|
||||||
|
return {
|
||||||
|
x: Math.max(0, r.left - PAD),
|
||||||
|
y: Math.max(0, r.top - PAD),
|
||||||
|
width: r.width + PAD * 2,
|
||||||
|
height: r.height + PAD * 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function placePopover(target: Rect, placement: TourPlacement = 'bottom') {
|
||||||
|
const popW = popoverRef.value?.offsetWidth ?? 352
|
||||||
|
const popH = popoverRef.value?.offsetHeight ?? 200
|
||||||
|
const vw = window.innerWidth
|
||||||
|
const vh = window.innerHeight
|
||||||
|
|
||||||
|
let top = target.y + target.height + POPOVER_GAP
|
||||||
|
let left = target.x
|
||||||
|
|
||||||
|
if (placement === 'top') {
|
||||||
|
top = target.y - popH - POPOVER_GAP
|
||||||
|
} else if (placement === 'left') {
|
||||||
|
top = target.y
|
||||||
|
left = target.x - popW - POPOVER_GAP
|
||||||
|
} else if (placement === 'right') {
|
||||||
|
top = target.y
|
||||||
|
left = target.x + target.width + POPOVER_GAP
|
||||||
|
}
|
||||||
|
|
||||||
|
left = Math.min(Math.max(16, left), vw - popW - 16)
|
||||||
|
top = Math.min(Math.max(16, top), vh - popH - 16)
|
||||||
|
|
||||||
|
if (placement === 'top' && target.y - popH - POPOVER_GAP < 16) {
|
||||||
|
top = Math.min(target.y + target.height + POPOVER_GAP, vh - popH - 16)
|
||||||
|
}
|
||||||
|
if (placement === 'bottom' && target.y + target.height + POPOVER_GAP + popH > vh - 16) {
|
||||||
|
top = Math.max(16, target.y - popH - POPOVER_GAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
popoverPos.value = { top, left }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLayout() {
|
||||||
|
const step = tour.currentStep
|
||||||
|
if (!step) {
|
||||||
|
highlight.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const rect = measureTarget(step.selector)
|
||||||
|
highlight.value = rect
|
||||||
|
if (rect) {
|
||||||
|
nextTick(() => placePopover(rect, step.placement ?? 'bottom'))
|
||||||
|
} else {
|
||||||
|
popoverPos.value = { top: 24, left: 24 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let focusRestore: HTMLElement | null = null
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [tour.active, tour.stepIndex, tour.currentStep?.selector] as const,
|
||||||
|
async ([isActive]) => {
|
||||||
|
if (!isActive) {
|
||||||
|
highlight.value = null
|
||||||
|
if (focusRestore) {
|
||||||
|
focusRestore.focus()
|
||||||
|
focusRestore = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!focusRestore) {
|
||||||
|
focusRestore = document.activeElement as HTMLElement | null
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
updateLayout()
|
||||||
|
await nextTick()
|
||||||
|
nextBtnRef.value?.focus()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
if (tour.active) updateLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
window.addEventListener('scroll', onResize, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', onResize)
|
||||||
|
window.removeEventListener('scroll', onResize, true)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full min-h-0 flex flex-col" data-testid="verification-form">
|
<div
|
||||||
|
class="h-full min-h-0 flex flex-col"
|
||||||
|
data-testid="verification-form"
|
||||||
|
data-tour="verification-form"
|
||||||
|
>
|
||||||
<div class="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
|
<div class="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<div
|
<div
|
||||||
class="sticky bottom-0 z-10 border-t border-line bg-surface/95 px-3 py-3 backdrop-blur-sm sm:px-4"
|
class="sticky bottom-0 z-10 border-t border-line bg-surface/95 px-3 py-3 backdrop-blur-sm sm:px-4"
|
||||||
data-testid="workstation-action-bar"
|
data-testid="workstation-action-bar"
|
||||||
|
data-tour="workstation-action-bar"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
|
class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
v-if="!hasBatch"
|
v-if="!hasBatch"
|
||||||
class="flex-1 min-h-0 overflow-auto p-4 sm:p-6"
|
class="flex-1 min-h-0 overflow-auto p-4 sm:p-6"
|
||||||
data-testid="workstation-queue"
|
data-testid="workstation-queue"
|
||||||
|
data-tour="workstation-queue"
|
||||||
>
|
>
|
||||||
<slot name="queue" />
|
<slot name="queue" />
|
||||||
</div>
|
</div>
|
||||||
@@ -22,7 +23,11 @@
|
|||||||
>
|
>
|
||||||
<slot name="rail" />
|
<slot name="rail" />
|
||||||
</aside>
|
</aside>
|
||||||
<section class="workstation-scan min-h-0 min-w-0" data-testid="workstation-scan">
|
<section
|
||||||
|
class="workstation-scan min-h-0 min-w-0"
|
||||||
|
data-testid="workstation-scan"
|
||||||
|
data-tour="workstation-scan"
|
||||||
|
>
|
||||||
<p class="evidence-level evidence-level--1" data-testid="evidence-level-1">
|
<p class="evidence-level evidence-level--1" data-testid="evidence-level-1">
|
||||||
<span class="evidence-level-mark" aria-hidden="true">1</span>
|
<span class="evidence-level-mark" aria-hidden="true">1</span>
|
||||||
Source scan
|
Source scan
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
|
||||||
|
export function useHelpPanel() {
|
||||||
|
function openPanel() {
|
||||||
|
open.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePanel() {
|
||||||
|
open.value = !open.value
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
open,
|
||||||
|
openPanel,
|
||||||
|
closePanel,
|
||||||
|
togglePanel,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
export interface PageGuide {
|
||||||
|
id: string
|
||||||
|
match: (path: string) => boolean
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
steps: string[]
|
||||||
|
tips?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathIs(prefix: string) {
|
||||||
|
return (path: string) => path === prefix || path.startsWith(`${prefix}/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fallbackGuide: PageGuide = {
|
||||||
|
id: 'fallback',
|
||||||
|
match: () => true,
|
||||||
|
title: 'Using VigilCare Records',
|
||||||
|
summary:
|
||||||
|
'Use the sidebar to open your workspace. Patient History is available when you need prior digitization records.',
|
||||||
|
steps: [
|
||||||
|
'Open the workspace link for your role from the left navigation.',
|
||||||
|
'Work oldest items first when a queue is shown.',
|
||||||
|
'Use Help on any page for instructions specific to that screen.',
|
||||||
|
'Replay the walkthrough from Help when you want a guided tour again.',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pageGuides: PageGuide[] = [
|
||||||
|
{
|
||||||
|
id: 'intake',
|
||||||
|
match: (path) => path === '/intake',
|
||||||
|
title: 'Intake',
|
||||||
|
summary:
|
||||||
|
'Create digitization batches from paper scans and attach cover sheet details so clerks can enter data.',
|
||||||
|
steps: [
|
||||||
|
'Optionally look up a cover sheet code to auto-fill batch type, track, and assignment.',
|
||||||
|
'Upload a PDF or image scan — this becomes the source document.',
|
||||||
|
'Confirm batch type, track, and patient link, then create the batch.',
|
||||||
|
'Assign an entry clerk from Recent Uploads when the batch is ready for data entry.',
|
||||||
|
],
|
||||||
|
tips: [
|
||||||
|
'Prefer cover sheet lookup when a printed separator is available.',
|
||||||
|
'You can upload without a cover sheet and set details manually.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cover-sheets',
|
||||||
|
match: pathIs('/cover-sheets'),
|
||||||
|
title: 'Cover Sheets',
|
||||||
|
summary:
|
||||||
|
'Generate and print cover sheets before scanning so intake can look them up by code.',
|
||||||
|
steps: [
|
||||||
|
'Set quantity, batch type, track, and optional patient or entry clerk.',
|
||||||
|
'Generate cover sheets, then print the PDF.',
|
||||||
|
'Place a printed sheet with the paper packet before scanning.',
|
||||||
|
'Review existing cover sheets and filter by status when needed.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'entry',
|
||||||
|
match: pathIs('/entry'),
|
||||||
|
title: 'Data Entry',
|
||||||
|
summary:
|
||||||
|
'Transcribe structured fields from the source scan, then submit for verification.',
|
||||||
|
steps: [
|
||||||
|
'Open the oldest batch from the Data Entry queue (returned rework appears here too).',
|
||||||
|
'Keep the source scan (level 1) visible — it is the source of truth.',
|
||||||
|
'Fill the structured draft (level 2). Watch OCR confidence badges on uncertain fields.',
|
||||||
|
'Save Draft to continue later, or Submit for Verification when the draft matches the scan.',
|
||||||
|
],
|
||||||
|
tips: [
|
||||||
|
'If the queue is empty, wait for intake to assign new batches.',
|
||||||
|
'Use the left rail or Full queue to switch batches while working.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'verification',
|
||||||
|
match: pathIs('/verification'),
|
||||||
|
title: 'Verification',
|
||||||
|
summary:
|
||||||
|
'Compare every field to the scan. Pass only when all fields match; return otherwise.',
|
||||||
|
steps: [
|
||||||
|
'Select a batch pending verification, oldest first.',
|
||||||
|
'Compare each field card to the source scan and mark fields as you verify them.',
|
||||||
|
'Pass sends the batch to clinical approval.',
|
||||||
|
'Return sends it back to data entry with a reason.',
|
||||||
|
'Review the audit trail before you decide.',
|
||||||
|
],
|
||||||
|
tips: [
|
||||||
|
'Separation of duties: you cannot verify a batch you entered.',
|
||||||
|
'If the SoD banner blocks you, pick another batch or ask a colleague.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'approval',
|
||||||
|
match: pathIs('/approval'),
|
||||||
|
title: 'Clinical Approval',
|
||||||
|
summary:
|
||||||
|
'Give final clinical sign-off. Approve promotes the record; reject returns it with a reason.',
|
||||||
|
steps: [
|
||||||
|
'Open a batch from the Clinical Approval queue.',
|
||||||
|
'Review the verified draft and any high-stakes or retroactive alerts.',
|
||||||
|
'Approve & Promote publishes the clinical record.',
|
||||||
|
'Reject sends the batch back with a reason.',
|
||||||
|
],
|
||||||
|
tips: [
|
||||||
|
'Promotion attributes the approving clinician — confirm the content carefully.',
|
||||||
|
'Use Patient History if you need prior digitization context for the patient.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'live-capture',
|
||||||
|
match: pathIs('/live-capture'),
|
||||||
|
title: 'Live Capture',
|
||||||
|
summary: 'Record vitals at the bedside. This is lighter than backfill data entry.',
|
||||||
|
steps: [
|
||||||
|
'Choose New Encounter or Existing Encounter.',
|
||||||
|
'Select the patient and fill encounter context (or enter an existing encounter ID).',
|
||||||
|
'Add observation rows for vitals and related measurements.',
|
||||||
|
'Confirm clinician attestation with your password, then Record Vitals.',
|
||||||
|
],
|
||||||
|
tips: [
|
||||||
|
'Critical alerts after promotion are clinical signals — review them immediately.',
|
||||||
|
'You can record more vitals after a successful submission without leaving the page.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
match: pathIs('/dashboard'),
|
||||||
|
title: 'Queue Dashboard',
|
||||||
|
summary:
|
||||||
|
'Monitor queues, aging work, and reject rate. Open any workspace from the sidebar when needed.',
|
||||||
|
steps: [
|
||||||
|
'Review pending entry, in-entry, verification volume, and reject rate.',
|
||||||
|
'Watch oldest pending verification and average time in queue against the 24-hour target.',
|
||||||
|
'Browse All Batches across statuses.',
|
||||||
|
'Use Administration for Users and FHIR Explorer.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'users',
|
||||||
|
match: pathIs('/users'),
|
||||||
|
title: 'Users',
|
||||||
|
summary: 'Create accounts, update roles, and reset passwords for active staff.',
|
||||||
|
steps: [
|
||||||
|
'Create a user with username, full name, role, and a strong password.',
|
||||||
|
'Filter the active users list by role when searching.',
|
||||||
|
'Edit role or name from a user row.',
|
||||||
|
'Reset password or deactivate when staff leave or credentials must change.',
|
||||||
|
],
|
||||||
|
tips: [
|
||||||
|
'Password must be at least 8 characters with one uppercase letter and one digit.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fhir-explorer',
|
||||||
|
match: pathIs('/fhir-explorer'),
|
||||||
|
title: 'FHIR Explorer',
|
||||||
|
summary:
|
||||||
|
'Read-only inspection of exposed FHIR resources for administrators and integration staff.',
|
||||||
|
steps: [
|
||||||
|
'Choose a resource type, search, and inspect result JSON.',
|
||||||
|
'Use Patient $everything to load all data for a selected patient.',
|
||||||
|
'Open CapabilityStatement when you need server metadata.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'patients',
|
||||||
|
match: pathIs('/patients'),
|
||||||
|
title: 'Patient History',
|
||||||
|
summary:
|
||||||
|
'Review digitization lineage, promotion attribution, and audit trail for a patient.',
|
||||||
|
steps: [
|
||||||
|
'Search by MRN or name, then view history.',
|
||||||
|
'Scan summary metrics (total, promoted, pending, superseded).',
|
||||||
|
'Expand timeline entries for audit detail.',
|
||||||
|
'Create a correction from history when a promoted record must be superseded.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getPageGuide(path: string): PageGuide {
|
||||||
|
const found = pageGuides.find((guide) => guide.match(path))
|
||||||
|
return found ?? fallbackGuide
|
||||||
|
}
|
||||||
@@ -2,8 +2,34 @@
|
|||||||
<AppShell>
|
<AppShell>
|
||||||
<router-view />
|
<router-view />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
<HelpPanel />
|
||||||
|
<TourOverlay />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted, watch } from 'vue'
|
||||||
import AppShell from '../components/AppShell.vue'
|
import AppShell from '../components/AppShell.vue'
|
||||||
|
import TourOverlay from '../components/TourOverlay.vue'
|
||||||
|
import HelpPanel from '../components/HelpPanel.vue'
|
||||||
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useTourStore } from '../stores/tour'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const tour = useTourStore()
|
||||||
|
|
||||||
|
async function maybeStartTour() {
|
||||||
|
if (!auth.isAuthenticated || !auth.userRole || !auth.userId) return
|
||||||
|
await tour.tryAutoStart(auth.userRole, auth.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void maybeStartTour()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [auth.isAuthenticated, auth.userId, auth.userRole] as const,
|
||||||
|
([isAuth]) => {
|
||||||
|
if (isAuth) void maybeStartTour()
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import router from '../router'
|
||||||
|
import { getTourForRole } from '../tours/definitions'
|
||||||
|
import type { TourStep } from '../tours/types'
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'vigilcare_tour_'
|
||||||
|
|
||||||
|
function completionKey(userId: string, role: string): string {
|
||||||
|
return `${STORAGE_PREFIX}${userId}_${role}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetExists(selector: string): boolean {
|
||||||
|
if (typeof document === 'undefined') return false
|
||||||
|
try {
|
||||||
|
return !!document.querySelector(selector)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECTOR_WAIT_MS = import.meta.env.MODE === 'test' ? 30 : 800
|
||||||
|
const SELECTOR_POLL_MS = import.meta.env.MODE === 'test' ? 5 : 50
|
||||||
|
|
||||||
|
function waitForSelector(selector: string, timeoutMs = SELECTOR_WAIT_MS): Promise<boolean> {
|
||||||
|
if (targetExists(selector)) return Promise.resolve(true)
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const start = Date.now()
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (targetExists(selector)) {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
resolve(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Date.now() - start >= timeoutMs) {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
resolve(false)
|
||||||
|
}
|
||||||
|
}, SELECTOR_POLL_MS)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTourStore = defineStore('tour', () => {
|
||||||
|
const active = ref(false)
|
||||||
|
const role = ref('')
|
||||||
|
const userId = ref('')
|
||||||
|
const stepIndex = ref(0)
|
||||||
|
const steps = ref<TourStep[]>([])
|
||||||
|
const preparing = ref(false)
|
||||||
|
|
||||||
|
const currentStep = computed(() => steps.value[stepIndex.value] ?? null)
|
||||||
|
const stepCount = computed(() => steps.value.length)
|
||||||
|
const isFirstStep = computed(() => stepIndex.value <= 0)
|
||||||
|
const isLastStep = computed(() => stepIndex.value >= steps.value.length - 1)
|
||||||
|
|
||||||
|
function hasCompleted(uid: string, userRole: string): boolean {
|
||||||
|
if (!uid || !userRole) return false
|
||||||
|
return localStorage.getItem(completionKey(uid, userRole)) === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCompleted(uid: string, userRole: string): void {
|
||||||
|
if (!uid || !userRole) return
|
||||||
|
localStorage.setItem(completionKey(uid, userRole), '1')
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDismissed(uid: string, userRole: string): void {
|
||||||
|
// Dismissing (Skip / Esc) also prevents auto-restart on next login.
|
||||||
|
markCompleted(uid, userRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToStep(index: number): Promise<void> {
|
||||||
|
if (index < 0 || index >= steps.value.length) {
|
||||||
|
complete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
preparing.value = true
|
||||||
|
try {
|
||||||
|
const step = steps.value[index]
|
||||||
|
if (step.route && router.currentRoute.value.path !== step.route) {
|
||||||
|
await router.push(step.route)
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = await waitForSelector(step.selector)
|
||||||
|
if (!found) {
|
||||||
|
// Skip missing targets (empty queue, no open batch, etc.)
|
||||||
|
if (index + 1 < steps.value.length) {
|
||||||
|
stepIndex.value = index + 1
|
||||||
|
await goToStep(index + 1)
|
||||||
|
} else {
|
||||||
|
complete()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stepIndex.value = index
|
||||||
|
} finally {
|
||||||
|
preparing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(userRole: string, uid: string): Promise<void> {
|
||||||
|
const definition = getTourForRole(userRole)
|
||||||
|
if (!definition || definition.steps.length === 0) return
|
||||||
|
|
||||||
|
role.value = userRole
|
||||||
|
userId.value = uid
|
||||||
|
steps.value = [...definition.steps]
|
||||||
|
active.value = true
|
||||||
|
await goToStep(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replay(userRole: string, uid: string): Promise<void> {
|
||||||
|
await start(userRole, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function next(): Promise<void> {
|
||||||
|
if (!active.value) return
|
||||||
|
if (isLastStep.value) {
|
||||||
|
complete()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await goToStep(stepIndex.value + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prev(): Promise<void> {
|
||||||
|
if (!active.value || isFirstStep.value) return
|
||||||
|
// Walk backward until a visible target is found
|
||||||
|
let candidate = stepIndex.value - 1
|
||||||
|
while (candidate >= 0) {
|
||||||
|
const step = steps.value[candidate]
|
||||||
|
if (step.route && router.currentRoute.value.path !== step.route) {
|
||||||
|
await router.push(step.route)
|
||||||
|
}
|
||||||
|
const found = await waitForSelector(step.selector)
|
||||||
|
if (found) {
|
||||||
|
stepIndex.value = candidate
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidate -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skip(): void {
|
||||||
|
if (!active.value) return
|
||||||
|
markDismissed(userId.value, role.value)
|
||||||
|
active.value = false
|
||||||
|
steps.value = []
|
||||||
|
stepIndex.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function complete(): void {
|
||||||
|
if (!active.value) return
|
||||||
|
markCompleted(userId.value, role.value)
|
||||||
|
active.value = false
|
||||||
|
steps.value = []
|
||||||
|
stepIndex.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-start after login when the user has not completed/dismissed this role tour. */
|
||||||
|
async function tryAutoStart(userRole: string, uid: string): Promise<void> {
|
||||||
|
if (!userRole || !uid) return
|
||||||
|
if (active.value) return
|
||||||
|
if (hasCompleted(uid, userRole)) return
|
||||||
|
if (!getTourForRole(userRole)) return
|
||||||
|
await start(userRole, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
active,
|
||||||
|
role,
|
||||||
|
userId,
|
||||||
|
stepIndex,
|
||||||
|
steps,
|
||||||
|
preparing,
|
||||||
|
currentStep,
|
||||||
|
stepCount,
|
||||||
|
isFirstStep,
|
||||||
|
isLastStep,
|
||||||
|
hasCompleted,
|
||||||
|
markCompleted,
|
||||||
|
start,
|
||||||
|
replay,
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
skip,
|
||||||
|
complete,
|
||||||
|
tryAutoStart,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import type { TourDefinition } from './types'
|
||||||
|
|
||||||
|
const patientHistoryStep = {
|
||||||
|
id: 'patient-history',
|
||||||
|
selector: '[data-tour="nav-patients"]',
|
||||||
|
title: 'Patient History',
|
||||||
|
body: 'Open Patient History from the sidebar when you need prior digitization records for a patient.',
|
||||||
|
placement: 'right' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tourDefinitions: Record<string, TourDefinition> = {
|
||||||
|
INTAKE_CLERK: {
|
||||||
|
role: 'INTAKE_CLERK',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'intake-job',
|
||||||
|
route: '/intake',
|
||||||
|
selector: '[data-tour="intake-header"]',
|
||||||
|
title: 'Your job: Intake',
|
||||||
|
body: 'Create digitization batches from paper scans and attach cover sheet details so clerks can enter data.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'intake-cover-lookup',
|
||||||
|
route: '/intake',
|
||||||
|
selector: '[data-tour="intake-cover-lookup"]',
|
||||||
|
title: 'Cover sheet lookup',
|
||||||
|
body: 'Scan or type a cover sheet code to auto-fill batch type, track, and assignment.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'intake-upload',
|
||||||
|
route: '/intake',
|
||||||
|
selector: '[data-tour="intake-upload"]',
|
||||||
|
title: 'Upload a scan',
|
||||||
|
body: 'Drop or choose a PDF or image. This becomes the source document for the batch.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'intake-metadata',
|
||||||
|
route: '/intake',
|
||||||
|
selector: '[data-tour="intake-metadata"]',
|
||||||
|
title: 'Batch details',
|
||||||
|
body: 'Confirm type, track, and patient, then create the batch.',
|
||||||
|
placement: 'top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'intake-recent',
|
||||||
|
route: '/intake',
|
||||||
|
selector: '[data-tour="intake-recent"]',
|
||||||
|
title: 'Recent uploads',
|
||||||
|
body: 'Assign clerks to batches waiting for data entry from this list.',
|
||||||
|
placement: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cover-sheets-nav',
|
||||||
|
route: '/cover-sheets',
|
||||||
|
selector: '[data-tour="cover-sheets-header"]',
|
||||||
|
title: 'Cover Sheets',
|
||||||
|
body: 'Generate and print cover sheets before scanning so intake can look them up by code.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cover-sheets-generate',
|
||||||
|
route: '/cover-sheets',
|
||||||
|
selector: '[data-tour="cover-sheets-generate"]',
|
||||||
|
title: 'Generate cover sheets',
|
||||||
|
body: 'Set count, batch type, track, and optional clerk, then generate and print.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
patientHistoryStep,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
DATA_ENTRY_CLERK: {
|
||||||
|
role: 'DATA_ENTRY_CLERK',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'entry-job',
|
||||||
|
route: '/entry',
|
||||||
|
selector: '[data-tour="entry-header"]',
|
||||||
|
title: 'Your job: Data Entry',
|
||||||
|
body: 'Transcribe structured fields from the source scan, then submit for verification.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'entry-queue',
|
||||||
|
route: '/entry',
|
||||||
|
selector: '[data-tour="workstation-queue"]',
|
||||||
|
title: 'Data Entry queue',
|
||||||
|
body: 'Open the oldest batch first. Batches returned for rework appear here too.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'entry-scan',
|
||||||
|
selector: '[data-tour="workstation-scan"]',
|
||||||
|
title: 'Source scan (level 1)',
|
||||||
|
body: 'The scan is the source of truth. Read from it while you fill the draft.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'entry-form',
|
||||||
|
selector: '[data-tour="entry-form"]',
|
||||||
|
title: 'Structured draft (level 2)',
|
||||||
|
body: 'Enter demographics and observations. Watch OCR confidence badges for uncertain fields.',
|
||||||
|
placement: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'entry-actions',
|
||||||
|
selector: '[data-tour="workstation-action-bar"]',
|
||||||
|
title: 'Save or submit',
|
||||||
|
body: 'Save Draft to continue later. Submit for Verification when the draft matches the scan.',
|
||||||
|
placement: 'top',
|
||||||
|
},
|
||||||
|
patientHistoryStep,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
VERIFIER: {
|
||||||
|
role: 'VERIFIER',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'verify-job',
|
||||||
|
route: '/verification',
|
||||||
|
selector: '[data-tour="verification-header"]',
|
||||||
|
title: 'Your job: Verification',
|
||||||
|
body: 'Compare every field to the scan. Pass only when all fields match; return otherwise.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'verify-queue',
|
||||||
|
route: '/verification',
|
||||||
|
selector: '[data-tour="workstation-queue"]',
|
||||||
|
title: 'Verification queue',
|
||||||
|
body: 'Select a batch pending verification. Work oldest first.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'verify-scan',
|
||||||
|
selector: '[data-tour="workstation-scan"]',
|
||||||
|
title: 'Source scan',
|
||||||
|
body: 'Keep the scan visible while you review each field card.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'verify-form',
|
||||||
|
selector: '[data-tour="verification-form"]',
|
||||||
|
title: 'Field review',
|
||||||
|
body: 'Check each field against the scan. Mark fields as you verify them.',
|
||||||
|
placement: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'verify-sod',
|
||||||
|
selector: '[data-tour="sod-banner"]',
|
||||||
|
title: 'Separation of duties',
|
||||||
|
body: 'You cannot verify a batch you entered. The banner explains when that applies.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'verify-decision',
|
||||||
|
selector: '[data-tour="workstation-action-bar"]',
|
||||||
|
title: 'Pass or return',
|
||||||
|
body: 'Pass sends the batch to clinical approval. Return sends it back to data entry with a reason.',
|
||||||
|
placement: 'top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'verify-audit',
|
||||||
|
selector: '[data-tour="audit-trail"]',
|
||||||
|
title: 'Audit trail',
|
||||||
|
body: 'Review who entered and changed the batch before you decide.',
|
||||||
|
placement: 'top',
|
||||||
|
},
|
||||||
|
patientHistoryStep,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
CLINICAL_APPROVER: {
|
||||||
|
role: 'CLINICAL_APPROVER',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'approval-job',
|
||||||
|
route: '/approval',
|
||||||
|
selector: '[data-tour="approval-header"]',
|
||||||
|
title: 'Your job: Clinical Approval',
|
||||||
|
body: 'Give final clinical sign-off. Approve promotes the record; reject returns it with a reason.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'approval-queue',
|
||||||
|
route: '/approval',
|
||||||
|
selector: '[data-tour="workstation-queue"]',
|
||||||
|
title: 'Clinical Approval queue',
|
||||||
|
body: 'Open a batch awaiting clinical approval.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'approval-form',
|
||||||
|
selector: '[data-tour="approval-form"]',
|
||||||
|
title: 'Sign-off review',
|
||||||
|
body: 'Review the structured data and any high-stakes or retroactive alerts before deciding.',
|
||||||
|
placement: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'approval-decision',
|
||||||
|
selector: '[data-tour="workstation-action-bar"]',
|
||||||
|
title: 'Approve or reject',
|
||||||
|
body: 'Approve & Promote publishes the clinical record. Reject sends the batch back with a reason.',
|
||||||
|
placement: 'top',
|
||||||
|
},
|
||||||
|
patientHistoryStep,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
CLINICIAN: {
|
||||||
|
role: 'CLINICIAN',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'live-job',
|
||||||
|
route: '/live-capture',
|
||||||
|
selector: '[data-tour="live-capture-header"]',
|
||||||
|
title: 'Your job: Live Capture',
|
||||||
|
body: 'Record vitals at the bedside. This is lighter than backfill data entry.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'live-tabs',
|
||||||
|
route: '/live-capture',
|
||||||
|
selector: '[data-tour="live-capture-tabs"]',
|
||||||
|
title: 'New or existing encounter',
|
||||||
|
body: 'Start a new encounter or attach observations to an existing one.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'live-patient',
|
||||||
|
route: '/live-capture',
|
||||||
|
selector: '[data-tour="live-capture-patient"]',
|
||||||
|
title: 'Patient and encounter',
|
||||||
|
body: 'Select the patient and fill encounter context (or pick an existing encounter).',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'live-observations',
|
||||||
|
route: '/live-capture',
|
||||||
|
selector: '[data-tour="live-capture-observations"]',
|
||||||
|
title: 'Observations',
|
||||||
|
body: 'Add vitals and other observations for this encounter.',
|
||||||
|
placement: 'top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'live-attest',
|
||||||
|
route: '/live-capture',
|
||||||
|
selector: '[data-tour="live-capture-attest"]',
|
||||||
|
title: 'Attest and record',
|
||||||
|
body: 'Confirm clinician attestation with your password, then Record Vitals.',
|
||||||
|
placement: 'top',
|
||||||
|
},
|
||||||
|
patientHistoryStep,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
ADMINISTRATOR: {
|
||||||
|
role: 'ADMINISTRATOR',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: 'admin-job',
|
||||||
|
route: '/dashboard',
|
||||||
|
selector: '[data-tour="dashboard-header"]',
|
||||||
|
title: 'Your job: Supervise',
|
||||||
|
body: 'Monitor queues, manage users, and inspect FHIR data. You can also open any workspace from the sidebar.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin-metrics',
|
||||||
|
route: '/dashboard',
|
||||||
|
selector: '[data-tour="dashboard-metrics"]',
|
||||||
|
title: 'Queue metrics',
|
||||||
|
body: 'Watch pending entry, verification, approval volume, and reject rate.',
|
||||||
|
placement: 'bottom',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin-batches',
|
||||||
|
route: '/dashboard',
|
||||||
|
selector: '[data-tour="dashboard-batches"]',
|
||||||
|
title: 'All batches',
|
||||||
|
body: 'Browse every batch across statuses from this list.',
|
||||||
|
placement: 'top',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin-nav',
|
||||||
|
route: '/dashboard',
|
||||||
|
selector: '[data-tour="nav-admin"]',
|
||||||
|
title: 'Administration',
|
||||||
|
body: 'Users manages accounts. FHIR Explorer inspects promoted clinical data.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin-workspace',
|
||||||
|
route: '/dashboard',
|
||||||
|
selector: '[data-tour="nav-workspace"]',
|
||||||
|
title: 'Workspace access',
|
||||||
|
body: 'As administrator you can open Intake, Data Entry, Verification, Approval, and Live Capture when needed.',
|
||||||
|
placement: 'right',
|
||||||
|
},
|
||||||
|
patientHistoryStep,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTourForRole(role: string): TourDefinition | null {
|
||||||
|
return tourDefinitions[role] ?? null
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export type TourPlacement = 'top' | 'bottom' | 'left' | 'right'
|
||||||
|
|
||||||
|
export interface TourStep {
|
||||||
|
id: string
|
||||||
|
/** Navigate here before highlighting (if different from current route). */
|
||||||
|
route?: string
|
||||||
|
/** CSS selector; prefer [data-tour="…"]. Missing targets are skipped. */
|
||||||
|
selector: string
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
placement?: TourPlacement
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TourDefinition {
|
||||||
|
role: string
|
||||||
|
steps: TourStep[]
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full min-h-0 flex flex-col">
|
<div class="h-full min-h-0 flex flex-col">
|
||||||
<AppHeader title="Clinical Approval">
|
<AppHeader title="Clinical Approval" tour-anchor="approval-header">
|
||||||
<template #subtitle>
|
<template #subtitle>
|
||||||
<span v-if="currentBatch" class="text-sm text-ink-secondary">
|
<span v-if="currentBatch" class="text-sm text-ink-secondary">
|
||||||
Batch {{ currentBatch.id.substring(0, 8) }}…
|
Batch {{ currentBatch.id.substring(0, 8) }}…
|
||||||
· {{ formatBatchType(currentBatch.batchType) }}
|
· {{ formatBatchType(currentBatch.batchType) }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<TourHelpButton />
|
||||||
|
</template>
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
|
|
||||||
<WorkstationLayout :has-batch="!!batchId">
|
<WorkstationLayout :has-batch="!!batchId">
|
||||||
@@ -68,6 +71,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useBatchStore } from '../stores/batches'
|
import { useBatchStore } from '../stores/batches'
|
||||||
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||||
import ScanViewer from '../components/ScanViewer.vue'
|
import ScanViewer from '../components/ScanViewer.vue'
|
||||||
import BatchList from '../components/BatchList.vue'
|
import BatchList from '../components/BatchList.vue'
|
||||||
import WorkstationLayout from '../components/WorkstationLayout.vue'
|
import WorkstationLayout from '../components/WorkstationLayout.vue'
|
||||||
|
|||||||
@@ -3,13 +3,18 @@
|
|||||||
<AppHeader
|
<AppHeader
|
||||||
title="Cover Sheets"
|
title="Cover Sheets"
|
||||||
description="Generate printable separators that reconnect scanned paper to batch metadata."
|
description="Generate printable separators that reconnect scanned paper to batch metadata."
|
||||||
/>
|
tour-anchor="cover-sheets-header"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<TourHelpButton />
|
||||||
|
</template>
|
||||||
|
</AppHeader>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||||
<div class="max-w-7xl mx-auto space-y-6 lg:space-y-8">
|
<div class="max-w-7xl mx-auto space-y-6 lg:space-y-8">
|
||||||
<!-- Top: 50 / 50 generation + preview -->
|
<!-- Top: 50 / 50 generation + preview -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8 items-start">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8 items-start">
|
||||||
<section class="card">
|
<section class="card" data-tour="cover-sheets-generate">
|
||||||
<h2 class="text-base font-semibold text-ink-strong mb-4">Generate Cover Sheets</h2>
|
<h2 class="text-base font-semibold text-ink-strong mb-4">Generate Cover Sheets</h2>
|
||||||
|
|
||||||
<form @submit.prevent="handleGenerate" class="space-y-4">
|
<form @submit.prevent="handleGenerate" class="space-y-4">
|
||||||
@@ -324,6 +329,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { get, post, postBlob } from '../api/client'
|
import { get, post, postBlob } from '../api/client'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||||
import PatientSearch from '../components/PatientSearch.vue'
|
import PatientSearch from '../components/PatientSearch.vue'
|
||||||
import StatusBadge from '../components/StatusBadge.vue'
|
import StatusBadge from '../components/StatusBadge.vue'
|
||||||
import EmptyState from '../components/EmptyState.vue'
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full min-h-0 flex flex-col">
|
<div class="h-full min-h-0 flex flex-col">
|
||||||
<AppHeader title="Data Entry">
|
<AppHeader title="Data Entry" tour-anchor="entry-header">
|
||||||
<template #subtitle>
|
<template #subtitle>
|
||||||
<span v-if="currentBatch" class="text-sm text-ink-secondary">
|
<span v-if="currentBatch" class="text-sm text-ink-secondary">
|
||||||
Batch {{ currentBatch.id.substring(0, 8) }}…
|
Batch {{ currentBatch.id.substring(0, 8) }}…
|
||||||
· {{ currentBatch.batchType.replace(/_/g, ' ') }}
|
· {{ currentBatch.batchType.replace(/_/g, ' ') }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<TourHelpButton />
|
||||||
|
</template>
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
|
|
||||||
<WorkstationLayout :has-batch="!!batchId">
|
<WorkstationLayout :has-batch="!!batchId">
|
||||||
@@ -62,6 +65,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useBatchStore } from '../stores/batches'
|
import { useBatchStore } from '../stores/batches'
|
||||||
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||||
import ScanViewer from '../components/ScanViewer.vue'
|
import ScanViewer from '../components/ScanViewer.vue'
|
||||||
import EntryForm from '../components/EntryForm.vue'
|
import EntryForm from '../components/EntryForm.vue'
|
||||||
import BatchList from '../components/BatchList.vue'
|
import BatchList from '../components/BatchList.vue'
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
<AppHeader
|
<AppHeader
|
||||||
title="FHIR Explorer"
|
title="FHIR Explorer"
|
||||||
description="Read-only inspection of exposed FHIR resources for administrators and integration staff."
|
description="Read-only inspection of exposed FHIR resources for administrators and integration staff."
|
||||||
|
tour-anchor="fhir-header"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
|
<TourHelpButton />
|
||||||
<button type="button" class="btn-secondary text-sm" @click="openMetadata">
|
<button type="button" class="btn-secondary text-sm" @click="openMetadata">
|
||||||
CapabilityStatement
|
CapabilityStatement
|
||||||
</button>
|
</button>
|
||||||
@@ -331,6 +333,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||||
import PatientSearch from '../components/PatientSearch.vue'
|
import PatientSearch from '../components/PatientSearch.vue'
|
||||||
import InlineError from '../components/InlineError.vue'
|
import InlineError from '../components/InlineError.vue'
|
||||||
import EmptyState from '../components/EmptyState.vue'
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
<AppHeader
|
<AppHeader
|
||||||
title="Intake"
|
title="Intake"
|
||||||
description="Create a digitization batch from a PDF or image scan."
|
description="Create a digitization batch from a PDF or image scan."
|
||||||
/>
|
tour-anchor="intake-header"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<TourHelpButton />
|
||||||
|
</template>
|
||||||
|
</AppHeader>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||||
<div class="max-w-7xl mx-auto">
|
<div class="max-w-7xl mx-auto">
|
||||||
@@ -11,7 +16,7 @@
|
|||||||
<!-- Main — ~70%: upload + batch details -->
|
<!-- Main — ~70%: upload + batch details -->
|
||||||
<div class="space-y-6 min-w-0">
|
<div class="space-y-6 min-w-0">
|
||||||
<!-- Cover sheet quick lookup -->
|
<!-- Cover sheet quick lookup -->
|
||||||
<section class="card">
|
<section class="card" data-tour="intake-cover-lookup">
|
||||||
<h2 class="text-base font-semibold text-ink-strong mb-1">Cover Sheet Code</h2>
|
<h2 class="text-base font-semibold text-ink-strong mb-1">Cover Sheet Code</h2>
|
||||||
<p class="text-sm text-ink-secondary mb-4">
|
<p class="text-sm text-ink-secondary mb-4">
|
||||||
Optional. Scan or type a barcode to auto-fill batch details.
|
Optional. Scan or type a barcode to auto-fill batch details.
|
||||||
@@ -100,7 +105,7 @@
|
|||||||
|
|
||||||
<form @submit.prevent="handleUpload" class="space-y-5">
|
<form @submit.prevent="handleUpload" class="space-y-5">
|
||||||
<!-- Drop zone -->
|
<!-- Drop zone -->
|
||||||
<div>
|
<div data-tour="intake-upload">
|
||||||
<label class="block text-sm font-semibold text-ink mb-2">
|
<label class="block text-sm font-semibold text-ink mb-2">
|
||||||
Scanned Document
|
Scanned Document
|
||||||
</label>
|
</label>
|
||||||
@@ -181,6 +186,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Batch type -->
|
<!-- Batch type -->
|
||||||
|
<div data-tour="intake-metadata" class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label for="batch-type" class="block text-sm font-semibold text-ink mb-2">
|
<label for="batch-type" class="block text-sm font-semibold text-ink mb-2">
|
||||||
Batch Type
|
Batch Type
|
||||||
@@ -277,6 +283,7 @@
|
|||||||
>
|
>
|
||||||
{{ uploadButtonLabel }}
|
{{ uploadButtonLabel }}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,7 +299,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card" data-tour="intake-recent">
|
||||||
<h2 class="text-base font-semibold text-ink-strong mb-4">Recent Uploads</h2>
|
<h2 class="text-base font-semibold text-ink-strong mb-4">Recent Uploads</h2>
|
||||||
<InlineError
|
<InlineError
|
||||||
v-if="assignError"
|
v-if="assignError"
|
||||||
@@ -334,6 +341,7 @@ import { get } from '../api/client'
|
|||||||
import { useBatchStore } from '../stores/batches'
|
import { useBatchStore } from '../stores/batches'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||||
import PatientSearch from '../components/PatientSearch.vue'
|
import PatientSearch from '../components/PatientSearch.vue'
|
||||||
import BatchList from '../components/BatchList.vue'
|
import BatchList from '../components/BatchList.vue'
|
||||||
import AssignClerkDialog from '../components/AssignClerkDialog.vue'
|
import AssignClerkDialog from '../components/AssignClerkDialog.vue'
|
||||||
|
|||||||
@@ -3,12 +3,17 @@
|
|||||||
<AppHeader
|
<AppHeader
|
||||||
title="Live Capture"
|
title="Live Capture"
|
||||||
description="Bedside observation recording — lighter than backfill digitization."
|
description="Bedside observation recording — lighter than backfill digitization."
|
||||||
/>
|
tour-anchor="live-capture-header"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<TourHelpButton />
|
||||||
|
</template>
|
||||||
|
</AppHeader>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||||
<div class="max-w-5xl mx-auto space-y-6">
|
<div class="max-w-5xl mx-auto space-y-6">
|
||||||
<!-- Mode selector -->
|
<!-- Mode selector -->
|
||||||
<div class="flex border-b border-line" role="tablist">
|
<div class="flex border-b border-line" role="tablist" data-tour="live-capture-tabs">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -35,6 +40,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-6" data-tour="live-capture-patient">
|
||||||
<!-- 1. Patient -->
|
<!-- 1. Patient -->
|
||||||
<section v-if="mode === 'new'" class="card space-y-4">
|
<section v-if="mode === 'new'" class="card space-y-4">
|
||||||
<header>
|
<header>
|
||||||
@@ -104,9 +110,10 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 3. Observations -->
|
<!-- 3. Observations -->
|
||||||
<section class="card space-y-4">
|
<section class="card space-y-4" data-tour="live-capture-observations">
|
||||||
<header class="flex flex-wrap items-end justify-between gap-3">
|
<header class="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="workstation-form-legend">
|
<p class="workstation-form-legend">
|
||||||
@@ -142,6 +149,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 4. Attestation -->
|
<!-- 4. Attestation -->
|
||||||
|
<div class="space-y-4" data-tour="live-capture-attest">
|
||||||
<section class="card space-y-4">
|
<section class="card space-y-4">
|
||||||
<header>
|
<header>
|
||||||
<p class="workstation-form-legend">
|
<p class="workstation-form-legend">
|
||||||
@@ -264,6 +272,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,6 +283,7 @@ import { ref, computed } from 'vue'
|
|||||||
import { useLiveCaptureStore } from '../stores/liveCapture'
|
import { useLiveCaptureStore } from '../stores/liveCapture'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||||
import PatientSearch from '../components/PatientSearch.vue'
|
import PatientSearch from '../components/PatientSearch.vue'
|
||||||
import InlineError from '../components/InlineError.vue'
|
import InlineError from '../components/InlineError.vue'
|
||||||
import ObservationRow from '../components/ObservationRow.vue'
|
import ObservationRow from '../components/ObservationRow.vue'
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
<AppHeader
|
<AppHeader
|
||||||
title="Patient History"
|
title="Patient History"
|
||||||
description="Digitization lineage, promotion attribution, and audit trail for a patient."
|
description="Digitization lineage, promotion attribution, and audit trail for a patient."
|
||||||
/>
|
tour-anchor="patient-history-header"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<TourHelpButton />
|
||||||
|
</template>
|
||||||
|
</AppHeader>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
<div class="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||||
<div class="max-w-5xl mx-auto space-y-6">
|
<div class="max-w-5xl mx-auto space-y-6">
|
||||||
@@ -261,6 +266,7 @@ import { ref, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useBatchStore } from '../stores/batches'
|
import { useBatchStore } from '../stores/batches'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||||
import PatientSearch from '../components/PatientSearch.vue'
|
import PatientSearch from '../components/PatientSearch.vue'
|
||||||
import StatusBadge from '../components/StatusBadge.vue'
|
import StatusBadge from '../components/StatusBadge.vue'
|
||||||
import EmptyState from '../components/EmptyState.vue'
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
<AppHeader
|
<AppHeader
|
||||||
title="Queue Dashboard"
|
title="Queue Dashboard"
|
||||||
description="Supervisor view of digitization backlog, aging work, and reject rate."
|
description="Supervisor view of digitization backlog, aging work, and reject rate."
|
||||||
|
tour-anchor="dashboard-header"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
|
<TourHelpButton />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-sm font-medium text-primary-700 hover:text-primary-800"
|
class="text-sm font-medium text-primary-700 hover:text-primary-800"
|
||||||
@@ -28,7 +30,10 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Priority work metrics -->
|
<!-- Priority work metrics -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 sm:gap-4">
|
<div
|
||||||
|
class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 sm:gap-4"
|
||||||
|
data-tour="dashboard-metrics"
|
||||||
|
>
|
||||||
<div class="card-elevated">
|
<div class="card-elevated">
|
||||||
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
|
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
|
||||||
Pending Entry
|
Pending Entry
|
||||||
@@ -124,7 +129,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Existing batch list via batch list API -->
|
<!-- Existing batch list via batch list API -->
|
||||||
<section class="card">
|
<section class="card" data-tour="dashboard-batches">
|
||||||
<h2 class="text-base font-semibold text-ink-strong mb-4">All Batches</h2>
|
<h2 class="text-base font-semibold text-ink-strong mb-4">All Batches</h2>
|
||||||
<BatchList
|
<BatchList
|
||||||
:batches="batchStore.batches"
|
:batches="batchStore.batches"
|
||||||
@@ -145,6 +150,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useBatchStore } from '../stores/batches'
|
import { useBatchStore } from '../stores/batches'
|
||||||
import { get } from '../api/client'
|
import { get } from '../api/client'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||||
import BatchList from '../components/BatchList.vue'
|
import BatchList from '../components/BatchList.vue'
|
||||||
import StatusBadge from '../components/StatusBadge.vue'
|
import StatusBadge from '../components/StatusBadge.vue'
|
||||||
import EmptyState from '../components/EmptyState.vue'
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
<AppHeader
|
<AppHeader
|
||||||
title="Users"
|
title="Users"
|
||||||
description="Create accounts, update roles, and reset passwords for active staff."
|
description="Create accounts, update roles, and reset passwords for active staff."
|
||||||
|
tour-anchor="users-header"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
|
<TourHelpButton />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
@@ -269,6 +271,7 @@ import { onMounted, reactive, ref } from 'vue'
|
|||||||
import { useUsersStore } from '../stores/users'
|
import { useUsersStore } from '../stores/users'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
import EmptyState from '../components/EmptyState.vue'
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
import InlineError from '../components/InlineError.vue'
|
import InlineError from '../components/InlineError.vue'
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full min-h-0 flex flex-col">
|
<div class="h-full min-h-0 flex flex-col">
|
||||||
<AppHeader title="Verification">
|
<AppHeader title="Verification" tour-anchor="verification-header">
|
||||||
<template #subtitle>
|
<template #subtitle>
|
||||||
<span v-if="currentBatch" class="text-sm text-ink-secondary">
|
<span v-if="currentBatch" class="text-sm text-ink-secondary">
|
||||||
Batch {{ currentBatch.id.substring(0, 8) }}…
|
Batch {{ currentBatch.id.substring(0, 8) }}…
|
||||||
<span v-if="enteredByLabel"> · Entered by {{ enteredByLabel }}</span>
|
<span v-if="enteredByLabel"> · Entered by {{ enteredByLabel }}</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<TourHelpButton />
|
||||||
|
</template>
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
|
|
||||||
<WorkstationLayout :has-batch="!!batchId">
|
<WorkstationLayout :has-batch="!!batchId">
|
||||||
@@ -63,6 +66,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { useBatchStore } from '../stores/batches'
|
import { useBatchStore } from '../stores/batches'
|
||||||
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
||||||
import AppHeader from '../components/AppHeader.vue'
|
import AppHeader from '../components/AppHeader.vue'
|
||||||
|
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||||
import ScanViewer from '../components/ScanViewer.vue'
|
import ScanViewer from '../components/ScanViewer.vue'
|
||||||
import VerificationForm from '../components/VerificationForm.vue'
|
import VerificationForm from '../components/VerificationForm.vue'
|
||||||
import BatchList from '../components/BatchList.vue'
|
import BatchList from '../components/BatchList.vue'
|
||||||
|
|||||||
Reference in New Issue
Block a user