Supplier Portal

🏗️ Arquitetura de Componentes e Fluxos - Portal do Fornecedor

🏗️ Arquitetura de Componentes e Fluxos - Portal do Fornecedor

📐 Arquitetura de Sistema

Visão Geral

graph TB
    subgraph "Frontend - Nuxt 3"
        A[Browser] --> B[Nuxt App]
        B --> C[Pages/Routes]
        C --> D[Components]
        D --> E[Composables]
        E --> F[API Client]
        B --> G[Pinia Stores]
        G --> F
    end

    subgraph "Backend - Laravel"
        F --> H[API Gateway]
        H --> I[Controllers]
        I --> J[Services]
        J --> K[Models]
        K --> L[(PostgreSQL)]
    end

    subgraph "External Services"
        J --> M[Redis Cache]
        J --> N[S3 Storage]
        J --> O[Queue Jobs]
        J --> P[WebSocket]
    end

🔄 Fluxo Principal: Visualizar e Enviar Proposta

User Journey Completo

journey
    title Jornada do Fornecedor - Envio de Proposta

    section Discovery
      Login no Portal: 5: Fornecedor
      Ver Dashboard: 4: Fornecedor
      Explorar Licitações: 5: Fornecedor

    section Evaluation
      Filtrar Licitações: 4: Fornecedor
      Ver Detalhes: 5: Fornecedor
      Analisar Requisitos: 3: Fornecedor

    section Decision
      Iniciar Proposta: 5: Fornecedor
      Preencher Preços: 3: Fornecedor
      Revisar Total: 4: Fornecedor

    section Action
      Adicionar Condições: 4: Fornecedor
      Upload Documentos: 3: Fornecedor
      Enviar Proposta: 5: Fornecedor

    section Result
      Ver Confirmação: 5: Fornecedor
      Acompanhar Status: 4: Fornecedor

Fluxo Técnico Detalhado

1. Autenticação e Acesso

// Flow: Login → Validate → Token → Dashboard

// 1. Login Request
POST /api/v1/login
{
  email: "fornecedor@example.com",
  password: "senha123"
}

// 2. Token Response
{
  token: "Bearer xxx",
  user: {
    id: 1,
    name: "Fornecedor X",
    supplier: {
      id: 10,
      cnpj: "12.345.678/0001-90",
      status: "active"
    }
  }
}

// 3. Store Token
const { setToken, setUser } = useAuthStore()
setToken(response.token)
setUser(response.user)

// 4. Redirect
await navigateTo('/dashboard')

2. Dashboard com Métricas

// Flow: Mount → Fetch → Display → Update

// composables/useDashboard.ts
export const useDashboard = () => {
  onMounted(async () => {
    // 1. Show loading
    loading.value = true

    // 2. Parallel fetch
    const [metricsRes, ordersRes] = await Promise.all([
      api.getDashboardMetrics(),
      api.getRecentOrders()
    ])

    // 3. Update state
    metrics.value = metricsRes.data
    recentOrders.value = ordersRes.data

    // 4. Start real-time
    subscribeToUpdates()
  })

  // Real-time updates
  const subscribeToUpdates = () => {
    const channel = pusher.subscribe('supplier.${supplierId}')
    channel.bind('metrics.updated', handleMetricsUpdate)
    channel.bind('new.order', handleNewOrder)
  }
}

3. Listagem de Licitações

// Flow: Load → Filter → Paginate → Interact

// pages/orders/index.vue
const loadOrders = async (page = 1) => {
  // 1. Build query
  const query = {
    page,
    per_page: 20,
    status: filters.status,
    category: filters.category,
    search: searchQuery.value
  }

  // 2. Fetch
  const { data, meta } = await api.getOrders(query)

  // 3. Virtual scroll append or replace
  if (page === 1) {
    orders.value = data
  } else {
    orders.value.push(...data)
  }

  // 4. Update pagination
  pagination.value = meta
}

// Infinite scroll
const { isIntersecting } = useIntersectionObserver(loadMoreTrigger, {
  rootMargin: '100px'
})

watch(isIntersecting, (val) => {
  if (val && hasMore.value && !loading.value) {
    loadOrders(pagination.value.current_page + 1)
  }
})

4. Detalhes da Licitação

// Flow: Open → Fetch → Display → Decide

