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