Major refactoring to clean up database migrations and reorganize static lookup data: - Consolidated 13 old migrations (2025) into 10 new numbered migrations (2026-01-01) - Deleted redundant migrations: Location, Users, Contact, ValueSet, Counter, RefRange, CRMOrganizations, Organization, AreaGeo, DeviceLogin, EdgeRes - New consolidated migrations: - 2026-01-01-000001_CreateLookups: valueset, counter, containerdef, occupation, specialty - 2026-01-01-000002_CreateOrganization: account, site, location, discipline, department - 2026-01-01-000003_CreatePatientCore: patient, patidentifier, pataddress, patcontact - 2026-01-01-000004_CreateSecurity: contact, contactdetail, userdevices, loginattempts - 2026-01-01-000005_CreatePatientVisits: patvisit, patinsurance - 2026-01-01-000006_CreateOrders: porder, orderitem - 2026-01-01-000007_CreateSpecimens: specimen, specmenactivity, containerdef - 2026-01-01-000008_CreateTestDefinitions: testdefinition, testactivity, refnum, reftxt - 2026-01-01-000009_CreateResults: patresult, patresultdetail, patresultcomment - 2026-01-01-000010_CreateLabInfrastructure: edgeres, edgestatus, edgeack, workstation - Moved 44 JSON files from valuesets/ subdirectory to app/Libraries/Data/ root - Added new country.json lookup - Added _meta.json for valueset metadata - Deleted old valuesets/_meta.json - Renamed gender.json to sex.json for consistency with patient.Sex column - Removed duplicate country.json from valuesets/ - AGENTS.md: Updated Lookups library documentation with new methods - README.md: Complete rewrite of lookup/valueset documentation - Renamed MVP_TODO.md to TODO.md - Added VUE_SPA_IMPLEMENTATION_PLAN.md - Removed deprecated prj_clinical laboratory quality management system_3a.docx - ValueSet.php: Enhanced with caching and new lookup methods - Lookups.php: Removed (functionality merged into ValueSet) Impact: Prepares codebase for 2026 with cleaner migration history and improved lookup data organization for the name-based valueset system.
29 KiB
29 KiB
Vue 3 SPA Implementation Plan for CLQMS
Executive Summary
This document outlines the implementation of a keyboard-friendly Single Page Application (SPA) using Vue 3 to replace the current Alpine.js frontend. The new SPA will integrate with the existing CodeIgniter 4 backend API, providing a more robust architecture for complex, heavy UI requirements while maintaining full keyboard accessibility.
Key Technologies:
- Vue 3 (Composition API)
- Vite (build tool)
- Pinia (state management)
- Vue Router
- PrimeVue 4 (component library)
- @vueuse/core (utilities)
- Headless UI for Vue (accessible primitives)
1. Architecture Overview
1.1 System Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Vue 3 SPA (Port 5173) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Views │ │ Components │ │ Stores (Pinia) │ │
│ │ (Pages) │ │ (UI) │ │ (State Management) │ │
│ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │
│ │ │ │ │
│ └────────────────┼──────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Vue Router (Client-side Routing) │ │
│ └───────────────────────────┬─────────────────────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ API Service Layer │ │
│ │ (Axios + JWT Interceptor) │ │
│ └───────────────────────────┬─────────────────────────────┘ │
└──────────────────────────────┼─────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ CodeIgniter 4 REST API (Port 8080) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Controllers │ │ Models │ │ Libraries │ │
│ │ (API) │ │ (DB) │ │ (Lookups, Auth) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
1.2 Communication Pattern
- SPA runs on separate port (Vite dev server: 5173)
- Backend API runs on port 8080
- CORS configured to allow SPA to communicate with API
- JWT authentication via HTTP-only cookies or Bearer tokens
- All dates in UTC, ISO 8601 format
2. Implementation Phases
Phase 1: Project Setup (Days 1-2)
2.1.1 Initialize Vue 3 Project
# Create Vue 3 project with Vite
npm create vite@latest clqms-frontend -- --template vue-ts
# Navigate to project
cd clqms-frontend
# Install dependencies
npm install
2.1.2 Core Dependencies
# State Management
npm install pinia
# Router
npm install vue-router
# HTTP Client
npm install axios
# Component Library with excellent accessibility
npm install primevue @primevue/themes primeicons
# VueUse utilities (keyboard, focus, shortcuts)
npm install @vueuse/core
# Headless UI for accessible primitives
npm install @headlessui/vue
# Validation
npm install zod
# Date handling
npm install dayjs
# CSS utility (optional, can use PrimeVue styling)
npm install -D tailwindcss postcss autoprefixer
2.1.3 Project Structure
clqms-frontend/
├── public/
│ └── favicon.ico
├── src/
│ ├── assets/
│ │ ├── images/
│ │ └── styles/
│ │ └── main.css
│ ├── components/
│ │ ├── common/
│ │ │ ├── BaseButton.vue
│ │ │ ├── BaseInput.vue
│ │ │ ├── BaseModal.vue
│ │ │ ├── BaseDataTable.vue
│ │ │ ├── PageHeader.vue
│ │ │ └── KeyboardShortcuts.vue
│ │ ├── layout/
│ │ │ ├── AppHeader.vue
│ │ │ ├── AppSidebar.vue
│ │ │ └── AppFooter.vue
│ │ └── domain/
│ │ ├── patient/
│ │ │ ├── PatientList.vue
│ │ │ ├── PatientForm.vue
│ │ │ └── PatientDetail.vue
│ │ └── ...
│ ├── composables/
│ │ ├── useAuth.ts
│ │ ├── useApi.ts
│ │ ├── useKeyboard.ts
│ │ └── useFocusTrap.ts
│ ├── router/
│ │ └── index.ts
│ ├── services/
│ │ ├── api.ts
│ │ ├── authService.ts
│ │ ├── patientService.ts
│ │ └── ...
│ ├── stores/
│ │ ├── auth.ts
│ │ ├── patient.ts
│ │ └── ...
│ ├── types/
│ │ ├── api.ts
│ │ ├── patient.ts
│ │ └── ...
│ ├── utils/
│ │ ├── constants.ts
│ │ └── helpers.ts
│ ├── views/
│ │ ├── Dashboard.vue
│ │ ├── Login.vue
│ │ ├── NotFound.vue
│ │ ├── patient/
│ │ │ ├── PatientIndex.vue
│ │ │ ├── PatientCreate.vue
│ │ │ └── PatientEdit.vue
│ │ └── ...
│ ├── App.vue
│ └── main.ts
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.js
└── .env
Phase 2: Core Infrastructure (Days 3-4)
2.2.1 Vite Configuration
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})
2.2.2 Main Entry Point
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
import App from './App.vue'
import router from './router'
import 'primevue/resources/themes/aura-light-blue/theme.css'
import 'primeicons/primeicons.css'
import './assets/styles/main.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(PrimeVue, { ripple: true })
app.use(ToastService)
app.use(ConfirmationService)
app.mount('#app')
2.2.3 Router Configuration
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { requiresAuth: false },
},
{
path: '/',
component: () => import('@/components/layout/AppLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
},
{
path: 'patients',
name: 'PatientIndex',
component: () => import('@/views/patient/PatientIndex.vue'),
},
{
path: 'patients/create',
name: 'PatientCreate',
component: () => import('@/views/patient/PatientCreate.vue'),
},
{
path: 'patients/:id',
name: 'PatientEdit',
component: () => import('@/views/patient/PatientEdit.vue'),
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/NotFound.vue'),
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, _from, next) => {
const authStore = useAuthStore()
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ name: 'Login', query: { redirect: to.fullPath } })
} else if (to.name === 'Login' && authStore.isAuthenticated) {
next({ name: 'Dashboard' })
} else {
next()
}
})
export default router
2.2.4 API Service Layer
// src/services/api.ts
import axios, type AxiosInstance, type AxiosError } from 'axios'
import { useAuthStore } from '@/stores/auth'
import router from '@/router'
const apiClient: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
})
apiClient.interceptors.request.use((config) => {
const authStore = useAuthStore()
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`
}
return config
})
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 401) {
const authStore = useAuthStore()
authStore.logout()
router.push({ name: 'Login' })
}
return Promise.reject(error)
}
)
export default apiClient
Phase 3: Authentication (Days 5-6)
2.3.1 Auth Store (Pinia)
// src/stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import authService from '@/services/authService'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('auth_token'))
const user = ref<Record<string, unknown> | null>(null)
const loading = ref(false)
const isAuthenticated = computed(() => !!token.value)
async function login(credentials: { username: string; password: string }) {
loading.value = true
try {
const response = await authService.login(credentials)
token.value = response.data.token
user.value = response.data.user
localStorage.setItem('auth_token', response.data.token)
return { success: true }
} catch (error) {
return { success: false, error: (error as Error).message }
} finally {
loading.value = false
}
}
function logout() {
token.value = null
user.value = null
localStorage.removeItem('auth_token')
}
return {
token,
user,
loading,
isAuthenticated,
login,
logout,
}
})
2.3.2 Login View with Keyboard Navigation
<!-- src/views/Login.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useToast } from 'primevue/usetoast'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Button from 'primevue/button'
const router = useRouter()
const authStore = useAuthStore()
const toast = useToast()
const username = ref('')
const password = ref('')
const loading = ref(false)
const usernameInput = ref<InstanceType<typeof InputText> | null>(null)
async function handleSubmit() {
loading.value = true
const result = await authStore.login({ username: username.value, password: password.value })
loading.value = false
if (result.success) {
router.push({ name: 'Dashboard' })
} else {
toast.add({
severity: 'error',
summary: 'Login Failed',
detail: 'Invalid username or password',
life: 3000,
})
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey) {
handleSubmit()
}
}
import { onMounted } from 'vue'
onMounted(() => {
usernameInput.value?.$el.focus()
})
</script>
<template>
<div class="min-h-screen flex items-center justify-center bg-gray-100">
<div class="bg-white p-8 rounded-lg shadow-lg w-full max-w-md">
<h1 class="text-2xl font-bold mb-6 text-center">CLQMS Login</h1>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">
Username
</label>
<InputText
id="username"
ref="usernameInput"
v-model="username"
class="w-full"
autocomplete="username"
@keydown="handleKeydown"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<Password
id="password"
v-model="password"
class="w-full"
:feedback="false"
toggleMask
autocomplete="current-password"
inputClass="w-full"
@keydown="handleKeydown"
/>
</div>
<Button
type="submit"
label="Sign In"
class="w-full"
:loading="loading"
/>
</form>
</div>
</div>
</template>
Phase 4: Patient Management Module (Days 7-10)
2.4.1 Patient List with DataTable
<!-- src/views/patient/PatientIndex.vue -->
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { usePatientStore } from '@/stores/patient'
import { useKeyboard } from '@/composables/useKeyboard'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
import Tag from 'primevue/tag'
const patientStore = usePatientStore()
const { shortcuts } = useKeyboard()
const searchQuery = ref('')
const loading = ref(false)
const patients = computed(() => patientStore.patients)
const totalRecords = computed(() => patientStore.totalRecords)
const lazyParams = ref({
first: 0,
rows: 10,
page: 0,
sortField: '',
sortOrder: 1,
filters: {},
})
onMounted(() => {
loadPatients()
setupKeyboardShortcuts()
})
function setupKeyboardShortcuts() {
shortcuts.add('c', () => navigateToCreate(), { ctrl: true })
shortcuts.add('f', () => searchInput.value?.$el.focus(), { ctrl: true })
}
function loadPatients() {
loading.value = true
patientStore.fetchPatients({
...lazyParams.value,
search: searchQuery.value,
}).finally(() => {
loading.value = false
})
}
function onPage(event: { first: number; rows: number; page: number }) {
lazyParams.value = event
loadPatients()
}
function onSort(event: { sortField: string; sortOrder: number }) {
lazyParams.value.sortField = event.sortField
lazyParams.value.sortOrder = event.sortOrder
loadPatients()
}
function navigateToCreate() {
// Use router to navigate
}
const searchInput = ref<InstanceType<typeof InputText> | null>(null)
</script>
<template>
<div class="p-4">
<div class="flex justify-between items-center mb-4">
<h1 class="text-2xl font-bold">Patients</h1>
<div class="flex gap-2">
<span class="p-input-icon-left">
<i class="pi pi-search" />
<InputText
ref="searchInput"
v-model="searchQuery"
placeholder="Search patients... (Ctrl+F)"
@input="loadPatients"
/>
</span>
<Button
label="Add Patient (Ctrl+C)"
icon="pi pi-plus"
@click="navigateToCreate"
/>
</div>
</div>
<DataTable
:value="patients"
:loading="loading"
:paginator="true"
:first="lazyParams.first"
:rows="lazyParams.rows"
:totalRecords="totalRecords"
:lazy="true"
:sortField="lazyParams.sortField"
:sortOrder="lazyParams.sortOrder"
:rowsPerPageOptions="[10, 25, 50]"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
@page="onPage"
@sort="onSort"
stripedRows
responsiveLayout="scroll"
keyboardKey="n"
selectionMode="single"
class="p-datatable-sm"
>
<Column field="InternalPID" header="ID" sortable></Column>
<Column field="PatientID" header="Patient ID" sortable></Column>
<Column field="FullName" header="Full Name" sortable></Column>
<Column field="Sex" header="Sex">
<template #body="{ data }">
<Tag :value="data.Sex === '1' ? 'Female' : 'Male'" />
</template>
</Column>
<Column field="DOB" header="Date of Birth" sortable>
<template #body="{ data }">
{{ new Date(data.DOB).toLocaleDateString() }}
</template>
</Column>
<Column header="Actions">
<template #body="{ data }">
<Button
icon="pi pi-pencil"
text
rounded
@click="navigateToEdit(data.InternalPID)"
/>
</template>
</Column>
</DataTable>
<div class="mt-4 text-sm text-gray-600">
<p>Keyboard shortcuts:</p>
<ul class="list-disc list-inside">
<li>Ctrl+C - Create new patient</li>
<li>Ctrl+F - Focus search</li>
<li>Arrow keys - Navigate between rows</li>
</ul>
</div>
</div>
</template>
Phase 5: Accessibility Infrastructure (Days 11-12)
2.5.1 Keyboard Shortcut Composable
// src/composables/useKeyboard.ts
import { onMounted, onUnmounted } from 'vue'
import { useMagicKeys, whenever } from '@vueuse/core'
interface ShortcutOptions {
ctrl?: boolean
shift?: boolean
alt?: boolean
key?: string
}
export function useKeyboard() {
const { ctrl, shift, alt, k, n, f, s, e, escape } = useMagicKeys()
const shortcuts = new Map<string, () => void>()
function add(key: string, callback: () => void, options: ShortcutOptions = {}) {
const keyLower = key.toLowerCase()
const fullKey = [
options.ctrl ? 'ctrl' : '',
options.shift ? 'shift' : '',
options.alt ? 'alt' : '',
keyLower,
]
.filter(Boolean)
.join('+')
shortcuts.set(fullKey, callback)
}
function remove(key: string, options: ShortcutOptions = {}) {
const keyLower = key.toLowerCase()
const fullKey = [
options.ctrl ? 'ctrl' : '',
options.shift ? 'shift' : '',
options.alt ? 'alt' : '',
keyLower,
]
.filter(Boolean)
.join('+')
shortcuts.delete(fullKey)
}
function execute(event: KeyboardEvent) {
const parts = ['ctrl', 'shift', 'alt'].filter((mod) => event[`${mod}Key`])
parts.push(event.key.toLowerCase())
const fullKey = parts.join('+')
const callback = shortcuts.get(fullKey)
if (callback) {
event.preventDefault()
callback()
}
}
onMounted(() => {
window.addEventListener('keydown', execute)
})
onUnmounted(() => {
window.removeEventListener('keydown', execute)
})
return {
shortcuts: { add, remove },
keys: { ctrl, shift, alt, k, n, f, s, e, escape },
}
}
2.5.2 Focus Trap Composable (for Modals/Dialogs)
// src/composables/useFocusTrap.ts
import { ref, onMounted, onUnmounted } from 'vue'
import { onKeyStroke } from '@vueuse/core'
export function useFocusTrap(containerRef: Ref<HTMLElement | null>, active: Ref<boolean>) {
const focusableElements = ref<HTMLElement[]>([])
const firstFocusable = ref<HTMLElement | null>(null)
const lastFocusable = ref<HTMLElement | null>(null)
function updateFocusableElements() {
if (!containerRef.value) return
const focusable = containerRef.value.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
focusableElements.value = Array.from(focusable)
firstFocusable.value = focusableElements.value[0] || null
lastFocusable.value = focusableElements.value[focusableElements.value.length - 1] || null
}
function focusFirst() {
firstFocusable.value?.focus()
}
function handleTab(event: KeyboardEvent) {
if (!active.value || !containerRef.value) return
if (event.shiftKey) {
if (document.activeElement === firstFocusable.value) {
event.preventDefault()
lastFocusable.value?.focus()
}
} else {
if (document.activeElement === lastFocusable.value) {
event.preventDefault()
firstFocusable.value?.focus()
}
}
}
function handleEscape() {
if (active.value) {
active.value = false
}
}
onMounted(() => {
updateFocusableElements()
})
onKeyStroke('Tab', handleTab, { target: containerRef })
onKeyStroke('Escape', handleEscape, { target: containerRef })
return {
updateFocusableElements,
focusFirst,
}
}
Phase 6: Layout Components (Days 13-14)
2.6.1 App Layout with Sidebar
<!-- src/components/layout/AppLayout.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import AppSidebar from './AppSidebar.vue'
import AppHeader from './AppHeader.vue'
import Toast from 'primevue/toast'
import ConfirmDialog />
</script>
<template>
<div class="min-h-screen flex">
<AppSidebar :collapsed="sidebarCollapsed" @toggle="sidebarCollapsed = !sidebarCollapsed" />
<div class="flex-1 flex flex-col">
<AppHeader @toggle-sidebar="sidebarCollapsed = !sidebarCollapsed" />
<main class="flex-1 bg-gray-50 p-4">
<router-view />
</main>
</div>
<Toast position="top-right" />
<ConfirmDialog />
</div>
</template>
Phase 7: Migration from Alpine.js (Days 15-18)
2.7.1 Migration Strategy
- Parallel Development: Keep Alpine.js version running alongside Vue
- Incremental Migration: Migrate one view at a time
- Shared API: Both frontends use the same CodeIgniter API
- Routing Switch: Routes point to Vue app for migrated views
- Feature Flags: Use feature flags to control which views use Vue
2.7.2 Shared Constants/Types
// src/types/api.ts
export interface ApiResponse<T> {
status: 'success' | 'error'
message: string
data: T
}
export interface Patient {
InternalPID: number
PatientID: string
FirstName: string
LastName: string
FullName: string
Sex: string
DOB: string
EmailAddress?: string
Phone?: string
[key: string]: unknown
}
Phase 8: Testing (Days 19-20)
2.8.1 Testing Stack
# Unit and Component Testing
npm install -D vitest @vue/test-utils happy-dom @testing-library/jest-dom
# E2E Testing
npm install -D cypress
2.8.2 Example Component Test
// src/components/__tests__/PatientList.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import PatientIndex from '@/views/patient/PatientIndex.vue'
import { createTestingPinia } from '@pinia/testing'
describe('PatientIndex', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('renders patient list', async () => {
const wrapper = mount(PatientIndex, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
initialState: {
patient: {
patients: [
{ InternalPID: 1, PatientID: 'P001', FullName: 'John Doe' },
],
totalRecords: 1,
},
},
}),
],
stubs: {
DataTable: {
template: '<div><slot name="body"></slot></div>',
},
},
},
})
expect(wrapper.text()).toContain('Patients')
})
})
3. Environment Configuration
3.1 Environment Variables (.env)
# API Configuration
VITE_API_BASE_URL=http://localhost:8080/api
# App Configuration
VITE_APP_TITLE=CLQMS
VITE_APP_VERSION=1.0.0
# Feature Flags
VITE_ENABLE_MOCK_API=false
3.2 API Endpoints Integration
| Endpoint | Method | Description |
|---|---|---|
/auth/login |
POST | User authentication |
/auth/logout |
POST | User logout |
/patients |
GET | List patients with filters |
/patients |
POST | Create new patient |
/patients/:id |
GET | Get patient details |
/patients/:id |
PUT | Update patient |
/patients/:id |
DELETE | Soft delete patient |
4. Keyboard Accessibility Standards
4.1 Required Keyboard Interactions
| Component | Keyboard Behavior |
|---|---|
| Modals/Dialogs | Focus trap, Escape to close, Tab cycles focus |
| Menus | Arrow keys navigate, Enter selects, Escape closes |
| Data Tables | Arrow keys row/col, Ctrl+Arrow scroll |
| Forms | Auto-focus first field, Tab order preserved |
| Shortcuts | Ctrl+Key global, prevent default browser shortcuts |
| Skip Links | Skip to main content, skip to navigation |
4.2 Global Keyboard Shortcuts
| Shortcut | Action |
|---|---|
| Ctrl+C | Create new record |
| Ctrl+F | Focus search |
| Ctrl+S | Save form |
| Ctrl+N | New form |
| Ctrl+E | Edit selected |
| Ctrl+D | Duplicate |
| Ctrl+Z | Undo |
| Ctrl+Shift+Z | Redo |
| Escape | Cancel/Close modal |
| Tab | Move to next focusable |
| Shift+Tab | Move to previous focusable |
4.3 ARIA Attributes Required
<!-- Landmark regions -->
<header role="banner">AppHeader.vue</header>
<nav role="navigation">AppSidebar.vue</nav>
<main role="main">Main content area</main>
<footer role="contentinfo">AppFooter.vue</footer>
<!-- Live regions for notifications -->
<div role="status" aria-live="polite"></div>
<div role="alert" aria-live="assertive"></div>
<!-- Focus management -->
<button aria-expanded="true">...</button>
<button aria-haspopup="true">...</button>
5. Implementation Checklist
Week 1
- Initialize Vue 3 project with Vite
- Configure TypeScript and build settings
- Set up Pinia store structure
- Configure Vue Router with auth guards
- Implement API service layer with Axios
- Create authentication flow (login/logout)
- Set up PrimeVue theming
Week 2
- Build main layout (header, sidebar, content)
- Create Patient List view with DataTable
- Create Patient Create/Edit forms
- Implement search and filtering
- Add pagination
- Create global keyboard shortcuts
- Implement focus trap for modals
Week 3
- Migrate additional domain modules
- Add Toast/Notification system
- Implement form validation
- Add loading states
- Create error handling
- Implement optimistic UI updates
- Add keyboard navigation for DataTable
Week 4
- Write unit tests (Vitest)
- Write component tests (Vue Test Utils)
- Set up E2E tests (Cypress)
- Accessibility audit (axe-core)
- Performance optimization
- Build and deployment setup
- Documentation
6. Estimated Timeline
| Phase | Duration | Total Days |
|---|---|---|
| Phase 1: Project Setup | 2 days | Day 1-2 |
| Phase 2: Core Infrastructure | 2 days | Day 3-4 |
| Phase 3: Authentication | 2 days | Day 5-6 |
| Phase 4: Patient Module | 4 days | Day 7-10 |
| Phase 5: Accessibility | 2 days | Day 11-12 |
| Phase 6: Layout Components | 2 days | Day 13-14 |
| Phase 7: Migration | 4 days | Day 15-18 |
| Phase 8: Testing | 2 days | Day 19-20 |
Total: 20 working days (4 weeks)
7. Rollback Plan
If Vue migration encounters issues:
- Feature Flag Rollback: Routes can be switched back to Alpine.js views
- Database Changes: None required (shared API)
- API Compatibility: Both frontends use identical API contracts
- Deployment: Can deploy Alpine.js version independently
8. References
- Vue 3 Documentation
- PrimeVue 4 Documentation
- VueUse Documentation
- Headless UI Documentation
- WAI-ARIA Practices
- WCAG 2.1 Guidelines
9. Next Steps
- Review and approve this plan
- Confirm development environment (Node.js version, package manager)
- Set up Git repository for frontend
- Begin Phase 1: Project Setup