// Modal or Page
const openOrderDetails = async (orderId: string) => {
  // 1. Show loading modal
  modalLoading.value = true
  showModal.value = true

  // 2. Fetch complete details
  const { data } = await api.getOrderDetails(orderId)

  // 3. Process data
  order.value = {
    ...data,
    timeLeft: calculateTimeLeft(data.deadline),
    totalValue: calculateTotalValue(data.items),
    distance: calculateDistance(data.location)
  }

  // 4. Check existing proposal
  existingProposal.value = await checkExistingProposal(orderId)

  // 5. Show content
  modalLoading.value = false
}

5. Formulário de Proposta

// Flow: Initialize → Fill → Validate → Submit

// pages/orders/[id]/proposal.vue
const initProposal = async () => {
  // 1. Load order details
  const order = await loadOrder(route.params.id)

  // 2. Initialize form with items
  form.items = order.items.map(item => ({
    id: item.id,
    product: item.product,
    quantity: item.quantity,
    unit: item.unit,
    unit_price: null, // User input
    subtotal: computed(() => item.quantity * (item.unit_price || 0))
  }))

  // 3. Load draft if exists
  const draft = localStorage.getItem(`proposal_draft_${order.id}`)
  if (draft) {
    Object.assign(form, JSON.parse(draft))
  }

  // 4. Start auto-save
  startAutoSave()
}

// Price calculation with real-time feedback
watch(() => form.items, (items) => {
  // Calculate totals
  totals.value = items.reduce((acc, item) => ({
    quantity: acc.quantity + item.quantity,
    value: acc.value + item.subtotal
  }), { quantity: 0, value: 0 })

  // Check competitiveness
  checkPriceCompetitiveness(items)
}, { deep: true })

// Submit flow
const submitProposal = async () => {
  // 1. Validate
  const isValid = await validateForm()
  if (!isValid) return

  // 2. Confirm
  const confirmed = await showConfirmDialog({
    title: 'Confirmar Envio',
    message: `Valor total: ${formatCurrency(totals.value)}`,
    details: generateProposalSummary()
  })

  if (!confirmed) return

  // 3. Submit
  loading.value = true
  try {
    const response = await api.submitProposal({
      order_id: order.value.id,
      items: form.items.map(transformItemForAPI),
      conditions: form.conditions,
      attachments: await uploadFiles(form.documents)
    })

    // 4. Success feedback
    await showSuccessDialog({
      title: 'Proposta Enviada!',
      proposalId: response.data.id,
      actions: [
        { label: 'Ver Proposta', to: `/proposals/${response.data.id}` },
        { label: 'Voltar', to: '/orders' }
      ]
    })

    // 5. Clear draft
    localStorage.removeItem(`proposal_draft_${order.value.id}`)

  } catch (error) {
    handleSubmitError(error)
  } finally {
    loading.value = false
  }
}

🧩 Arquitetura de Componentes

Hierarquia de Componentes

App.vue
├── layouts/
│   └── default.vue
│       ├── TopNav.vue
│       ├── Sidebar.vue (desktop)
│       ├── BottomNav.vue (mobile)
│       └── <slot /> (page content)
├── pages/
│   ├── index.vue (Dashboard)
│   │   ├── DashboardMetrics.vue
│   │   ├── RecentActivity.vue
│   │   └── QuickActions.vue
│   │
│   ├── orders/
│   │   ├── index.vue (List)
│   │   │   ├── OrderFilters.vue
│   │   │   ├── OrderList.vue
│   │   │   │   └── OrderCard.vue
│   │   │   └── OrderPagination.vue
│   │   │
│   │   └── [id]/
│   │       └── proposal.vue
│   │           ├── ProposalWizard.vue
│   │           ├── ProposalItems.vue
│   │           ├── ProposalConditions.vue
│   │           ├── ProposalDocuments.vue
│   │           └── ProposalSummary.vue
│   │
│   └── proposals/
│       ├── index.vue (My Proposals)
│       └── [id].vue (Details)
└── components/
    ├── shared/
    │   ├── BaseButton.vue
    │   ├── BaseInput.vue
    │   ├── BaseModal.vue
    │   └── BaseToast.vue
    ├── ui/
    │   ├── VirtualScroller.vue
    │   ├── InfiniteLoader.vue
    │   ├── SkeletonLoader.vue
    │   └── EmptyState.vue
    └── business/
        ├── PriceCalculator.vue
        ├── CompetitivenessIndicator.vue
        ├── FileUploader.vue
        └── ProposalTimer.vue

Component Specifications

OrderCard Component

<!-- components/orders/OrderCard.vue -->
<template>
  <article
    class="order-card"
    :class="{
      'order-card--urgent': isUrgent,
      'order-card--favorited': isFavorited,
      'order-card--has-proposal': hasProposal
    }"
    @click="$emit('click', order)"
  >
    <!-- Status Badges -->
    <div class="order-badges">
      <Badge v-if="isNew" variant="info" size="sm">
        Novo
      </Badge>
      <Badge v-if="isUrgent" variant="danger" size="sm" pulse>
        Urgente
      </Badge>
      <Badge v-if="hasProposal" variant="success" size="sm">
        Proposta Enviada
      </Badge>
    </div>

    <!-- Header -->
    <header class="order-header">
      <div class="order-title-group">
        <h3 class="order-title">{{ order.title }}</h3>
        <p class="order-producer">{{ order.producer.name }}</p>
      </div>
      <button
        @click.stop="toggleFavorite"
        class="favorite-btn"
        :aria-label="isFavorited ? 'Remover favorito' : 'Adicionar favorito'"
      >
        <HeartIcon :filled="isFavorited" />
      </button>
    </header>

    <!-- Key Metrics -->
    <div class="order-metrics">
      <Metric
        icon="package"
        :label="itemsLabel"
        :value="order.items_count"
      />
      <Metric
        icon="map-pin"
        label="Distância"
        :value="`${order.distance} km`"
      />
      <Metric
        icon="clock"
        label="Prazo"
        :value="deadlineLabel"
        :variant="deadlineVariant"
      />
    </div>

    <!-- Products Preview -->
    <div class="order-products">
      <ProductChip
        v-for="item in topProducts"
        :key="item.id"
        :product="item.product"
        :quantity="item.quantity"
        :unit="item.unit"
      />
      <span v-if="remainingCount > 0" class="more-products">
        +{{ remainingCount }} produtos
      </span>
    </div>

    <!-- Progress Bar -->
    <TimeProgress
      :deadline="order.deadline"
      :created="order.created_at"
      show-label
    />

    <!-- Actions -->
    <footer class="order-actions">
      <Button
        variant="outline"
        size="sm"
        @click.stop="viewDetails"
      >
        Detalhes
      </Button>
      <Button
        variant="primary"
        size="sm"
        @click.stop="startProposal"
        :disabled="hasProposal || isExpired"
      >
        {{ proposalButtonLabel }}
      </Button>
    </footer>
  </article>
</template>

<script setup lang="ts">
interface Order {
  id: string
  title: string
  producer: {
    id: string
    name: string
    location: Coordinates
  }
  items: OrderItem[]
  items_count: number
  deadline: string
  created_at: string
  status: OrderStatus
  distance?: number
  has_proposal?: boolean
}

const props = defineProps<{
  order: Order
}>()

const emit = defineEmits<{
  click: [order: Order]
  favorite: [id: string]
  view: [id: string]
  propose: [id: string]
}>()

// Computed
const isUrgent = computed(() => {
  const hoursLeft = differenceInHours(new Date(props.order.deadline), new Date())
  return hoursLeft <= 24 && hoursLeft > 0
})

const isNew = computed(() => {
  const hoursOld = differenceInHours(new Date(), new Date(props.order.created_at))
  return hoursOld <= 24
})

const isExpired = computed(() => {
  return new Date(props.order.deadline) < new Date()
})

const hasProposal = computed(() => props.order.has_proposal)

const deadlineLabel = computed(() => {
  const days = differenceInDays(new Date(props.order.deadline), new Date())
  if (days === 0) return 'Hoje'
  if (days === 1) return 'Amanhã'
  if (days < 0) return 'Encerrado'
  return `${days} dias`
})

const deadlineVariant = computed(() => {
  const days = differenceInDays(new Date(props.order.deadline), new Date())
  if (days < 0) return 'danger'
  if (days <= 1) return 'warning'
  return 'default'
})

