This commit is contained in:
Junior
2026-04-21 18:05:15 -03:00
parent 8c3c56de09
commit d29137be9d
41 changed files with 3945 additions and 318 deletions

8
.gitignore vendored
View File

@@ -1 +1,9 @@
.worktrees/
# Secrets — NUNCA commitar
.env
.env.prod
.env.local
back-end/.env
back-end/.env.local
!back-end/.env.example

View File

@@ -0,0 +1,23 @@
[ 44110ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://163.176.236.167/api/_nuxt_icon/lucide.json?icons=loader-circle:0
[ 44111ms] [WARNING] [Icon] failed to load icon `lucide:loader-circle` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45020ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://163.176.236.167/api/_nuxt_icon/heroicons.json?icons=arrow-right-on-rectangle%2Cbuilding-library%2Cbuilding-office-2%2Cchart-bar%2Cclipboard-document-list%2Cclock%2Ccog-6-tooth%2Cdocument-text%2Cfolder%2Chome%2Cmagnifying-glass%2Cmegaphone%2Cpencil-square%2Cplay%2Cscale%2Ctrophy%2Cusers%2Cview-columns%2Cx-circle:0
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:home` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:home` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:clipboard-document-list` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:magnifying-glass` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:pencil-square` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:megaphone` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:play` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:scale` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:trophy` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:x-circle` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:view-columns` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:folder` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:clock` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:building-library` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:building-office-2` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:document-text` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:chart-bar` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:users` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:cog-6-tooth` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5
[ 45021ms] [WARNING] [Icon] failed to load icon `heroicons:arrow-right-on-rectangle` @ http://163.176.236.167/_nuxt/CCWgtBsX.js:5

View File

@@ -0,0 +1,20 @@
- generic [ref=e4]:
- generic [ref=e5]:
- heading "Bem-vindo de volta" [level=1] [ref=e6]
- paragraph [ref=e7]: Informe suas credenciais para acessar o sistema
- generic [ref=e8]:
- generic [ref=e9]:
- generic [ref=e12]: E-mail
- textbox "E-mail" [ref=e15]:
- /placeholder: usuario@orgao.gov.br
- generic [ref=e16]:
- generic [ref=e19]: Senha
- textbox "Senha" [ref=e22]:
- /placeholder: ••••••••
- link "Esqueceu a senha?" [ref=e24] [cursor=pointer]:
- /url: "#"
- button "Entrar" [ref=e25]
- paragraph [ref=e26]:
- text: Não tem conta?
- link "Criar conta" [ref=e27] [cursor=pointer]:
- /url: /register

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

View File

@@ -0,0 +1,29 @@
- generic [ref=e28]:
- generic [ref=e29]:
- heading "Criar conta" [level=1] [ref=e30]
- paragraph [ref=e31]: Preencha os dados para começar a usar o Licitatche
- generic [ref=e32]:
- generic [ref=e33]:
- button "Pessoa Jurídica" [ref=e34] [cursor=pointer]
- button "Pessoa Física" [ref=e35] [cursor=pointer]
- generic [ref=e36]:
- generic [ref=e39]: Razão Social
- textbox "Razão Social" [ref=e42]:
- /placeholder: Razão Social completa
- generic [ref=e43]:
- generic [ref=e46]: CNPJ
- textbox "CNPJ" [ref=e49]:
- /placeholder: 00.000.000/0001-00
- generic [ref=e50]:
- generic [ref=e53]: E-mail
- textbox "E-mail" [ref=e56]:
- /placeholder: voce@empresa.com.br
- generic [ref=e57]:
- generic [ref=e60]: Senha
- textbox "Senha" [ref=e63]:
- /placeholder: Mínimo 8 caracteres
- button "Criar conta" [ref=e64]
- paragraph [ref=e65]:
- text: Já tem conta?
- link "Fazer login" [ref=e66] [cursor=pointer]:
- /url: /login

View File

@@ -0,0 +1,151 @@
- generic [ref=e67]:
- complementary [ref=e68]:
- generic [ref=e72]: Licitatche
- navigation [ref=e73]:
- link "Dashboard" [ref=e74] [cursor=pointer]:
- /url: /
- generic [ref=e76]: Dashboard
- paragraph [ref=e77]: Oportunidades
- link "Todos os Editais" [ref=e78] [cursor=pointer]:
- /url: /oportunidades
- generic [ref=e80]: Todos os Editais
- link "Mapeamento" [ref=e81] [cursor=pointer]:
- /url: /oportunidades/em-analise
- generic [ref=e83]: Mapeamento
- link "Termo de Referência" [ref=e84] [cursor=pointer]:
- /url: /oportunidades/elaborando-proposta
- generic [ref=e86]: Termo de Referência
- link "Edital Publicado" [ref=e87] [cursor=pointer]:
- /url: /oportunidades/edital-publicado
- generic [ref=e89]: Edital Publicado
- link "Fase de Lances" [ref=e90] [cursor=pointer]:
- /url: /oportunidades/fase-lances
- generic [ref=e92]: Fase de Lances
- link "Recurso" [ref=e93] [cursor=pointer]:
- /url: /oportunidades/recurso
- generic [ref=e95]: Recurso
- link "Vencidas" [ref=e96] [cursor=pointer]:
- /url: /oportunidades/vencidas
- generic [ref=e98]: Vencidas
- link "Perdidas" [ref=e99] [cursor=pointer]:
- /url: /oportunidades/perdidas
- generic [ref=e101]: Perdidas
- paragraph [ref=e103]: Pipeline
- link "Kanban de Processos" [ref=e104] [cursor=pointer]:
- /url: /pipeline
- generic [ref=e106]: Kanban de Processos
- paragraph [ref=e108]: Gestão
- link "Documentos" [ref=e109] [cursor=pointer]:
- /url: /gestao/documentos
- generic [ref=e111]: Documentos
- link "Prazos" [ref=e112] [cursor=pointer]:
- /url: /gestao/prazos
- generic [ref=e114]: Prazos
- link "Órgãos Públicos" [ref=e115] [cursor=pointer]:
- /url: /gestao/orgaos
- generic [ref=e117]: Órgãos Públicos
- link "Concorrentes" [ref=e118] [cursor=pointer]:
- /url: /gestao/concorrentes
- generic [ref=e120]: Concorrentes
- link "Contratos" [ref=e121] [cursor=pointer]:
- /url: /gestao/contratos
- generic [ref=e123]: Contratos
- paragraph [ref=e125]: Inteligência
- link "Inteligência de Mercado" [ref=e126] [cursor=pointer]:
- /url: /inteligencia
- generic [ref=e128]: Inteligência de Mercado
- paragraph [ref=e130]: Sistema
- link "Usuários" [ref=e131] [cursor=pointer]:
- /url: /sistema/usuarios
- generic [ref=e133]: Usuários
- link "Configurações" [ref=e134] [cursor=pointer]:
- /url: /sistema/configuracoes
- generic [ref=e136]: Configurações
- generic [ref=e139]:
- generic [ref=e141]: a
- generic [ref=e142]:
- paragraph [ref=e143]: admin
- paragraph [ref=e144]: admin
- button [ref=e145]
- generic [ref=e148]:
- banner [ref=e149]:
- generic [ref=e150]:
- heading "Dashboard" [level=1] [ref=e151]
- paragraph [ref=e152]: Visão geral · 2 de abril de 2026
- link "Ver Oportunidades" [ref=e154] [cursor=pointer]:
- /url: /oportunidades
- generic [ref=e155]:
- generic [ref=e156]:
- generic [ref=e157]:
- paragraph [ref=e158]: Total de Editais
- paragraph [ref=e159]: "0"
- paragraph [ref=e160]: Cadastrados
- generic [ref=e161]:
- paragraph [ref=e162]: Taxa de Vitória
- paragraph [ref=e163]: 0%
- paragraph [ref=e164]: Processos finalizados
- generic [ref=e165]:
- paragraph [ref=e166]: Contratos Ativos
- paragraph [ref=e167]: R$ 0
- paragraph [ref=e168]: 0 contratos
- generic [ref=e169]:
- paragraph [ref=e170]: Alertas Ativos
- paragraph [ref=e171]: "0"
- paragraph [ref=e172]: Requerem atenção
- generic [ref=e173]:
- generic [ref=e174]:
- heading "Pipeline de Oportunidades" [level=3] [ref=e175]
- link "Ver kanban →" [ref=e176] [cursor=pointer]:
- /url: /pipeline
- generic [ref=e178]:
- generic [ref=e179]:
- generic [ref=e181]: "1"
- paragraph [ref=e182]: Mapeamento
- paragraph [ref=e183]: 0 editais
- generic [ref=e184]:
- generic [ref=e186]: "2"
- paragraph [ref=e187]: Termo de Referência
- paragraph [ref=e188]: 0 editais
- generic [ref=e189]:
- generic [ref=e191]: "3"
- paragraph [ref=e192]: Edital Publicado
- paragraph [ref=e193]: 0 editais
- generic [ref=e194]:
- generic [ref=e196]: "4"
- paragraph [ref=e197]: Fase de Lances
- paragraph [ref=e198]: 0 editais
- generic [ref=e199]:
- generic [ref=e201]: "5"
- paragraph [ref=e202]: Habilitação
- paragraph [ref=e203]: 0 editais
- generic [ref=e204]:
- generic [ref=e206]: "6"
- paragraph [ref=e207]: Recursos
- paragraph [ref=e208]: 0 editais
- generic [ref=e209]:
- generic [ref=e211]: "7"
- paragraph [ref=e212]: Adjudicado
- paragraph [ref=e213]: 0 editais
- generic [ref=e214]:
- generic [ref=e216]: "8"
- paragraph [ref=e217]: Contrato
- paragraph [ref=e218]: 0 editais
- generic [ref=e219]:
- generic [ref=e220]:
- generic [ref=e221]:
- heading "Alertas de Prazo" [level=3] [ref=e222]
- link "Ver todos →" [ref=e223] [cursor=pointer]:
- /url: /gestao/prazos
- paragraph [ref=e225]: Nenhum prazo urgente nos próximos 30 dias
- generic [ref=e226]:
- generic [ref=e227]:
- heading "Documentos com Alerta" [level=3] [ref=e228]
- link "Gerenciar →" [ref=e229] [cursor=pointer]:
- /url: /gestao/documentos
- paragraph [ref=e231]: Todos os documentos estão em dia
- generic [ref=e232]:
- generic [ref=e233]:
- heading "Editais Recentes" [level=3] [ref=e234]
- link "Ver todos →" [ref=e235] [cursor=pointer]:
- /url: /oportunidades
- paragraph [ref=e237]: Nenhum edital cadastrado ainda

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

52
Makefile Normal file
View File

@@ -0,0 +1,52 @@
.PHONY: help run stop up down db migrate api front logs
MIGRATE=$(HOME)/go/bin/migrate
# ─── Defaults ───────────────────────────────────────────────────────────────
help:
@echo ""
@echo " Licitatche — comandos disponíveis"
@echo ""
@echo " make up Sobe tudo: DB + API + Front"
@echo " make down Para tudo e remove containers"
@echo " make db Sobe apenas o PostgreSQL"
@echo " make migrate Roda as migrations"
@echo " make api Sobe apenas a API Go"
@echo " make front Sobe apenas o front Nuxt"
@echo " make logs Tail dos logs da API"
@echo ""
# ─── Infra ───────────────────────────────────────────────────────────────────
db:
cd back-end && docker compose up -d
@echo "⏳ Aguardando PostgreSQL ficar pronto..."
@until docker exec $$(cd back-end && docker compose ps -q postgres) pg_isready -U licitatche > /dev/null 2>&1; do sleep 1; done
@echo "✅ PostgreSQL pronto"
migrate: db
cd back-end && $(MIGRATE) -path migrations -database "postgres://licitatche:licitatche@localhost:5432/licitatche?sslmode=disable" up
down:
cd back-end && docker compose down
# ─── Serviços ────────────────────────────────────────────────────────────────
api:
cd back-end && go run ./cmd/api/...
front:
cd front-end/app && npm run dev
# ─── Tudo junto (processos paralelos) ────────────────────────────────────────
up: migrate
@echo "🚀 Iniciando API e Front em paralelo..."
@trap 'kill 0' SIGINT SIGTERM; \
(cd back-end && go run ./cmd/api/... 2>&1 | sed 's/^/[api] /') & \
(cd front-end/app && npm run dev 2>&1 | sed 's/^/[front] /') & \
wait
logs:
cd back-end && docker compose logs -f
# ─── Aliases ─────────────────────────────────────────────────────────────────
run: up
stop: down

Submodule back-end updated: 6480a285f5...ffa085e6ad

100
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,100 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER:-licitatche}
POSTGRES_PASSWORD: ${DB_PASSWORD:-licitatche}
POSTGRES_DB: ${DB_NAME:-licitatche}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-licitatche}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- internal
migrate:
image: migrate/migrate:v4.17.0
depends_on:
postgres:
condition: service_healthy
volumes:
- ./back-end/migrations:/migrations
command: [
"-path", "/migrations",
"-database", "postgres://${DB_USER:-licitatche}:${DB_PASSWORD:-licitatche}@postgres:5432/${DB_NAME:-licitatche}?sslmode=disable",
"up"
]
networks:
- internal
api:
build:
context: ./back-end
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully
environment:
APP_PORT: "8080"
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3000}
DB_HOST: postgres
DB_PORT: "5432"
DB_USER: ${DB_USER:-licitatche}
DB_PASSWORD: ${DB_PASSWORD:-licitatche}
DB_NAME: ${DB_NAME:-licitatche}
JWT_SECRET: ${JWT_SECRET}
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
FILES_DIR: /app/data/files
volumes:
- api_files:/app/data/files
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/editais", "-o", "/dev/null", "-s", "-w", "%{http_code}", "||", "true"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
networks:
- internal
front:
build:
context: ./front-end
args:
NUXT_PUBLIC_API_BASE: ${PUBLIC_API_BASE:-http://163.176.236.167:8080/api/v1}
restart: unless-stopped
depends_on:
- api
environment:
NUXT_PUBLIC_API_BASE: ${PUBLIC_API_BASE:-http://163.176.236.167:8080/api/v1}
ports:
- "3000:3000"
networks:
- internal
nginx:
image: nginx:alpine
restart: unless-stopped
depends_on:
- api
- front
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- internal
volumes:
postgres_data:
api_files:
networks:
internal:
driver: bridge

BIN
edital-real.pdf Normal file

Binary file not shown.

74
edital-teste.pdf Normal file
View File

@@ -0,0 +1,74 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R /F2 3 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
>>
endobj
4 0 obj
<<
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
5 0 obj
<<
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
>>
endobj
6 0 obj
<<
/Author (anonymous) /CreationDate (D:20260323201142-03'00') /Creator (anonymous) /Keywords () /ModDate (D:20260323201142-03'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (unspecified) /Title (untitled) /Trapped /False
>>
endobj
7 0 obj
<<
/Count 1 /Kids [ 4 0 R ] /Type /Pages
>>
endobj
8 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 564
>>
stream
GasJO:N)aW&B4,6'^sC9>HM"s/DHED-Da_=,cnGMpAm@<;o$m@rVR]8U^9pkKS7:ZqosBB6UL8ga*Z>:QNag)-"Mto*c`(a:g>4qcGk/=\KC.0U\MAPB7ps#T+Z^L_>E7Aqf^&C?"_,*^\rVHj#2MA-/2P?q[Y(r;1/:sID+G<)c,,a`7,J[VDUPNB.ObU#PC\BR1$-aZo;#_D%nFiiop":=_+oMcF0UUs-N1-k18Xh%Q;;bGAk-n=KOCmrh-Pkf*>4)C!S[:/(b=P3C;V`mJI<=5J]u2OFnO%3"9t^@$Lm;d[=KuPI=F.gJT&oi-#e%-e6cko>*7G=,PHg`"C<6#pD2nX8qBGJ"]-F0ZUo/Gq_F!$CP_Lq()FroO=daPhLiJ;2Y/.D07V;qH1*%hR\Ga=1hg/5'jG_U=-/WkMYIcfE^4:hOu]UC0k>$Nn7]0"$rTH%VgIVW*t6_nL6JjkU\FT3*BVhK'DWcjVrYIe\,\M[>$KVVJWhe<O2eS>jB#ZXE9P&LG:b&FK;bN-_f.Vp3t-rLY_!.%M^Ug1Gf"206;]N^Wh$4)#~>endstream
endobj
xref
0 9
0000000000 65535 f
0000000061 00000 n
0000000102 00000 n
0000000209 00000 n
0000000321 00000 n
0000000524 00000 n
0000000592 00000 n
0000000853 00000 n
0000000912 00000 n
trailer
<<
/ID
[<db71a336607b06e433c83b381547d8fa><db71a336607b06e433c83b381547d8fa>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1566
%%EOF

5
front-end/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.nuxt
.data
app
public

13
front-end/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:22-alpine
RUN apk add --no-cache curl
ENV NODE_ENV=production
ENV TZ=America/Sao_Paulo
WORKDIR /app
COPY .output ./.output
RUN cd .output/server && npm install --omit=dev
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

View File

@@ -1,21 +1,33 @@
<script setup lang="ts">
import { editais } from '~/data/mock/editais'
import { prazos } from '~/data/mock/prazos'
import { documentos } from '~/data/mock/documentos'
const { user, logout } = useAuth()
const route = useRoute()
const { apiFetch } = useApi()
const { data: editais } = await useAsyncData('sidebar-editais', () =>
apiFetch<{ Status: string }[]>('/editais'), { server: false }
)
const { data: documentos } = await useAsyncData('sidebar-documentos', () =>
apiFetch<{ DataVencimento: string | null }[]>('/documents'), { server: false }
)
const contagemPorStatus = computed(() => {
const counts: Record<string, number> = {}
for (const e of editais) {
counts[e.status] = (counts[e.status] ?? 0) + 1
for (const e of editais.value ?? []) {
counts[e.Status] = (counts[e.Status] ?? 0) + 1
}
return counts
})
const alertasPrazos = computed(() => prazos.filter(p => p.urgencia === 'critico' || p.urgencia === 'urgente').length)
const docsVencendo = computed(() => documentos.filter(d => d.status === 'vencendo' || d.status === 'vencida').length)
const alertasPrazos = computed(() => 0)
const docsVencendo = computed(() => {
const hoje = new Date()
const limite = new Date(hoje.getTime() + 30 * 24 * 60 * 60 * 1000)
return (documentos.value ?? []).filter(d => {
if (!d.DataVencimento) return false
const venc = new Date(d.DataVencimento)
return venc <= limite
}).length
})
const navItems = computed(() => [
{
@@ -27,10 +39,11 @@ const navItems = computed(() => [
{
label: 'Oportunidades',
items: [
{ label: 'Todos os Editais', icon: 'i-heroicons-clipboard-document-list', to: '/oportunidades', badge: editais.length, badgeVariant: 'default' },
{ label: 'Em Análise', icon: 'i-heroicons-magnifying-glass', to: '/oportunidades/em-analise', badge: contagemPorStatus.value.em_analise ?? 0, badgeVariant: 'default' },
{ label: 'Elaborando Proposta', icon: 'i-heroicons-pencil-square', to: '/oportunidades/elaborando-proposta', badge: contagemPorStatus.value.elaborando_proposta ?? 0, badgeVariant: 'warning' },
{ label: 'Participando', icon: 'i-heroicons-play', to: '/oportunidades/participando', badge: contagemPorStatus.value.participando ?? 0, badgeVariant: 'default' },
{ label: 'Todos os Editais', icon: 'i-heroicons-clipboard-document-list', to: '/oportunidades', badge: (editais.value ?? []).length, badgeVariant: 'default' },
{ label: 'Mapeamento', icon: 'i-heroicons-magnifying-glass', to: '/oportunidades/em-analise', badge: contagemPorStatus.value.em_analise ?? 0, badgeVariant: 'default' },
{ label: 'Termo de Referência', icon: 'i-heroicons-pencil-square', to: '/oportunidades/elaborando-proposta', badge: contagemPorStatus.value.elaborando_proposta ?? 0, badgeVariant: 'warning' },
{ label: 'Edital Publicado', icon: 'i-heroicons-megaphone', to: '/oportunidades/edital-publicado', badge: contagemPorStatus.value.edital_publicado ?? 0, badgeVariant: 'default' },
{ label: 'Fase de Lances', icon: 'i-heroicons-play', to: '/oportunidades/fase-lances', badge: contagemPorStatus.value.fase_lances ?? 0, badgeVariant: 'default' },
{ label: 'Recurso', icon: 'i-heroicons-scale', to: '/oportunidades/recurso', badge: contagemPorStatus.value.recurso ?? 0, badgeVariant: 'warning' },
{ label: 'Vencidas', icon: 'i-heroicons-trophy', to: '/oportunidades/vencidas', badge: contagemPorStatus.value.vencida ?? 0, badgeVariant: 'success' },
{ label: 'Perdidas', icon: 'i-heroicons-x-circle', to: '/oportunidades/perdidas', badge: contagemPorStatus.value.perdida ?? 0, badgeVariant: 'neutral' },

View File

@@ -1,41 +1,99 @@
<!-- front-end/app/components/EditaisTable.vue -->
<script setup lang="ts">
import type { Edital } from '~/types'
defineProps<{ editais: Edital[] }>()
const modalidadeLabel: Record<string, string> = {
pregao_eletronico: 'Pregão Eletrônico',
pregao_presencial: 'Pregão Presencial',
concorrencia: 'Concorrência',
dispensa: 'Dispensa',
inexigibilidade: 'Inexigibilidade',
interface ApiEdital {
ID: string
Numero: string
Orgao: string
Modalidade: string
Objeto: string
Plataforma: string
ValorEstimado: number
DataPublicacao: string
DataAbertura: string
Status: string
}
const columns = [
{ id: 'numero', accessorKey: 'numero', header: 'Nº Edital' },
{ id: 'objeto', accessorKey: 'objeto', header: 'Objeto' },
{ id: 'orgao', accessorKey: 'orgao', header: 'Órgão' },
{ id: 'modalidade', accessorKey: 'modalidade', header: 'Modalidade' },
{ id: 'valorEstimado', accessorKey: 'valorEstimado', header: 'Valor Est.' },
{ id: 'status', accessorKey: 'status', header: 'Status' },
{ id: 'dataAbertura', accessorKey: 'dataAbertura', header: 'Abertura' },
]
defineProps<{ editais: ApiEdital[] }>()
const MODALIDADE_LABEL: 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' },
}
function formatDate(iso: string): string {
if (!iso) return '—'
const [y, m, d] = iso.split('T')[0].split('-')
return `${d}/${m}/${y}`
}
function formatBRL(value: number): string {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(value)
}
</script>
<template>
<UTable :data="editais" :columns="columns">
<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>
<table 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-if="!editais || editais.length === 0">
<td colspan="7" class="empty">Nenhum edital encontrado.</td>
</tr>
<tr v-for="e in editais" :key="e.ID">
<td class="numero">{{ e.Numero }}</td>
<td>{{ e.Orgao }}</td>
<td class="objeto">{{ e.Objeto }}</td>
<td>{{ MODALIDADE_LABEL[e.Modalidade] ?? e.Modalidade }}</td>
<td>{{ 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>
<span v-else class="badge">{{ e.Status }}</span>
</td>
</tr>
</tbody>
</table>
</template>
<style scoped>
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.tbl thead tr { border-bottom: 1px solid #e2e8f0; background: #f8fafc; }
.tbl th { padding: 10px 14px; text-align: left; font-weight: 600; color: #64748b; font-size: 12px; text-transform: uppercase; letter-spacing: .4px; white-space: nowrap; }
.tbl td { padding: 11px 14px; color: #1e293b; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
.tbl tbody tr:last-child td { border-bottom: none; }
.tbl tbody tr:hover td { background: #f8fafc; }
.numero { font-weight: 600; white-space: nowrap; }
.objeto { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty { text-align: center; color: #94a3b8; padding: 40px; }
.badge { display: inline-block; padding: 3px 9px; border-radius: 20px; font-size: 11.5px; font-weight: 600; }
</style>

View File

@@ -1,16 +1,45 @@
// Wrapper sobre $fetch que injeta o Authorization header automaticamente.
// Em caso de 401, tenta renovar o token via refresh e repete a requisição.
export function useApi() {
const { public: { apiBase } } = useRuntimeConfig()
const token = useCookie<string | null>('auth_token')
const refreshToken = useCookie<string | null>('refresh_token')
function apiFetch<T>(path: string, options: Parameters<typeof $fetch>[1] = {}): Promise<T> {
return $fetch<T>(`${apiBase}${path}`, {
async function tryRefresh(): Promise<boolean> {
if (!refreshToken.value) return false
try {
const res = await $fetch<{ access_token: string; refresh_token: string }>(
`${apiBase}/auth/refresh`,
{ method: 'POST', body: { refresh_token: refreshToken.value } }
)
token.value = res.access_token
refreshToken.value = res.refresh_token
return true
} catch {
token.value = null
refreshToken.value = null
navigateTo('/login')
return false
}
}
async function apiFetch<T>(path: string, options: Parameters<typeof $fetch>[1] = {}): Promise<T> {
const doFetch = () => $fetch<T>(`${apiBase}${path}`, {
...options,
headers: {
...(options.headers as Record<string, string> || {}),
...(token.value ? { Authorization: `Bearer ${token.value}` } : {}),
},
})
try {
return await doFetch()
} catch (err: any) {
if (err?.status === 401 && await tryRefresh()) {
return await doFetch()
}
throw err
}
}
return { apiFetch }

View File

@@ -4,6 +4,17 @@ interface AuthUser {
papel: string
}
export interface TenantOption {
id: string
name: string
slug: string
}
export type LoginResult =
| { success: true }
| { success: false; error: string }
| { needsTenantSelect: true; tenants: TenantOption[]; email: string; password: string }
export function useAuth() {
const { public: { apiBase } } = useRuntimeConfig()
@@ -23,23 +34,48 @@ export function useAuth() {
}
}
async function login(email: string, password: string, slug: string): Promise<{ success: boolean; error?: string }> {
function _applyTokens(access: string, refresh: string) {
token.value = access
refreshToken.value = refresh
const payload = JSON.parse(atob(access.split('.')[1]))
user.value = { nome: payload.email.split('@')[0], email: payload.email, papel: payload.role }
}
async function login(email: string, password: string): Promise<LoginResult> {
try {
const res = await $fetch<{ access_token: string; refresh_token: string }>(`${apiBase}/auth/login`, {
const res = await $fetch<{
tokens?: { access_token: string; refresh_token: string }
tenants?: TenantOption[]
}>(`${apiBase}/auth/login`, {
method: 'POST',
body: { email, password, slug },
body: { email, password },
})
token.value = res.access_token
refreshToken.value = res.refresh_token
if (res.tokens) {
_applyTokens(res.tokens.access_token, res.tokens.refresh_token)
return { success: true }
}
const payload = JSON.parse(atob(res.access_token.split('.')[1]))
user.value = { nome: payload.email.split('@')[0], email: payload.email, papel: payload.role }
if (res.tenants && res.tenants.length > 0) {
return { needsTenantSelect: true, tenants: res.tenants, email, password }
}
return { success: false, error: 'Resposta inesperada do servidor.' }
} catch (err: any) {
return { success: false, error: err?.data?.error || 'E-mail ou senha incorretos.' }
}
}
async function selectTenant(email: string, password: string, tenantId: string): Promise<{ success: boolean; error?: string }> {
try {
const res = await $fetch<{ access_token: string; refresh_token: string }>(`${apiBase}/auth/login/select-tenant`, {
method: 'POST',
body: { email, password, tenant_id: tenantId },
})
_applyTokens(res.access_token, res.refresh_token)
return { success: true }
} catch (err: any) {
const msg = err?.data?.error || 'E-mail, senha ou organização incorretos.'
return { success: false, error: msg }
return { success: false, error: err?.data?.error || 'Erro ao selecionar empresa.' }
}
}
@@ -57,5 +93,5 @@ export function useAuth() {
navigateTo('/login')
}
return { user, isAuthenticated, login, logout }
return { user, isAuthenticated, login, selectTenant, logout }
}

View File

@@ -1,6 +1,8 @@
<!-- front-end/app/pages/gestao/documentos.vue -->
<script setup lang="ts">
const { apiFetch } = useApi()
const { public: { apiBase } } = useRuntimeConfig()
const token = useCookie<string | null>('auth_token')
interface ApiDocument {
ID: string
@@ -10,6 +12,13 @@ interface ApiDocument {
Observacoes: string
}
interface ApiFile {
ID: string
Nome: string
Size: number
CreatedAt: string
}
const { data: documentos, refresh } = await useAsyncData('documentos', () =>
apiFetch<ApiDocument[]>('/documents')
)
@@ -56,6 +65,12 @@ function formatDate(iso: string | null): string {
return `${d}/${m}/${y}`
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
// ---------- menu ⋯ ----------
const menuAberto = ref<string | null>(null)
const menuPos = ref({ top: 0, left: 0 })
@@ -69,6 +84,65 @@ function abrirMenu(id: string, event: MouseEvent) {
function fecharMenu() { menuAberto.value = null }
// ---------- modal visualizar ----------
const showVisualizar = ref(false)
const viewDoc = ref<ApiDocument | null>(null)
const viewFiles = ref<ApiFile[]>([])
const viewFilesLoading = ref(false)
const uploadLoading = ref(false)
async function abrirVisualizar(doc: ApiDocument) {
fecharMenu()
viewDoc.value = doc
showVisualizar.value = true
viewFilesLoading.value = true
try {
viewFiles.value = await apiFetch<ApiFile[]>(`/documents/${doc.ID}/files`)
} catch { viewFiles.value = [] }
viewFilesLoading.value = false
}
async function uploadArquivos(event: Event) {
const input = event.target as HTMLInputElement
if (!input.files?.length || !viewDoc.value) return
uploadLoading.value = true
const formData = new FormData()
for (const file of Array.from(input.files)) formData.append('file', file)
try {
await $fetch(`${apiBase}/documents/${viewDoc.value.ID}/files`, {
method: 'POST',
headers: { Authorization: `Bearer ${token.value}` },
body: formData,
})
viewFiles.value = await apiFetch<ApiFile[]>(`/documents/${viewDoc.value.ID}/files`)
} catch {}
uploadLoading.value = false
input.value = ''
}
async function excluirArquivo(fileId: string) {
if (!viewDoc.value) return
try {
await apiFetch(`/documents/${viewDoc.value.ID}/files/${fileId}`, { method: 'DELETE' })
viewFiles.value = viewFiles.value.filter(f => f.ID !== fileId)
} catch {}
}
async function downloadArquivo(docId: string, fileId: string, nome: string) {
try {
const blob = await $fetch<Blob>(`${apiBase}/documents/${docId}/files/${fileId}/download`, {
headers: { Authorization: `Bearer ${token.value}` },
responseType: 'blob',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = nome
a.click()
URL.revokeObjectURL(url)
} catch {}
}
// ---------- modal criar ----------
const showCriar = ref(false)
const criando = ref(false)
@@ -221,8 +295,69 @@ async function confirmarExclusao() {
:style="{ top: menuPos.top + 'px', left: menuPos.left + 'px' }"
@click.stop
>
<button @click="abrirVisualizar(documentos!.find(d => d.ID === menuAberto)!); menuAberto = null">Visualizar</button>
<button @click="abrirEditar(documentos!.find(d => d.ID === menuAberto)!)">Editar</button>
<button @click="abrirExcluir(documentos!.find(d => d.ID === menuAberto)!)">Excluir</button>
<button class="drop-danger" @click="abrirExcluir(documentos!.find(d => d.ID === menuAberto)!)">Excluir</button>
</div>
</Teleport>
<!-- Modal Visualizar -->
<Teleport to="body">
<div v-if="showVisualizar" class="modal-overlay" @click.self="showVisualizar = false">
<div class="modal modal-view">
<div class="modal-header">
<div>
<h2>{{ viewDoc?.Nome || tipoLabel(viewDoc?.Tipo ?? '') }}</h2>
<span
class="doc-status"
:style="{ color: STATUS_CFG[calcStatus(viewDoc?.DataVencimento ?? null)].color, background: STATUS_CFG[calcStatus(viewDoc?.DataVencimento ?? null)].bg }"
>
{{ STATUS_CFG[calcStatus(viewDoc?.DataVencimento ?? null)].label }}
</span>
</div>
<button class="close-btn" @click="showVisualizar = false"></button>
</div>
<div class="view-grid">
<div class="view-field">
<label>Tipo</label>
<span>{{ tipoLabel(viewDoc?.Tipo ?? '') }}</span>
</div>
<div class="view-field">
<label>Vencimento</label>
<span>{{ formatDate(viewDoc?.DataVencimento ?? null) }}</span>
</div>
<div class="view-field view-full">
<label>Observações</label>
<span>{{ viewDoc?.Observacoes || '—' }}</span>
</div>
</div>
<div class="files-section">
<div class="files-header">
<h3>Arquivos</h3>
<label class="upload-btn" :class="{ loading: uploadLoading }">
<input type="file" multiple @change="uploadArquivos" style="display:none" :disabled="uploadLoading" />
{{ uploadLoading ? 'Enviando...' : '+ Anexar' }}
</label>
</div>
<div v-if="viewFilesLoading" class="files-empty">Carregando arquivos...</div>
<div v-else-if="viewFiles.length === 0" class="files-empty">Nenhum arquivo anexado.</div>
<div v-else class="files-list">
<div v-for="f in viewFiles" :key="f.ID" class="file-item">
<span class="file-icon">📄</span>
<span class="file-name">{{ f.Nome }}</span>
<span class="file-size">{{ formatFileSize(f.Size) }}</span>
<button class="file-act" title="Download" @click="downloadArquivo(viewDoc!.ID, f.ID, f.Nome)"></button>
<button class="file-act file-del" title="Remover" @click="excluirArquivo(f.ID)"></button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-cancel" @click="showVisualizar = false">Fechar</button>
<button class="btn-save" @click="showVisualizar = false; abrirEditar(viewDoc!)">Editar</button>
</div>
</div>
</div>
</Teleport>
@@ -348,9 +483,11 @@ async function confirmarExclusao() {
.btn-menu:hover { background: #f1f5f9; color: #334155; }
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); color: white; border: none; border-radius: 8px; padding: 7px 14px; font-size: 13px; font-weight: 600; cursor: pointer; }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 12px; border-bottom: 1px solid #f1f5f9; }
.modal-header h2 { font-size: 16px; font-weight: 700; color: #0f172a; margin: 0; }
.modal-header { display: flex; align-items: flex-start; justify-content: space-between; padding: 18px 20px 12px; border-bottom: 1px solid #f1f5f9; }
.modal-header h2 { font-size: 16px; font-weight: 700; color: #0f172a; margin: 0 0 6px; }
.modal-header button { background: none; border: none; font-size: 18px; cursor: pointer; color: #94a3b8; }
.close-btn { background: none; border: none; font-size: 16px; color: #94a3b8; cursor: pointer; padding: 4px 8px; border-radius: 6px; }
.close-btn:hover { background: #f1f5f9; color: #475569; }
.modal-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 14px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 20px 18px; border-top: 1px solid #f1f5f9; }
@@ -365,6 +502,32 @@ async function confirmarExclusao() {
.btn-danger { background: #dc2626; color: white; border: none; border-radius: 8px; padding: 7px 16px; font-size: 13px; font-weight: 600; cursor: pointer; }
.btn-danger:disabled { opacity: 0.6; cursor: not-allowed; }
.erro { color: #dc2626; font-size: 12px; }
/* Modal visualizar */
.modal-view { max-width: 620px !important; }
.view-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; padding: 16px 20px 4px; }
.view-full { grid-column: 1 / -1; }
.view-field { display: flex; flex-direction: column; gap: 3px; }
.view-field label { font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.5px; }
.view-field span { font-size: 13px; color: #1e293b; }
/* Arquivos */
.files-section { border-top: 1px solid #f1f5f9; padding: 16px 20px 4px; margin-top: 8px; }
.files-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.files-header h3 { font-size: 11px; font-weight: 700; color: #94a3b8; margin: 0; text-transform: uppercase; letter-spacing: 0.5px; }
.upload-btn { font-size: 12px; font-weight: 600; padding: 5px 12px; border-radius: 8px; border: 1px dashed #667eea; color: #667eea; cursor: pointer; transition: all 0.15s; }
.upload-btn:hover { background: #f0f3ff; }
.upload-btn.loading { opacity: 0.6; cursor: not-allowed; }
.files-empty { font-size: 13px; color: #94a3b8; text-align: center; padding: 16px 0; }
.files-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
.file-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; }
.file-icon { font-size: 16px; flex-shrink: 0; }
.file-name { flex: 1; font-size: 13px; color: #1e293b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-size { font-size: 11px; color: #94a3b8; white-space: nowrap; flex-shrink: 0; }
.file-act { font-size: 13px; padding: 3px 7px; background: none; border: none; cursor: pointer; color: #667eea; border-radius: 4px; flex-shrink: 0; }
.file-act:hover { background: #f0f3ff; }
.file-del { color: #dc2626; }
.file-del:hover { background: #fff1f1 !important; }
</style>
<style>
@@ -387,4 +550,6 @@ async function confirmarExclusao() {
font-size: 13px; background: none; border: none; cursor: pointer; color: #374151;
}
.dropdown-fixed button:hover { background: #f8fafc; }
.dropdown-fixed .drop-danger { color: #dc2626; }
.dropdown-fixed .drop-danger:hover { background: #fff1f1; }
</style>

View File

@@ -1,57 +1,416 @@
<!-- front-end/app/pages/gestao/prazos.vue -->
<script setup lang="ts">
import { prazos } from '~/data/mock/prazos'
const { apiFetch } = useApi()
const hoje = new Date()
const urgenciaConfig = {
critico: { label: 'Crítico — Hoje', color: '#dc2626', bg: '#fef2f2' },
urgente: { label: 'Urgente', color: '#d97706', bg: '#fffbeb' },
normal: { label: 'Normal', color: '#667eea', bg: '#eff6ff' },
interface ApiEdital {
ID: string; Numero: string; Orgao: string; Modalidade: string
Objeto: 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
DataInicio: string; DataFim: string; Status: string
}
function diasRestantes(data: Date) {
const diff = Math.ceil((data.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24))
if (diff <= 0) return 'Hoje'
const { data: editais } = await useAsyncData('prazos-editais', () =>
apiFetch<ApiEdital[]>('/editais'), { server: false }
)
const { data: documentos } = await useAsyncData('prazos-docs', () =>
apiFetch<ApiDocument[]>('/documents'), { server: false }
)
const { data: contratos } = await useAsyncData('prazos-contratos', () =>
apiFetch<ApiContract[]>('/contracts'), { server: false, default: () => [] }
)
// ---------- helpers ----------
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',
}
const MODALIDADE: Record<string, string> = {
pregao_eletronico: 'Pregão Eletrônico', pregao_presencial: 'Pregão Presencial',
concorrencia: 'Concorrência', dispensa: 'Dispensa', inexigibilidade: 'Inexigibilidade',
}
function toDate(iso: string): Date {
const [y, m, d] = iso.split('T')[0].split('-').map(Number)
return new Date(y, m - 1, d)
}
function formatDate(iso: string): string {
const [y, m, d] = iso.split('T')[0].split('-')
return `${d}/${m}/${y}`
}
function diffDays(iso: string): number {
const hoje = new Date()
const hojeMs = Date.UTC(hoje.getFullYear(), hoje.getMonth(), hoje.getDate())
const [y, m, d] = iso.split('T')[0].split('-').map(Number)
const tMs = Date.UTC(y, m - 1, d)
return Math.ceil((tMs - 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 `${diff} dias`
return `em ${diff}d`
}
// ---------- prazo items ----------
interface PrazoItem {
id: string
titulo: string
subtitulo: string
dataIso: string
diff: number
tipo: 'edital' | 'documento' | 'contrato'
link?: string
icon: string
}
const tipoIcone: Record<string, string> = {
edital: '📋', documento: '📄', contrato: '📝',
}
const tipoCor: Record<string, { color: string; bg: string }> = {
edital: { color: '#3b82f6', bg: '#eff6ff' },
documento: { color: '#8b5cf6', bg: '#f5f3ff' },
contrato: { color: '#059669', bg: '#ecfdf5' },
}
const prazos = computed<PrazoItem[]>(() => {
const items: PrazoItem[] = []
// Editais — data de abertura (só status ativos)
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)
items.push({
id: `e-${e.ID}`,
titulo: `Abertura — ${e.Numero}`,
subtitulo: `${MODALIDADE[e.Modalidade] ?? e.Modalidade} · ${e.Orgao}`,
dataIso: e.DataAbertura,
diff: d,
tipo: 'edital',
link: `/oportunidades/${e.ID}`,
icon: tipoIcone.edital,
})
}
// Documentos — data de vencimento
for (const doc of documentos.value ?? []) {
if (!doc.DataVencimento) continue
const d = diffDays(doc.DataVencimento)
items.push({
id: `d-${doc.ID}`,
titulo: `Vencimento — ${doc.Nome || TIPO_DOC[doc.Tipo] || doc.Tipo}`,
subtitulo: `${TIPO_DOC[doc.Tipo] || doc.Tipo}${doc.Observacoes ? ' · ' + doc.Observacoes : ''}`,
dataIso: doc.DataVencimento,
diff: d,
tipo: 'documento',
link: '/gestao/documentos',
icon: tipoIcone.documento,
})
}
// Contratos — data fim
for (const c of contratos.value ?? []) {
if (!c.DataFim || c.Status === 'encerrado') continue
const d = diffDays(c.DataFim)
items.push({
id: `c-${c.ID}`,
titulo: `Fim vigência — ${c.Numero}`,
subtitulo: `Contrato · ${c.Orgao}`,
dataIso: c.DataFim,
diff: d,
tipo: 'contrato',
link: '/gestao/contratos',
icon: tipoIcone.contrato,
})
}
// Ordenar por data (mais próximo primeiro)
items.sort((a, b) => a.diff - b.diff)
return items
})
// ---------- filtros ----------
type FiltroTipo = 'todos' | 'edital' | 'documento' | 'contrato'
type FiltroPeriodo = 'todos' | 'vencido' | '7dias' | '30dias' | '90dias'
const filtroTipo = ref<FiltroTipo>('todos')
const filtroPeriodo = ref<FiltroPeriodo>('todos')
const filtroTipoOptions: { value: FiltroTipo; label: string }[] = [
{ value: 'todos', label: 'Todos' },
{ value: 'edital', label: 'Editais' },
{ value: 'documento', label: 'Documentos' },
{ value: 'contrato', label: 'Contratos' },
]
const filtroPeriodoOptions: { value: FiltroPeriodo; label: string }[] = [
{ value: 'todos', label: 'Todos' },
{ value: 'vencido', label: 'Vencidos' },
{ value: '7dias', label: 'Próx. 7 dias' },
{ value: '30dias', label: 'Próx. 30 dias' },
{ value: '90dias', label: 'Próx. 90 dias' },
]
const prazosFiltrados = computed(() => {
return prazos.value.filter(p => {
if (filtroTipo.value !== 'todos' && p.tipo !== filtroTipo.value) return false
if (filtroPeriodo.value === 'vencido' && p.diff >= 0) return false
if (filtroPeriodo.value === '7dias' && (p.diff < 0 || p.diff > 7)) return false
if (filtroPeriodo.value === '30dias' && (p.diff < 0 || p.diff > 30)) return false
if (filtroPeriodo.value === '90dias' && (p.diff < 0 || p.diff > 90)) return false
return true
})
})
// ---------- stats ----------
const stats = computed(() => {
const all = prazos.value
return {
total: all.length,
vencidos: all.filter(p => p.diff < 0).length,
hoje: all.filter(p => p.diff === 0).length,
semana: all.filter(p => p.diff > 0 && p.diff <= 7).length,
mes: all.filter(p => p.diff > 0 && p.diff <= 30).length,
}
})
function urgencia(diff: number): 'vencido' | 'critico' | 'urgente' | 'normal' | 'folgado' {
if (diff < 0) return 'vencido'
if (diff === 0) return 'critico'
if (diff <= 7) return 'urgente'
if (diff <= 30) return 'normal'
return 'folgado'
}
const urgenciaCfg = {
vencido: { label: 'Vencido', color: '#dc2626', bg: '#fef2f2', border: '#fecaca' },
critico: { label: 'Hoje', color: '#dc2626', bg: '#fef2f2', border: '#fecaca' },
urgente: { label: 'Urgente', color: '#ea580c', bg: '#fff7ed', border: '#fed7aa' },
normal: { label: 'Normal', color: '#d97706', bg: '#fffbeb', border: '#fde68a' },
folgado: { label: 'Folgado', color: '#16a34a', bg: '#f0fdf4', border: '#bbf7d0' },
}
</script>
<template>
<div class="page">
<AppTopbar title="Prazos" breadcrumb="Gestão · Prazos" />
<div class="content">
<div class="card">
<div v-for="prazo in prazos" :key="prazo.id" class="prazo-row">
<div class="prazo-dot" :style="{ background: urgenciaConfig[prazo.urgencia].color }" />
<div class="prazo-info">
<p class="prazo-titulo">{{ prazo.titulo }}</p>
<p class="prazo-desc">{{ prazo.descricao }}</p>
</div>
<div class="prazo-right">
<span class="prazo-badge" :style="{ color: urgenciaConfig[prazo.urgencia].color, background: urgenciaConfig[prazo.urgencia].bg }">
{{ urgenciaConfig[prazo.urgencia].label }}
</span>
<p class="prazo-data">{{ prazo.dataLimite.toLocaleDateString('pt-BR') }} · {{ diasRestantes(prazo.dataLimite) }}</p>
</div>
<!-- Stats cards -->
<div class="stats-row">
<div class="stat-card stat-danger" @click="filtroPeriodo = 'vencido'; filtroTipo = 'todos'">
<span class="stat-num">{{ stats.vencidos }}</span>
<span class="stat-label">Vencidos</span>
</div>
<div class="stat-card stat-critical" @click="filtroPeriodo = 'todos'; filtroTipo = 'todos'">
<span class="stat-num">{{ stats.hoje }}</span>
<span class="stat-label">Hoje</span>
</div>
<div class="stat-card stat-warning" @click="filtroPeriodo = '7dias'; filtroTipo = 'todos'">
<span class="stat-num">{{ stats.semana }}</span>
<span class="stat-label">Próx. 7 dias</span>
</div>
<div class="stat-card stat-info" @click="filtroPeriodo = '30dias'; filtroTipo = 'todos'">
<span class="stat-num">{{ stats.mes }}</span>
<span class="stat-label">Próx. 30 dias</span>
</div>
<div class="stat-card stat-total">
<span class="stat-num">{{ stats.total }}</span>
<span class="stat-label">Total</span>
</div>
</div>
<!-- Filters -->
<div class="filters-row">
<div class="filter-group">
<button
v-for="opt in filtroTipoOptions" :key="opt.value"
class="filter-btn"
:class="{ active: filtroTipo === opt.value }"
@click="filtroTipo = opt.value"
>{{ opt.label }}</button>
</div>
<div class="filter-group">
<button
v-for="opt in filtroPeriodoOptions" :key="opt.value"
class="filter-btn"
:class="{ active: filtroPeriodo === opt.value }"
@click="filtroPeriodo = opt.value"
>{{ opt.label }}</button>
</div>
</div>
<!-- Timeline -->
<div class="timeline-card">
<div v-if="prazosFiltrados.length === 0" class="timeline-empty">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<p>Nenhum prazo encontrado para os filtros selecionados.</p>
</div>
<NuxtLink
v-for="(prazo, idx) in prazosFiltrados"
:key="prazo.id"
:to="prazo.link ?? '#'"
class="timeline-item"
:class="urgencia(prazo.diff)"
>
<div class="tl-left">
<div class="tl-dot" :style="{ background: urgenciaCfg[urgencia(prazo.diff)].color }" />
<div v-if="idx < prazosFiltrados.length - 1" class="tl-line" />
</div>
<div class="tl-content">
<div class="tl-header">
<div class="tl-icon-badge" :style="{ background: tipoCor[prazo.tipo].bg, color: tipoCor[prazo.tipo].color }">
{{ prazo.icon }}
</div>
<div class="tl-title-area">
<span class="tl-title">{{ prazo.titulo }}</span>
<span class="tl-sub">{{ prazo.subtitulo }}</span>
</div>
<div class="tl-right-area">
<span
class="tl-urgencia"
:style="{
color: urgenciaCfg[urgencia(prazo.diff)].color,
background: urgenciaCfg[urgencia(prazo.diff)].bg,
borderColor: urgenciaCfg[urgencia(prazo.diff)].border,
}"
>{{ urgenciaCfg[urgencia(prazo.diff)].label }}</span>
<span class="tl-date">{{ formatDate(prazo.dataIso) }}</span>
<span class="tl-diff" :style="{ color: urgenciaCfg[urgencia(prazo.diff)].color }">
{{ diasLabel(prazo.diff) }}
</span>
</div>
</div>
</div>
</NuxtLink>
</div>
</div>
</div>
</template>
<style scoped>
.page { display: flex; flex-direction: column; height: 100vh; }
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
.prazo-row { display: flex; align-items: center; gap: 12px; padding: 14px 18px; border-bottom: 1px solid #f8fafc; }
.prazo-row:last-child { border-bottom: none; }
.prazo-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.prazo-info { flex: 1; }
.prazo-titulo { font-size: 13px; font-weight: 600; color: #0f172a; }
.prazo-desc { font-size: 11px; color: #94a3b8; margin-top: 2px; }
.prazo-right { text-align: right; flex-shrink: 0; }
.prazo-badge { font-size: 10.5px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-block; margin-bottom: 4px; }
.prazo-data { font-size: 11px; color: #64748b; }
@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; display: flex; flex-direction: column; gap: 18px; }
/* ── Stats ── */
.stats-row { display: flex; gap: 12px; }
.stat-card {
flex: 1; background: white; border-radius: 12px;
border: 1px solid #e2e8f0; padding: 16px 18px;
display: flex; flex-direction: column; gap: 4px;
cursor: pointer; transition: all .15s;
box-shadow: 0 1px 3px rgba(0,0,0,.04);
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,.08); }
.stat-num { font-size: 28px; font-weight: 800; font-family: 'JetBrains Mono', monospace; line-height: 1; }
.stat-label { font-size: 11.5px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: .5px; }
.stat-danger .stat-num { color: #dc2626; }
.stat-danger { border-left: 3px solid #dc2626; }
.stat-critical .stat-num { color: #ea580c; }
.stat-critical { border-left: 3px solid #ea580c; }
.stat-warning .stat-num { color: #d97706; }
.stat-warning { border-left: 3px solid #d97706; }
.stat-info .stat-num { color: #3b82f6; }
.stat-info { border-left: 3px solid #3b82f6; }
.stat-total .stat-num { color: #0f172a; }
.stat-total { border-left: 3px solid #667eea; }
/* ── Filters ── */
.filters-row { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
.filter-group { display: flex; gap: 4px; background: #f1f5f9; border-radius: 9px; padding: 3px; }
.filter-btn {
padding: 6px 14px; border-radius: 7px; border: none;
font-size: 12px; font-weight: 600; cursor: pointer;
color: #64748b; background: transparent; transition: all .15s;
}
.filter-btn:hover { color: #334155; }
.filter-btn.active { background: white; color: #0f172a; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
/* ── Timeline ── */
.timeline-card {
background: white; border-radius: 14px;
border: 1px solid #e2e8f0; overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,.04);
}
.timeline-empty {
display: flex; flex-direction: column; align-items: center;
gap: 10px; padding: 48px 20px; color: #94a3b8;
}
.timeline-empty p { font-size: 13px; margin: 0; }
.timeline-item {
display: flex; gap: 0; text-decoration: none;
transition: background .12s;
}
.timeline-item:hover { background: #fafbfd; }
.timeline-item:not(:last-child) { border-bottom: 1px solid #f5f7fa; }
/* Urgency left accent */
.timeline-item.vencido { border-left: 3px solid #dc2626; }
.timeline-item.critico { border-left: 3px solid #dc2626; }
.timeline-item.urgente { border-left: 3px solid #ea580c; }
.timeline-item.normal { border-left: 3px solid #d97706; }
.timeline-item.folgado { border-left: 3px solid #16a34a; }
/* Timeline dots + line */
.tl-left {
width: 32px; display: flex; flex-direction: column;
align-items: center; padding-top: 22px; flex-shrink: 0;
}
.tl-dot {
width: 10px; height: 10px; border-radius: 50%;
flex-shrink: 0; position: relative; z-index: 1;
}
.tl-line {
width: 2px; flex: 1; background: #e9edf3; margin-top: 4px;
}
/* Content */
.tl-content { flex: 1; padding: 14px 18px 14px 6px; min-width: 0; }
.tl-header { display: flex; align-items: center; gap: 12px; }
.tl-icon-badge {
width: 36px; height: 36px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 16px; flex-shrink: 0;
}
.tl-title-area { flex: 1; min-width: 0; }
.tl-title {
display: block; font-size: 13.5px; font-weight: 700; color: #0f172a;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tl-sub {
display: block; font-size: 12px; color: #94a3b8; margin-top: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tl-right-area {
display: flex; flex-direction: column; align-items: flex-end;
gap: 3px; flex-shrink: 0;
}
.tl-urgencia {
font-size: 10.5px; font-weight: 700; padding: 2px 9px;
border-radius: 20px; border: 1px solid;
}
.tl-date {
font-size: 12px; font-weight: 600; color: #475569;
font-family: 'JetBrains Mono', monospace;
}
.tl-diff { font-size: 11px; font-weight: 700; }
/* ── Responsive ── */
@media (max-width: 768px) {
.stats-row { flex-wrap: wrap; }
.stat-card { min-width: calc(50% - 8px); }
.filters-row { flex-direction: column; }
.tl-header { flex-wrap: wrap; }
.tl-right-area { flex-direction: row; gap: 8px; align-items: center; }
}
</style>

View File

@@ -1,65 +1,232 @@
<!-- 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'
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 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'
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 urgenciaLabel(p: typeof prazos[0]) {
const diff = Math.ceil((p.dataLimite.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24))
if (diff <= 0) return 'Hoje'
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 p.dataLimite.toLocaleDateString('pt-BR')
return `em ${diff}d`
}
function docStatusCor(status: string) {
if (status === 'vencida') return '#dc2626'
if (status === 'vencendo') return '#d97706'
return '#16a34a'
// ---------- 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 modalidadeLabel: Record<string, string> = {
pregao_eletronico: 'Pregão Eletrônico',
pregao_presencial: 'Pregão Presencial',
concorrencia: 'Concorrência',
dispensa: 'Dispensa',
inexigibilidade: 'Inexigibilidade',
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>
<UButton variant="outline" color="neutral" size="sm">Importar Edital</UButton>
<UButton size="sm" class="btn-primary">+ Nova Oportunidade</UButton>
<NuxtLink to="/oportunidades" class="btn-outline-sm">Ver Oportunidades</NuxtLink>
</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 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 -->
@@ -68,8 +235,8 @@ const modalidadeLabel: Record<string, string> = {
<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 class="pipeline-wrap">
<PipelineBar :contagem-por-etapa="contagemPorEtapa" />
</div>
</div>
@@ -81,33 +248,49 @@ const modalidadeLabel: Record<string, string> = {
<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 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.descricao }}</p>
<p class="as">{{ p.sub }}</p>
</div>
<span class="alert-date" :style="{ color: urgenciaCor(p.urgencia) }">{{ urgenciaLabel(p) }}</span>
</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 da Empresa</h3>
<h3>Documentos com Alerta</h3>
<NuxtLink to="/gestao/documentos" class="card-link">Gerenciar </NuxtLink>
</div>
<div v-for="d in docsAlerta" :key="d.id" class="doc-item">
<div>
<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 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>
@@ -117,57 +300,134 @@ const modalidadeLabel: Record<string, string> = {
<h3>Editais Recentes</h3>
<NuxtLink to="/oportunidades" class="card-link">Ver todos </NuxtLink>
</div>
<UTable
:data="editaisRecentes"
:columns="[
{ id: 'numero', accessorKey: 'numero', header: 'Nº Edital' },
{ id: 'objeto', accessorKey: 'objeto', header: 'Objeto' },
{ id: 'orgao', accessorKey: 'orgao', header: 'Órgão' },
{ id: 'modalidade', accessorKey: 'modalidade', header: 'Modalidade' },
{ id: 'valorEstimado', accessorKey: 'valorEstimado', header: 'Valor Est.' },
{ id: 'status', accessorKey: 'status', header: 'Status' },
{ id: 'dataAbertura', accessorKey: 'dataAbertura', header: '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 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>
.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; }
@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; }
.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; }
.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-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; }
.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>

View File

@@ -1,23 +1,50 @@
<!-- front-end/app/pages/login.vue -->
<script setup lang="ts">
import type { TenantOption } from '~/composables/useAuth'
definePageMeta({ layout: 'auth' })
const { login } = useAuth()
const { login, selectTenant } = useAuth()
const email = ref('')
const password = ref('')
const slug = ref('')
const error = ref('')
const loading = ref(false)
// Estado do modal multi-tenant
const tenants = ref<TenantOption[]>([])
const pendingEmail = ref('')
const pendingPassword = ref('')
const showTenantModal = ref(false)
const tenantError = ref('')
const tenantLoading = ref(false)
async function handleSubmit() {
error.value = ''
loading.value = true
const result = await login(email.value, password.value, slug.value)
const result = await login(email.value, password.value)
loading.value = false
if ('success' in result && result.success) {
navigateTo('/')
} else if ('needsTenantSelect' in result) {
tenants.value = result.tenants
pendingEmail.value = result.email
pendingPassword.value = result.password
showTenantModal.value = true
} else if ('error' in result) {
error.value = result.error ?? 'Erro ao autenticar.'
}
}
async function handleSelectTenant(tenantId: string) {
tenantError.value = ''
tenantLoading.value = true
const result = await selectTenant(pendingEmail.value, pendingPassword.value, tenantId)
tenantLoading.value = false
if (result.success) {
navigateTo('/')
} else {
error.value = result.error ?? 'Erro ao autenticar.'
tenantError.value = result.error ?? 'Erro ao selecionar empresa.'
}
}
</script>
@@ -52,17 +79,6 @@ async function handleSubmit() {
/>
</UFormField>
<UFormField label="Organização" class="field">
<UInput
v-model="slug"
type="text"
placeholder="minha-empresa"
size="md"
class="w-full"
required
/>
</UFormField>
<div class="forgot">
<a href="#">Esqueceu a senha?</a>
</div>
@@ -84,6 +100,29 @@ async function handleSubmit() {
</p>
</form>
</div>
<!-- Modal seleção de empresa -->
<div v-if="showTenantModal" class="modal-overlay">
<div class="modal-card">
<h2>Selecione a empresa</h2>
<p>Seu acesso está vinculado a mais de uma empresa.</p>
<div class="tenant-list">
<button
v-for="t in tenants"
:key="t.id"
class="tenant-btn"
:disabled="tenantLoading"
@click="handleSelectTenant(t.id)"
>
<span class="tenant-name">{{ t.name }}</span>
<span class="tenant-slug">{{ t.slug }}</span>
</button>
</div>
<p v-if="tenantError" class="error-msg">{{ tenantError }}</p>
</div>
</div>
</template>
<style scoped>
@@ -108,4 +147,30 @@ async function handleSubmit() {
}
.register-link { text-align: center; font-size: 13px; color: #64748b; margin-top: 16px; }
.register-link a { color: #667eea; font-weight: 600; text-decoration: none; }
/* Modal */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
z-index: 100;
}
.modal-card {
background: white; border-radius: 16px; padding: 32px;
width: 100%; max-width: 380px;
box-shadow: 0 20px 50px rgba(0,0,0,0.3);
}
.modal-card h2 { font-size: 18px; font-weight: 700; color: #0f172a; margin-bottom: 6px; }
.modal-card p { font-size: 13px; color: #64748b; margin-bottom: 20px; }
.tenant-list { display: flex; flex-direction: column; gap: 10px; }
.tenant-btn {
display: flex; flex-direction: column; align-items: flex-start;
padding: 14px 16px; border-radius: 10px;
border: 1.5px solid #e2e8f0; background: white;
cursor: pointer; transition: all 0.15s; text-align: left;
}
.tenant-btn:hover { border-color: #667eea; background: #f0f3ff; }
.tenant-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.tenant-name { font-size: 14px; font-weight: 600; color: #0f172a; }
.tenant-slug { font-size: 12px; color: #94a3b8; margin-top: 2px; }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
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 }
const { data: todos } = await useAsyncData('editais-edital-publicado', () => apiFetch<ApiEdital[]>('/editais'))
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'edital_publicado'))
</script>
<template>
<div class="page">
<AppTopbar title="Edital Publicado" breadcrumb="Oportunidades · Edital Publicado" />
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
</div>
</template>
<style scoped>
.page { display: flex; flex-direction: column; height: 100vh; }
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
</style>

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { editais } from '~/data/mock/editais'
const filtrados = computed(() => editais.filter(e => e.status === 'elaborando_proposta'))
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 }
const { data: todos } = await useAsyncData('editais-elaborando', () => apiFetch<ApiEdital[]>('/editais'))
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'elaborando_proposta'))
</script>
<template>
<div class="page">
<AppTopbar title="Elaborando Proposta" breadcrumb="Oportunidades · Elaborando Proposta" />
<div class="content"><div class="card"><EditaisTable :editais="filtrados" /></div></div>
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
</div>
</template>
<style scoped>

View File

@@ -1,15 +1,13 @@
<script setup lang="ts">
import { editais } from '~/data/mock/editais'
const filtrados = computed(() => editais.filter(e => e.status === 'em_analise'))
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 }
const { data: todos } = await useAsyncData('editais-em-analise', () => apiFetch<ApiEdital[]>('/editais'))
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'em_analise'))
</script>
<template>
<div class="page">
<AppTopbar title="Em Análise" breadcrumb="Oportunidades · Em Análise" />
<div class="content">
<div class="card">
<EditaisTable :editais="filtrados" />
</div>
</div>
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
</div>
</template>
<style scoped>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
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 }
const { data: todos } = await useAsyncData('editais-fase-lances', () => apiFetch<ApiEdital[]>('/editais'))
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'fase_lances'))
</script>
<template>
<div class="page">
<AppTopbar title="Fase de Lances" breadcrumb="Oportunidades · Fase de Lances" />
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
</div>
</template>
<style scoped>
.page { display: flex; flex-direction: column; height: 100vh; }
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
</style>

View File

@@ -1,19 +1,451 @@
<!-- front-end/app/pages/oportunidades/index.vue -->
<script setup lang="ts">
import { editais } from '~/data/mock/editais'
const { apiFetch } = useApi()
const { public: { apiBase } } = useRuntimeConfig()
const token = useCookie<string | null>('auth_token')
interface ApiEdital {
ID: string
Numero: string
Orgao: string
Modalidade: string
Objeto: string
Plataforma: string
ValorEstimado: number
DataPublicacao: string
DataAbertura: string
Status: string
}
interface ApiOrgao {
ID: string
Nome: string
Esfera: string
Estado: string
}
const { data: editais, refresh } = await useAsyncData('editais', () =>
apiFetch<ApiEdital[]>('/editais')
)
const { data: orgaos } = await useAsyncData('orgaos-oportunidades', () =>
apiFetch<ApiOrgao[]>('/organs'), { server: false }
)
// ---------- labels ----------
const MODALIDADE_LABEL: 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' },
}
function formatDate(iso: string): string {
const [y, m, d] = iso.split('T')[0].split('-')
return `${d}/${m}/${y}`
}
function formatBRL(value: number): string {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(value)
}
// ---------- menu ⋯ ----------
const menuAberto = ref<string | null>(null)
const menuPos = ref({ top: 0, left: 0 })
function abrirMenu(id: string, event: MouseEvent) {
const btn = event.currentTarget as HTMLElement
const rect = btn.getBoundingClientRect()
menuPos.value = { top: rect.bottom + 4, left: rect.left - 130 }
menuAberto.value = menuAberto.value === id ? null : id
}
function fecharMenu() { menuAberto.value = null }
const router = useRouter()
function abrirVisualizar(edital: ApiEdital) {
fecharMenu()
router.push(`/oportunidades/${edital.ID}`)
}
// ---------- modal criar / editar ----------
const showModal = ref(false)
const editando = ref<ApiEdital | null>(null)
const saving = ref(false)
const erroModal = ref('')
const STATUS_OPTIONS = Object.entries(STATUS_CFG).map(([value, cfg]) => ({ value, label: cfg.label }))
const MODALIDADE_OPTIONS = Object.entries(MODALIDADE_LABEL).map(([value, label]) => ({ value, label }))
const form = ref({
Numero: '',
Orgao: '',
Modalidade: 'pregao_eletronico',
Objeto: '',
Plataforma: '',
ValorEstimado: 0,
DataPublicacao: '',
DataAbertura: '',
Status: 'em_analise',
})
function abrirCriar() {
editando.value = null
erroModal.value = ''
importedFile.value = null
importedFileName.value = ''
importError.value = ''
form.value = {
Numero: '', Orgao: '', Modalidade: 'pregao_eletronico',
Objeto: '', Plataforma: '', ValorEstimado: 0,
DataPublicacao: '', DataAbertura: '', Status: 'em_analise',
}
showModal.value = true
}
function abrirEditar(edital: ApiEdital) {
fecharMenu()
editando.value = edital
erroModal.value = ''
form.value = {
Numero: edital.Numero,
Orgao: edital.Orgao,
Modalidade: edital.Modalidade,
Objeto: edital.Objeto,
Plataforma: edital.Plataforma,
ValorEstimado: edital.ValorEstimado,
DataPublicacao: edital.DataPublicacao.split('T')[0],
DataAbertura: edital.DataAbertura.split('T')[0],
Status: edital.Status,
}
showModal.value = true
}
async function salvar() {
erroModal.value = ''
saving.value = true
try {
let editalId: string | null = null
if (editando.value) {
await apiFetch(`/editais/${editando.value.ID}`, { method: 'PUT', body: form.value })
editalId = editando.value.ID
} else {
const created = await apiFetch<ApiEdital>('/editais', { method: 'POST', body: form.value })
editalId = created?.ID ?? null
}
// If this was a partial import, attach the PDF file to the created edital
if (importedFile.value && editalId) {
try {
const formData = new FormData()
formData.append('file', importedFile.value)
await $fetch(`${apiBase}/editais/${editalId}/files`, {
method: 'POST',
headers: { Authorization: `Bearer ${token.value}` },
body: formData,
})
} catch {
// Non-fatal: edital was created, file attach failed silently
}
importedFile.value = null
importedFileName.value = ''
}
showModal.value = false
importError.value = ''
await refresh()
} catch {
erroModal.value = 'Erro ao salvar edital.'
} finally {
saving.value = false
}
}
// ---------- importar PDF ----------
const importLoading = ref(false)
const importError = ref('')
const importedFile = ref<File | null>(null)
const importedFileName = ref('')
async function importarEdital(event: Event) {
const input = event.target as HTMLInputElement
if (!input.files?.length) return
const file = input.files[0]
if (file.type !== 'application/pdf') {
importError.value = 'Selecione um arquivo PDF.'
return
}
importLoading.value = true
importError.value = ''
try {
const formData = new FormData()
formData.append('file', file)
const res = await apiFetch<any>('/editais/import', {
method: 'POST',
body: formData,
})
// If backend returned partial data, open modal for manual completion
if (res?.partial) {
importedFile.value = file
importedFileName.value = res.file_name || file.name
editando.value = null
erroModal.value = ''
form.value = {
Numero: res.numero || '',
Orgao: res.orgao || '',
Modalidade: res.modalidade || 'pregao_eletronico',
Objeto: res.objeto || '',
Plataforma: res.plataforma || '',
ValorEstimado: res.valor_estimado || 0,
DataPublicacao: res.data_publicacao || '',
DataAbertura: res.data_abertura || '',
Status: res.status || 'em_analise',
}
showModal.value = true
const tipoLabel = res.tipo_documento === 'edital' ? 'Edital' : res.tipo_documento === 'termo_referencia' ? 'Termo de Referência' : 'Documento'
importError.value = `⚠️ ${tipoLabel} detectado. Alguns campos precisam de revisão. Confira e complete manualmente.`
return
}
await refresh()
} catch (err: any) {
importError.value = err?.data?.error || 'Erro ao importar edital do PDF.'
} finally {
importLoading.value = false
input.value = ''
}
}
// ---------- excluir ----------
const showDeleteConfirm = ref(false)
const deleteTarget = ref<ApiEdital | null>(null)
const deleting = ref(false)
function confirmarExclusao(edital: ApiEdital) {
fecharMenu()
deleteTarget.value = edital
showDeleteConfirm.value = true
}
function cancelarExclusao() {
showDeleteConfirm.value = false
deleteTarget.value = null
}
async function executarExclusao() {
if (!deleteTarget.value) return
deleting.value = true
try {
await apiFetch(`/editais/${deleteTarget.value.ID}`, { method: 'DELETE' })
showDeleteConfirm.value = false
deleteTarget.value = null
await refresh()
} catch {
// keep modal open on error
} finally {
deleting.value = false
}
}
</script>
<template>
<div class="page">
<AppTopbar title="Todos os Editais" breadcrumb="Oportunidades · Todos">
<template #actions>
<UButton size="sm" class="btn-primary">+ Nova Oportunidade</UButton>
<label class="btn-import" :class="{ disabled: importLoading }">
<input type="file" accept=".pdf" style="display:none" @change="importarEdital" />
{{ importLoading ? '⏳ Importando…' : '📄 Importar Edital' }}
</label>
<button class="btn-primary" @click="abrirCriar">+ Nova Oportunidade</button>
</template>
</AppTopbar>
<div class="content">
<p v-if="importError" class="import-error">{{ importError }}</p>
<div class="card">
<EditaisTable :editais="editais" />
<table 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>
<th></th>
</tr>
</thead>
<tbody>
<tr v-if="!editais || editais.length === 0">
<td colspan="8" class="empty">Nenhum edital cadastrado.</td>
</tr>
<tr v-for="e in editais" :key="e.ID">
<td class="numero link" @click="router.push(`/oportunidades/${e.ID}`)">{{ e.Numero }}</td>
<td>{{ e.Orgao }}</td>
<td class="objeto">{{ e.Objeto }}</td>
<td>{{ MODALIDADE_LABEL[e.Modalidade] ?? e.Modalidade }}</td>
<td>{{ 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>
<span v-else class="badge">{{ e.Status }}</span>
</td>
<td class="actions-cell">
<button class="btn-menu" @click.stop="abrirMenu(e.ID, $event)"></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Dropdown menu -->
<Teleport to="body">
<div v-if="menuAberto" class="overlay-menu" @click="fecharMenu" />
<div
v-if="menuAberto"
class="dropdown"
:style="{ top: menuPos.top + 'px', left: menuPos.left + 'px' }"
>
<button @click="abrirVisualizar(editais!.find(e => e.ID === menuAberto)!)">Visualizar</button>
<button @click="abrirEditar(editais!.find(e => e.ID === menuAberto)!)">Editar</button>
<button class="drop-danger" @click="confirmarExclusao(editais!.find(e => e.ID === menuAberto)!)">Excluir</button>
</div>
</Teleport>
<!-- Modal criar / editar -->
<Teleport to="body">
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
<div class="modal">
<div class="modal-header">
<h3>{{ editando ? 'Editar Edital' : importedFile ? 'Completar Importação' : 'Nova Oportunidade' }}</h3>
<button class="close-btn" @click="showModal = false; importedFile = null; importedFileName = ''; importError = ''"></button>
</div>
<div class="modal-body">
<!-- Import partial notice -->
<div v-if="importedFile" class="import-notice">
<span class="import-notice-icon">📄</span>
<div>
<strong>Importado do PDF:</strong> {{ importedFileName }}
<p>A IA extraiu os dados abaixo. Complete os campos que ficaram vazios e confira os preenchidos.</p>
</div>
</div>
<div class="form-row">
<div class="field">
<label> do Edital / Processo *</label>
<input v-model="form.Numero" class="inp" placeholder="PE 001/2026" />
</div>
<div class="field">
<label>Órgão Público *</label>
<select v-model="form.Orgao" class="inp">
<option value="" disabled>Selecione o órgão</option>
<option v-for="o in orgaos" :key="o.ID" :value="o.Nome">{{ o.Nome }}</option>
</select>
</div>
</div>
<div class="field">
<label>Objeto</label>
<input v-model="form.Objeto" class="inp" placeholder="Descrição do objeto licitado" />
</div>
<div class="form-row">
<div class="field">
<label>Modalidade *</label>
<select v-model="form.Modalidade" class="inp">
<option v-for="opt in MODALIDADE_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
<div class="field">
<label>Plataforma</label>
<input v-model="form.Plataforma" class="inp" placeholder="Compras.gov.br" />
</div>
</div>
<div class="form-row">
<div class="field">
<label>Valor Estimado (R$)</label>
<input v-model.number="form.ValorEstimado" type="number" min="0" step="0.01" class="inp" />
</div>
<div class="field">
<label>Status *</label>
<select v-model="form.Status" class="inp">
<option v-for="opt in STATUS_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
</div>
<div class="form-row">
<div class="field">
<label>Data Publicação *</label>
<input v-model="form.DataPublicacao" type="date" class="inp" />
</div>
<div class="field">
<label>Data Abertura *</label>
<input v-model="form.DataAbertura" type="date" class="inp" />
</div>
</div>
<p v-if="erroModal" class="erro-msg">{{ erroModal }}</p>
</div>
<div class="modal-footer">
<button class="btn-cancel" @click="showModal = false">Cancelar</button>
<button class="btn-save" :disabled="saving" @click="salvar">
{{ saving ? 'Salvando' : 'Salvar' }}
</button>
</div>
</div>
</div>
</Teleport>
<!-- Delete confirmation modal -->
<Teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click.self="cancelarExclusao">
<div class="modal-delete">
<div class="modal-delete-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</div>
<h3 class="modal-delete-title">Excluir Oportunidade</h3>
<p class="modal-delete-text">
Tem certeza que deseja excluir o edital
<strong>{{ deleteTarget?.Numero }}</strong>?
<br/>Esta ação não pode ser desfeita.
</p>
<div class="modal-delete-actions">
<button class="btn-cancel" @click="cancelarExclusao">Cancelar</button>
<button class="btn-delete" :disabled="deleting" @click="executarExclusao">
{{ deleting ? 'Excluindo' : 'Sim, excluir' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
@@ -21,5 +453,119 @@ import { editais } from '~/data/mock/editais'
.page { display: flex; flex-direction: column; height: 100vh; }
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2) !important; }
.btn-import {
background: linear-gradient(135deg, #10b981, #059669);
color: white; border: none; border-radius: 7px;
padding: 7px 16px; font-size: 13px; font-weight: 600; cursor: pointer;
margin-right: 8px;
}
.btn-import:hover { opacity: 0.9; }
.btn-import.disabled { opacity: 0.6; pointer-events: none; }
.btn-primary {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white; border: none; border-radius: 7px;
padding: 7px 16px; font-size: 13px; font-weight: 600; cursor: pointer;
}
.btn-primary:hover { opacity: 0.9; }
.import-error {
background: #fef2f2; color: #dc2626; border: 1px solid #fecaca;
border-radius: 8px; padding: 10px 16px; font-size: 13px; margin-bottom: 12px;
}
/* table */
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.tbl thead tr { border-bottom: 1px solid #e2e8f0; background: #f8fafc; }
.tbl th { padding: 10px 14px; text-align: left; font-weight: 600; color: #64748b; font-size: 12px; text-transform: uppercase; letter-spacing: .4px; white-space: nowrap; }
.tbl td { padding: 11px 14px; color: #1e293b; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
.tbl tbody tr:last-child td { border-bottom: none; }
.tbl tbody tr:hover td { background: #f8fafc; }
.numero { font-weight: 600; white-space: nowrap; }
.numero.link { color: #667eea; cursor: pointer; }
.numero.link:hover { color: #764ba2; text-decoration: underline; }
.objeto { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty { text-align: center; color: #94a3b8; padding: 40px; }
.actions-cell { text-align: right; white-space: nowrap; }
.btn-menu { background: none; border: none; cursor: pointer; font-size: 18px; color: #94a3b8; padding: 2px 6px; border-radius: 4px; }
.btn-menu:hover { background: #f1f5f9; color: #475569; }
.badge { display: inline-block; padding: 3px 9px; border-radius: 20px; font-size: 11.5px; font-weight: 600; }
/* dropdown */
.overlay-menu { position: fixed; inset: 0; z-index: 999; }
.dropdown { position: fixed; z-index: 1000; background: white; border: 1px solid #e2e8f0; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.12); min-width: 140px; overflow: hidden; }
.dropdown button { display: block; width: 100%; padding: 9px 14px; text-align: left; background: none; border: none; cursor: pointer; font-size: 13px; color: #334155; }
.dropdown button:hover { background: #f8fafc; }
.drop-danger { color: #dc2626 !important; }
/* modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.35); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.modal { background: white; border-radius: 12px; width: 600px; max-width: 95vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 40px rgba(0,0,0,.2); }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid #e2e8f0; }
.modal-header h3 { margin: 0; font-size: 15px; font-weight: 700; color: #1e293b; }
.close-btn { background: none; border: none; cursor: pointer; font-size: 16px; color: #64748b; }
.modal-body { padding: 20px 22px; display: flex; flex-direction: column; gap: 18px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 22px; border-top: 1px solid #f1f5f9; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 5px; }
.field label { font-size: 12px; font-weight: 600; color: #475569; }
.inp { border: 1px solid #e2e8f0; border-radius: 7px; padding: 8px 10px; font-size: 13px; color: #1e293b; width: 100%; box-sizing: border-box; }
.inp:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,.1); }
.btn-cancel { background: #f1f5f9; color: #475569; border: none; border-radius: 7px; padding: 8px 18px; font-size: 13px; font-weight: 600; cursor: pointer; }
.btn-save { background: linear-gradient(135deg, #667eea, #764ba2); color: white; border: none; border-radius: 7px; padding: 8px 18px; font-size: 13px; font-weight: 600; cursor: pointer; }
.btn-save:disabled { opacity: .6; cursor: not-allowed; }
.erro-msg { color: #dc2626; font-size: 12px; margin: 0; }
/* delete confirm modal */
.modal-delete {
background: white; border-radius: 16px;
width: 400px; max-width: 90vw;
padding: 32px 28px 24px;
box-shadow: 0 20px 60px rgba(0,0,0,.25);
text-align: center;
animation: modalPopIn .2s ease;
}
@keyframes modalPopIn {
from { opacity: 0; transform: scale(.92); }
to { opacity: 1; transform: scale(1); }
}
.modal-delete-icon {
width: 56px; height: 56px; border-radius: 50%;
background: #fef2f2; color: #dc2626;
display: flex; align-items: center; justify-content: center;
margin: 0 auto 16px;
}
.modal-delete-title {
font-size: 16px; font-weight: 700; color: #0f172a;
margin: 0 0 8px;
}
.modal-delete-text {
font-size: 13.5px; color: #64748b; line-height: 1.6;
margin: 0 0 24px;
}
.modal-delete-text strong { color: #1e293b; }
.modal-delete-actions {
display: flex; gap: 10px; justify-content: center;
}
.btn-delete {
background: #dc2626; color: white;
border: none; border-radius: 9px;
padding: 9px 22px; font-size: 13px; font-weight: 700;
cursor: pointer; transition: all .15s;
}
.btn-delete:hover { background: #b91c1c; }
.btn-delete:disabled { opacity: .5; cursor: not-allowed; }
/* import notice */
.import-notice {
display: flex; align-items: flex-start; gap: 12px;
padding: 14px 16px; border-radius: 10px;
background: #eff6ff; border: 1px solid #bfdbfe;
margin-bottom: 4px;
}
.import-notice-icon { font-size: 24px; flex-shrink: 0; margin-top: 2px; }
.import-notice strong { font-size: 13px; color: #1e40af; }
.import-notice p { font-size: 12px; color: #3b82f6; margin: 4px 0 0; line-height: 1.4; }
</style>

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { editais } from '~/data/mock/editais'
const filtrados = computed(() => editais.filter(e => e.status === 'participando'))
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 }
const { data: todos } = await useAsyncData('editais-participando', () => apiFetch<ApiEdital[]>('/editais'))
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'participando'))
</script>
<template>
<div class="page">
<AppTopbar title="Participando" breadcrumb="Oportunidades · Participando" />
<div class="content"><div class="card"><EditaisTable :editais="filtrados" /></div></div>
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
</div>
</template>
<style scoped>

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { editais } from '~/data/mock/editais'
const filtrados = computed(() => editais.filter(e => e.status === 'perdida'))
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 }
const { data: todos } = await useAsyncData('editais-perdidas', () => apiFetch<ApiEdital[]>('/editais'))
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'perdida'))
</script>
<template>
<div class="page">
<AppTopbar title="Perdidas" breadcrumb="Oportunidades · Perdidas" />
<div class="content"><div class="card"><EditaisTable :editais="filtrados" /></div></div>
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
</div>
</template>
<style scoped>

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { editais } from '~/data/mock/editais'
const filtrados = computed(() => editais.filter(e => e.status === 'recurso'))
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 }
const { data: todos } = await useAsyncData('editais-recurso', () => apiFetch<ApiEdital[]>('/editais'))
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'recurso'))
</script>
<template>
<div class="page">
<AppTopbar title="Recurso" breadcrumb="Oportunidades · Recurso" />
<div class="content"><div class="card"><EditaisTable :editais="filtrados" /></div></div>
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
</div>
</template>
<style scoped>

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { editais } from '~/data/mock/editais'
const filtrados = computed(() => editais.filter(e => e.status === 'vencida'))
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 }
const { data: todos } = await useAsyncData('editais-vencidas', () => apiFetch<ApiEdital[]>('/editais'))
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'vencida'))
</script>
<template>
<div class="page">
<AppTopbar title="Vencidas" breadcrumb="Oportunidades · Vencidas" />
<div class="content"><div class="card"><EditaisTable :editais="filtrados" /></div></div>
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
</div>
</template>
<style scoped>

View File

@@ -1,14 +1,43 @@
<!-- front-end/app/pages/pipeline/index.vue -->
<script setup lang="ts">
import { editais } from '~/data/mock/editais'
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
}
const { data: rawEditais } = await useAsyncData('pipeline-editais', () =>
apiFetch<ApiEdital[]>('/editais')
)
// Cópia local reativa — permite optimistic updates que o computed detecta
const items = ref<ApiEdital[]>([])
watchEffect(() => { items.value = [...(rawEditais.value ?? [])] })
const etapaParaStatus: Record<number, string> = {
1: 'em_analise',
2: 'elaborando_proposta',
3: 'edital_publicado',
4: 'fase_lances',
5: 'habilitacao',
6: 'recurso',
7: 'adjudicado',
8: 'contrato',
}
const statusParaEtapa: Record<string, number> = {
em_analise: 1,
elaborando_proposta: 3,
impugnacao: 2,
participando: 4,
elaborando_proposta: 2,
edital_publicado: 3,
fase_lances: 4,
habilitacao: 5,
recurso: 6,
adjudicado: 7,
contrato: 8,
vencida: 8,
perdida: 8,
deserta: 8,
@@ -18,7 +47,7 @@ const colunas = computed(() =>
PIPELINE_ETAPAS.map((etapa, idx) => ({
etapa,
numero: idx + 1,
cards: editais.filter(e => statusParaEtapa[e.status] === idx + 1),
cards: items.value.filter(e => statusParaEtapa[e.Status] === idx + 1),
}))
)
@@ -29,6 +58,79 @@ const modalidadeAbrev: Record<string, string> = {
dispensa: 'DI',
inexigibilidade: 'IN',
}
/* ── Drag & Drop ── */
const draggedId = ref<string | null>(null)
const dragOverCol = ref<number | null>(null)
const movingCardId = ref<string | null>(null)
function onDragStart(e: DragEvent, card: ApiEdital) {
draggedId.value = card.ID
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', card.ID)
}
const el = e.target as HTMLElement
requestAnimationFrame(() => el.classList.add('dragging'))
}
function onDragEnd(e: DragEvent) {
draggedId.value = null
dragOverCol.value = null
const el = e.target as HTMLElement
el.classList.remove('dragging')
}
function onDragOver(e: DragEvent, colNum: number) {
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
dragOverCol.value = colNum
}
function onDragLeave(_e: DragEvent, colNum: number) {
if (dragOverCol.value === colNum) dragOverCol.value = null
}
function draggedStatus() {
const card = items.value.find(e => e.ID === draggedId.value)
return card?.Status ?? ''
}
async function onDrop(e: DragEvent, colNum: number) {
e.preventDefault()
dragOverCol.value = null
const cardId = draggedId.value
draggedId.value = null
if (!cardId) return
const idx = items.value.findIndex(e => e.ID === cardId)
if (idx === -1) return
const card = items.value[idx]
const currentEtapa = statusParaEtapa[card.Status]
if (currentEtapa === colNum) return
const newStatus = etapaParaStatus[colNum]
if (!newStatus) return
// Optimistic update — substitui o objeto no array para triggerar reatividade
const oldStatus = card.Status
items.value[idx] = { ...card, Status: newStatus }
movingCardId.value = cardId
try {
await apiFetch(`/editais/${cardId}`, {
method: 'PUT',
body: { Status: newStatus },
})
} catch {
// Reverte
items.value[idx] = { ...items.value[idx], Status: oldStatus }
} finally {
setTimeout(() => { movingCardId.value = null }, 400)
}
}
</script>
<template>
@@ -36,24 +138,41 @@ const modalidadeAbrev: Record<string, string> = {
<AppTopbar title="Kanban de Processos" breadcrumb="Pipeline" />
<div class="kanban-scroll">
<div class="kanban-board">
<div v-for="col in colunas" :key="col.numero" class="kanban-col">
<div
v-for="col in colunas"
:key="col.numero"
class="kanban-col"
:class="{ 'drop-target': dragOverCol === col.numero && statusParaEtapa[draggedStatus()] !== col.numero }"
@dragover="onDragOver($event, col.numero)"
@dragleave="onDragLeave($event, col.numero)"
@drop="onDrop($event, col.numero)"
>
<div class="col-header">
<span class="col-num">{{ col.numero }}</span>
<span class="col-title">{{ col.etapa }}</span>
<UBadge :label="String(col.cards.length)" variant="soft" color="neutral" size="xs" />
</div>
<div class="col-cards">
<div v-for="card in col.cards" :key="card.id" class="kanban-card">
<div
v-for="card in col.cards"
:key="card.ID"
class="kanban-card"
:class="{ 'card-just-moved': movingCardId === card.ID }"
draggable="true"
@dragstart="onDragStart($event, card)"
@dragend="onDragEnd"
@click="navigateTo(`/oportunidades/${card.ID}`)"
>
<div class="card-top">
<span class="card-num">{{ card.numero }}</span>
<StatusChip :status="card.status" />
<span class="card-num">{{ card.Numero }}</span>
<StatusChip :status="(card.Status as any)" />
</div>
<p class="card-objeto">{{ card.objeto }}</p>
<p class="card-orgao">{{ card.orgao }}</p>
<p class="card-objeto">{{ card.Objeto }}</p>
<p class="card-orgao">{{ card.Orgao }}</p>
<div class="card-bottom">
<span class="card-modal">{{ modalidadeAbrev[card.modalidade] }}</span>
<span class="card-modal">{{ modalidadeAbrev[card.Modalidade] ?? card.Modalidade }}</span>
<span class="card-valor">
{{ new Intl.NumberFormat('pt-BR', { notation: 'compact', currency: 'BRL', style: 'currency' }).format(card.valorEstimado) }}
{{ new Intl.NumberFormat('pt-BR', { notation: 'compact', currency: 'BRL', style: 'currency' }).format(card.ValorEstimado) }}
</span>
</div>
</div>
@@ -69,12 +188,47 @@ const modalidadeAbrev: Record<string, string> = {
.page { display: flex; flex-direction: column; height: 100vh; }
.kanban-scroll { flex: 1; overflow-x: auto; padding: 20px 22px; }
.kanban-board { display: flex; gap: 12px; min-width: max-content; align-items: flex-start; }
.kanban-col { width: 220px; background: #f8fafc; border-radius: 10px; border: 1px solid #e2e8f0; flex-shrink: 0; }
.kanban-col {
width: 220px;
background: #f8fafc;
border-radius: 10px;
border: 1px solid #e2e8f0;
flex-shrink: 0;
transition: border-color .2s, background .2s, box-shadow .2s;
}
.kanban-col.drop-target {
border-color: #667eea;
background: #eef2ff;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.25);
}
.col-header { display: flex; align-items: center; gap: 6px; padding: 10px 12px; border-bottom: 1px solid #e2e8f0; }
.col-num { width: 20px; height: 20px; background: #667eea; color: white; border-radius: 50%; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.col-title { font-size: 11.5px; font-weight: 600; color: #374151; flex: 1; }
.col-cards { padding: 8px; display: flex; flex-direction: column; gap: 8px; min-height: 80px; }
.kanban-card { background: white; border-radius: 8px; padding: 10px 12px; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.kanban-card {
background: white;
border-radius: 8px;
padding: 10px 12px;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
cursor: grab;
transition: border-color .15s, box-shadow .15s, opacity .15s, transform .2s;
user-select: none;
}
.kanban-card:hover { border-color: #667eea; box-shadow: 0 2px 8px rgba(102,126,234,.15); }
.kanban-card:active { cursor: grabbing; }
.kanban-card.dragging {
opacity: 0.4;
transform: scale(0.95);
}
.kanban-card.card-just-moved {
animation: card-pop 0.4s ease;
}
@keyframes card-pop {
0% { transform: scale(0.9); opacity: 0.5; }
50% { transform: scale(1.03); }
100% { transform: scale(1); opacity: 1; }
}
.card-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.card-num { font-size: 11px; font-weight: 700; color: #0f172a; }
.card-objeto { font-size: 12px; color: #374151; font-weight: 500; margin-bottom: 3px; line-height: 1.3; }

View File

@@ -8,28 +8,24 @@ const refreshToken = useCookie<string | null>('refresh_token', { maxAge: 60 * 60
const documentType = ref<'pf' | 'pj'>('pj')
const form = reactive({
companyName: '',
legalName: '',
document: '',
slug: '',
adminName: '',
adminEmail: '',
adminPassword: '',
})
const documentLabel = computed(() => documentType.value === 'pj' ? 'CNPJ' : 'CPF')
const documentPlaceholder = computed(() => documentType.value === 'pj' ? '00.000.000/0001-00' : '000.000.000-00')
const nameLabel = computed(() => documentType.value === 'pj' ? 'Razão Social' : 'Nome Completo')
const isPJ = computed(() => documentType.value === 'pj')
const documentLabel = computed(() => isPJ.value ? 'CNPJ' : 'CPF')
const documentPlaceholder = computed(() => isPJ.value ? '00.000.000/0001-00' : '000.000.000-00')
// Auto-gera slug a partir do nome fantasia
watch(() => form.companyName, (val) => {
form.slug = val.toLowerCase()
function toSlug(val: string) {
return val.toLowerCase()
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.trim()
.replace(/\s+/g, '-')
.slice(0, 50)
})
}
const error = ref('')
const loading = ref(false)
@@ -41,12 +37,12 @@ async function handleSubmit() {
const res = await $fetch<{ access_token: string; refresh_token: string }>(`${apiBase}/auth/register`, {
method: 'POST',
body: {
company_name: form.companyName,
company_name: form.legalName,
legal_name: form.legalName,
document_type: documentType.value,
document: form.document,
slug: form.slug,
admin_name: form.adminName,
slug: toSlug(form.legalName),
admin_name: form.legalName,
admin_email: form.adminEmail,
admin_password: form.adminPassword,
},
@@ -88,33 +84,14 @@ async function handleSubmit() {
</button>
</div>
<div class="section-label">Dados da Empresa</div>
<UFormField label="Nome Fantasia / Apelido" class="field">
<UInput v-model="form.companyName" placeholder="Ex: Tech Gov" class="w-full" required />
</UFormField>
<UFormField :label="nameLabel" class="field">
<UInput v-model="form.legalName" :placeholder="documentType === 'pj' ? 'Razão Social completa' : 'Nome completo'" class="w-full" required />
<UFormField :label="isPJ ? 'Razão Social' : 'Nome Completo'" class="field">
<UInput v-model="form.legalName" :placeholder="isPJ ? 'Razão Social completa' : 'Nome completo'" class="w-full" required />
</UFormField>
<UFormField :label="documentLabel" class="field">
<UInput v-model="form.document" :placeholder="documentPlaceholder" class="w-full" required />
</UFormField>
<UFormField label="Identificador único (slug)" class="field">
<UInput v-model="form.slug" placeholder="minha-empresa" class="w-full" required />
<template #hint>
<span class="slug-hint">Usado para fazer login. Ex: <strong>{{ form.slug || 'minha-empresa' }}</strong></span>
</template>
</UFormField>
<div class="section-label">Dados de Acesso</div>
<UFormField label="Seu nome" class="field">
<UInput v-model="form.adminName" placeholder="Nome do responsável" class="w-full" required />
</UFormField>
<UFormField label="E-mail" class="field">
<UInput v-model="form.adminEmail" type="email" placeholder="voce@empresa.com.br" class="w-full" required />
</UFormField>

View File

@@ -10,7 +10,7 @@ interface ApiUser {
IsActive: boolean
}
const { data: usuarios, pending } = await useAsyncData('usuarios', () =>
const { data: usuarios, refresh } = await useAsyncData('usuarios', () =>
apiFetch<ApiUser[]>('/users')
)
@@ -19,34 +19,185 @@ const roleLabel: Record<string, string> = {
member: 'Membro',
}
const columns = [
{ id: 'Name', accessorKey: 'Name', header: 'Nome' },
{ id: 'Email', accessorKey: 'Email', header: 'E-mail' },
{ id: 'Role', accessorKey: 'Role', header: 'Perfil' },
{ id: 'IsActive', accessorKey: 'IsActive', header: 'Status' },
]
// --- Modal Criar ---
const showCreate = ref(false)
const createForm = reactive({ name: '', email: '', password: '', role: 'member' })
const createError = ref('')
const createLoading = ref(false)
async function criarUsuario() {
createError.value = ''
createLoading.value = true
try {
await apiFetch('/users', {
method: 'POST',
body: { name: createForm.name, email: createForm.email, password: createForm.password, role: createForm.role },
})
showCreate.value = false
createForm.name = ''
createForm.email = ''
createForm.password = ''
createForm.role = 'member'
await refresh()
} catch (err: any) {
createError.value = err?.data?.error || 'Erro ao criar usuário.'
} finally {
createLoading.value = false
}
}
// --- Modal Editar ---
const showEdit = ref(false)
const editUser = ref<ApiUser | null>(null)
const editForm = reactive({ name: '', role: 'member' })
const editError = ref('')
const editLoading = ref(false)
function abrirEditar(u: ApiUser) {
editUser.value = u
editForm.name = u.Name
editForm.role = u.Role
editError.value = ''
showEdit.value = true
}
async function salvarEdicao() {
if (!editUser.value) return
editError.value = ''
editLoading.value = true
try {
await apiFetch(`/users/${editUser.value.ID}`, {
method: 'PUT',
body: { name: editForm.name, role: editForm.role },
})
showEdit.value = false
await refresh()
} catch (err: any) {
editError.value = err?.data?.error || 'Erro ao salvar.'
} finally {
editLoading.value = false
}
}
// --- Toggle status ---
const togglingId = ref<string | null>(null)
async function toggleStatus(u: ApiUser) {
togglingId.value = u.ID
try {
if (u.IsActive) {
await apiFetch(`/users/${u.ID}`, { method: 'DELETE' })
} else {
const isActive = true
await apiFetch(`/users/${u.ID}`, { method: 'PUT', body: { is_active: isActive } })
}
await refresh()
} catch {}
togglingId.value = null
}
</script>
<template>
<div class="page">
<AppTopbar title="Usuários" breadcrumb="Sistema · Usuários">
<template #actions>
<UButton size="sm" class="btn-primary">+ Novo Usuário</UButton>
<UButton size="sm" class="btn-primary" @click="showCreate = true">+ Novo Usuário</UButton>
</template>
</AppTopbar>
<div class="content">
<div class="card">
<UTable :data="usuarios ?? []" :columns="columns" :loading="pending">
<template #Role-cell="{ row }">{{ roleLabel[row.original.Role] ?? row.original.Role }}</template>
<template #IsActive-cell="{ row }">
<UBadge
:label="row.original.IsActive ? 'Ativo' : 'Inativo'"
:color="row.original.IsActive ? 'success' : 'neutral'"
variant="soft"
size="xs"
/>
</template>
</UTable>
<table class="tbl">
<thead>
<tr>
<th>Nome</th>
<th>E-mail</th>
<th>Perfil</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="u in (usuarios ?? [])" :key="u.ID">
<td>{{ u.Name }}</td>
<td class="email">{{ u.Email }}</td>
<td>{{ roleLabel[u.Role] ?? u.Role }}</td>
<td>
<span :class="['badge', u.IsActive ? 'badge-active' : 'badge-inactive']">
{{ u.IsActive ? 'Ativo' : 'Inativo' }}
</span>
</td>
<td class="actions-cell">
<button class="act-btn" @click="abrirEditar(u)">Editar</button>
<button
class="act-btn"
:class="u.IsActive ? 'act-danger' : 'act-success'"
:disabled="togglingId === u.ID"
@click="toggleStatus(u)"
>
{{ u.IsActive ? 'Desativar' : 'Ativar' }}
</button>
</td>
</tr>
<tr v-if="!usuarios?.length">
<td colspan="5" class="empty">Nenhum usuário encontrado.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Modal Criar -->
<div v-if="showCreate" class="overlay">
<div class="modal">
<h2>Novo Usuário</h2>
<div class="field">
<label>Nome</label>
<UInput v-model="createForm.name" placeholder="Nome completo" class="w-full" />
</div>
<div class="field">
<label>E-mail</label>
<UInput v-model="createForm.email" type="email" placeholder="email@empresa.com" class="w-full" />
</div>
<div class="field">
<label>Senha</label>
<UInput v-model="createForm.password" type="password" placeholder="Mínimo 8 caracteres" class="w-full" />
</div>
<div class="field">
<label>Perfil</label>
<select v-model="createForm.role" class="sel">
<option value="member">Membro</option>
<option value="admin">Administrador</option>
</select>
</div>
<p v-if="createError" class="err">{{ createError }}</p>
<div class="modal-actions">
<button class="btn-cancel" @click="showCreate = false">Cancelar</button>
<UButton class="btn-primary" size="sm" :loading="createLoading" @click="criarUsuario">Criar</UButton>
</div>
</div>
</div>
<!-- Modal Editar -->
<div v-if="showEdit" class="overlay">
<div class="modal">
<h2>Editar Usuário</h2>
<div class="field">
<label>Nome</label>
<UInput v-model="editForm.name" class="w-full" />
</div>
<div class="field">
<label>Perfil</label>
<select v-model="editForm.role" class="sel">
<option value="member">Membro</option>
<option value="admin">Administrador</option>
</select>
</div>
<p v-if="editError" class="err">{{ editError }}</p>
<div class="modal-actions">
<button class="btn-cancel" @click="showEdit = false">Cancelar</button>
<UButton class="btn-primary" size="sm" :loading="editLoading" @click="salvarEdicao">Salvar</UButton>
</div>
</div>
</div>
</div>
@@ -57,4 +208,38 @@ const columns = [
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2) !important; }
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.tbl thead tr { background: #f8fafc; border-bottom: 1px solid #e2e8f0; }
.tbl th { padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.5px; }
.tbl td { padding: 12px 14px; border-bottom: 1px solid #f1f5f9; color: #1e293b; }
.tbl tbody tr:last-child td { border-bottom: none; }
.tbl tbody tr:hover { background: #fafafa; }
.email { color: #64748b; }
.empty { text-align: center; color: #94a3b8; padding: 32px !important; }
.badge { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; }
.badge-active { background: #dcfce7; color: #16a34a; }
.badge-inactive { background: #f1f5f9; color: #94a3b8; }
.actions-cell { display: flex; gap: 6px; }
.act-btn { font-size: 12px; font-weight: 500; padding: 4px 10px; border-radius: 6px; border: 1px solid #e2e8f0; background: white; cursor: pointer; color: #667eea; transition: all 0.15s; }
.act-btn:hover { background: #f0f3ff; border-color: #667eea; }
.act-danger { color: #dc2626; }
.act-danger:hover { background: #fff1f1; border-color: #dc2626; }
.act-success { color: #16a34a; }
.act-success:hover { background: #dcfce7; border-color: #16a34a; }
.act-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Modal */
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; z-index: 100; }
.modal { background: white; border-radius: 14px; padding: 28px; width: 100%; max-width: 400px; box-shadow: 0 20px 50px rgba(0,0,0,0.2); }
.modal h2 { font-size: 16px; font-weight: 700; color: #0f172a; margin-bottom: 18px; }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 12px; font-weight: 600; color: #475569; margin-bottom: 5px; }
.sel { width: 100%; padding: 8px 10px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 13px; color: #0f172a; background: white; }
.err { color: #dc2626; font-size: 12px; margin-bottom: 10px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
.btn-cancel { font-size: 13px; padding: 6px 14px; border-radius: 8px; border: 1px solid #e2e8f0; background: white; cursor: pointer; color: #64748b; }
.btn-cancel:hover { background: #f8fafc; }
</style>

View File

@@ -3,9 +3,12 @@
export type StatusEdital =
| 'em_analise'
| 'elaborando_proposta'
| 'impugnacao'
| 'edital_publicado'
| 'fase_lances'
| 'habilitacao'
| 'recurso'
| 'participando'
| 'adjudicado'
| 'contrato'
| 'vencida'
| 'perdida'
| 'deserta'
@@ -90,23 +93,26 @@ export interface Usuario {
}
export const STATUS_EDITAL_CONFIG: Record<StatusEdital, { label: string; bg: string; color: string }> = {
em_analise: { label: 'Em Análise', bg: '#eff6ff', color: '#0284c7' },
elaborando_proposta: { label: 'Elaborando Proposta', bg: '#faf5ff', color: '#7c3aed' },
impugnacao: { label: 'Impugnação', bg: '#fff7ed', color: '#ea580c' },
recurso: { label: 'Recurso', bg: '#fffbeb', color: '#d97706' },
participando: { label: 'Participando', bg: '#eff6ff', color: '#3b82f6' },
em_analise: { label: 'Mapeamento', bg: '#eff6ff', color: '#0284c7' },
elaborando_proposta: { label: 'Termo de Referência', bg: '#faf5ff', color: '#7c3aed' },
edital_publicado: { label: 'Edital Publicado', bg: '#fff7ed', color: '#ea580c' },
fase_lances: { label: 'Fase de Lances', bg: '#eff6ff', color: '#3b82f6' },
habilitacao: { label: 'Habilitação', bg: '#fef3c7', color: '#d97706' },
recurso: { label: 'Recursos', bg: '#fffbeb', color: '#d97706' },
adjudicado: { label: 'Adjudicado', bg: '#ecfdf5', color: '#059669' },
contrato: { label: 'Contrato', bg: '#f0fdf4', color: '#16a34a' },
vencida: { label: 'Vencida', bg: '#f0fdf4', color: '#16a34a' },
perdida: { label: 'Perdida', bg: '#fef2f2', color: '#dc2626' },
deserta: { label: 'Deserta / Fracassada', bg: '#f8fafc', color: '#64748b' },
}
export const PIPELINE_ETAPAS = [
'Identificado',
'Análise de Viabilidade',
'Elaborando Proposta',
'Disputa / Lances',
'Mapeamento',
'Termo de Referência',
'Edital Publicado',
'Fase de Lances',
'Habilitação',
'Recurso / Contrarrazões',
'Adjudicação',
'Recursos',
'Adjudicado',
'Contrato',
] as const

View File

@@ -12,7 +12,7 @@ export default defineNuxtConfig({
css: ['~/assets/css/main.css'],
routeRules: {
'/': { prerender: true }
'/': { prerender: process.env.NODE_ENV !== 'production' }
},
runtimeConfig: {
@@ -21,6 +21,19 @@ export default defineNuxtConfig({
},
},
devServer: {
host: '0.0.0.0',
},
vite: {
server: {
allowedHosts: true,
hmr: {
protocol: 'ws',
},
},
},
compatibilityDate: '2025-01-15',
eslint: {

35
nginx.conf Normal file
View File

@@ -0,0 +1,35 @@
upstream frontend {
server front:3000;
}
upstream backend {
server api:8080;
}
server {
listen 80;
server_name _;
client_max_body_size 50M;
# API
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

BIN
plano.pdf Normal file

Binary file not shown.

112
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
set -euo pipefail
# ─── Config ──────────────────────────────────────────────────────────────────
SERVER="opc@163.176.236.167"
REMOTE_DIR="/home/opc/licitatche"
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
echo "🚀 Deploy Licitatche → $SERVER"
echo ""
# ─── 1. Build Go binary para linux/arm64 ────────────────────────────────────
echo "🔨 [1/6] Compilando API Go para linux/arm64..."
cd "$PROJECT_DIR/back-end"
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o api-linux-arm64 ./cmd/api/...
echo " ✅ Binário Go compilado ($(du -h api-linux-arm64 | cut -f1))"
# ─── 2. Build Frontend Nuxt ─────────────────────────────────────────────────
echo ""
echo "🔨 [2/6] Compilando Frontend Nuxt..."
cd "$PROJECT_DIR/front-end"
NUXT_PUBLIC_API_BASE="http://163.176.236.167/api/v1" npx nuxi build 2>&1 | tail -5
echo " ✅ Frontend compilado"
# ─── 3. Verificar Docker no servidor ────────────────────────────────────────
echo ""
echo "📦 [3/6] Verificando Docker no servidor..."
ssh "$SERVER" 'bash -s' <<'INSTALL_DOCKER'
if command -v docker &>/dev/null && docker compose version &>/dev/null; then
echo " ✅ Docker e Docker Compose já instalados"
else
echo " 📥 Instalando Docker..."
sudo dnf install -y dnf-utils 2>/dev/null || true
sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo 2>/dev/null || true
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin 2>/dev/null
sudo systemctl enable docker
sudo systemctl start docker
sudo usermod -aG docker "$USER"
echo " ✅ Docker instalado"
fi
INSTALL_DOCKER
# ─── 4. Enviar arquivos ─────────────────────────────────────────────────────
echo ""
echo "📤 [4/6] Enviando projeto para o servidor..."
ssh "$SERVER" "mkdir -p $REMOTE_DIR"
rsync -avz --progress --delete \
--exclude 'node_modules' \
--exclude '.nuxt' \
--exclude '.data' \
--exclude '.git' \
--exclude '*.pdf' \
--exclude 'back-end/.env' \
--exclude 'front-end/app' \
--exclude 'front-end/public' \
--exclude 'vendor' \
"$PROJECT_DIR/" "$SERVER:$REMOTE_DIR/"
# ─── 5. Enviar .env de produção ─────────────────────────────────────────────
echo ""
echo "🔐 [5/6] Configurando variáveis de ambiente..."
scp "$PROJECT_DIR/.env.prod" "$SERVER:$REMOTE_DIR/.env"
# ─── 6. Build containers e start ────────────────────────────────────────────
echo ""
echo "🐳 [6/6] Construindo e iniciando containers..."
ssh "$SERVER" "bash -s" <<REMOTE_START
cd $REMOTE_DIR
# Abrir portas no firewall (Oracle Linux)
sudo firewall-cmd --permanent --add-service=http 2>/dev/null || true
sudo firewall-cmd --permanent --add-port=3000/tcp 2>/dev/null || true
sudo firewall-cmd --permanent --add-port=8080/tcp 2>/dev/null || true
sudo firewall-cmd --reload 2>/dev/null || true
sudo iptables -I INPUT -p tcp --dport 80 -j ACCEPT 2>/dev/null || true
# Build & deploy
docker compose -f docker-compose.prod.yml down 2>/dev/null || true
docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml up -d
echo ""
echo "⏳ Aguardando serviços ficarem prontos..."
sleep 15
echo ""
echo "=== Status dos containers ==="
docker compose -f docker-compose.prod.yml ps
echo ""
echo "=== Logs API (últimas 10 linhas) ==="
docker compose -f docker-compose.prod.yml logs --tail=10 api 2>&1 || true
echo ""
echo "=== Logs Frontend (últimas 10 linhas) ==="
docker compose -f docker-compose.prod.yml logs --tail=10 front 2>&1 || true
echo ""
echo "=== Logs Migrate ==="
docker compose -f docker-compose.prod.yml logs migrate 2>&1 || true
echo ""
echo "✅ Deploy concluído!"
echo "🌐 Acesse: http://163.176.236.167"
REMOTE_START
# Limpar binário local
rm -f "$PROJECT_DIR/back-end/api-linux-arm64"
echo ""
echo "═══════════════════════════════════════════"
echo " ✅ Deploy finalizado!"
echo " 🌐 http://163.176.236.167"
echo "═══════════════════════════════════════════"

BIN
visualizar-pdf-btn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB