feat: módulo de inteligência de mercado

This commit is contained in:
Junior
2026-03-14 10:45:32 -03:00
parent 234d13461c
commit 4db2762b0f
2 changed files with 134 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
// front-end/app/composables/useInteligencia.ts
import { editais } from '~/data/mock/editais'
import { concorrentes } from '~/data/mock/concorrentes'
export function useInteligencia() {
const totalEditais = editais.length
const vencidas = editais.filter(e => e.status === 'vencida')
const perdidas = editais.filter(e => e.status === 'perdida')
const taxaVitoria = computed(() =>
totalEditais > 0 ? Math.round((vencidas.length / totalEditais) * 100) : 0
)
const valorGanho = computed(() =>
vencidas.reduce((acc, e) => acc + e.valorEstimado, 0)
)
const ticketMedio = computed(() =>
vencidas.length > 0 ? valorGanho.value / vencidas.length : 0
)
const porModalidade = computed(() => {
const counts: Record<string, { total: number; vitorias: number }> = {}
for (const e of editais) {
if (!counts[e.modalidade]) counts[e.modalidade] = { total: 0, vitorias: 0 }
counts[e.modalidade].total++
if (e.status === 'vencida') counts[e.modalidade].vitorias++
}
return counts
})
const motivoPerda = computed(() => {
const motivos = [
'GOV - PERDIDO POR PREÇO',
'GOV - PERDIDO POR DOCUMENTAÇÃO',
'GOV - PERDIDO NO ALEATÓRIO',
'GOV - DECLINADO POR REQUISITO TÉCNICO',
'GOV - DECLINADO OUTROS',
'Outro',
]
return motivos.map((m, i) => ({ motivo: m, quantidade: [3, 1, 1, 1, 0, 1][i] ?? 0 }))
})
const concorrentesFrequentes = computed(() =>
[...concorrentes].sort((a, b) => b.totalDisputas - a.totalDisputas).slice(0, 5)
)
return { taxaVitoria, valorGanho, ticketMedio, porModalidade, motivoPerda, concorrentesFrequentes, vencidas, perdidas }
}

View File

@@ -0,0 +1,85 @@
<!-- front-end/app/pages/inteligencia/index.vue -->
<script setup lang="ts">
const { taxaVitoria, valorGanho, ticketMedio, porModalidade, motivoPerda, concorrentesFrequentes } = useInteligencia()
const modalidadeLabel: Record<string, string> = {
pregao_eletronico: 'Pregão Eletrônico',
pregao_presencial: 'Pregão Presencial',
concorrencia: 'Concorrência',
dispensa: 'Dispensa',
inexigibilidade: 'Inexigibilidade',
}
const fmt = (v: number) => new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(v)
</script>
<template>
<div class="page">
<AppTopbar title="Inteligência de Mercado" breadcrumb="Inteligência" />
<div class="content">
<!-- Stats topo -->
<div class="stats-grid mb-4">
<StatCard label="Taxa de Vitória" :value="`${taxaVitoria}%`" color="#10b981" sub="Processos ganhos" />
<StatCard label="Valor Ganho Total" :value="fmt(valorGanho)" color="#667eea" />
<StatCard label="Ticket Médio" :value="fmt(ticketMedio)" color="#764ba2" />
<StatCard label="Concorrentes" :value="concorrentesFrequentes.length" sub="Cadastrados" />
</div>
<div class="grid-2 mb-4">
<!-- Por modalidade -->
<div class="card">
<div class="card-header"><h3>Modalidades</h3></div>
<div class="pad">
<div v-for="(val, key) in porModalidade" :key="key" class="modal-row">
<span class="modal-nome">{{ modalidadeLabel[key] }}</span>
<span class="modal-stats">{{ val.vitorias }}/{{ val.total }} {{ val.total > 0 ? Math.round((val.vitorias / val.total) * 100) : 0 }}%</span>
</div>
</div>
</div>
<!-- Motivo de perda -->
<div class="card">
<div class="card-header"><h3>Classificação de Perdas</h3></div>
<div class="pad">
<div v-for="m in motivoPerda" :key="m.motivo" class="modal-row">
<span class="modal-nome">{{ m.motivo }}</span>
<UBadge :label="String(m.quantidade)" variant="soft" color="error" size="xs" />
</div>
</div>
</div>
</div>
<!-- Concorrentes frequentes -->
<div class="card">
<div class="card-header"><h3>Concorrentes Mais Frequentes</h3></div>
<UTable
:data="concorrentesFrequentes"
:columns="[
{ key: 'nome', label: 'Empresa' },
{ key: 'cnpj', label: 'CNPJ' },
{ key: 'totalDisputas', label: 'Disputas' },
{ key: 'totalVitorias', label: 'Vitórias' },
]"
/>
</div>
</div>
</div>
</template>
<style scoped>
.page { display: flex; flex-direction: column; height: 100vh; }
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
.mb-4 { margin-bottom: 14px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
.card-header { padding: 14px 18px 10px; border-bottom: 1px solid #f1f5f9; }
.card-header h3 { font-size: 13px; font-weight: 700; color: #0f172a; }
.pad { padding: 12px 18px; }
.modal-row { display: flex; justify-content: space-between; align-items: center; padding: 7px 0; border-bottom: 1px solid #f8fafc; font-size: 12px; }
.modal-row:last-child { border-bottom: none; }
.modal-nome { color: #374151; }
.modal-stats { font-weight: 600; color: #667eea; }
</style>