const topProducts = computed(() => props.order.items.slice(0, 3))
const remainingCount = computed(() => Math.max(0, props.order.items_count - 3))

const proposalButtonLabel = computed(() => {
  if (hasProposal.value) return 'Proposta Enviada'
  if (isExpired.value) return 'Encerrado'
  return 'Enviar Proposta'
})

// Methods
const viewDetails = () => {
  emit('view', props.order.id)
  navigateTo(`/orders/${props.order.id}`)
}

const startProposal = () => {
  emit('propose', props.order.id)
  navigateTo(`/orders/${props.order.id}/proposal`)
}

const toggleFavorite = () => {
  // Implement favorite toggle
  emit('favorite', props.order.id)
}
</script>

<style scoped>
.order-card {
  @apply bg-white rounded-lg shadow-sm border border-gray-200;
  @apply hover:shadow-md transition-all duration-200;
  @apply p-4 space-y-4 cursor-pointer;

  &--urgent {
    @apply border-red-400 bg-red-50;
  }

  &--favorited {
    @apply border-blue-400;
  }

  &--has-proposal {
    @apply bg-green-50 border-green-400;
  }
}

/* Animações */
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.5; }
}

.order-badges :deep(.badge--danger) {
  animation: pulse 2s infinite;
}
</style>

🔌 API Integration Layer

API Client Architecture

// composables/useApi.ts
export const useApi = () => {
  const config = useRuntimeConfig()
  const { token } = useAuthStore()

  const client = $fetch.create({
    baseURL: config.public.apiUrl,
    headers: {
      'Authorization': `Bearer ${token.value}`,
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    },
    retry: 3,
    retryDelay: 1000,
    onRequest({ options }) {
      // Add timestamp for cache busting
      options.params = {
        ...options.params,
        _t: Date.now()
      }
    },
    onResponse({ response }) {
      // Log for debugging
      if (config.public.debug) {
        console.log('[API Response]', response._data)
      }
    },
    onResponseError({ response }) {
      // Global error handling
      handleApiError(response)
    }
  })

  return {
    get: (url: string, params?: any) => client(url, { method: 'GET', params }),
    post: (url: string, body?: any) => client(url, { method: 'POST', body }),
    put: (url: string, body?: any) => client(url, { method: 'PUT', body }),
    delete: (url: string) => client(url, { method: 'DELETE' })
  }
}

Service Layer

// services/OrderService.ts
export class OrderService {
  private api = useApi()
  private cache = new Map()

  async getOrders(filters: OrderFilters): Promise<PaginatedResponse<Order>> {
    const cacheKey = JSON.stringify(filters)

    // Check cache
    if (this.cache.has(cacheKey)) {
      const cached = this.cache.get(cacheKey)
      if (Date.now() - cached.timestamp < 60000) { // 1 min cache
        return cached.data
      }
    }

    // Fetch
    const response = await this.api.get('/fornecedor/licitacoes', filters)

    // Transform
    const transformed = {
      ...response,
      data: response.data.map(this.transformOrder)
    }

    // Cache
    this.cache.set(cacheKey, {
      data: transformed,
      timestamp: Date.now()
    })

    return transformed
  }

  private transformOrder(raw: any): Order {
    return {
      id: raw.id,
      title: raw.titulo,
      producer: {
        id: raw.produtor_id,
        name: raw.produtor_nome,
        location: {
          lat: raw.latitude,
          lng: raw.longitude
        }
      },
      items: raw.itens,
      deadline: raw.prazo_final,
      status: raw.status,
      // Calculated fields
      distance: calculateDistance(userLocation, raw.location),
      urgency: calculateUrgency(raw.prazo_final)
    }
  }
}

🚦 State Management (Pinia)

Store Architecture

