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.
1070 lines
29 KiB
Markdown
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
|