Files
lic/front-end/app/pages/index.vue
2026-04-21 18:05:15 -03:00

434 lines
17 KiB
Vue

<!-- front-end/app/pages/index.vue -->
<script setup lang="ts">
import { PIPELINE_ETAPAS } from '~/types'
const { apiFetch } = useApi()
interface ApiEdital {
ID: string; Numero: string; Orgao: string; Modalidade: string
Objeto: string; Plataforma: string; ValorEstimado: number
DataPublicacao: string; DataAbertura: string; Status: string
}
interface ApiDocument {
ID: string; Tipo: string; Nome: string
DataVencimento: string | null; Observacoes: string
}
interface ApiContract {
ID: string; Numero: string; Orgao: string; Objeto: string
Valor: number; DataInicio: string; DataFim: string; Status: string
}
const { data: editais } = await useAsyncData('dash-editais', () =>
apiFetch<ApiEdital[]>('/editais'), { server: false, default: () => [] }
)
const { data: documentos } = await useAsyncData('dash-docs', () =>
apiFetch<ApiDocument[]>('/documents'), { server: false, default: () => [] }
)
const { data: contratos } = await useAsyncData('dash-contratos', () =>
apiFetch<ApiContract[]>('/contracts'), { server: false, default: () => [] }
)
// ---------- helpers ----------
const hoje = new Date()
const dataFormatada = hoje.toLocaleDateString('pt-BR', { day: 'numeric', month: 'long', year: 'numeric' })
const MODALIDADE: Record<string, string> = {
pregao_eletronico: 'Pregão Eletrônico', pregao_presencial: 'Pregão Presencial',
concorrencia: 'Concorrência', dispensa: 'Dispensa', inexigibilidade: 'Inexigibilidade',
}
const STATUS_CFG: Record<string, { label: string; color: string; bg: string }> = {
em_analise: { label: 'Mapeamento', color: '#0284c7', bg: '#eff6ff' },
elaborando_proposta: { label: 'Termo de Referência', color: '#7c3aed', bg: '#faf5ff' },
edital_publicado: { label: 'Edital Publicado', color: '#ea580c', bg: '#fff7ed' },
fase_lances: { label: 'Fase de Lances', color: '#3b82f6', bg: '#eff6ff' },
habilitacao: { label: 'Habilitação', color: '#d97706', bg: '#fef3c7' },
recurso: { label: 'Recursos', color: '#d97706', bg: '#fffbeb' },
adjudicado: { label: 'Adjudicado', color: '#059669', bg: '#ecfdf5' },
contrato: { label: 'Contrato', color: '#16a34a', bg: '#f0fdf4' },
vencida: { label: 'Vencida', color: '#16a34a', bg: '#f0fdf4' },
perdida: { label: 'Perdida', color: '#dc2626', bg: '#fef2f2' },
deserta: { label: 'Deserta/Fracassada', color: '#64748b', bg: '#f8fafc' },
}
const TIPO_DOC: Record<string, string> = {
cnpj: 'CNPJ', contrato_social: 'Contrato Social', balanco: 'Balanço',
certidao: 'Certidão', atestado: 'Atestado Técnico', procuracao: 'Procuração', outro: 'Outro',
}
function formatDate(iso: string): string {
if (!iso) return '—'
const [y, m, d] = iso.split('T')[0].split('-')
return `${d}/${m}/${y}`
}
function formatBRL(v: number): string {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(v)
}
function diffDays(iso: string): number {
const hojeMs = Date.UTC(hoje.getFullYear(), hoje.getMonth(), hoje.getDate())
const [y, m, d] = iso.split('T')[0].split('-').map(Number)
return Math.ceil((Date.UTC(y, m - 1, d) - hojeMs) / (1000 * 60 * 60 * 24))
}
function diasLabel(diff: number): string {
if (diff < 0) return `${Math.abs(diff)}d atrás`
if (diff === 0) return 'Hoje'
if (diff === 1) return 'Amanhã'
return `em ${diff}d`
}
// ---------- stats ----------
const totalEditais = computed(() => editais.value.length)
const vencidas = computed(() => editais.value.filter(e => e.Status === 'vencida').length)
const taxaVitoria = computed(() => {
const finalizados = editais.value.filter(e => ['vencida', 'perdida', 'deserta'].includes(e.Status))
if (finalizados.length === 0) return 0
return Math.round((vencidas.value / finalizados.length) * 100)
})
const valorContratosAtivos = computed(() =>
contratos.value.filter(c => c.Status === 'ativo').reduce((sum, c) => sum + (c.Valor ?? 0), 0)
)
// Alertas = docs vencendo/vencidos + prazos editais próximos
const alertasAtivos = computed(() => {
let count = 0
for (const d of documentos.value) {
if (!d.DataVencimento) continue
const diff = diffDays(d.DataVencimento)
if (diff <= 30) count++
}
const statusAtivos = new Set(['em_analise', 'elaborando_proposta', 'edital_publicado', 'fase_lances', 'habilitacao', 'recurso'])
for (const e of editais.value) {
if (!statusAtivos.has(e.Status)) continue
const diff = diffDays(e.DataAbertura)
if (diff >= 0 && diff <= 7) count++
}
return count
})
// ---------- pipeline ----------
const statusParaEtapa: Record<string, number> = {
em_analise: 1, elaborando_proposta: 2, edital_publicado: 3,
fase_lances: 4, habilitacao: 5, recurso: 6,
adjudicado: 7, contrato: 8, vencida: 8, perdida: 8, deserta: 8,
}
const contagemPorEtapa = computed(() => {
const counts: Record<number, number> = {}
for (const e of editais.value) {
const etapa = statusParaEtapa[e.Status] ?? 1
counts[etapa] = (counts[etapa] ?? 0) + 1
}
return counts
})
// ---------- prazos urgentes (próx 30 dias) ----------
interface PrazoItem {
id: string; titulo: string; sub: string; dataIso: string
diff: number; tipo: 'edital' | 'documento' | 'contrato'; link: string
}
const prazosUrgentes = computed<PrazoItem[]>(() => {
const items: PrazoItem[] = []
const statusAtivos = new Set(['em_analise', 'elaborando_proposta', 'edital_publicado', 'fase_lances', 'habilitacao', 'recurso'])
for (const e of editais.value) {
if (!statusAtivos.has(e.Status)) continue
const d = diffDays(e.DataAbertura)
if (d > 30) continue
items.push({
id: `e-${e.ID}`, titulo: `Abertura — ${e.Numero}`,
sub: `${e.Orgao}`, dataIso: e.DataAbertura, diff: d,
tipo: 'edital', link: `/oportunidades/${e.ID}`,
})
}
for (const doc of documentos.value) {
if (!doc.DataVencimento) continue
const d = diffDays(doc.DataVencimento)
if (d > 30) continue
items.push({
id: `d-${doc.ID}`, titulo: `Vencimento — ${doc.Nome || TIPO_DOC[doc.Tipo] || doc.Tipo}`,
sub: TIPO_DOC[doc.Tipo] || doc.Tipo, dataIso: doc.DataVencimento, diff: d,
tipo: 'documento', link: '/gestao/documentos',
})
}
for (const c of contratos.value) {
if (!c.DataFim || c.Status === 'encerrado') continue
const d = diffDays(c.DataFim)
if (d > 30) continue
items.push({
id: `c-${c.ID}`, titulo: `Fim vigência — ${c.Numero}`,
sub: c.Orgao, dataIso: c.DataFim, diff: d,
tipo: 'contrato', link: '/gestao/contratos',
})
}
items.sort((a, b) => a.diff - b.diff)
return items.slice(0, 6)
})
function urgenciaCor(diff: number): string {
if (diff < 0) return '#dc2626'
if (diff === 0) return '#dc2626'
if (diff <= 7) return '#ea580c'
return '#d97706'
}
// ---------- docs com alerta ----------
const docsAlerta = computed(() => {
return documentos.value
.filter(d => {
if (!d.DataVencimento) return false
return diffDays(d.DataVencimento) <= 30
})
.sort((a, b) => diffDays(a.DataVencimento!) - diffDays(b.DataVencimento!))
.slice(0, 5)
})
function docStatus(d: ApiDocument) {
if (!d.DataVencimento) return { label: 'OK', color: '#64748b', bg: '#f8fafc' }
const diff = diffDays(d.DataVencimento)
if (diff < 0) return { label: 'Vencida', color: '#dc2626', bg: '#fef2f2' }
if (diff <= 30) return { label: 'Vencendo', color: '#d97706', bg: '#fffbeb' }
return { label: 'Válida', color: '#16a34a', bg: '#f0fdf4' }
}
// ---------- editais recentes ----------
const editaisRecentes = computed(() =>
[...editais.value]
.sort((a, b) => new Date(b.DataAbertura).getTime() - new Date(a.DataAbertura).getTime())
.slice(0, 5)
)
</script>
<template>
<div class="page">
<AppTopbar title="Dashboard" :breadcrumb="`Visão geral · ${dataFormatada}`">
<template #actions>
<NuxtLink to="/oportunidades" class="btn-outline-sm">Ver Oportunidades</NuxtLink>
</template>
</AppTopbar>
<div class="content">
<!-- Stats -->
<div class="stats-grid">
<div class="stat-card">
<p class="stat-label">Total de Editais</p>
<p class="stat-value">{{ totalEditais }}</p>
<p class="stat-sub">Cadastrados</p>
</div>
<div class="stat-card">
<p class="stat-label">Taxa de Vitória</p>
<p class="stat-value" style="color: #10b981">{{ taxaVitoria }}%</p>
<p class="stat-sub">Processos finalizados</p>
</div>
<div class="stat-card">
<p class="stat-label">Contratos Ativos</p>
<p class="stat-value" style="color: #667eea">{{ formatBRL(valorContratosAtivos) }}</p>
<p class="stat-sub">{{ contratos.filter(c => c.Status === 'ativo').length }} contratos</p>
</div>
<div class="stat-card">
<p class="stat-label">Alertas Ativos</p>
<p class="stat-value" :style="{ color: alertasAtivos > 0 ? '#f59e0b' : '#94a3b8' }">{{ alertasAtivos }}</p>
<p class="stat-sub">Requerem atenção</p>
</div>
</div>
<!-- Pipeline -->
<div class="card mb-4">
<div class="card-header">
<h3>Pipeline de Oportunidades</h3>
<NuxtLink to="/pipeline" class="card-link">Ver kanban </NuxtLink>
</div>
<div class="pipeline-wrap">
<PipelineBar :contagem-por-etapa="contagemPorEtapa" />
</div>
</div>
<!-- Grid 2 colunas -->
<div class="grid-2 mb-4">
<!-- Alertas de Prazo -->
<div class="card">
<div class="card-header">
<h3>Alertas de Prazo</h3>
<NuxtLink to="/gestao/prazos" class="card-link">Ver todos </NuxtLink>
</div>
<div v-if="prazosUrgentes.length === 0" class="empty-section">
<p>Nenhum prazo urgente nos próximos 30 dias</p>
</div>
<NuxtLink
v-for="p in prazosUrgentes" :key="p.id"
:to="p.link"
class="alert-item"
>
<div class="alert-dot" :style="{ background: urgenciaCor(p.diff) }" />
<div class="alert-text">
<p class="at">{{ p.titulo }}</p>
<p class="as">{{ p.sub }}</p>
</div>
<div class="alert-right">
<span class="alert-date" :style="{ color: urgenciaCor(p.diff) }">{{ diasLabel(p.diff) }}</span>
<span class="alert-date-full">{{ formatDate(p.dataIso) }}</span>
</div>
</NuxtLink>
</div>
<!-- Documentos -->
<div class="card">
<div class="card-header">
<h3>Documentos com Alerta</h3>
<NuxtLink to="/gestao/documentos" class="card-link">Gerenciar </NuxtLink>
</div>
<div v-if="docsAlerta.length === 0" class="empty-section">
<p>Todos os documentos estão em dia</p>
</div>
<NuxtLink
v-for="d in docsAlerta" :key="d.ID"
to="/gestao/documentos"
class="doc-item"
>
<div>
<p class="doc-nome">{{ d.Nome || TIPO_DOC[d.Tipo] || d.Tipo }}</p>
<p class="doc-sub">{{ TIPO_DOC[d.Tipo] || d.Tipo }} · {{ formatDate(d.DataVencimento!) }}</p>
</div>
<span
class="doc-badge"
:style="{ color: docStatus(d).color, background: docStatus(d).bg }"
>{{ docStatus(d).label }}</span>
</NuxtLink>
</div>
</div>
<!-- Tabela Editais Recentes -->
<div class="card">
<div class="card-header">
<h3>Editais Recentes</h3>
<NuxtLink to="/oportunidades" class="card-link">Ver todos </NuxtLink>
</div>
<div v-if="editaisRecentes.length === 0" class="empty-section">
<p>Nenhum edital cadastrado ainda</p>
</div>
<table v-else class="tbl">
<thead>
<tr>
<th> Edital</th>
<th>Órgão</th>
<th>Objeto</th>
<th>Modalidade</th>
<th>Valor Est.</th>
<th>Abertura</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr
v-for="e in editaisRecentes" :key="e.ID"
class="row-click"
@click="navigateTo(`/oportunidades/${e.ID}`)"
>
<td class="td-num">{{ e.Numero }}</td>
<td>{{ e.Orgao }}</td>
<td class="td-obj">{{ e.Objeto }}</td>
<td>{{ MODALIDADE[e.Modalidade] ?? e.Modalidade }}</td>
<td class="td-val">{{ formatBRL(e.ValorEstimado) }}</td>
<td>{{ formatDate(e.DataAbertura) }}</td>
<td>
<span
v-if="STATUS_CFG[e.Status]"
class="badge"
:style="{ background: STATUS_CFG[e.Status].bg, color: STATUS_CFG[e.Status].color }"
>{{ STATUS_CFG[e.Status].label }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<style scoped>
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300..700&family=JetBrains+Mono:wght@400;600&display=swap');
.page { display: flex; flex-direction: column; height: 100vh; font-family: 'DM Sans', system-ui, sans-serif; }
.content { padding: 24px 28px 40px; flex: 1; overflow-y: auto; }
.btn-outline-sm {
padding: 6px 14px; border-radius: 7px; border: 1px solid #e2e8f0;
background: white; font-size: 12px; font-weight: 600; color: #475569;
text-decoration: none; transition: all .15s;
}
.btn-outline-sm:hover { background: #f8fafc; border-color: #cbd5e1; }
/* ── Stats ── */
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 18px; }
.stat-card {
background: white; border-radius: 12px; padding: 18px 20px;
border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,.04);
}
.stat-label { font-size: 11px; color: #94a3b8; text-transform: uppercase; letter-spacing: .5px; font-weight: 700; margin-bottom: 8px; }
.stat-value { font-size: 28px; font-weight: 800; color: #0f172a; line-height: 1; font-family: 'JetBrains Mono', monospace; }
.stat-sub { font-size: 11px; color: #64748b; margin-top: 6px; }
/* ── Cards ── */
.card { background: white; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
.mb-4 { margin-bottom: 18px; }
.card-header {
padding: 16px 20px 12px; border-bottom: 1px solid #f1f5f9;
display: flex; align-items: center; justify-content: space-between;
}
.card-header h3 { font-size: 14px; font-weight: 700; color: #0f172a; margin: 0; }
.card-link { font-size: 12px; color: #667eea; text-decoration: none; font-weight: 600; }
.card-link:hover { text-decoration: underline; }
.pipeline-wrap { padding: 0 20px; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.empty-section { padding: 28px 20px; text-align: center; }
.empty-section p { font-size: 13px; color: #94a3b8; margin: 0; }
/* ── Alertas ── */
.alert-item {
display: flex; align-items: center; gap: 12px;
padding: 12px 20px; border-bottom: 1px solid #f5f7fa;
text-decoration: none; transition: background .12s;
}
.alert-item:last-child { border-bottom: none; }
.alert-item:hover { background: #fafbfd; }
.alert-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.alert-text { flex: 1; min-width: 0; }
.at { font-size: 13px; font-weight: 600; color: #0f172a; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.as { font-size: 11.5px; color: #94a3b8; margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.alert-right { text-align: right; flex-shrink: 0; }
.alert-date { font-size: 12px; font-weight: 700; display: block; }
.alert-date-full { font-size: 10.5px; color: #94a3b8; display: block; margin-top: 1px; }
/* ── Docs ── */
.doc-item {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; border-bottom: 1px solid #f5f7fa;
text-decoration: none; transition: background .12s;
}
.doc-item:last-child { border-bottom: none; }
.doc-item:hover { background: #fafbfd; }
.doc-nome { font-size: 13px; font-weight: 600; color: #0f172a; }
.doc-sub { font-size: 11.5px; color: #94a3b8; margin-top: 1px; }
.doc-badge { font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 20px; flex-shrink: 0; }
/* ── Table ── */
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.tbl thead tr { border-bottom: 1px solid #e2e8f0; background: #f8fafc; }
.tbl th {
padding: 10px 16px; text-align: left; font-weight: 700; color: #64748b;
font-size: 11px; text-transform: uppercase; letter-spacing: .4px; white-space: nowrap;
}
.tbl td { padding: 12px 16px; color: #1e293b; border-bottom: 1px solid #f5f7fa; vertical-align: middle; }
.tbl tbody tr:last-child td { border-bottom: none; }
.row-click { cursor: pointer; transition: background .12s; }
.row-click:hover td { background: #fafbfd; }
.td-num { font-weight: 700; white-space: nowrap; }
.td-obj { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.td-val { font-family: 'JetBrains Mono', monospace; font-size: 12px; white-space: nowrap; }
.badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; }
/* ── Responsive ── */
@media (max-width: 900px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.grid-2 { grid-template-columns: 1fr; }
}
</style>