// stores/orders.ts
export const useOrdersStore = defineStore('orders', () => {
  // State
  const orders = ref<Order[]>([])
  const filters = ref<OrderFilters>(defaultFilters)
  const pagination = ref<Pagination>(defaultPagination)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  // Cache
  const cache = new Map()
  const CACHE_TTL = 5 * 60 * 1000 // 5 minutes

  // Getters
  const filteredOrders = computed(() => {
    return orders.value.filter(order => {
      // Apply client-side filters
      if (filters.value.onlyUrgent && !isUrgent(order)) return false
      if (filters.value.onlyFavorites && !isFavorite(order)) return false
      return true
    })
  })

  const urgentOrders = computed(() =>
    orders.value.filter(isUrgent).slice(0, 5)
  )

  const stats = computed(() => ({
    total: orders.value.length,
    urgent: urgentOrders.value.length,
    withProposals: orders.value.filter(o => o.has_proposal).length
  }))

  // Actions
  const fetchOrders = async (options: FetchOptions = {}) => {
    const cacheKey = JSON.stringify({ filters: filters.value, ...options })

    // Check cache
    if (!options.force && cache.has(cacheKey)) {
      const cached = cache.get(cacheKey)
      if (Date.now() - cached.timestamp < CACHE_TTL) {
        orders.value = cached.data
        return
      }
    }

    loading.value = true
    error.value = null

    try {
      const response = await OrderService.getOrders({
        ...filters.value,
        ...options
      })

      if (options.append) {
        orders.value.push(...response.data)
      } else {
        orders.value = response.data
      }

      pagination.value = response.meta

      // Update cache
      cache.set(cacheKey, {
        data: orders.value,
        timestamp: Date.now()
      })

    } catch (e) {
      error.value = e as Error
      useToast().error('Erro ao carregar licitações')
    } finally {
      loading.value = false
    }
  }

  const updateFilters = (newFilters: Partial<OrderFilters>) => {
    filters.value = { ...filters.value, ...newFilters }
    // Auto-fetch with debounce
    debouncedFetch()
  }

  const debouncedFetch = useDebounceFn(() => {
    fetchOrders()
  }, 500)

  // Real-time updates
  const subscribeToUpdates = () => {
    const channel = pusher.subscribe('orders')

    channel.bind('order.created', (order: Order) => {
      // Add to beginning
      orders.value.unshift(order)
      useToast().info('Nova licitação disponível!')
    })

    channel.bind('order.updated', (order: Order) => {
      const index = orders.value.findIndex(o => o.id === order.id)
      if (index >= 0) {
        orders.value[index] = order
      }
    })

    channel.bind('order.expired', (orderId: string) => {
      const order = orders.value.find(o => o.id === orderId)
      if (order) {
        order.status = 'expired'
        useToast().warning(`Licitação "${order.title}" encerrada`)
      }
    })
  }

  // Initialize
  onMounted(() => {
    fetchOrders()
    subscribeToUpdates()
  })

  return {
    // State
    orders: readonly(orders),
    filters: readonly(filters),
    pagination: readonly(pagination),
    loading: readonly(loading),
    error: readonly(error),
    // Getters
    filteredOrders,
    urgentOrders,
    stats,
    // Actions
    fetchOrders,
    updateFilters,
    refresh: () => fetchOrders({ force: true })
  }
})

🔐 Security Considerations

Authentication Flow

// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
  const { isAuthenticated, token, validateToken } = useAuthStore()

  // Check authentication
  if (!isAuthenticated.value) {
    return navigateTo('/login')
  }

  // Validate token
  if (!validateToken()) {
    useToast().error('Sessão expirada. Por favor, faça login novamente.')
    return navigateTo('/login')
  }

  // Check supplier association
  const { user } = useAuthStore()
  if (!user.value?.supplier) {
    useToast().error('Usuário não está associado a um fornecedor')
    return navigateTo('/register/complete')
  }

  // Check permissions
  if (to.meta.requiresPermission) {
    const hasPermission = checkPermission(to.meta.requiresPermission)
    if (!hasPermission) {
      throw createError({
        statusCode: 403,
        statusMessage: 'Acesso negado'
      })
    }
  }
})

Input Sanitization

// utils/sanitize.ts
import DOMPurify from 'isomorphic-dompurify'

export const sanitizeInput = (input: string): string => {
  return DOMPurify.sanitize(input, {
    ALLOWED_TAGS: [],
    ALLOWED_ATTR: []
  })
}

export const sanitizeHTML = (html: string): string => {
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
    ALLOWED_ATTR: ['href', 'target']
  })
}

// Use in components
const handleSubmit = () => {
  const sanitized = {
    observacoes: sanitizeHTML(form.observacoes),
    preco: sanitizeInput(form.preco.toString())
  }
  // Submit sanitized data
}

File Upload Validation

