434 lines
17 KiB
Vue
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>Nº 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>
|