feat: dashboard completo com stats, pipeline, alertas e tabela
This commit is contained in:
@@ -1,76 +1,173 @@
|
||||
<!-- front-end/app/pages/index.vue -->
|
||||
<script setup lang="ts">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
import { prazos } from '~/data/mock/prazos'
|
||||
import { documentos } from '~/data/mock/documentos'
|
||||
import { dashboardStats } from '~/data/mock/stats'
|
||||
|
||||
const hoje = new Date()
|
||||
const dataFormatada = hoje.toLocaleDateString('pt-BR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
|
||||
const valorFormatado = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(dashboardStats.valorGanho)
|
||||
|
||||
const editaisRecentes = computed(() => [...editais].sort((a, b) => b.dataAbertura.getTime() - a.dataAbertura.getTime()).slice(0, 5))
|
||||
|
||||
const prazosUrgentes = computed(() => prazos.filter(p => p.urgencia === 'critico' || p.urgencia === 'urgente'))
|
||||
|
||||
const docsAlerta = computed(() => documentos.filter(d => d.status === 'vencendo' || d.status === 'vencida'))
|
||||
|
||||
function urgenciaCor(urgencia: string) {
|
||||
if (urgencia === 'critico') return '#ef4444'
|
||||
if (urgencia === 'urgente') return '#f59e0b'
|
||||
return '#667eea'
|
||||
}
|
||||
|
||||
function urgenciaLabel(p: typeof prazos[0]) {
|
||||
const diff = Math.ceil((p.dataLimite.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (diff <= 0) return 'Hoje'
|
||||
if (diff === 1) return 'Amanhã'
|
||||
return p.dataLimite.toLocaleDateString('pt-BR')
|
||||
}
|
||||
|
||||
function docStatusCor(status: string) {
|
||||
if (status === 'vencida') return '#dc2626'
|
||||
if (status === 'vencendo') return '#d97706'
|
||||
return '#16a34a'
|
||||
}
|
||||
|
||||
const modalidadeLabel: Record<string, string> = {
|
||||
pregao_eletronico: 'Pregão Eletrônico',
|
||||
pregao_presencial: 'Pregão Presencial',
|
||||
concorrencia: 'Concorrência',
|
||||
dispensa: 'Dispensa',
|
||||
inexigibilidade: 'Inexigibilidade',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Dashboard" :breadcrumb="`Visão geral · ${dataFormatada}`">
|
||||
<template #actions>
|
||||
<UButton variant="outline" color="neutral" size="sm">Importar Edital</UButton>
|
||||
<UButton size="sm" class="btn-primary">+ Nova Oportunidade</UButton>
|
||||
</template>
|
||||
</AppTopbar>
|
||||
|
||||
<div class="content">
|
||||
<!-- Stats -->
|
||||
<div class="stats-grid">
|
||||
<StatCard label="Total de Editais" :value="dashboardStats.totalEditais" sub="Este ano" />
|
||||
<StatCard label="Taxa de Vitória" :value="`${dashboardStats.taxaVitoria}%`" sub="Processos ganhos" color="#10b981" />
|
||||
<StatCard label="Valor Ganho" :value="valorFormatado" sub="Em contratos ativos" color="#667eea" />
|
||||
<StatCard label="Alertas Ativos" :value="dashboardStats.alertasAtivos" sub="Requerem atenção" color="#f59e0b" />
|
||||
</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="px-4">
|
||||
<PipelineBar :contagem-por-etapa="dashboardStats.editalsPorEtapaPipeline" />
|
||||
</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-for="p in prazosUrgentes" :key="p.id" class="alert-item">
|
||||
<div class="alert-dot" :style="{ background: urgenciaCor(p.urgencia) }" />
|
||||
<div class="alert-text">
|
||||
<p class="at">{{ p.titulo }}</p>
|
||||
<p class="as">{{ p.descricao }}</p>
|
||||
</div>
|
||||
<span class="alert-date" :style="{ color: urgenciaCor(p.urgencia) }">{{ urgenciaLabel(p) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documentos -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Documentos da Empresa</h3>
|
||||
<NuxtLink to="/gestao/documentos" class="card-link">Gerenciar →</NuxtLink>
|
||||
</div>
|
||||
<div v-for="d in docsAlerta" :key="d.id" class="doc-item">
|
||||
<div>
|
||||
<UPageHero
|
||||
title="Nuxt Starter Template"
|
||||
description="A production-ready starter template powered by Nuxt UI. Build beautiful, accessible, and performant applications in minutes, not hours."
|
||||
:links="[{
|
||||
label: 'Get started',
|
||||
to: 'https://ui.nuxt.com/docs/getting-started/installation/nuxt',
|
||||
target: '_blank',
|
||||
trailingIcon: 'i-lucide-arrow-right',
|
||||
size: 'xl'
|
||||
}, {
|
||||
label: 'Use this template',
|
||||
to: 'https://github.com/nuxt-ui-templates/starter',
|
||||
target: '_blank',
|
||||
icon: 'i-simple-icons-github',
|
||||
size: 'xl',
|
||||
color: 'neutral',
|
||||
variant: 'subtle'
|
||||
}]"
|
||||
/>
|
||||
<p class="doc-nome">{{ d.nome }}</p>
|
||||
<p class="doc-sub">
|
||||
{{ d.status === 'vencida' ? 'Vencida' : 'Vencendo em breve' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="doc-badge" :style="{ color: docStatusCor(d.status), background: docStatusCor(d.status) + '18' }">
|
||||
{{ d.status === 'vencida' ? 'Vencida' : 'Vencendo' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UPageSection
|
||||
id="features"
|
||||
title="Everything you need to build modern Nuxt apps"
|
||||
description="Start with a solid foundation. This template includes all the essentials for building production-ready applications with Nuxt UI's powerful component system."
|
||||
:features="[{
|
||||
icon: 'i-lucide-rocket',
|
||||
title: 'Production-ready from day one',
|
||||
description: 'Pre-configured with TypeScript, ESLint, Tailwind CSS, and all the best practices. Focus on building features, not setting up tooling.'
|
||||
}, {
|
||||
icon: 'i-lucide-palette',
|
||||
title: 'Beautiful by default',
|
||||
description: 'Leveraging Nuxt UI\'s design system with automatic dark mode, consistent spacing, and polished components that look great out of the box.'
|
||||
}, {
|
||||
icon: 'i-lucide-zap',
|
||||
title: 'Lightning fast',
|
||||
description: 'Optimized for performance with SSR/SSG support, automatic code splitting, and edge-ready deployment. Your users will love the speed.'
|
||||
}, {
|
||||
icon: 'i-lucide-blocks',
|
||||
title: '100+ components included',
|
||||
description: 'Access Nuxt UI\'s comprehensive component library. From forms to navigation, everything is accessible, responsive, and customizable.'
|
||||
}, {
|
||||
icon: 'i-lucide-code-2',
|
||||
title: 'Developer experience first',
|
||||
description: 'Auto-imports, hot module replacement, and TypeScript support. Write less boilerplate and ship more features.'
|
||||
}, {
|
||||
icon: 'i-lucide-shield-check',
|
||||
title: 'Built for scale',
|
||||
description: 'Enterprise-ready architecture with proper error handling, SEO optimization, and security best practices built-in.'
|
||||
}]"
|
||||
/>
|
||||
|
||||
<UPageSection>
|
||||
<UPageCTA
|
||||
title="Ready to build your next Nuxt app?"
|
||||
description="Join thousands of developers building with Nuxt and Nuxt UI. Get this template and start shipping today."
|
||||
variant="subtle"
|
||||
:links="[{
|
||||
label: 'Start building',
|
||||
to: 'https://ui.nuxt.com/docs/getting-started/installation/nuxt',
|
||||
target: '_blank',
|
||||
trailingIcon: 'i-lucide-arrow-right',
|
||||
color: 'neutral'
|
||||
}, {
|
||||
label: 'View on GitHub',
|
||||
to: 'https://github.com/nuxt-ui-templates/starter',
|
||||
target: '_blank',
|
||||
icon: 'i-simple-icons-github',
|
||||
color: 'neutral',
|
||||
variant: 'outline'
|
||||
}]"
|
||||
/>
|
||||
</UPageSection>
|
||||
<!-- Tabela Editais Recentes -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Editais Recentes</h3>
|
||||
<NuxtLink to="/oportunidades" class="card-link">Ver todos →</NuxtLink>
|
||||
</div>
|
||||
<UTable
|
||||
:data="editaisRecentes"
|
||||
:columns="[
|
||||
{ key: 'numero', label: 'Nº Edital' },
|
||||
{ key: 'objeto', label: 'Objeto' },
|
||||
{ key: 'orgao', label: 'Órgão' },
|
||||
{ key: 'modalidade', label: 'Modalidade' },
|
||||
{ key: 'valorEstimado', label: 'Valor Est.' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
{ key: 'dataAbertura', label: 'Abertura' },
|
||||
]"
|
||||
>
|
||||
<template #modalidade-cell="{ row }">
|
||||
{{ modalidadeLabel[row.original.modalidade] }}
|
||||
</template>
|
||||
<template #valorEstimado-cell="{ row }">
|
||||
{{ new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(row.original.valorEstimado) }}
|
||||
</template>
|
||||
<template #status-cell="{ row }">
|
||||
<StatusChip :status="row.original.status" />
|
||||
</template>
|
||||
<template #dataAbertura-cell="{ row }">
|
||||
{{ row.original.dataAbertura.toLocaleDateString('pt-BR') }}
|
||||
</template>
|
||||
</UTable>
|
||||
</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; margin-bottom: 16px; }
|
||||
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
|
||||
.mb-4 { margin-bottom: 14px; }
|
||||
.card-header { padding: 14px 18px 10px; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: space-between; }
|
||||
.card-header h3 { font-size: 13px; font-weight: 700; color: #0f172a; }
|
||||
.card-link { font-size: 11px; color: #667eea; text-decoration: none; font-weight: 500; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.alert-item { display: flex; align-items: center; gap: 10px; padding: 10px 18px; border-bottom: 1px solid #f8fafc; }
|
||||
.alert-item:last-child { border-bottom: none; }
|
||||
.alert-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.at { font-size: 12px; font-weight: 600; color: #0f172a; }
|
||||
.as { font-size: 11px; color: #94a3b8; }
|
||||
.alert-date { font-size: 11px; font-weight: 600; flex-shrink: 0; }
|
||||
.doc-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 18px; border-bottom: 1px solid #f8fafc; }
|
||||
.doc-item:last-child { border-bottom: none; }
|
||||
.doc-nome { font-size: 12px; font-weight: 500; color: #0f172a; }
|
||||
.doc-sub { font-size: 10.5px; color: #94a3b8; }
|
||||
.doc-badge { font-size: 10.5px; font-weight: 600; padding: 2px 8px; border-radius: 20px; }
|
||||
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2) !important; }
|
||||
.px-4 { padding: 0 18px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user