Skip to content

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

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