Vue.js Development Best Practices¶
Overview¶
This document provides comprehensive Vue.js development best practices for the Dispatch Center Application frontend, covering coding standards, security, performance, testing, deployment, and integration with the backend APIs.
Table of Contents¶
- Project Structure
- Coding Standards
- Component Architecture
- State Management
- Security Best Practices
- API Integration
- Performance Optimization
- Testing Standards
- Build and Deployment
- Monitoring and Logging
Project Structure¶
Recommended Directory Structure¶
src/
├── assets/ # Static assets (images, fonts, etc.)
├── components/ # Reusable components
│ ├── common/ # Generic UI components
│ ├── forms/ # Form components
│ └── layout/ # Layout components
├── composables/ # Vue 3 composition functions
├── views/ # Page components (route components)
│ ├── CustomerView/ # Customer management pages
│ ├── ServiceRequestView/ # Service request pages
│ ├── TechnicianView/ # Technician management pages
│ ├── BillingView/ # Billing and invoice pages
│ └── DispatchView/ # Dispatch operations pages
├── stores/ # Pinia store modules
├── router/ # Vue Router configuration
├── services/ # API service layers
├── utils/ # Utility functions
├── types/ # TypeScript type definitions
├── constants/ # Application constants
└── styles/ # Global styles and Tailwind configuration
Vue 3 Composition API Project Setup¶
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import routes from './router/routes'
import './styles/main.css'
const app = createApp(App)
const pinia = createPinia()
const router = createRouter({
history: createWebHistory(),
routes
})
// Global error handler - see [Alerting Documentation](./ALERTING.md)
app.config.errorHandler = (error, instance, info) => {
console.error('Global error:', error, info)
// Send to monitoring service
window.dispatchCenterMonitoring?.captureException(error, { context: info })
}
app.use(pinia)
app.use(router)
app.mount('#app')
Coding Standards¶
TypeScript Configuration¶
// types/api.ts
export interface ServiceRequest {
id: number
customerId: string
description: string
priority: Priority
status: ServiceRequestStatus
requestedDate: string
scheduledDate?: string
assignedTechnicianId?: string
customer?: Customer
assignedTechnician?: Technician
}
export interface Customer {
id: string
customerNumber: string
companyName: string
contactFirstName: string
contactLastName: string
email: string
phone: string
isActive: boolean
}
export enum Priority {
Low = 1,
Medium = 2,
High = 3,
Critical = 4,
Emergency = 5
}
export enum ServiceRequestStatus {
Open = 'Open',
Assigned = 'Assigned',
InProgress = 'InProgress',
Completed = 'Completed',
Cancelled = 'Cancelled'
}
Component Naming and Structure¶
<!-- ✅ Good - PascalCase for component names -->
<!-- components/ServiceRequestCard.vue -->
<template>
<div class="service-request-card bg-white rounded-lg shadow-md p-6">
<div class="flex justify-between items-start mb-4">
<h3 class="text-lg font-semibold text-gray-900">
Request #{{ serviceRequest.id }}
</h3>
<PriorityBadge :priority="serviceRequest.priority" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700">Customer</label>
<p class="text-sm text-gray-900">{{ serviceRequest.customer?.companyName }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Status</label>
<StatusBadge :status="serviceRequest.status" />
</div>
</div>
<p class="text-sm text-gray-600 mb-4">{{ serviceRequest.description }}</p>
<div class="flex justify-end space-x-2">
<SecondaryButton @click="$emit('edit', serviceRequest.id)">
Edit
</SecondaryButton>
<PrimaryButton
v-if="canAssignTechnician"
@click="$emit('assign-technician', serviceRequest.id)"
>
Assign Technician
</PrimaryButton>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import type { ServiceRequest } from '@/types/api'
import PriorityBadge from '@/components/common/PriorityBadge.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import PrimaryButton from '@/components/common/PrimaryButton.vue'
import SecondaryButton from '@/components/common/SecondaryButton.vue'
interface Props {
serviceRequest: ServiceRequest
}
interface Emits {
(e: 'edit', id: number): void
(e: 'assign-technician', id: number): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const authStore = useAuthStore()
const canAssignTechnician = computed(() => {
return authStore.hasPermission('service_requests.assign') &&
props.serviceRequest.status === 'Open'
})
</script>
<style scoped>
.service-request-card {
transition: transform 0.2s ease-in-out;
}
.service-request-card:hover {
transform: translateY(-2px);
}
</style>
Composition API Best Practices¶
// composables/useServiceRequests.ts
import { ref, computed, onMounted } from 'vue'
import { useNotifications } from '@/composables/useNotifications'
import { serviceRequestService } from '@/services/serviceRequestService'
import type { ServiceRequest, CreateServiceRequestDto } from '@/types/api'
export function useServiceRequests() {
const { showError, showSuccess } = useNotifications()
const serviceRequests = ref<ServiceRequest[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const openRequests = computed(() =>
serviceRequests.value.filter(sr => sr.status === 'Open')
)
const highPriorityRequests = computed(() =>
serviceRequests.value.filter(sr => sr.priority >= 4)
)
const loadServiceRequests = async () => {
loading.value = true
error.value = null
try {
const response = await serviceRequestService.getAll()
serviceRequests.value = response.data
} catch (err) {
error.value = 'Failed to load service requests'
showError('Failed to load service requests')
console.error('Error loading service requests:', err)
} finally {
loading.value = false
}
}
const createServiceRequest = async (data: CreateServiceRequestDto) => {
loading.value = true
try {
const response = await serviceRequestService.create(data)
serviceRequests.value.unshift(response.data)
showSuccess('Service request created successfully')
return response.data
} catch (err) {
showError('Failed to create service request')
throw err
} finally {
loading.value = false
}
}
const assignTechnician = async (serviceRequestId: number, technicianId: string) => {
try {
const response = await serviceRequestService.assignTechnician(serviceRequestId, technicianId)
// Update local state
const index = serviceRequests.value.findIndex(sr => sr.id === serviceRequestId)
if (index !== -1) {
serviceRequests.value[index] = response.data
}
showSuccess('Technician assigned successfully')
} catch (err) {
showError('Failed to assign technician')
throw err
}
}
onMounted(() => {
loadServiceRequests()
})
return {
serviceRequests: readonly(serviceRequests),
openRequests,
highPriorityRequests,
loading: readonly(loading),
error: readonly(error),
loadServiceRequests,
createServiceRequest,
assignTechnician
}
}
Component Architecture¶
Reusable Component Design¶
<!-- components/common/DataTable.vue -->
<template>
<div class="data-table">
<div class="flex justify-between items-center mb-4">
<h2 v-if="title" class="text-lg font-semibold text-gray-900">{{ title }}</h2>
<div class="flex space-x-2">
<SearchInput
v-if="searchable"
v-model="searchQuery"
placeholder="Search..."
/>
<slot name="actions" />
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th
v-for="column in columns"
:key="column.key"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
@click="handleSort(column.key)"
>
<div class="flex items-center space-x-1">
<span>{{ column.label }}</span>
<SortIcon v-if="column.sortable" :direction="getSortDirection(column.key)" />
</div>
</th>
<th v-if="actions" class="relative px-6 py-3">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="(item, index) in paginatedData"
:key="getRowKey(item, index)"
class="hover:bg-gray-50"
>
<td
v-for="column in columns"
:key="column.key"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
>
<slot
:name="`cell-${column.key}`"
:item="item"
:value="getNestedValue(item, column.key)"
:column="column"
>
{{ getNestedValue(item, column.key) }}
</slot>
</td>
<td v-if="actions" class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<slot name="actions" :item="item" :index="index" />
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
v-if="pagination"
:current-page="currentPage"
:total-pages="totalPages"
:total-items="filteredData.length"
:page-size="pageSize"
@page-changed="currentPage = $event"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import SearchInput from './SearchInput.vue'
import SortIcon from './SortIcon.vue'
import Pagination from './Pagination.vue'
interface Column {
key: string
label: string
sortable?: boolean
width?: string
}
interface Props {
data: Record<string, any>[]
columns: Column[]
title?: string
searchable?: boolean
pagination?: boolean
pageSize?: number
actions?: boolean
rowKey?: string
}
const props = withDefaults(defineProps<Props>(), {
searchable: true,
pagination: true,
pageSize: 10,
actions: false,
rowKey: 'id'
})
const searchQuery = ref('')
const sortKey = ref<string | null>(null)
const sortDirection = ref<'asc' | 'desc'>('asc')
const currentPage = ref(1)
const filteredData = computed(() => {
let filtered = props.data
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(item =>
props.columns.some(column =>
String(getNestedValue(item, column.key)).toLowerCase().includes(query)
)
)
}
if (sortKey.value) {
filtered = [...filtered].sort((a, b) => {
const aVal = getNestedValue(a, sortKey.value!)
const bVal = getNestedValue(b, sortKey.value!)
if (aVal < bVal) return sortDirection.value === 'asc' ? -1 : 1
if (aVal > bVal) return sortDirection.value === 'asc' ? 1 : -1
return 0
})
}
return filtered
})
const totalPages = computed(() =>
Math.ceil(filteredData.value.length / props.pageSize)
)
const paginatedData = computed(() => {
if (!props.pagination) return filteredData.value
const start = (currentPage.value - 1) * props.pageSize
const end = start + props.pageSize
return filteredData.value.slice(start, end)
})
const handleSort = (key: string) => {
const column = props.columns.find(c => c.key === key)
if (!column?.sortable) return
if (sortKey.value === key) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortKey.value = key
sortDirection.value = 'asc'
}
}
const getSortDirection = (key: string) => {
if (sortKey.value !== key) return null
return sortDirection.value
}
const getNestedValue = (obj: any, path: string) => {
return path.split('.').reduce((current, key) => current?.[key], obj) ?? ''
}
const getRowKey = (item: any, index: number) => {
return item[props.rowKey] ?? index
}
// Reset page when search changes
watch(searchQuery, () => {
currentPage.value = 1
})
</script>
Form Components with Validation¶
<!-- components/forms/ServiceRequestForm.vue -->
<template>
<form @submit.prevent="handleSubmit" class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
label="Customer"
:error="errors.customerId"
required
>
<CustomerSelect
v-model="form.customerId"
:error="!!errors.customerId"
placeholder="Select a customer"
/>
</FormField>
<FormField
label="Priority"
:error="errors.priority"
required
>
<PrioritySelect
v-model="form.priority"
:error="!!errors.priority"
/>
</FormField>
</div>
<FormField
label="Description"
:error="errors.description"
required
>
<TextArea
v-model="form.description"
:error="!!errors.description"
placeholder="Describe the service request..."
rows="4"
/>
</FormField>
<FormField
label="Requested Date"
:error="errors.requestedDate"
required
>
<DatePicker
v-model="form.requestedDate"
:error="!!errors.requestedDate"
:min-date="new Date()"
/>
</FormField>
<div class="flex justify-end space-x-4">
<SecondaryButton type="button" @click="$emit('cancel')">
Cancel
</SecondaryButton>
<PrimaryButton
type="submit"
:loading="loading"
:disabled="!isFormValid"
>
{{ isEditMode ? 'Update' : 'Create' }} Service Request
</PrimaryButton>
</div>
</form>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useValidation } from '@/composables/useValidation'
import type { CreateServiceRequestDto, ServiceRequest } from '@/types/api'
interface Props {
initialData?: Partial<ServiceRequest>
loading?: boolean
}
interface Emits {
(e: 'submit', data: CreateServiceRequestDto): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
const emit = defineEmits<Emits>()
const form = ref<CreateServiceRequestDto>({
customerId: '',
description: '',
priority: 2,
requestedDate: new Date().toISOString().split('T')[0]
})
const validationRules = {
customerId: [
{ required: true, message: 'Customer is required' }
],
description: [
{ required: true, message: 'Description is required' },
{ minLength: 10, message: 'Description must be at least 10 characters' },
{ maxLength: 2000, message: 'Description cannot exceed 2000 characters' }
],
priority: [
{ required: true, message: 'Priority is required' },
{ min: 1, max: 5, message: 'Priority must be between 1 and 5' }
],
requestedDate: [
{ required: true, message: 'Requested date is required' },
{
custom: (value: string) => new Date(value) >= new Date().setHours(0, 0, 0, 0),
message: 'Requested date cannot be in the past'
}
]
}
const { errors, validateField, validateForm, clearErrors } = useValidation(validationRules)
const isEditMode = computed(() => !!props.initialData?.id)
const isFormValid = computed(() => {
return Object.keys(validationRules).every(field => !errors.value[field])
})
const handleSubmit = async () => {
const isValid = await validateForm(form.value)
if (isValid) {
emit('submit', form.value)
}
}
// Watch for field changes and validate
watch(() => form.value.customerId, (value) => validateField('customerId', value))
watch(() => form.value.description, (value) => validateField('description', value))
watch(() => form.value.priority, (value) => validateField('priority', value))
watch(() => form.value.requestedDate, (value) => validateField('requestedDate', value))
// Initialize form with initial data
watch(() => props.initialData, (data) => {
if (data) {
form.value = {
customerId: data.customerId || '',
description: data.description || '',
priority: data.priority || 2,
requestedDate: data.requestedDate?.split('T')[0] || new Date().toISOString().split('T')[0]
}
clearErrors()
}
}, { immediate: true })
</script>
State Management¶
Pinia Store Implementation¶
// stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authService } from '@/services/authService'
import type { User, LoginCredentials } from '@/types/auth'
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(localStorage.getItem('token'))
const refreshToken = ref<string | null>(localStorage.getItem('refreshToken'))
const loading = ref(false)
const isAuthenticated = computed(() => !!token.value && !!user.value)
const userRoles = computed(() => user.value?.roles || [])
const userPermissions = computed(() =>
user.value?.permissions || []
)
const hasRole = (role: string) => {
return userRoles.value.includes(role)
}
const hasPermission = (permission: string) => {
return userPermissions.value.includes(permission) || hasRole('Admin')
}
const hasAnyRole = (roles: string[]) => {
return roles.some(role => hasRole(role))
}
const login = async (credentials: LoginCredentials) => {
loading.value = true
try {
const response = await authService.login(credentials)
token.value = response.token
refreshToken.value = response.refreshToken
user.value = response.user
localStorage.setItem('token', response.token)
localStorage.setItem('refreshToken', response.refreshToken)
return response
} catch (error) {
// Clear any existing auth state on login failure
logout()
throw error
} finally {
loading.value = false
}
}
const logout = () => {
user.value = null
token.value = null
refreshToken.value = null
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
// Redirect to login page
window.location.href = '/login'
}
const refreshAuthToken = async () => {
if (!refreshToken.value) {
logout()
return
}
try {
const response = await authService.refreshToken(refreshToken.value)
token.value = response.token
localStorage.setItem('token', response.token)
return response.token
} catch (error) {
logout()
throw error
}
}
const fetchUserProfile = async () => {
if (!token.value) return
try {
const userProfile = await authService.getProfile()
user.value = userProfile
} catch (error) {
console.error('Failed to fetch user profile:', error)
logout()
}
}
return {
user: readonly(user),
token: readonly(token),
loading: readonly(loading),
isAuthenticated,
userRoles,
userPermissions,
hasRole,
hasPermission,
hasAnyRole,
login,
logout,
refreshAuthToken,
fetchUserProfile
}
})
Service Request Store¶
// stores/serviceRequests.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { serviceRequestService } from '@/services/serviceRequestService'
import type { ServiceRequest, CreateServiceRequestDto, ServiceRequestFilters } from '@/types/api'
export const useServiceRequestStore = defineStore('serviceRequests', () => {
const serviceRequests = ref<ServiceRequest[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const filters = ref<ServiceRequestFilters>({
status: null,
priority: null,
customerId: null,
technicianId: null,
dateFrom: null,
dateTo: null
})
const filteredServiceRequests = computed(() => {
let filtered = serviceRequests.value
if (filters.value.status) {
filtered = filtered.filter(sr => sr.status === filters.value.status)
}
if (filters.value.priority) {
filtered = filtered.filter(sr => sr.priority === filters.value.priority)
}
if (filters.value.customerId) {
filtered = filtered.filter(sr => sr.customerId === filters.value.customerId)
}
if (filters.value.technicianId) {
filtered = filtered.filter(sr => sr.assignedTechnicianId === filters.value.technicianId)
}
return filtered
})
const openServiceRequests = computed(() =>
serviceRequests.value.filter(sr => sr.status === 'Open')
)
const myServiceRequests = computed(() => {
const authStore = useAuthStore()
const currentUserId = authStore.user?.id
if (authStore.hasRole('Technician')) {
return serviceRequests.value.filter(sr => sr.assignedTechnicianId === currentUserId)
}
return serviceRequests.value
})
const fetchServiceRequests = async () => {
loading.value = true
error.value = null
try {
const response = await serviceRequestService.getAll()
serviceRequests.value = response.data
} catch (err) {
error.value = 'Failed to fetch service requests'
console.error('Error fetching service requests:', err)
} finally {
loading.value = false
}
}
const createServiceRequest = async (data: CreateServiceRequestDto) => {
try {
const response = await serviceRequestService.create(data)
serviceRequests.value.unshift(response.data)
return response.data
} catch (error) {
console.error('Error creating service request:', error)
throw error
}
}
const updateServiceRequest = async (id: number, data: Partial<ServiceRequest>) => {
try {
const response = await serviceRequestService.update(id, data)
const index = serviceRequests.value.findIndex(sr => sr.id === id)
if (index !== -1) {
serviceRequests.value[index] = response.data
}
return response.data
} catch (error) {
console.error('Error updating service request:', error)
throw error
}
}
const assignTechnician = async (serviceRequestId: number, technicianId: string) => {
try {
const response = await serviceRequestService.assignTechnician(serviceRequestId, technicianId)
const index = serviceRequests.value.findIndex(sr => sr.id === serviceRequestId)
if (index !== -1) {
serviceRequests.value[index] = response.data
}
return response.data
} catch (error) {
console.error('Error assigning technician:', error)
throw error
}
}
const setFilters = (newFilters: Partial<ServiceRequestFilters>) => {
filters.value = { ...filters.value, ...newFilters }
}
const clearFilters = () => {
filters.value = {
status: null,
priority: null,
customerId: null,
technicianId: null,
dateFrom: null,
dateTo: null
}
}
return {
serviceRequests: readonly(serviceRequests),
filteredServiceRequests,
openServiceRequests,
myServiceRequests,
loading: readonly(loading),
error: readonly(error),
filters: readonly(filters),
fetchServiceRequests,
createServiceRequest,
updateServiceRequest,
assignTechnician,
setFilters,
clearFilters
}
})
Security Best Practices¶
For comprehensive security guidelines, see Security Documentation
XSS Prevention¶
// utils/sanitization.ts
import DOMPurify from 'dompurify'
export const sanitizeHtml = (html: string): string => {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: []
})
}
export const escapeHtml = (text: string): string => {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// Custom directive for safe HTML rendering
// directives/safe-html.ts
import { Directive } from 'vue'
import { sanitizeHtml } from '@/utils/sanitization'
export const vSafeHtml: Directive = {
mounted(el: HTMLElement, binding) {
el.innerHTML = sanitizeHtml(binding.value)
},
updated(el: HTMLElement, binding) {
el.innerHTML = sanitizeHtml(binding.value)
}
}
Content Security Policy¶
// utils/csp.ts
export const CSP_DIRECTIVES = {
'default-src': ["'self'"],
'script-src': [
"'self'",
"'unsafe-inline'", // For Vue.js development - remove in production
'https://cdn.jsdelivr.net' // For external libraries
],
'style-src': [
"'self'",
"'unsafe-inline'", // For Tailwind CSS
'https://fonts.googleapis.com'
],
'font-src': [
"'self'",
'https://fonts.gstatic.com'
],
'img-src': [
"'self'",
'data:',
'https:'
],
'connect-src': [
"'self'",
process.env.VUE_APP_API_BASE_URL || 'http://localhost:5000'
],
'frame-ancestors': ["'none'"],
'form-action': ["'self'"],
'base-uri': ["'self'"]
}
Authentication Token Management¶
// services/tokenService.ts
class TokenService {
private readonly TOKEN_KEY = 'auth_token'
private readonly REFRESH_TOKEN_KEY = 'refresh_token'
private readonly TOKEN_EXPIRY_KEY = 'token_expiry'
setToken(token: string, expiresIn: number): void {
localStorage.setItem(this.TOKEN_KEY, token)
const expiryTime = Date.now() + (expiresIn * 1000)
localStorage.setItem(this.TOKEN_EXPIRY_KEY, expiryTime.toString())
}
getToken(): string | null {
const token = localStorage.getItem(this.TOKEN_KEY)
const expiry = localStorage.getItem(this.TOKEN_EXPIRY_KEY)
if (!token || !expiry) return null
if (Date.now() > parseInt(expiry)) {
this.clearTokens()
return null
}
return token
}
setRefreshToken(refreshToken: string): void {
localStorage.setItem(this.REFRESH_TOKEN_KEY, refreshToken)
}
getRefreshToken(): string | null {
return localStorage.getItem(this.REFRESH_TOKEN_KEY)
}
clearTokens(): void {
localStorage.removeItem(this.TOKEN_KEY)
localStorage.removeItem(this.REFRESH_TOKEN_KEY)
localStorage.removeItem(this.TOKEN_EXPIRY_KEY)
}
isTokenExpired(): boolean {
const expiry = localStorage.getItem(this.TOKEN_EXPIRY_KEY)
if (!expiry) return true
return Date.now() > parseInt(expiry)
}
}
export const tokenService = new TokenService()
API Integration¶
For detailed integration patterns, see Integration Patterns Documentation
HTTP Client Configuration¶
// services/httpClient.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { useAuthStore } from '@/stores/auth'
import { useNotifications } from '@/composables/useNotifications'
import { tokenService } from './tokenService'
class HttpClient {
private readonly instance: AxiosInstance
private readonly { showError } = useNotifications()
constructor() {
this.instance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
this.setupInterceptors()
}
private setupInterceptors(): void {
// Request interceptor for adding auth token
this.instance.interceptors.request.use(
(config) => {
const token = tokenService.getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// Add correlation ID for request tracing - see [Logging Documentation](./LOGGING.md)
config.headers['X-Correlation-ID'] = crypto.randomUUID()
return config
},
(error) => Promise.reject(error)
)
// Response interceptor for handling errors and token refresh
this.instance.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
try {
const authStore = useAuthStore()
await authStore.refreshAuthToken()
// Retry the original request with the new token
const token = tokenService.getToken()
if (token) {
originalRequest.headers.Authorization = `Bearer ${token}`
}
return this.instance(originalRequest)
} catch (refreshError) {
// Refresh failed, redirect to login
const authStore = useAuthStore()
authStore.logout()
return Promise.reject(refreshError)
}
}
// Handle other error types
this.handleApiError(error)
return Promise.reject(error)
}
)
}
private handleApiError(error: any): void {
if (error.response) {
// Server responded with error status
const { status, data } = error.response
switch (status) {
case 400:
this.showError(data.message || 'Invalid request')
break
case 403:
this.showError('Access denied')
break
case 404:
this.showError('Resource not found')
break
case 500:
this.showError('Server error. Please try again later.')
// Report to monitoring service - see [Alerting Documentation](./ALERTING.md)
window.dispatchCenterMonitoring?.captureException(error)
break
default:
this.showError('An unexpected error occurred')
}
} else if (error.request) {
// Network error
this.showError('Network error. Please check your connection.')
} else {
// Other error
this.showError('An unexpected error occurred')
}
}
// HTTP methods
async get<T>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.instance.get(url, config)
}
async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.instance.post(url, data, config)
}
async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.instance.put(url, data, config)
}
async patch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.instance.patch(url, data, config)
}
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.instance.delete(url, config)
}
}
export const httpClient = new HttpClient()
Service Layer Implementation¶
// services/serviceRequestService.ts
import { httpClient } from './httpClient'
import type {
ServiceRequest,
CreateServiceRequestDto,
UpdateServiceRequestDto,
PaginatedResponse
} from '@/types/api'
class ServiceRequestService {
private readonly basePath = '/api/servicerequests'
async getAll(params?: {
page?: number
pageSize?: number
status?: string
customerId?: string
}): Promise<PaginatedResponse<ServiceRequest>> {
const response = await httpClient.get<PaginatedResponse<ServiceRequest>>(this.basePath, { params })
return response.data
}
async getById(id: number): Promise<ServiceRequest> {
const response = await httpClient.get<ServiceRequest>(`${this.basePath}/${id}`)
return response.data
}
async create(data: CreateServiceRequestDto): Promise<ServiceRequest> {
const response = await httpClient.post<ServiceRequest>(this.basePath, data)
return response.data
}
async update(id: number, data: UpdateServiceRequestDto): Promise<ServiceRequest> {
const response = await httpClient.put<ServiceRequest>(`${this.basePath}/${id}`, data)
return response.data
}
async delete(id: number): Promise<void> {
await httpClient.delete(`${this.basePath}/${id}`)
}
async assignTechnician(serviceRequestId: number, technicianId: string): Promise<ServiceRequest> {
const response = await httpClient.post<ServiceRequest>(
`${this.basePath}/${serviceRequestId}/assign-technician`,
{ technicianId }
)
return response.data
}
async updateStatus(id: number, status: string, notes?: string): Promise<ServiceRequest> {
const response = await httpClient.patch<ServiceRequest>(`${this.basePath}/${id}/status`, {
status,
notes
})
return response.data
}
}
export const serviceRequestService = new ServiceRequestService()
Performance Optimization¶
Lazy Loading and Code Splitting¶
// router/routes.ts
import { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Dashboard',
component: () => import('@/views/DashboardView.vue')
},
{
path: '/customers',
name: 'Customers',
component: () => import('@/views/CustomerView/CustomerListView.vue'),
meta: { requiredPermission: 'customers.view' }
},
{
path: '/customers/:id',
name: 'CustomerDetail',
component: () => import('@/views/CustomerView/CustomerDetailView.vue'),
props: true,
meta: { requiredPermission: 'customers.view' }
},
{
path: '/service-requests',
name: 'ServiceRequests',
component: () => import('@/views/ServiceRequestView/ServiceRequestListView.vue'),
meta: { requiredPermission: 'service_requests.view' }
},
{
path: '/dispatch',
name: 'Dispatch',
component: () => import('@/views/DispatchView/DispatchBoardView.vue'),
meta: { requiredRole: 'Dispatcher' }
},
{
path: '/billing',
name: 'Billing',
component: () => import('@/views/BillingView/BillingDashboardView.vue'),
meta: { requiredRole: 'BillingClerk' }
}
]
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/LoginView.vue'),
meta: { requiresGuest: true }
},
{
path: '/unauthorized',
name: 'Unauthorized',
component: () => import('@/views/UnauthorizedView.vue')
}
]
export default routes
Virtual Scrolling for Large Lists¶
<!-- components/VirtualList.vue -->
<template>
<div
ref="containerRef"
class="virtual-list-container"
:style="{ height: `${containerHeight}px` }"
@scroll="handleScroll"
>
<div
class="virtual-list-spacer"
:style="{ height: `${totalHeight}px` }"
>
<div
class="virtual-list-items"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="item in visibleItems"
:key="getItemKey(item.data)"
class="virtual-list-item"
:style="{ height: `${itemHeight}px` }"
>
<slot :item="item.data" :index="item.index" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
interface Props {
items: any[]
itemHeight: number
containerHeight: number
itemKey?: string
overscan?: number
}
const props = withDefaults(defineProps<Props>(), {
itemKey: 'id',
overscan: 5
})
const containerRef = ref<HTMLElement>()
const scrollTop = ref(0)
const totalHeight = computed(() => props.items.length * props.itemHeight)
const startIndex = computed(() =>
Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.overscan)
)
const endIndex = computed(() =>
Math.min(
props.items.length - 1,
Math.ceil((scrollTop.value + props.containerHeight) / props.itemHeight) + props.overscan
)
)
const visibleItems = computed(() => {
const items = []
for (let i = startIndex.value; i <= endIndex.value; i++) {
items.push({
index: i,
data: props.items[i]
})
}
return items
})
const offsetY = computed(() => startIndex.value * props.itemHeight)
const handleScroll = (event: Event) => {
const target = event.target as HTMLElement
scrollTop.value = target.scrollTop
}
const getItemKey = (item: any) => {
return item[props.itemKey] || item
}
onMounted(() => {
if (containerRef.value) {
containerRef.value.addEventListener('scroll', handleScroll, { passive: true })
}
})
onUnmounted(() => {
if (containerRef.value) {
containerRef.value.removeEventListener('scroll', handleScroll)
}
})
</script>
<style scoped>
.virtual-list-container {
overflow-y: auto;
position: relative;
}
.virtual-list-spacer {
position: relative;
}
.virtual-list-items {
position: absolute;
top: 0;
left: 0;
right: 0;
}
</style>
Image Lazy Loading¶
<!-- components/LazyImage.vue -->
<template>
<div class="lazy-image-container" :class="containerClass">
<img
v-if="loaded"
:src="src"
:alt="alt"
:class="imageClass"
@load="onLoad"
@error="onError"
/>
<div v-else-if="loading" class="lazy-image-placeholder">
<LoadingSpinner />
</div>
<div v-else-if="error" class="lazy-image-error">
<ExclamationTriangleIcon class="w-8 h-8 text-gray-400" />
<span class="text-sm text-gray-500">Failed to load image</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import LoadingSpinner from './LoadingSpinner.vue'
import { ExclamationTriangleIcon } from '@heroicons/vue/24/outline'
interface Props {
src: string
alt: string
containerClass?: string
imageClass?: string
threshold?: number
}
const props = withDefaults(defineProps<Props>(), {
threshold: 0.1
})
const containerRef = ref<HTMLElement>()
const loaded = ref(false)
const loading = ref(false)
const error = ref(false)
const observer = ref<IntersectionObserver>()
const loadImage = () => {
if (loaded.value || loading.value || error.value) return
loading.value = true
const img = new Image()
img.onload = () => {
loaded.value = true
loading.value = false
}
img.onerror = () => {
error.value = true
loading.value = false
}
img.src = props.src
}
const onLoad = () => {
// Image successfully loaded
}
const onError = () => {
error.value = true
loaded.value = false
}
onMounted(() => {
observer.value = new IntersectionObserver(
(entries) => {
const entry = entries[0]
if (entry.isIntersecting) {
loadImage()
observer.value?.disconnect()
}
},
{ threshold: props.threshold }
)
if (containerRef.value) {
observer.value.observe(containerRef.value)
}
})
onUnmounted(() => {
observer.value?.disconnect()
})
</script>
Testing Standards¶
Unit Testing with Vitest¶
// tests/components/ServiceRequestCard.test.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import ServiceRequestCard from '@/components/ServiceRequestCard.vue'
import { useAuthStore } from '@/stores/auth'
import type { ServiceRequest } from '@/types/api'
describe('ServiceRequestCard', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
const mockServiceRequest: ServiceRequest = {
id: 1,
customerId: 'CUST001',
description: 'Test service request',
priority: 3,
status: 'Open',
requestedDate: '2025-11-03T10:00:00Z',
customer: {
id: 'CUST001',
customerNumber: '001',
companyName: 'Test Company',
contactFirstName: 'John',
contactLastName: 'Doe',
email: 'john@test.com',
phone: '555-1234',
isActive: true
}
}
it('renders service request information correctly', () => {
const wrapper = mount(ServiceRequestCard, {
props: { serviceRequest: mockServiceRequest }
})
expect(wrapper.text()).toContain('Request #1')
expect(wrapper.text()).toContain('Test Company')
expect(wrapper.text()).toContain('Test service request')
})
it('shows assign technician button for dispatchers', () => {
const authStore = useAuthStore()
vi.spyOn(authStore, 'hasPermission').mockReturnValue(true)
const wrapper = mount(ServiceRequestCard, {
props: { serviceRequest: mockServiceRequest }
})
expect(wrapper.find('[data-testid="assign-technician-btn"]').exists()).toBe(true)
})
it('hides assign technician button for unauthorized users', () => {
const authStore = useAuthStore()
vi.spyOn(authStore, 'hasPermission').mockReturnValue(false)
const wrapper = mount(ServiceRequestCard, {
props: { serviceRequest: mockServiceRequest }
})
expect(wrapper.find('[data-testid="assign-technician-btn"]').exists()).toBe(false)
})
it('emits assign-technician event when button clicked', async () => {
const authStore = useAuthStore()
vi.spyOn(authStore, 'hasPermission').mockReturnValue(true)
const wrapper = mount(ServiceRequestCard, {
props: { serviceRequest: mockServiceRequest }
})
await wrapper.find('[data-testid="assign-technician-btn"]').trigger('click')
expect(wrapper.emitted('assign-technician')).toHaveLength(1)
expect(wrapper.emitted('assign-technician')[0]).toEqual([1])
})
})
E2E Testing with Playwright¶
// tests/e2e/service-requests.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Service Requests', () => {
test.beforeEach(async ({ page }) => {
// Login as dispatcher
await page.goto('/login')
await page.fill('[data-testid="username"]', 'dispatcher@test.com')
await page.fill('[data-testid="password"]', 'password123')
await page.click('[data-testid="login-btn"]')
// Wait for navigation to dashboard
await expect(page).toHaveURL('/dashboard')
})
test('should create a new service request', async ({ page }) => {
// Navigate to service requests
await page.click('[data-testid="nav-service-requests"]')
await expect(page).toHaveURL('/service-requests')
// Click create button
await page.click('[data-testid="create-service-request-btn"]')
// Fill form
await page.selectOption('[data-testid="customer-select"]', 'CUST001')
await page.fill('[data-testid="description-input"]', 'Test service request from E2E')
await page.selectOption('[data-testid="priority-select"]', '3')
await page.fill('[data-testid="requested-date"]', '2025-11-10')
// Submit form
await page.click('[data-testid="submit-btn"]')
// Verify success
await expect(page.locator('[data-testid="success-message"]')).toContainText('Service request created')
await expect(page.locator('[data-testid="service-request-list"]')).toContainText('Test service request from E2E')
})
test('should assign technician to service request', async ({ page }) => {
await page.goto('/service-requests')
// Find first open service request
const serviceRequestCard = page.locator('[data-testid="service-request-card"]').first()
// Click assign technician button
await serviceRequestCard.locator('[data-testid="assign-technician-btn"]').click()
// Select technician in modal
await page.selectOption('[data-testid="technician-select"]', 'TECH001')
await page.click('[data-testid="assign-confirm-btn"]')
// Verify assignment
await expect(page.locator('[data-testid="success-message"]')).toContainText('Technician assigned')
await expect(serviceRequestCard).toContainText('Assigned')
})
})
Build and Deployment¶
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')
}
},
build: {
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
charts: ['chart.js', 'vue-chartjs'],
utils: ['axios', 'date-fns', 'lodash-es']
}
}
},
chunkSizeWarningLimit: 1000
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
secure: false
}
}
},
define: {
__VUE_OPTIONS_API__: false,
__VUE_PROD_DEVTOOLS__: false
}
})
Environment Configuration¶
# .env.development
VITE_APP_TITLE=Dispatch Center - Development
VITE_API_BASE_URL=http://localhost:5000/api
VITE_MONITORING_DSN=
VITE_LOG_LEVEL=debug
# .env.production
VITE_APP_TITLE=Dispatch Center
VITE_API_BASE_URL=https://api.dispatchcenter.com/api
VITE_MONITORING_DSN=https://your-monitoring-dsn
VITE_LOG_LEVEL=warn
Docker Configuration¶
# Dockerfile
FROM node:18-alpine AS build
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci --only=production
# Copy source code
COPY . .
# Build application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built application
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Copy entrypoint script for environment variable substitution
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
Monitoring and Logging¶
For comprehensive monitoring guidelines, see Monitoring Documentation and Logging Documentation
Frontend Error Tracking¶
// services/monitoringService.ts
interface ErrorContext {
userId?: string
route?: string
userAgent?: string
timestamp?: string
additionalData?: Record<string, any>
}
class MonitoringService {
private dsn: string
private userId?: string
constructor(dsn: string) {
this.dsn = dsn
}
setUser(userId: string): void {
this.userId = userId
}
captureException(error: Error, context?: ErrorContext): void {
const errorData = {
message: error.message,
stack: error.stack,
name: error.name,
userId: this.userId,
route: window.location.pathname,
userAgent: navigator.userAgent,
timestamp: new Date().toISOString(),
...context
}
// Send to monitoring service
this.sendToMonitoring('error', errorData)
}
captureMessage(message: string, level: 'info' | 'warning' | 'error' = 'info'): void {
const messageData = {
message,
level,
userId: this.userId,
route: window.location.pathname,
timestamp: new Date().toISOString()
}
this.sendToMonitoring('message', messageData)
}
trackPageView(route: string): void {
const pageViewData = {
route,
userId: this.userId,
timestamp: new Date().toISOString(),
referrer: document.referrer
}
this.sendToMonitoring('pageview', pageViewData)
}
trackUserAction(action: string, data?: Record<string, any>): void {
const actionData = {
action,
userId: this.userId,
route: window.location.pathname,
timestamp: new Date().toISOString(),
...data
}
this.sendToMonitoring('action', actionData)
}
private async sendToMonitoring(type: string, data: any): Promise<void> {
try {
await fetch(`${this.dsn}/api/monitoring`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ type, data })
})
} catch (error) {
console.error('Failed to send monitoring data:', error)
}
}
}
export const monitoringService = new MonitoringService(
import.meta.env.VITE_MONITORING_DSN || ''
)
// Make available globally for error handler
declare global {
interface Window {
dispatchCenterMonitoring: MonitoringService
}
}
window.dispatchCenterMonitoring = monitoringService
Performance Monitoring¶
// composables/usePerformanceMonitoring.ts
import { onMounted, onUnmounted } from 'vue'
import { monitoringService } from '@/services/monitoringService'
export function usePerformanceMonitoring(routeName: string) {
let navigationStartTime: number
let observer: PerformanceObserver | null = null
const measureNavigationTiming = () => {
navigationStartTime = performance.now()
}
const measurePageLoadTime = () => {
const loadTime = performance.now() - navigationStartTime
monitoringService.trackUserAction('page_load_time', {
route: routeName,
loadTime: Math.round(loadTime),
navigationTiming: getNavigationTiming()
})
}
const getNavigationTiming = () => {
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
return {
domContentLoaded: Math.round(navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart),
loadComplete: Math.round(navigation.loadEventEnd - navigation.loadEventStart),
networkTime: Math.round(navigation.responseEnd - navigation.requestStart),
renderTime: Math.round(navigation.domComplete - navigation.domLoading)
}
}
const observeLargestContentfulPaint = () => {
observer = new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1]
monitoringService.trackUserAction('lcp', {
route: routeName,
value: Math.round(lastEntry.startTime)
})
})
observer.observe({ entryTypes: ['largest-contentful-paint'] })
}
onMounted(() => {
measureNavigationTiming()
observeLargestContentfulPaint()
// Measure after a short delay to ensure page is loaded
setTimeout(measurePageLoadTime, 100)
})
onUnmounted(() => {
observer?.disconnect()
})
}
Document Version: 1.0
Last Updated: January 2026
Next Review: April 2026
Related Documentation¶
- 🔒 Security Documentation - Security best practices and compliance
- 📝 Logging Documentation - Comprehensive logging strategies
- 🚨 Alerting Documentation - Alerting and incident response
- 🔄 Integration Patterns - System integration guidelines
- 🔗 Integration Systems - External system specifications
- 💻 C# Best Practices - Backend development guidelines