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 />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/TourHelpButton.vue', () => ({
|
||||
default: { template: '<button type="button" data-testid="tour-help" />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/PatientSearch.vue', () => ({
|
||||
default: {
|
||||
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', () => ({
|
||||
default: {
|
||||
props: ['modelValue'],
|
||||
|
||||
@@ -18,6 +18,10 @@ vi.mock('@/components/AppHeader.vue', () => ({
|
||||
default: { template: '<div />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/TourHelpButton.vue', () => ({
|
||||
default: { template: '<button type="button" data-testid="tour-help" />' },
|
||||
}))
|
||||
|
||||
vi.mock('@/components/PatientSearch.vue', () => ({
|
||||
default: {
|
||||
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', () => ({
|
||||
default: {
|
||||
props: ['title', 'description'],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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="min-w-0">
|
||||
<h1 class="text-lg sm:text-xl font-semibold text-ink-strong leading-tight">
|
||||
@@ -25,7 +25,9 @@ withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
/** Stable walkthrough anchor, e.g. intake-header */
|
||||
tourAnchor?: string
|
||||
}>(),
|
||||
{ description: undefined },
|
||||
{ description: undefined, tourAnchor: undefined },
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
v-if="!sidebarCollapsed"
|
||||
class="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/40"
|
||||
@@ -59,6 +59,7 @@
|
||||
:to="item.to"
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
:class="navItemClass(item.to)"
|
||||
:data-tour="item.tourAnchor"
|
||||
@click="closeMobile"
|
||||
>
|
||||
<span class="shrink-0 w-5 h-5 flex items-center justify-center" aria-hidden="true">
|
||||
@@ -70,7 +71,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="adminItems.length">
|
||||
<div v-if="adminItems.length" data-tour="nav-admin">
|
||||
<p
|
||||
v-if="!sidebarCollapsed"
|
||||
class="px-2 mb-1.5 text-[10px] font-semibold uppercase tracking-wider text-white/40"
|
||||
@@ -83,6 +84,7 @@
|
||||
:to="item.to"
|
||||
:title="sidebarCollapsed ? item.label : undefined"
|
||||
:class="navItemClass(item.to)"
|
||||
:data-tour="item.tourAnchor"
|
||||
@click="closeMobile"
|
||||
>
|
||||
<span class="shrink-0 w-5 h-5 flex items-center justify-center" aria-hidden="true">
|
||||
@@ -251,6 +253,7 @@ interface NavItem {
|
||||
to: string
|
||||
icon: () => ReturnType<typeof h>
|
||||
show: boolean
|
||||
tourAnchor?: string
|
||||
}
|
||||
|
||||
const workspaceItems = computed<NavItem[]>(() =>
|
||||
@@ -261,7 +264,13 @@ const workspaceItems = computed<NavItem[]>(() =>
|
||||
{ label: 'Verification', to: '/verification', icon: icons.verify, show: auth.canVerify },
|
||||
{ label: 'Clinical Approval', to: '/approval', icon: icons.approve, show: auth.canApprove },
|
||||
{ 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),
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div
|
||||
class="h-full min-h-0 flex flex-col approval-frame"
|
||||
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 items-start justify-between gap-3">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
ref="root"
|
||||
class="mt-2 group workstation-form-section !p-3"
|
||||
data-testid="audit-trail-panel"
|
||||
data-tour="audit-trail"
|
||||
@toggle="onToggle"
|
||||
>
|
||||
<summary
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 items-start justify-between gap-3">
|
||||
<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'"
|
||||
role="status"
|
||||
data-testid="sod-banner"
|
||||
data-tour="sod-banner"
|
||||
>
|
||||
<template v-if="blocked">
|
||||
<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>
|
||||
<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 items-center justify-between gap-3">
|
||||
<div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div
|
||||
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-tour="workstation-action-bar"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
v-if="!hasBatch"
|
||||
class="flex-1 min-h-0 overflow-auto p-4 sm:p-6"
|
||||
data-testid="workstation-queue"
|
||||
data-tour="workstation-queue"
|
||||
>
|
||||
<slot name="queue" />
|
||||
</div>
|
||||
@@ -22,7 +23,11 @@
|
||||
>
|
||||
<slot name="rail" />
|
||||
</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">
|
||||
<span class="evidence-level-mark" aria-hidden="true">1</span>
|
||||
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>
|
||||
<router-view />
|
||||
</AppShell>
|
||||
<HelpPanel />
|
||||
<TourOverlay />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, watch } from '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>
|
||||
|
||||
@@ -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>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<AppHeader title="Clinical Approval">
|
||||
<AppHeader title="Clinical Approval" tour-anchor="approval-header">
|
||||
<template #subtitle>
|
||||
<span v-if="currentBatch" class="text-sm text-ink-secondary">
|
||||
Batch {{ currentBatch.id.substring(0, 8) }}…
|
||||
· {{ formatBatchType(currentBatch.batchType) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<TourHelpButton />
|
||||
</template>
|
||||
</AppHeader>
|
||||
|
||||
<WorkstationLayout :has-batch="!!batchId">
|
||||
@@ -68,6 +71,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||
import ScanViewer from '../components/ScanViewer.vue'
|
||||
import BatchList from '../components/BatchList.vue'
|
||||
import WorkstationLayout from '../components/WorkstationLayout.vue'
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
<AppHeader
|
||||
title="Cover Sheets"
|
||||
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="max-w-7xl mx-auto space-y-6 lg:space-y-8">
|
||||
<!-- Top: 50 / 50 generation + preview -->
|
||||
<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>
|
||||
|
||||
<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 { useToast } from '../composables/useToast'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||
import PatientSearch from '../components/PatientSearch.vue'
|
||||
import StatusBadge from '../components/StatusBadge.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<AppHeader title="Data Entry">
|
||||
<AppHeader title="Data Entry" tour-anchor="entry-header">
|
||||
<template #subtitle>
|
||||
<span v-if="currentBatch" class="text-sm text-ink-secondary">
|
||||
Batch {{ currentBatch.id.substring(0, 8) }}…
|
||||
· {{ currentBatch.batchType.replace(/_/g, ' ') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<TourHelpButton />
|
||||
</template>
|
||||
</AppHeader>
|
||||
|
||||
<WorkstationLayout :has-batch="!!batchId">
|
||||
@@ -62,6 +65,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||
import ScanViewer from '../components/ScanViewer.vue'
|
||||
import EntryForm from '../components/EntryForm.vue'
|
||||
import BatchList from '../components/BatchList.vue'
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
<AppHeader
|
||||
title="FHIR Explorer"
|
||||
description="Read-only inspection of exposed FHIR resources for administrators and integration staff."
|
||||
tour-anchor="fhir-header"
|
||||
>
|
||||
<template #actions>
|
||||
<TourHelpButton />
|
||||
<button type="button" class="btn-secondary text-sm" @click="openMetadata">
|
||||
CapabilityStatement
|
||||
</button>
|
||||
@@ -331,6 +333,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||
import PatientSearch from '../components/PatientSearch.vue'
|
||||
import InlineError from '../components/InlineError.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
<AppHeader
|
||||
title="Intake"
|
||||
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="max-w-7xl mx-auto">
|
||||
@@ -11,7 +16,7 @@
|
||||
<!-- Main — ~70%: upload + batch details -->
|
||||
<div class="space-y-6 min-w-0">
|
||||
<!-- 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>
|
||||
<p class="text-sm text-ink-secondary mb-4">
|
||||
Optional. Scan or type a barcode to auto-fill batch details.
|
||||
@@ -100,7 +105,7 @@
|
||||
|
||||
<form @submit.prevent="handleUpload" class="space-y-5">
|
||||
<!-- Drop zone -->
|
||||
<div>
|
||||
<div data-tour="intake-upload">
|
||||
<label class="block text-sm font-semibold text-ink mb-2">
|
||||
Scanned Document
|
||||
</label>
|
||||
@@ -181,6 +186,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Batch type -->
|
||||
<div data-tour="intake-metadata" class="space-y-5">
|
||||
<div>
|
||||
<label for="batch-type" class="block text-sm font-semibold text-ink mb-2">
|
||||
Batch Type
|
||||
@@ -277,6 +283,7 @@
|
||||
>
|
||||
{{ uploadButtonLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
@@ -292,7 +299,7 @@
|
||||
</ul>
|
||||
</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>
|
||||
<InlineError
|
||||
v-if="assignError"
|
||||
@@ -334,6 +341,7 @@ import { get } from '../api/client'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||
import PatientSearch from '../components/PatientSearch.vue'
|
||||
import BatchList from '../components/BatchList.vue'
|
||||
import AssignClerkDialog from '../components/AssignClerkDialog.vue'
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
<AppHeader
|
||||
title="Live Capture"
|
||||
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="max-w-5xl mx-auto space-y-6">
|
||||
<!-- 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
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -35,6 +40,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6" data-tour="live-capture-patient">
|
||||
<!-- 1. Patient -->
|
||||
<section v-if="mode === 'new'" class="card space-y-4">
|
||||
<header>
|
||||
@@ -104,9 +110,10 @@
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<div>
|
||||
<p class="workstation-form-legend">
|
||||
@@ -142,6 +149,7 @@
|
||||
</section>
|
||||
|
||||
<!-- 4. Attestation -->
|
||||
<div class="space-y-4" data-tour="live-capture-attest">
|
||||
<section class="card space-y-4">
|
||||
<header>
|
||||
<p class="workstation-form-legend">
|
||||
@@ -264,6 +272,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,6 +283,7 @@ import { ref, computed } from 'vue'
|
||||
import { useLiveCaptureStore } from '../stores/liveCapture'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||
import PatientSearch from '../components/PatientSearch.vue'
|
||||
import InlineError from '../components/InlineError.vue'
|
||||
import ObservationRow from '../components/ObservationRow.vue'
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
<AppHeader
|
||||
title="Patient History"
|
||||
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="max-w-5xl mx-auto space-y-6">
|
||||
@@ -261,6 +266,7 @@ import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||
import PatientSearch from '../components/PatientSearch.vue'
|
||||
import StatusBadge from '../components/StatusBadge.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
<AppHeader
|
||||
title="Queue Dashboard"
|
||||
description="Supervisor view of digitization backlog, aging work, and reject rate."
|
||||
tour-anchor="dashboard-header"
|
||||
>
|
||||
<template #actions>
|
||||
<TourHelpButton />
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm font-medium text-primary-700 hover:text-primary-800"
|
||||
@@ -28,7 +30,10 @@
|
||||
/>
|
||||
|
||||
<!-- 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">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-ink-secondary">
|
||||
Pending Entry
|
||||
@@ -124,7 +129,7 @@
|
||||
</section>
|
||||
|
||||
<!-- 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>
|
||||
<BatchList
|
||||
:batches="batchStore.batches"
|
||||
@@ -145,6 +150,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import { get } from '../api/client'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||
import BatchList from '../components/BatchList.vue'
|
||||
import StatusBadge from '../components/StatusBadge.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
<AppHeader
|
||||
title="Users"
|
||||
description="Create accounts, update roles, and reset passwords for active staff."
|
||||
tour-anchor="users-header"
|
||||
>
|
||||
<template #actions>
|
||||
<TourHelpButton />
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
@@ -269,6 +271,7 @@ import { onMounted, reactive, ref } from 'vue'
|
||||
import { useUsersStore } from '../stores/users'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
import InlineError from '../components/InlineError.vue'
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<AppHeader title="Verification">
|
||||
<AppHeader title="Verification" tour-anchor="verification-header">
|
||||
<template #subtitle>
|
||||
<span v-if="currentBatch" class="text-sm text-ink-secondary">
|
||||
Batch {{ currentBatch.id.substring(0, 8) }}…
|
||||
<span v-if="enteredByLabel"> · Entered by {{ enteredByLabel }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<TourHelpButton />
|
||||
</template>
|
||||
</AppHeader>
|
||||
|
||||
<WorkstationLayout :has-batch="!!batchId">
|
||||
@@ -63,6 +66,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useBatchStore } from '../stores/batches'
|
||||
import { usePresignedUrl } from '../composables/usePresignedUrl'
|
||||
import AppHeader from '../components/AppHeader.vue'
|
||||
import TourHelpButton from '../components/TourHelpButton.vue'
|
||||
import ScanViewer from '../components/ScanViewer.vue'
|
||||
import VerificationForm from '../components/VerificationForm.vue'
|
||||
import BatchList from '../components/BatchList.vue'
|
||||
|
||||
Reference in New Issue
Block a user