// utils/fileValidation.ts
export const validateFile = (file: File, options: ValidationOptions = {}) => {
  const {
    maxSize = 10 * 1024 * 1024, // 10MB
    allowedTypes = ['application/pdf', 'image/jpeg', 'image/png'],
    allowedExtensions = ['.pdf', '.jpg', '.jpeg', '.png']
  } = options

  const errors: string[] = []

  // Size check
  if (file.size > maxSize) {
    errors.push(`Arquivo muito grande. Máximo: ${formatBytes(maxSize)}`)
  }

  // Type check
  if (!allowedTypes.includes(file.type)) {
    errors.push(`Tipo não permitido. Permitidos: ${allowedTypes.join(', ')}`)
  }

  // Extension check
  const extension = file.name.slice(file.name.lastIndexOf('.')).toLowerCase()
  if (!allowedExtensions.includes(extension)) {
    errors.push(`Extensão não permitida`)
  }

  // Malware scan (simplified)
  if (file.name.includes('..') || file.name.includes('/')) {
    errors.push('Nome de arquivo inválido')
  }

  return {
    valid: errors.length === 0,
    errors
  }
}

🎭 Error Handling

Global Error Handler

// plugins/errorHandler.ts
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.config.errorHandler = (error, instance, info) => {
    // Log to monitoring service
    if (process.client) {
      Sentry.captureException(error, {
        extra: {
          component: instance?.$options.name,
          info
        }
      })
    }

    // Show user-friendly message
    const toast = useToast()
    if (error.code === 'NETWORK_ERROR') {
      toast.error('Erro de conexão. Verifique sua internet.')
    } else if (error.code === 'VALIDATION_ERROR') {
      toast.error('Dados inválidos. Verifique o formulário.')
    } else {
      toast.error('Ocorreu um erro inesperado.')
    }

    // Log in development
    if (process.dev) {
      console.error('Error:', error)
      console.error('Component:', instance)
      console.error('Info:', info)
    }
  }

  // Handle async errors
  nuxtApp.hook('app:error', (error) => {
    console.error('App error:', error)
  })

  // Handle page errors
  nuxtApp.hook('page:error', (error) => {
    console.error('Page error:', error)
  })
})

Component Error Boundaries

<!-- components/ErrorBoundary.vue -->
<template>
  <div v-if="hasError" class="error-boundary">
    <div class="error-content">
      <ErrorIcon class="error-icon" />
      <h2>Ops! Algo deu errado</h2>
      <p>{{ errorMessage }}</p>
      <div class="error-actions">
        <Button @click="retry" variant="primary">
          Tentar Novamente
        </Button>
        <Button @click="reset" variant="outline">
          Recarregar Página
        </Button>
      </div>
    </div>
  </div>
  <slot v-else />
</template>

<script setup lang="ts">
const hasError = ref(false)
const errorMessage = ref('')

onErrorCaptured((error, instance, info) => {
  hasError.value = true
  errorMessage.value = error.message || 'Erro desconhecido'

  // Log error
  console.error('Component error:', { error, instance, info })

  // Prevent error propagation
  return false
})

const retry = () => {
  hasError.value = false
  errorMessage.value = ''
}

const reset = () => {
  window.location.reload()
}
</script>

📈 Performance Optimizations

Virtual Scrolling

<!-- components/VirtualOrderList.vue -->
<template>
  <RecycleScroller
    class="order-list"
    :items="items"
    :item-size="itemHeight"
    :buffer="300"
    key-field="id"
    v-slot="{ item }"
  >
    <OrderCard
      :order="item"
      @click="handleOrderClick"
      @favorite="handleFavorite"
    />
  </RecycleScroller>
</template>

<script setup lang="ts">
import { RecycleScroller } from '@tanstack/vue-virtual'

const props = defineProps<{
  items: Order[]
  itemHeight?: number
}>()

// Calculate dynamic height
const itemHeight = ref(180)

onMounted(() => {
  // Measure actual card height
  const card = document.querySelector('.order-card')
  if (card) {
    itemHeight.value = card.getBoundingClientRect().height + 16 // gap
  }
})
</script>

Image Optimization

