clqms-be/docs/VUE_SPA_IMPLEMENTATION_PLAN.md
mahdahar 4aa9cefc3d refactor: consolidate migrations and reorganize valueset data structure
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.
2026-01-13 07:22:25 +07:00

1070 lines
29 KiB
Markdown

# 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
```bash
# 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
```bash
# 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
```typescript
// 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
```typescript
// 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
```typescript
// 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
```typescript
// 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)
```typescript
// 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
```vue
<!-- 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
```vue
<!-- 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
```typescript
// 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)
```typescript
// 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
```vue
<!-- 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
1. **Parallel Development**: Keep Alpine.js version running alongside Vue
2. **Incremental Migration**: Migrate one view at a time
3. **Shared API**: Both frontends use the same CodeIgniter API
4. **Routing Switch**: Routes point to Vue app for migrated views
5. **Feature Flags**: Use feature flags to control which views use Vue
#### 2.7.2 Shared Constants/Types
```typescript
// 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
```bash
# 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
```typescript
// 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)
```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
```html
<!-- 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:
1. **Feature Flag Rollback**: Routes can be switched back to Alpine.js views
2. **Database Changes**: None required (shared API)
3. **API Compatibility**: Both frontends use identical API contracts
4. **Deployment**: Can deploy Alpine.js version independently
---
## 8. References
- [Vue 3 Documentation](https://vuejs.org/)
- [PrimeVue 4 Documentation](https://primevue.org/)
- [VueUse Documentation](https://vueuse.org/)
- [Headless UI Documentation](https://headlessui.com/)
- [WAI-ARIA Practices](https://www.w3.org/WAI/ARIA/apg/)
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
---
## 9. Next Steps
1. Review and approve this plan
2. Confirm development environment (Node.js version, package manager)
3. Set up Git repository for frontend
4. Begin Phase 1: Project Setup