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

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

  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

// 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:

  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


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