Add walkthrough
CI / backend (push) Canceled after 15s
CI / frontend (push) Successful in 1m36s

This commit is contained in:
2026-08-12 07:05:37 +08:00
parent 3680dc5ea7
commit 4fb9c95dfb
37 changed files with 1682 additions and 23 deletions
@@ -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>
+191
View File
@@ -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
}
+17
View File
@@ -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'
+12 -4
View File
@@ -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'