🔄 PLANO DE REVISÃO COMPLETA - Portal do Fornecedor
🔄 PLANO DE REVISÃO COMPLETA - Portal do Fornecedor
Data: 11 de Janeiro de 2025 Versão: 2.0 Status: 🚧 Em Implementação
📋 Sumário Executivo
Este documento detalha a revisão completa do frontend do Portal do Fornecedor, corrigindo problemas identificados e implementando funcionalidades críticas para o modelo de negócio do AgrSis.
Problemas Críticos Identificados:
- ❌ Anonimato violado: Dados do produtor expostos antes da seleção
- ❌ Dashboard mockado: Métricas não refletem dados reais
- ❌ Filtros inadequados: "Valor Estimado" não existe no modelo de dados
- ⚠️ Proposta incompleta: Falta observações, desconto e entrega escalonada
- ⚠️ Sem opção de produtos similares: Funcionalidade não implementada
🎯 Princípios do Modelo de Negócio
Regra de Ouro: Anonimato da Licitação
O fornecedor NÃO deve saber quem é o produtor até que sua proposta seja selecionada.
Por quê?
- Evita favorecimentos baseados em relacionamentos pessoais
- Garante competição justa baseada apenas em preço/qualidade
- Protege o produtor de assédio comercial
- Aumenta a confiança na plataforma
O que pode ser mostrado:
- ✅ Número da licitação (anônimo)
- ✅ Descrição dos produtos
- ✅ Quantidades solicitadas
- ✅ Raio de fornecimento
- ✅ Prazo de entrega
- ✅ Localização (cidade/UF apenas)
- ✅ Se aceita produtos similares
- ✅ Se aceita entrega escalonada
O que NÃO pode ser mostrado:
- ❌ Nome do produtor
- ❌ CPF/CNPJ do produtor
- ❌ Endereço completo
- ❌ Telefone/email
- ❌ Histórico de compras
- ❌ Foto/logo da propriedade
📊 Análise Detalhada por Tela
1. Dashboard (/)
✅ Estado Atual:
- Layout e UX bem estruturados
- Métricas definidas
- Responsivo
❌ Problemas:
// composables/useDashboardFornecedor.ts - Linha ~15
const metricas = ref({
licitacoesDisponiveis: 0, // ❌ Hardcoded
propostasEnviadas: 0, // ❌ Hardcoded
propostasVencedoras: 0, // ❌ Hardcoded
valorTotalNegociado: 0, // ❌ Hardcoded
// ... variações também hardcoded
})
🔧 Solução:
// Integração real com API
const carregarMetricas = async () => {
try {
const { data } = await fornecedorApi.getDashboard()
metricas.value = {
licitacoesDisponiveis: data.licitacoes_disponiveis,
licitacoesDisponiveisVariacao: data.licitacoes_disponiveis_variacao,
propostasEnviadas: data.propostas_enviadas,
propostasEnviadasVariacao: data.propostas_enviadas_variacao,
propostasVencedoras: data.propostas_vencedoras,
propostasVencedorasVariacao: data.propostas_vencedoras_variacao,
valorTotalNegociado: data.valor_total_negociado,
valorTotalNegociadoVariacao: data.valor_total_negociado_variacao
}
} catch (error) {
toast.error('Erro ao carregar métricas')
}
}
📍 Endpoint Backend Necessário:
// app/Http/Controllers/Api/FornecedorController.php
public function dashboard(Request $request)
{
$fornecedor = $request->user()->fornecedor;
// Período de comparação (30 dias)
$periodoAtual = now()->subDays(30);
$periodoAnterior = now()->subDays(60);
// Licitações disponíveis (dentro do raio)
$licitacoesDisponiveis = Order::published()
->withinSupplyRadius($fornecedor)
->whereDoesntHave('proposals', fn($q) =>
$q->where('supplier_id', $fornecedor->id)
)
->count();
$licitacoesDisponiveisAnterior = Order::published()
->whereBetween('created_at', [$periodoAnterior, $periodoAtual])
->withinSupplyRadius($fornecedor)
->count();
// Propostas enviadas
$propostasEnviadas = Proposal::where('supplier_id', $fornecedor->id)
->where('created_at', '>=', $periodoAtual)
->count();
$propostasEnviadasAnterior = Proposal::where('supplier_id', $fornecedor->id)
->whereBetween('created_at', [$periodoAnterior, $periodoAtual])
->count();
// Propostas vencedoras
$propostasVencedoras = Proposal::where('supplier_id', $fornecedor->id)
->where('status', 'accepted')
->where('created_at', '>=', $periodoAtual)
->count();
// Valor total negociado
$valorTotal = Proposal::where('supplier_id', $fornecedor->id)
->where('status', 'accepted')
->where('created_at', '>=', $periodoAtual)
->sum('total_amount');
return response()->json([
'licitacoes_disponiveis' => $licitacoesDisponiveis,
'licitacoes_disponiveis_variacao' => $licitacoesDisponiveis - $licitacoesDisponiveisAnterior,
'propostas_enviadas' => $propostasEnviadas,
'propostas_enviadas_variacao' => $propostasEnviadas - $propostasEnviadasAnterior,
'propostas_vencedoras' => $propostasVencedoras,
'propostas_vencedoras_variacao' => '15%', // Calcular taxa
'valor_total_negociado' => $valorTotal,
'valor_total_negociado_variacao' => 'R$ ' . number_format($valorTotal * 0.25, 2)
]);
}
2. Listagem de Licitações (/orders)
❌ Problemas Identificados:
Problema 1: Exposição de Dados do Produtor
<!-- Linha 750 - pages/orders/index.vue -->
<span>{{ licitacao.produtor.nome }}</span> <!-- ❌ VIOLA ANONIMATO -->
Problema 2: Filtros Inadequados
<!-- Linhas 429-454 - Filtro "Valor Estimado" -->
<div>
<h3 class="label mb-3">Valor Estimado</h3> <!-- ❌ Não existe no modelo -->
<div class="space-y-3">
<input v-model.number="filtros.valorMin" />
<input v-model.number="filtros.valorMax" />
</div>
</div>
🔧 Solução:
1. Remover dados do produtor:
<!-- ANTES -->
<div class="flex items-center gap-2">
<span>📍</span>
<span>{{ licitacao.produtor.nome }}</span> <!-- ❌ -->
<span>({{ licitacao.raio_fornecimento }}km)</span>
</div>
<!-- DEPOIS -->
<div class="flex items-center gap-2">
<span>📍</span>
<span>{{ licitacao.endereco.cidade }}/{{ licitacao.endereco.uf }}</span> <!-- ✅ -->
<span class="text-xs text-agrsis-neutral-500">
({{ licitacao.supply_radius }}km de você)
</span>
</div>
2. Remover filtro de "Valor Estimado":
<!-- REMOVER COMPLETAMENTE SEÇÃO DE VALOR -->
<!-- ADICIONAR FILTROS REAIS: -->
<div>
<h3 class="label mb-3">Status</h3>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="filtros.status" type="radio" value="ativa" class="radio" />
<span class="text-sm text-agrsis-neutral-700">Ativas</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="filtros.status" type="radio" value="minhas" class="radio" />
<span class="text-sm text-agrsis-neutral-700">Onde enviei proposta</span>
</label>
</div>
</div>
<div class="divider"></div>
<div>
<h3 class="label mb-3">Aceita Produtos Similares</h3>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="filtros.aceitaSimilares" type="checkbox" class="checkbox" />
<span class="text-sm text-agrsis-neutral-700">Apenas licitações que aceitam</span>
</label>
</div>
<div class="divider"></div>
<div>
<h3 class="label mb-3">Entrega Escalonada</h3>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="filtros.aceitaEscalonada" type="checkbox" class="checkbox" />
<span class="text-sm text-agrsis-neutral-700">Apenas licitações que aceitam</span>
</label>
</div>
3. Atualizar filtros no composable:
// composables/useFornecedor.ts
const filtros = reactive({
categorias: [] as string[],
estado: '',
raioKm: 100,
prazo: 'todos',
status: 'ativa', // ativa | minhas
aceitaSimilares: false,
aceitaEscalonada: false
})
const listarLicitacoes = async (params?: any) => {
const queryParams = {
status: filtros.status,
search: params?.search,
estado: filtros.estado || undefined,
raio_max: filtros.raioKm,
aceita_similares: filtros.aceitaSimilares || undefined,
aceita_escalonada: filtros.aceitaEscalonada || undefined,
page: params?.page || 1,
per_page: params?.per_page || 12
}
const { data, error } = await fornecedorApi.getLicitacoes(queryParams)
if (!error && data) {
licitacoes.value = data.data
paginationMeta.value = data.meta
}
}
3. Detalhes da Licitação (/orders/[id])
❌ Problemas Identificados:
Problema 1: Nome do Produtor Exposto
<!-- Linha 273 - pages/orders/[id].vue -->
<span>Produtor: <strong>{{ licitacao.produtor.nome }}</strong></span> <!-- ❌ -->
Problema 2: Endereço Completo Exposto
<!-- Linhas 376-406 - Endereço completo -->
<p>{{ licitacao.produtor.endereco.logradouro }}, {{ licitacao.produtor.endereco.numero }}</p>
<p>{{ licitacao.produtor.endereco.bairro }}</p>
<p>{{ licitacao.produtor.endereco.cidade }}/{{ licitacao.produtor.endereco.uf }}</p>
🔧 Solução:
1. Remover identificação do produtor:
<!-- ANTES -->
<div class="flex items-center gap-2 text-sm text-gray-500">
<svg>...</svg>
<span>Produtor: <strong>{{ licitacao.produtor.nome }}</strong></span> <!-- ❌ -->
</div>
<!-- DEPOIS -->
<div class="flex items-center gap-2 text-sm text-gray-500">
<svg>...</svg>
<span>Licitação: <strong>{{ licitacao.number }}</strong></span> <!-- ✅ -->
</div>
2. Mostrar apenas cidade/UF:
<!-- Logística e Entrega -->
<section class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 class="text-lg font-bold text-gray-900 mb-5 flex items-center gap-2">
<svg>...</svg>
Logística e Entrega
</h2>
<!-- Localização (sem endereço completo) -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="text-sm font-semibold text-blue-900 mb-3">Região de Entrega</h3>
<div class="flex items-start gap-2">
<svg class="w-5 h-5 text-blue-600 mt-0.5">...</svg>
<div>
<p class="text-base font-semibold text-gray-900">
{{ licitacao.endereco.cidade }} / {{ licitacao.endereco.uf }}
</p>
<p class="text-sm text-gray-600 mt-1">
Raio de fornecimento: <strong>{{ licitacao.supply_radius }} km</strong>
</p>
</div>
</div>
</div>
<!-- Informações de Entrega -->
<div class="mt-4 space-y-3">
<div class="flex items-center justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-600">Prazo de Entrega</span>
<span class="text-sm font-semibold text-gray-900">
Até {{ formatData(licitacao.delivery_deadline) }}
</span>
</div>
<div class="flex items-center justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-600">Aceita Produtos Similares</span>
<span :class="licitacao.accepts_similar_products ? 'badge-success' : 'badge-neutral'">
{{ licitacao.accepts_similar_products ? 'Sim' : 'Não' }}
</span>
</div>
<div class="flex items-center justify-between py-2">
<span class="text-sm text-gray-600">Aceita Entrega Escalonada</span>
<span :class="licitacao.accepts_staggered_delivery ? 'badge-success' : 'badge-neutral'">
{{ licitacao.accepts_staggered_delivery ? 'Sim' : 'Não' }}
</span>
</div>
</div>
</section>
4. Envio de Proposta (/orders/[id]/proposal)
⚠️ Estado Atual:
- ✅ Tela única (sem wizard)
- ✅ Cálculo automático de subtotais
- ❌ Falta campo de observações
- ❌ Falta campo de desconto
- ❌ Falta opção de produtos similares
- ❌ Falta entrega escalonada
🔧 Solução Completa:
Interface TypeScript Atualizada:
// types/fornecedor.ts
interface PropostaItem {
order_item_id: number
produto_nome: string
marca?: string
quantidade: number
unidade: string
preco_unitario: number | null
valor_total: number
// NOVO: Produto Similar
produto_similar?: {
produto_id: number
nome: string
marca: string
motivo: string // Por que é similar
}
usa_produto_similar: boolean
}
interface PropostaFormData {
order_id: string
items: PropostaItem[]
// NOVO: Desconto e Observações
subtotal: number
desconto_percentual: number // 0-100
desconto_valor: number // Calculado
total_final: number
observacoes: string // Máx 1000 caracteres
// NOVO: Entrega Escalonada
aceita_entrega_escalonada: boolean
entregas_escalonadas?: EntregaEscalonada[]
}
interface EntregaEscalonada {
ordem: number // 1, 2, 3...
data_prevista: string // YYYY-MM-DD
percentual: number // 0-100
quantidade_items: { [order_item_id: number]: number }
observacao?: string
}
Layout da Tela:
<template>
<div class="min-h-screen bg-gray-50">
<div class="container mx-auto px-4 py-8">
<!-- Header com Info da Licitação -->
<section class="bg-white rounded-lg shadow-sm border p-6 mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">
Enviar Proposta - {{ licitacao.number }}
</h1>
<p class="text-gray-600">{{ licitacao.description }}</p>
<!-- Deadline Indicator -->
<div class="mt-4 flex items-center gap-3">
<div :class="prazoClass" class="px-4 py-2 rounded-lg border-2">
<p class="text-xs font-semibold uppercase tracking-wide">
Prazo para Propostas
</p>
<p class="text-xl font-bold">{{ textoPrazo }}</p>
<p class="text-xs">{{ formatarData(licitacao.deadline_date) }}</p>
</div>
<!-- Badges -->
<div class="flex gap-2">
<span v-if="licitacao.accepts_similar_products" class="badge-success">
✓ Aceita Similares
</span>
<span v-if="licitacao.accepts_staggered_delivery" class="badge-info">
✓ Aceita Escalonada
</span>
</div>
</div>
</section>
<!-- Grid: 2/3 + 1/3 -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- COLUNA PRINCIPAL (2 colunas) -->
<div class="lg:col-span-2 space-y-6">
<!-- SEÇÃO 1: Produtos e Preços -->
<section class="bg-white rounded-lg shadow-sm border p-6">
<h2 class="text-xl font-bold mb-4">Produtos e Preços</h2>
<!-- Desktop: Tabela -->
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 border-b-2 border-gray-200">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase">
Produto
</th>
<th class="px-4 py-3 text-center text-xs font-semibold uppercase">
Quantidade
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase">
Preço Unit.
</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase">
Subtotal
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="(item, index) in form.items" :key="item.order_item_id">
<!-- Produto -->
<td class="px-4 py-4">
<div>
<p class="font-semibold text-gray-900">{{ item.produto_nome }}</p>
<p v-if="item.marca" class="text-xs text-gray-500">
Marca: {{ item.marca }}
</p>
<!-- NOVO: Produto Similar -->
<div v-if="licitacao.accepts_similar_products" class="mt-2">
<button
@click="abrirModalProdutoSimilar(index)"
class="text-xs text-blue-600 hover:text-blue-700 flex items-center gap-1"
>
<svg class="w-4 h-4">...</svg>
{{ item.usa_produto_similar ? 'Produto Similar Selecionado' : 'Oferecer Similar' }}
</button>
<!-- Preview do Similar -->
<div v-if="item.usa_produto_similar && item.produto_similar"
class="mt-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs">
<p class="font-medium text-blue-900">
{{ item.produto_similar.nome }}
</p>
<p class="text-blue-700">{{ item.produto_similar.marca }}</p>
<p class="text-blue-600 italic">{{ item.produto_similar.motivo }}</p>
</div>
</div>
</div>
</td>
<!-- Quantidade -->
<td class="px-4 py-4 text-center">
<p class="font-medium">{{ item.quantidade }} {{ item.unidade }}</p>
</td>
<!-- Preço Unitário -->
<td class="px-4 py-4">
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">R$</span>
<input
v-model.number="item.preco_unitario"
@input="calcularTotais"
type="number"
step="0.01"
min="0"
required
class="w-full pl-10 pr-4 py-2.5 border rounded-lg"
:class="{
'border-gray-300': item.preco_unitario && item.preco_unitario > 0,
'border-red-300 bg-red-50': !item.preco_unitario || item.preco_unitario <= 0
}"
/>
</div>
</td>
<!-- Subtotal -->
<td class="px-4 py-4 text-right">
<p class="font-bold text-gray-900">
{{ formatCurrency(item.valor_total) }}
</p>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- SEÇÃO 2: Desconto e Observações -->
<section class="bg-white rounded-lg shadow-sm border p-6">
<h2 class="text-xl font-bold mb-4">Condições Comerciais</h2>
<div class="space-y-6">
<!-- Desconto -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Desconto (%)
</label>
<div class="flex items-center gap-4">
<input
v-model.number="form.desconto_percentual"
@input="calcularTotais"
type="number"
min="0"
max="100"
step="0.1"
class="input w-32"
placeholder="0.00"
/>
<span class="text-sm text-gray-600">
= {{ formatCurrency(form.desconto_valor) }} de desconto
</span>
</div>
<p class="text-xs text-gray-500 mt-1">
Aplicado sobre o valor total dos produtos
</p>
</div>
<!-- Observações -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Observações
</label>
<textarea
v-model="form.observacoes"
rows="4"
maxlength="1000"
class="input w-full"
placeholder="Adicione informações relevantes: condições de pagamento, prazo de entrega, garantias, etc."
></textarea>
<div class="flex justify-between items-center mt-1">
<p class="text-xs text-gray-500">
Máximo 1000 caracteres
</p>
<p class="text-xs" :class="form.observacoes.length > 900 ? 'text-red-600' : 'text-gray-500'">
{{ form.observacoes.length }} / 1000
</p>
</div>
</div>
</div>
</section>
<!-- SEÇÃO 3: Entrega Escalonada (Condicional) -->
<section v-if="licitacao.accepts_staggered_delivery"
class="bg-white rounded-lg shadow-sm border p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold">Entrega Escalonada</h2>
<label class="flex items-center gap-2">
<input
v-model="form.aceita_entrega_escalonada"
type="checkbox"
class="checkbox"
/>
<span class="text-sm font-medium text-gray-700">
Oferecer entrega em etapas
</span>
</label>
</div>
<!-- Configuração de Entregas -->
<div v-if="form.aceita_entrega_escalonada" class="space-y-4">
<div class="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<p class="text-sm text-blue-900">
Configure as datas e quantidades para cada etapa de entrega.
A soma dos percentuais deve ser 100%.
</p>
</div>
<!-- Lista de Entregas -->
<div v-for="(entrega, idx) in form.entregas_escalonadas"
:key="idx"
class="border border-gray-200 rounded-lg p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-gray-900">
Etapa {{ idx + 1 }}
</h3>
<button
v-if="form.entregas_escalonadas.length > 1"
@click="removerEntrega(idx)"
class="text-red-600 hover:text-red-700 text-sm"
>
Remover
</button>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Data Prevista -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Data Prevista
</label>
<input
v-model="entrega.data_prevista"
type="date"
:min="getDataMinimaEntrega()"
required
class="input w-full"
/>
</div>
<!-- Percentual -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Percentual (%)
</label>
<input
v-model.number="entrega.percentual"
@input="calcularQuantidadesEscalonadas(idx)"
type="number"
min="1"
max="100"
step="1"
required
class="input w-full"
/>
</div>
</div>
<!-- Quantidades por Produto -->
<div class="mt-4 space-y-2">
<p class="text-sm font-medium text-gray-700">
Quantidades nesta etapa:
</p>
<div v-for="item in form.items" :key="item.order_item_id"
class="flex justify-between items-center text-sm py-1 border-b border-gray-100">
<span class="text-gray-600">{{ item.produto_nome }}</span>
<span class="font-medium text-gray-900">
{{ calcularQuantidadeEtapa(item, entrega.percentual) }} {{ item.unidade }}
</span>
</div>
</div>
<!-- Observação da Etapa -->
<div class="mt-4">
<label class="block text-sm font-medium text-gray-700 mb-1">
Observação (opcional)
</label>
<input
v-model="entrega.observacao"
type="text"
maxlength="200"
placeholder="Ex: Primeira parcela disponível imediatamente"
class="input w-full"
/>
</div>
</div>
<!-- Botão Adicionar Etapa -->
<button
@click="adicionarEntrega"
:disabled="form.entregas_escalonadas.length >= 5"
class="btn-outline w-full"
>
+ Adicionar Etapa de Entrega
</button>
<!-- Validação de Percentual -->
<div v-if="totalPercentualEscalonado !== 100"
class="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p class="text-sm text-yellow-900">
⚠️ Total atual: {{ totalPercentualEscalonado }}%
(deve ser exatamente 100%)
</p>
</div>
</div>
</section>
</div>
<!-- SIDEBAR (1 coluna) -->
<aside class="lg:col-span-1">
<div class="bg-white rounded-lg shadow-sm border p-6 sticky top-6">
<h3 class="text-lg font-bold text-gray-900 mb-4">
Resumo da Proposta
</h3>
<!-- Estatísticas -->
<div class="space-y-3 mb-6">
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-600">Produtos cotados</span>
<span class="font-semibold text-gray-900">
{{ produtosCotados }} / {{ form.items.length }}
</span>
</div>
<div class="flex justify-between py-2 border-b border-gray-100">
<span class="text-sm text-gray-600">Subtotal</span>
<span class="font-semibold text-gray-900">
{{ formatCurrency(form.subtotal) }}
</span>
</div>
<div v-if="form.desconto_valor > 0"
class="flex justify-between py-2 border-b border-gray-100">
<span class="text-sm text-green-600">
Desconto ({{ form.desconto_percentual }}%)
</span>
<span class="font-semibold text-green-600">
- {{ formatCurrency(form.desconto_valor) }}
</span>
</div>
<div class="flex justify-between py-3 pt-3 border-t-2 border-gray-200">
<span class="text-base font-medium text-gray-900">Total Final</span>
<span class="text-2xl font-bold text-agrsis-primary-700">
{{ formatCurrency(form.total_final) }}
</span>
</div>
</div>
<!-- Barra de Progresso -->
<div class="mb-6">
<div class="flex justify-between items-center mb-2">
<span class="text-xs font-medium text-gray-600">Progresso</span>
<span class="text-xs font-bold text-agrsis-primary-700">
{{ Math.round((produtosCotados / form.items.length) * 100) }}%
</span>
</div>
<div class="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
<div
class="h-full bg-agrsis-primary-600 transition-all duration-500 rounded-full"
:style="{ width: `${(produtosCotados / form.items.length) * 100}%` }"
/>
</div>
</div>
<!-- Botão de Envio -->
<button
@click="enviarProposta"
:disabled="!formValido || isSubmitting"
class="w-full py-4 px-6 rounded-lg font-semibold text-white transition-all min-h-[56px]"
:class="{
'bg-agrsis-primary-600 hover:bg-agrsis-primary-700 hover:shadow-lg transform hover:-translate-y-0.5': formValido && !isSubmitting,
'bg-gray-300 cursor-not-allowed': !formValido || isSubmitting
}"
>
<span v-if="!isSubmitting">✓ Enviar Proposta</span>
<span v-else class="flex items-center justify-center gap-2">
<svg class="w-5 h-5 animate-spin">...</svg>
Enviando...
</span>
</button>
<!-- Informações Adicionais -->
<div class="mt-6 pt-6 border-t border-gray-200 space-y-3">
<div class="flex items-start gap-2 text-xs text-gray-600">
<svg class="w-4 h-4 text-green-600 mt-0.5">...</svg>
<p>Sua proposta será enviada imediatamente</p>
</div>
<div class="flex items-start gap-2 text-xs text-gray-600">
<svg class="w-4 h-4 text-blue-600 mt-0.5">...</svg>
<p>Você será notificado sobre atualizações</p>
</div>
</div>
</div>
</aside>
</div>
</div>
</div>
<!-- Modal: Produto Similar -->
<ModalProdutoSimilar
v-model:show="showModalSimilar"
:item="itemSelecionado"
@confirmar="selecionarProdutoSimilar"
/>
</template>
Lógica do Composable:
// composables/usePropostaForm.ts
export const usePropostaForm = (licitacaoId: string) => {
const form = reactive<PropostaFormData>({
order_id: licitacaoId,
items: [],
subtotal: 0,
desconto_percentual: 0,
desconto_valor: 0,
total_final: 0,
observacoes: '',
aceita_entrega_escalonada: false,
entregas_escalonadas: []
})
// Cálculo de totais
const calcularTotais = () => {
// 1. Subtotal (soma de todos os itens)
form.subtotal = form.items.reduce((sum, item) => {
item.valor_total = (item.preco_unitario || 0) * item.quantidade
return sum + item.valor_total
}, 0)
// 2. Desconto
form.desconto_valor = (form.subtotal * form.desconto_percentual) / 100
// 3. Total final
form.total_final = form.subtotal - form.desconto_valor
}
// Produtos cotados
const produtosCotados = computed(() => {
return form.items.filter(item => item.preco_unitario && item.preco_unitario > 0).length
})
// Validação
const formValido = computed(() => {
// Todos os preços preenchidos
if (produtosCotados.value !== form.items.length) return false
// Se entrega escalonada, validar
if (form.aceita_entrega_escalonada) {
if (!form.entregas_escalonadas.length) return false
const totalPercentual = form.entregas_escalonadas.reduce(
(sum, e) => sum + (e.percentual || 0),
0
)
if (totalPercentual !== 100) return false
// Todas as datas preenchidas
if (form.entregas_escalonadas.some(e => !e.data_prevista)) return false
}
return true
})
// Entrega escalonada
const adicionarEntrega = () => {
if (form.entregas_escalonadas.length >= 5) return
form.entregas_escalonadas.push({
ordem: form.entregas_escalonadas.length + 1,
data_prevista: '',
percentual: 0,
quantidade_items: {},
observacao: ''
})
}
const removerEntrega = (index: number) => {
form.entregas_escalonadas.splice(index, 1)
// Reordenar
form.entregas_escalonadas.forEach((e, i) => e.ordem = i + 1)
}
const calcularQuantidadeEtapa = (item: PropostaItem, percentual: number) => {
return Math.round((item.quantidade * percentual) / 100)
}
const totalPercentualEscalonado = computed(() => {
return form.entregas_escalonadas.reduce((sum, e) => sum + (e.percentual || 0), 0)
})
// Submit
const enviarProposta = async () => {
if (!formValido.value) return
// Confirmação
const confirmar = confirm(
`Enviar proposta no valor de ${formatCurrency(form.total_final)}?`
)
if (!confirmar) return
isSubmitting.value = true
try {
// Preparar payload
const payload = {
order_id: form.order_id,
items: form.items.map(item => ({
order_item_id: item.order_item_id,
preco_unitario: item.preco_unitario,
quantidade: item.quantidade,
usa_produto_similar: item.usa_produto_similar,
produto_similar_id: item.produto_similar?.produto_id
})),
desconto_percentual: form.desconto_percentual,
observacoes: form.observacoes,
aceita_entrega_escalonada: form.aceita_entrega_escalonada,
entregas_escalonadas: form.aceita_entrega_escalonada
? form.entregas_escalonadas
: undefined
}
const { data, error } = await fornecedorApi.criarProposta(payload)
if (error) throw error
toast.success('Proposta enviada com sucesso!')
// Redirecionar
setTimeout(() => {
router.push(`/orders/${licitacaoId}`)
}, 1500)
} catch (error) {
console.error('Erro ao enviar proposta:', error)
toast.error('Erro ao enviar proposta. Tente novamente.')
} finally {
isSubmitting.value = false
}
}
return {
form,
produtosCotados,
formValido,
isSubmitting,
calcularTotais,
adicionarEntrega,
removerEntrega,
calcularQuantidadeEtapa,
totalPercentualEscalonado,
enviarProposta
}
}
📦 Componente: Modal Produto Similar
<!-- components/propostas/ModalProdutoSimilar.vue -->
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center p-4">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black bg-opacity-50" @click="fechar"></div>
<!-- Modal -->
<div class="relative bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<h3 class="text-xl font-bold text-gray-900">
Oferecer Produto Similar
</h3>
<button @click="fechar" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6">...</svg>
</button>
</div>
<!-- Body -->
<div class="p-6 space-y-6">
<!-- Produto Original -->
<div class="p-4 bg-gray-50 border border-gray-200 rounded-lg">
<p class="text-xs font-semibold text-gray-500 uppercase mb-1">
Produto Solicitado
</p>
<p class="text-lg font-bold text-gray-900">{{ item?.produto_nome }}</p>
<p v-if="item?.marca" class="text-sm text-gray-600">
Marca: {{ item.marca }}
</p>
</div>
<!-- Busca de Produtos -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Buscar Produto Similar
</label>
<input
v-model="busca"
@input="buscarProdutos"
type="text"
placeholder="Digite o nome ou marca do produto..."
class="input w-full"
/>
</div>
<!-- Lista de Resultados -->
<div v-if="resultados.length" class="space-y-2 max-h-60 overflow-y-auto">
<button
v-for="produto in resultados"
:key="produto.id"
@click="selecionarProduto(produto)"
class="w-full text-left p-4 border rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors"
:class="{
'border-blue-500 bg-blue-50': produtoSelecionado?.id === produto.id
}"
>
<p class="font-semibold text-gray-900">{{ produto.nome }}</p>
<p class="text-sm text-gray-600">{{ produto.marca }}</p>
<p class="text-xs text-gray-500 mt-1">{{ produto.categoria }}</p>
</button>
</div>
<div v-else-if="busca && !isSearching" class="text-center py-8 text-gray-500">
Nenhum produto encontrado
</div>
<!-- Motivo -->
<div v-if="produtoSelecionado">
<label class="block text-sm font-medium text-gray-700 mb-2">
Por que este produto é similar? *
</label>
<textarea
v-model="motivo"
rows="3"
required
placeholder="Ex: Mesmo princípio ativo, mesma composição, mesma finalidade..."
class="input w-full"
></textarea>
<p class="text-xs text-gray-500 mt-1">
Explique ao produtor por que você está oferecendo este produto no lugar do solicitado
</p>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
<button @click="fechar" class="btn-outline">
Cancelar
</button>
<button
@click="confirmar"
:disabled="!podeConfirmar"
class="btn-primary"
:class="{ 'opacity-50 cursor-not-allowed': !podeConfirmar }"
>
Confirmar Produto Similar
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const props = defineProps<{
show: boolean
item: PropostaItem | null
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
'confirmar': [produto: any, motivo: string]
}>()
const busca = ref('')
const resultados = ref<any[]>([])
const produtoSelecionado = ref<any>(null)
const motivo = ref('')
const isSearching = ref(false)
const podeConfirmar = computed(() => {
return produtoSelecionado.value && motivo.value.length >= 20
})
const buscarProdutos = useDebounceFn(async () => {
if (busca.value.length < 3) {
resultados.value = []
return
}
isSearching.value = true
try {
const { data } = await fornecedorApi.buscarProdutos({
search: busca.value,
categoria: props.item?.categoria
})
resultados.value = data.produtos || []
} finally {
isSearching.value = false
}
}, 500)
const selecionarProduto = (produto: any) => {
produtoSelecionado.value = produto
}
const confirmar = () => {
if (!podeConfirmar.value) return
emit('confirmar', produtoSelecionado.value, motivo.value)
fechar()
}
const fechar = () => {
emit('update:show', false)
busca.value = ''
resultados.value = []
produtoSelecionado.value = null
motivo.value = ''
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
🔌 Backend: Endpoints Necessários
1. Dashboard com Dados Reais
GET /api/v1/fornecedor/dashboard
2. Buscar Produtos Similares
GET /api/v1/fornecedor/produtos/buscar?search=nome&categoria=fertilizantes
3. Criar Proposta com Funcionalidades Novas
POST /api/v1/fornecedor/propostas
Body:
{
"order_id": "uuid",
"items": [
{
"order_item_id": 123,
"preco_unitario": 150.00,
"quantidade": 100,
"usa_produto_similar": false,
"produto_similar_id": null
}
],
"desconto_percentual": 5,
"observacoes": "Entrega em até 7 dias úteis",
"aceita_entrega_escalonada": true,
"entregas_escalonadas": [
{
"ordem": 1,
"data_prevista": "2025-02-01",
"percentual": 50,
"observacao": "Primeira parcela"
},
{
"ordem": 2,
"data_prevista": "2025-03-01",
"percentual": 50,
"observacao": "Segunda parcela"
}
]
}
📅 Plano de Implementação
Sprint 1: Correções Críticas (3 dias)
Dia 1: Anonimato
- Remover nome do produtor de todas as telas
- Atualizar endpoints backend (remover dados sensíveis)
- Mostrar apenas cidade/UF
- Testar em todas as telas
Dia 2: Dashboard Real + Filtros
- Implementar endpoint
/dashboardno backend - Integrar composable com API real
- Remover filtro "Valor Estimado"
- Adicionar filtros corretos (Similares, Escalonada)
- Testar paginação e filtros
Dia 3: Testes e Validação
- Testar fluxo completo
- Validar anonimato
- Validar métricas
- Deploy em staging
Sprint 2: Funcionalidades Novas (5 dias)
Dia 4-5: Desconto e Observações
- Adicionar campos no formulário
- Implementar cálculo automático
- Validações
- Backend: atualizar endpoint de proposta
Dia 6-7: Produto Similar
- Criar modal de busca
- Endpoint de busca de produtos
- Integrar com formulário
- Backend: salvar produto similar
Dia 8: Entrega Escalonada
- Interface de configuração de etapas
- Cálculo de quantidades
- Validação de percentuais
- Backend: salvar entregas escalonadas
Sprint 3: Polish e Testes (2 dias)
Dia 9: UX e Feedback
- Mensagens de erro claras
- Loading states
- Animações suaves
- Responsividade mobile
Dia 10: Testes Finais
- Testes E2E completos
- Validação com stakeholders
- Deploy em produção
✅ Checklist de Qualidade
Anonimato
- Nome do produtor removido de todas as páginas
- Endereço completo não visível
- Apenas cidade/UF exibidos
- Teste com múltiplas licitações
Dashboard
- Métricas carregam dados reais da API
- Loading states funcionando
- Erro tratado adequadamente
- Números animam ao carregar
Filtros
- "Valor Estimado" removido
- "Aceita Similares" funciona
- "Aceita Escalonada" funciona
- Filtros combinam corretamente
Proposta
- Cálculo automático funciona
- Desconto aplica corretamente
- Observações salvam
- Produto similar busca e seleciona
- Entrega escalonada valida 100%
- Submit envia todos os dados
📈 Métricas de Sucesso
Técnicas
- Tempo de carregamento dashboard < 1s
- Zero erros de console
- Lighthouse score > 90
- Mobile-first responsivo
Negócio
- Tempo médio para enviar proposta < 5 min
- Taxa de conclusão > 85%
- Uso de produtos similares > 20%
- Uso de entrega escalonada > 15%
Próximos Passos:
- Validar este plano com a equipe
- Criar tasks no Linear
- Iniciar Sprint 1
- Daily reviews
Documento criado por: Engenharia de SoftwareData: 11 de Janeiro de 2025Versão: 2.0