// utils/imageOptimization.ts
export const optimizeImage = async (file: File): Promise<Blob> => {
  return new Promise((resolve, reject) => {
    const img = new Image()
    const canvas = document.createElement('canvas')
    const ctx = canvas.getContext('2d')!

    img.onload = () => {
      // Calculate new dimensions
      let { width, height } = img
      const maxWidth = 1920
      const maxHeight = 1080

      if (width > maxWidth || height > maxHeight) {
        const ratio = Math.min(maxWidth / width, maxHeight / height)
        width *= ratio
        height *= ratio
      }

      // Draw resized image
      canvas.width = width
      canvas.height = height
      ctx.drawImage(img, 0, 0, width, height)

      // Convert to blob
      canvas.toBlob(
        (blob) => {
          if (blob) {
            resolve(blob)
          } else {
            reject(new Error('Failed to optimize image'))
          }
        },
        'image/jpeg',
        0.85 // Quality
      )
    }

    img.onerror = reject
    img.src = URL.createObjectURL(file)
  })
}

Code Splitting

// nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    payloadExtraction: false // Reduce initial payload
  },

  nitro: {
    prerender: {
      routes: ['/'] // Pre-render only home
    }
  },

  vite: {
    build: {
      rollupOptions: {
        output: {
          manualChunks: {
            'vendor': ['vue', 'vue-router', 'pinia'],
            'ui': ['@headlessui/vue', '@heroicons/vue'],
            'utils': ['lodash-es', 'date-fns']
          }
        }
      }
    }
  }
})

📱 Progressive Web App

PWA Configuration

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@vite-pwa/nuxt'],

  pwa: {
    manifest: {
      name: 'AgrSis Fornecedor',
      short_name: 'AgrSis',
      description: 'Portal do Fornecedor - Licitações Agrícolas',
      theme_color: '#10b981',
      background_color: '#ffffff',
      display: 'standalone',
      orientation: 'portrait',
      icons: [
        {
          src: '/icon-192.png',
          sizes: '192x192',
          type: 'image/png'
        },
        {
          src: '/icon-512.png',
          sizes: '512x512',
          type: 'image/png'
        }
      ]
    },

    workbox: {
      navigateFallback: '/',
      globPatterns: ['**/*.{js,css,html,png,svg,ico}'],
      runtimeCaching: [
        {
          urlPattern: /^https:\/\/api\.agrsis\.com\/.*$/,
          handler: 'NetworkFirst',
          options: {
            cacheName: 'api-cache',
            expiration: {
              maxEntries: 100,
              maxAgeSeconds: 300 // 5 minutes
            }
          }
        }
      ]
    }
  }
})

Offline Support

// composables/useOffline.ts
export const useOffline = () => {
  const isOnline = ref(navigator.onLine)
  const queue = ref<QueuedRequest[]>([])

  // Monitor connection
  window.addEventListener('online', () => {
    isOnline.value = true
    processQueue()
  })

  window.addEventListener('offline', () => {
    isOnline.value = false
    useToast().warning('Você está offline. As ações serão sincronizadas quando voltar.')
  })

  // Queue requests when offline
  const queueRequest = (request: QueuedRequest) => {
    queue.value.push(request)
    localStorage.setItem('offline_queue', JSON.stringify(queue.value))
  }

  // Process queue when back online
  const processQueue = async () => {
    const pending = [...queue.value]
    queue.value = []

    for (const request of pending) {
      try {
        await request.execute()
        useToast().success(`Ação sincronizada: ${request.description}`)
      } catch (error) {
        queue.value.push(request) // Re-queue if failed
      }
    }

    if (queue.value.length > 0) {
      localStorage.setItem('offline_queue', JSON.stringify(queue.value))
    } else {
      localStorage.removeItem('offline_queue')
    }
  }

  return {
    isOnline,
    queueRequest,
    processQueue
  }
}

🎉 Conclusão

Esta arquitetura foi projetada para ser:

  • Escalável: Suporta crescimento de 100x
  • Manutenível: Código limpo e bem organizado
  • Performática: Otimizações em todos os níveis
  • Resiliente: Tratamento robusto de erros
  • Acessível: WCAG AA compliance

O sistema está pronto para transformar a experiência dos fornecedores, tornando o processo de licitação simples, rápido e eficiente.


Documento elaborado por: Engenharia de Software SeniorData: Janeiro/2025Versão: 1.0

Copyright © 2026