ajustes
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1 +1,9 @@
|
|||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
|
# Secrets — NUNCA commitar
|
||||||
|
.env
|
||||||
|
.env.prod
|
||||||
|
.env.local
|
||||||
|
back-end/.env
|
||||||
|
back-end/.env.local
|
||||||
|
!back-end/.env.example
|
||||||
|
|||||||
23
.playwright-mcp/console-2026-04-02T18-39-05-247Z.log
Normal file
23
.playwright-mcp/console-2026-04-02T18-39-05-247Z.log
Normal 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
|
||||||
20
.playwright-mcp/page-2026-04-02T18-39-06-804Z.yml
Normal file
20
.playwright-mcp/page-2026-04-02T18-39-06-804Z.yml
Normal 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
|
||||||
BIN
.playwright-mcp/page-2026-04-02T18-39-10-569Z.png
Normal file
BIN
.playwright-mcp/page-2026-04-02T18-39-10-569Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 295 KiB |
29
.playwright-mcp/page-2026-04-02T18-39-22-154Z.yml
Normal file
29
.playwright-mcp/page-2026-04-02T18-39-22-154Z.yml
Normal 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
|
||||||
151
.playwright-mcp/page-2026-04-02T18-39-51-372Z.yml
Normal file
151
.playwright-mcp/page-2026-04-02T18-39-51-372Z.yml
Normal 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
|
||||||
BIN
.playwright-mcp/page-2026-04-02T18-39-56-786Z.png
Normal file
BIN
.playwright-mcp/page-2026-04-02T18-39-56-786Z.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
52
Makefile
Normal file
52
Makefile
Normal 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
|
||||||
2
back-end
2
back-end
Submodule back-end updated: 6480a285f5...ffa085e6ad
100
docker-compose.prod.yml
Normal file
100
docker-compose.prod.yml
Normal 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
BIN
edital-real.pdf
Normal file
Binary file not shown.
74
edital-teste.pdf
Normal file
74
edital-teste.pdf
Normal 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
5
front-end/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.data
|
||||||
|
app
|
||||||
|
public
|
||||||
13
front-end/Dockerfile
Normal file
13
front-end/Dockerfile
Normal 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"]
|
||||||
@@ -1,21 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<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 { user, logout } = useAuth()
|
||||||
const route = useRoute()
|
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 contagemPorStatus = computed(() => {
|
||||||
const counts: Record<string, number> = {}
|
const counts: Record<string, number> = {}
|
||||||
for (const e of editais) {
|
for (const e of editais.value ?? []) {
|
||||||
counts[e.status] = (counts[e.status] ?? 0) + 1
|
counts[e.Status] = (counts[e.Status] ?? 0) + 1
|
||||||
}
|
}
|
||||||
return counts
|
return counts
|
||||||
})
|
})
|
||||||
|
|
||||||
const alertasPrazos = computed(() => prazos.filter(p => p.urgencia === 'critico' || p.urgencia === 'urgente').length)
|
const alertasPrazos = computed(() => 0)
|
||||||
const docsVencendo = computed(() => documentos.filter(d => d.status === 'vencendo' || d.status === 'vencida').length)
|
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(() => [
|
const navItems = computed(() => [
|
||||||
{
|
{
|
||||||
@@ -27,10 +39,11 @@ const navItems = computed(() => [
|
|||||||
{
|
{
|
||||||
label: 'Oportunidades',
|
label: 'Oportunidades',
|
||||||
items: [
|
items: [
|
||||||
{ label: 'Todos os Editais', icon: 'i-heroicons-clipboard-document-list', to: '/oportunidades', badge: editais.length, badgeVariant: 'default' },
|
{ label: 'Todos os Editais', icon: 'i-heroicons-clipboard-document-list', to: '/oportunidades', badge: (editais.value ?? []).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: 'Mapeamento', 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: 'Termo de Referência', 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: '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: '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: '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' },
|
{ label: 'Perdidas', icon: 'i-heroicons-x-circle', to: '/oportunidades/perdidas', badge: contagemPorStatus.value.perdida ?? 0, badgeVariant: 'neutral' },
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
<!-- front-end/app/components/EditaisTable.vue -->
|
<!-- front-end/app/components/EditaisTable.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Edital } from '~/types'
|
interface ApiEdital {
|
||||||
|
ID: string
|
||||||
|
Numero: string
|
||||||
|
Orgao: string
|
||||||
|
Modalidade: string
|
||||||
|
Objeto: string
|
||||||
|
Plataforma: string
|
||||||
|
ValorEstimado: number
|
||||||
|
DataPublicacao: string
|
||||||
|
DataAbertura: string
|
||||||
|
Status: string
|
||||||
|
}
|
||||||
|
|
||||||
defineProps<{ editais: Edital[] }>()
|
defineProps<{ editais: ApiEdital[] }>()
|
||||||
|
|
||||||
const modalidadeLabel: Record<string, string> = {
|
const MODALIDADE_LABEL: Record<string, string> = {
|
||||||
pregao_eletronico: 'Pregão Eletrônico',
|
pregao_eletronico: 'Pregão Eletrônico',
|
||||||
pregao_presencial: 'Pregão Presencial',
|
pregao_presencial: 'Pregão Presencial',
|
||||||
concorrencia: 'Concorrência',
|
concorrencia: 'Concorrência',
|
||||||
@@ -12,30 +23,77 @@ const modalidadeLabel: Record<string, string> = {
|
|||||||
inexigibilidade: 'Inexigibilidade',
|
inexigibilidade: 'Inexigibilidade',
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const STATUS_CFG: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
{ id: 'numero', accessorKey: 'numero', header: 'Nº Edital' },
|
em_analise: { label: 'Mapeamento', color: '#0284c7', bg: '#eff6ff' },
|
||||||
{ id: 'objeto', accessorKey: 'objeto', header: 'Objeto' },
|
elaborando_proposta: { label: 'Termo de Referência', color: '#7c3aed', bg: '#faf5ff' },
|
||||||
{ id: 'orgao', accessorKey: 'orgao', header: 'Órgão' },
|
edital_publicado: { label: 'Edital Publicado', color: '#ea580c', bg: '#fff7ed' },
|
||||||
{ id: 'modalidade', accessorKey: 'modalidade', header: 'Modalidade' },
|
fase_lances: { label: 'Fase de Lances', color: '#3b82f6', bg: '#eff6ff' },
|
||||||
{ id: 'valorEstimado', accessorKey: 'valorEstimado', header: 'Valor Est.' },
|
habilitacao: { label: 'Habilitação', color: '#d97706', bg: '#fef3c7' },
|
||||||
{ id: 'status', accessorKey: 'status', header: 'Status' },
|
recurso: { label: 'Recursos', color: '#d97706', bg: '#fffbeb' },
|
||||||
{ id: 'dataAbertura', accessorKey: 'dataAbertura', header: 'Abertura' },
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UTable :data="editais" :columns="columns">
|
<table class="tbl">
|
||||||
<template #modalidade-cell="{ row }">
|
<thead>
|
||||||
{{ modalidadeLabel[row.original.modalidade] }}
|
<tr>
|
||||||
</template>
|
<th>Nº Edital</th>
|
||||||
<template #valorEstimado-cell="{ row }">
|
<th>Órgão</th>
|
||||||
{{ new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(row.original.valorEstimado) }}
|
<th>Objeto</th>
|
||||||
</template>
|
<th>Modalidade</th>
|
||||||
<template #status-cell="{ row }">
|
<th>Valor Est.</th>
|
||||||
<StatusChip :status="row.original.status" />
|
<th>Abertura</th>
|
||||||
</template>
|
<th>Status</th>
|
||||||
<template #dataAbertura-cell="{ row }">
|
</tr>
|
||||||
{{ row.original.dataAbertura.toLocaleDateString('pt-BR') }}
|
</thead>
|
||||||
</template>
|
<tbody>
|
||||||
</UTable>
|
<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>
|
</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>
|
||||||
|
|||||||
@@ -1,16 +1,45 @@
|
|||||||
// Wrapper sobre $fetch que injeta o Authorization header automaticamente.
|
// 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() {
|
export function useApi() {
|
||||||
const { public: { apiBase } } = useRuntimeConfig()
|
const { public: { apiBase } } = useRuntimeConfig()
|
||||||
const token = useCookie<string | null>('auth_token')
|
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> {
|
async function tryRefresh(): Promise<boolean> {
|
||||||
return $fetch<T>(`${apiBase}${path}`, {
|
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,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
...(options.headers as Record<string, string> || {}),
|
...(options.headers as Record<string, string> || {}),
|
||||||
...(token.value ? { Authorization: `Bearer ${token.value}` } : {}),
|
...(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 }
|
return { apiFetch }
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ interface AuthUser {
|
|||||||
papel: string
|
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() {
|
export function useAuth() {
|
||||||
const { public: { apiBase } } = useRuntimeConfig()
|
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 {
|
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',
|
method: 'POST',
|
||||||
body: { email, password, slug },
|
body: { email, password },
|
||||||
})
|
})
|
||||||
|
|
||||||
token.value = res.access_token
|
if (res.tokens) {
|
||||||
refreshToken.value = res.refresh_token
|
_applyTokens(res.tokens.access_token, res.tokens.refresh_token)
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
const payload = JSON.parse(atob(res.access_token.split('.')[1]))
|
if (res.tenants && res.tenants.length > 0) {
|
||||||
user.value = { nome: payload.email.split('@')[0], email: payload.email, papel: payload.role }
|
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 }
|
return { success: true }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = err?.data?.error || 'E-mail, senha ou organização incorretos.'
|
return { success: false, error: err?.data?.error || 'Erro ao selecionar empresa.' }
|
||||||
return { success: false, error: msg }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,5 +93,5 @@ export function useAuth() {
|
|||||||
navigateTo('/login')
|
navigateTo('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user, isAuthenticated, login, logout }
|
return { user, isAuthenticated, login, selectTenant, logout }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<!-- front-end/app/pages/gestao/documentos.vue -->
|
<!-- front-end/app/pages/gestao/documentos.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { apiFetch } = useApi()
|
const { apiFetch } = useApi()
|
||||||
|
const { public: { apiBase } } = useRuntimeConfig()
|
||||||
|
const token = useCookie<string | null>('auth_token')
|
||||||
|
|
||||||
interface ApiDocument {
|
interface ApiDocument {
|
||||||
ID: string
|
ID: string
|
||||||
@@ -10,6 +12,13 @@ interface ApiDocument {
|
|||||||
Observacoes: string
|
Observacoes: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApiFile {
|
||||||
|
ID: string
|
||||||
|
Nome: string
|
||||||
|
Size: number
|
||||||
|
CreatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
const { data: documentos, refresh } = await useAsyncData('documentos', () =>
|
const { data: documentos, refresh } = await useAsyncData('documentos', () =>
|
||||||
apiFetch<ApiDocument[]>('/documents')
|
apiFetch<ApiDocument[]>('/documents')
|
||||||
)
|
)
|
||||||
@@ -56,6 +65,12 @@ function formatDate(iso: string | null): string {
|
|||||||
return `${d}/${m}/${y}`
|
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 ⋯ ----------
|
// ---------- menu ⋯ ----------
|
||||||
const menuAberto = ref<string | null>(null)
|
const menuAberto = ref<string | null>(null)
|
||||||
const menuPos = ref({ top: 0, left: 0 })
|
const menuPos = ref({ top: 0, left: 0 })
|
||||||
@@ -69,6 +84,65 @@ function abrirMenu(id: string, event: MouseEvent) {
|
|||||||
|
|
||||||
function fecharMenu() { menuAberto.value = null }
|
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 ----------
|
// ---------- modal criar ----------
|
||||||
const showCriar = ref(false)
|
const showCriar = ref(false)
|
||||||
const criando = ref(false)
|
const criando = ref(false)
|
||||||
@@ -221,8 +295,69 @@ async function confirmarExclusao() {
|
|||||||
:style="{ top: menuPos.top + 'px', left: menuPos.left + 'px' }"
|
:style="{ top: menuPos.top + 'px', left: menuPos.left + 'px' }"
|
||||||
@click.stop
|
@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="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>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
@@ -348,9 +483,11 @@ async function confirmarExclusao() {
|
|||||||
.btn-menu:hover { background: #f1f5f9; color: #334155; }
|
.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; }
|
.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 { 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; }
|
.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; }
|
.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-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; }
|
.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 { 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; }
|
.btn-danger:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
.erro { color: #dc2626; font-size: 12px; }
|
.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>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -387,4 +550,6 @@ async function confirmarExclusao() {
|
|||||||
font-size: 13px; background: none; border: none; cursor: pointer; color: #374151;
|
font-size: 13px; background: none; border: none; cursor: pointer; color: #374151;
|
||||||
}
|
}
|
||||||
.dropdown-fixed button:hover { background: #f8fafc; }
|
.dropdown-fixed button:hover { background: #f8fafc; }
|
||||||
|
.dropdown-fixed .drop-danger { color: #dc2626; }
|
||||||
|
.dropdown-fixed .drop-danger:hover { background: #fff1f1; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,57 +1,416 @@
|
|||||||
<!-- front-end/app/pages/gestao/prazos.vue -->
|
<!-- front-end/app/pages/gestao/prazos.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { prazos } from '~/data/mock/prazos'
|
const { apiFetch } = useApi()
|
||||||
|
|
||||||
const hoje = new Date()
|
interface ApiEdital {
|
||||||
|
ID: string; Numero: string; Orgao: string; Modalidade: string
|
||||||
const urgenciaConfig = {
|
Objeto: string; ValorEstimado: number; DataPublicacao: string
|
||||||
critico: { label: 'Crítico — Hoje', color: '#dc2626', bg: '#fef2f2' },
|
DataAbertura: string; Status: string
|
||||||
urgente: { label: 'Urgente', color: '#d97706', bg: '#fffbeb' },
|
}
|
||||||
normal: { label: 'Normal', color: '#667eea', bg: '#eff6ff' },
|
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 { data: editais } = await useAsyncData('prazos-editais', () =>
|
||||||
const diff = Math.ceil((data.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24))
|
apiFetch<ApiEdital[]>('/editais'), { server: false }
|
||||||
if (diff <= 0) return 'Hoje'
|
)
|
||||||
|
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ã'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<AppTopbar title="Prazos" breadcrumb="Gestão · Prazos" />
|
<AppTopbar title="Prazos" breadcrumb="Gestão · Prazos" />
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<!-- Stats cards -->
|
||||||
<div v-for="prazo in prazos" :key="prazo.id" class="prazo-row">
|
<div class="stats-row">
|
||||||
<div class="prazo-dot" :style="{ background: urgenciaConfig[prazo.urgencia].color }" />
|
<div class="stat-card stat-danger" @click="filtroPeriodo = 'vencido'; filtroTipo = 'todos'">
|
||||||
<div class="prazo-info">
|
<span class="stat-num">{{ stats.vencidos }}</span>
|
||||||
<p class="prazo-titulo">{{ prazo.titulo }}</p>
|
<span class="stat-label">Vencidos</span>
|
||||||
<p class="prazo-desc">{{ prazo.descricao }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="prazo-right">
|
<div class="stat-card stat-critical" @click="filtroPeriodo = 'todos'; filtroTipo = 'todos'">
|
||||||
<span class="prazo-badge" :style="{ color: urgenciaConfig[prazo.urgencia].color, background: urgenciaConfig[prazo.urgencia].bg }">
|
<span class="stat-num">{{ stats.hoje }}</span>
|
||||||
{{ urgenciaConfig[prazo.urgencia].label }}
|
<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>
|
</span>
|
||||||
<p class="prazo-data">{{ prazo.dataLimite.toLocaleDateString('pt-BR') }} · {{ diasRestantes(prazo.dataLimite) }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page { display: flex; flex-direction: column; height: 100vh; }
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300..700&family=JetBrains+Mono:wght@400;600&display=swap');
|
||||||
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
|
|
||||||
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
|
.page { display: flex; flex-direction: column; height: 100vh; font-family: 'DM Sans', system-ui, sans-serif; }
|
||||||
.prazo-row { display: flex; align-items: center; gap: 12px; padding: 14px 18px; border-bottom: 1px solid #f8fafc; }
|
.content { padding: 24px 28px 40px; flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 18px; }
|
||||||
.prazo-row:last-child { border-bottom: none; }
|
|
||||||
.prazo-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
/* ── Stats ── */
|
||||||
.prazo-info { flex: 1; }
|
.stats-row { display: flex; gap: 12px; }
|
||||||
.prazo-titulo { font-size: 13px; font-weight: 600; color: #0f172a; }
|
.stat-card {
|
||||||
.prazo-desc { font-size: 11px; color: #94a3b8; margin-top: 2px; }
|
flex: 1; background: white; border-radius: 12px;
|
||||||
.prazo-right { text-align: right; flex-shrink: 0; }
|
border: 1px solid #e2e8f0; padding: 16px 18px;
|
||||||
.prazo-badge { font-size: 10.5px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-block; margin-bottom: 4px; }
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
.prazo-data { font-size: 11px; color: #64748b; }
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,65 +1,232 @@
|
|||||||
<!-- front-end/app/pages/index.vue -->
|
<!-- front-end/app/pages/index.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { editais } from '~/data/mock/editais'
|
import { PIPELINE_ETAPAS } from '~/types'
|
||||||
import { prazos } from '~/data/mock/prazos'
|
|
||||||
import { documentos } from '~/data/mock/documentos'
|
|
||||||
import { dashboardStats } from '~/data/mock/stats'
|
|
||||||
|
|
||||||
|
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 hoje = new Date()
|
||||||
const dataFormatada = hoje.toLocaleDateString('pt-BR', { day: 'numeric', month: 'long', year: 'numeric' })
|
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 MODALIDADE: Record<string, string> = {
|
||||||
|
pregao_eletronico: 'Pregão Eletrônico', pregao_presencial: 'Pregão Presencial',
|
||||||
const editaisRecentes = computed(() => [...editais].sort((a, b) => b.dataAbertura.getTime() - a.dataAbertura.getTime()).slice(0, 5))
|
concorrencia: 'Concorrência', dispensa: 'Dispensa', inexigibilidade: 'Inexigibilidade',
|
||||||
|
}
|
||||||
const prazosUrgentes = computed(() => prazos.filter(p => p.urgencia === 'critico' || p.urgencia === 'urgente'))
|
const STATUS_CFG: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
|
em_analise: { label: 'Mapeamento', color: '#0284c7', bg: '#eff6ff' },
|
||||||
const docsAlerta = computed(() => documentos.filter(d => d.status === 'vencendo' || d.status === 'vencida'))
|
elaborando_proposta: { label: 'Termo de Referência', color: '#7c3aed', bg: '#faf5ff' },
|
||||||
|
edital_publicado: { label: 'Edital Publicado', color: '#ea580c', bg: '#fff7ed' },
|
||||||
function urgenciaCor(urgencia: string) {
|
fase_lances: { label: 'Fase de Lances', color: '#3b82f6', bg: '#eff6ff' },
|
||||||
if (urgencia === 'critico') return '#ef4444'
|
habilitacao: { label: 'Habilitação', color: '#d97706', bg: '#fef3c7' },
|
||||||
if (urgencia === 'urgente') return '#f59e0b'
|
recurso: { label: 'Recursos', color: '#d97706', bg: '#fffbeb' },
|
||||||
return '#667eea'
|
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]) {
|
function formatDate(iso: string): string {
|
||||||
const diff = Math.ceil((p.dataLimite.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24))
|
if (!iso) return '—'
|
||||||
if (diff <= 0) return 'Hoje'
|
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ã'
|
if (diff === 1) return 'Amanhã'
|
||||||
return p.dataLimite.toLocaleDateString('pt-BR')
|
return `em ${diff}d`
|
||||||
}
|
}
|
||||||
|
|
||||||
function docStatusCor(status: string) {
|
// ---------- stats ----------
|
||||||
if (status === 'vencida') return '#dc2626'
|
const totalEditais = computed(() => editais.value.length)
|
||||||
if (status === 'vencendo') return '#d97706'
|
const vencidas = computed(() => editais.value.filter(e => e.Status === 'vencida').length)
|
||||||
return '#16a34a'
|
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> = {
|
const contagemPorEtapa = computed(() => {
|
||||||
pregao_eletronico: 'Pregão Eletrônico',
|
const counts: Record<number, number> = {}
|
||||||
pregao_presencial: 'Pregão Presencial',
|
for (const e of editais.value) {
|
||||||
concorrencia: 'Concorrência',
|
const etapa = statusParaEtapa[e.Status] ?? 1
|
||||||
dispensa: 'Dispensa',
|
counts[etapa] = (counts[etapa] ?? 0) + 1
|
||||||
inexigibilidade: 'Inexigibilidade',
|
|
||||||
}
|
}
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<AppTopbar title="Dashboard" :breadcrumb="`Visão geral · ${dataFormatada}`">
|
<AppTopbar title="Dashboard" :breadcrumb="`Visão geral · ${dataFormatada}`">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<UButton variant="outline" color="neutral" size="sm">Importar Edital</UButton>
|
<NuxtLink to="/oportunidades" class="btn-outline-sm">Ver Oportunidades</NuxtLink>
|
||||||
<UButton size="sm" class="btn-primary">+ Nova Oportunidade</UButton>
|
|
||||||
</template>
|
</template>
|
||||||
</AppTopbar>
|
</AppTopbar>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<StatCard label="Total de Editais" :value="dashboardStats.totalEditais" sub="Este ano" />
|
<div class="stat-card">
|
||||||
<StatCard label="Taxa de Vitória" :value="`${dashboardStats.taxaVitoria}%`" sub="Processos ganhos" color="#10b981" />
|
<p class="stat-label">Total de Editais</p>
|
||||||
<StatCard label="Valor Ganho" :value="valorFormatado" sub="Em contratos ativos" color="#667eea" />
|
<p class="stat-value">{{ totalEditais }}</p>
|
||||||
<StatCard label="Alertas Ativos" :value="dashboardStats.alertasAtivos" sub="Requerem atenção" color="#f59e0b" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Pipeline -->
|
<!-- Pipeline -->
|
||||||
@@ -68,8 +235,8 @@ const modalidadeLabel: Record<string, string> = {
|
|||||||
<h3>Pipeline de Oportunidades</h3>
|
<h3>Pipeline de Oportunidades</h3>
|
||||||
<NuxtLink to="/pipeline" class="card-link">Ver kanban →</NuxtLink>
|
<NuxtLink to="/pipeline" class="card-link">Ver kanban →</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-4">
|
<div class="pipeline-wrap">
|
||||||
<PipelineBar :contagem-por-etapa="dashboardStats.editalsPorEtapaPipeline" />
|
<PipelineBar :contagem-por-etapa="contagemPorEtapa" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -81,33 +248,49 @@ const modalidadeLabel: Record<string, string> = {
|
|||||||
<h3>Alertas de Prazo</h3>
|
<h3>Alertas de Prazo</h3>
|
||||||
<NuxtLink to="/gestao/prazos" class="card-link">Ver todos →</NuxtLink>
|
<NuxtLink to="/gestao/prazos" class="card-link">Ver todos →</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="p in prazosUrgentes" :key="p.id" class="alert-item">
|
<div v-if="prazosUrgentes.length === 0" class="empty-section">
|
||||||
<div class="alert-dot" :style="{ background: urgenciaCor(p.urgencia) }" />
|
<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">
|
<div class="alert-text">
|
||||||
<p class="at">{{ p.titulo }}</p>
|
<p class="at">{{ p.titulo }}</p>
|
||||||
<p class="as">{{ p.descricao }}</p>
|
<p class="as">{{ p.sub }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="alert-date" :style="{ color: urgenciaCor(p.urgencia) }">{{ urgenciaLabel(p) }}</span>
|
<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>
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Documentos -->
|
<!-- Documentos -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3>Documentos da Empresa</h3>
|
<h3>Documentos com Alerta</h3>
|
||||||
<NuxtLink to="/gestao/documentos" class="card-link">Gerenciar →</NuxtLink>
|
<NuxtLink to="/gestao/documentos" class="card-link">Gerenciar →</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<div v-for="d in docsAlerta" :key="d.id" class="doc-item">
|
<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>
|
<div>
|
||||||
<p class="doc-nome">{{ d.nome }}</p>
|
<p class="doc-nome">{{ d.Nome || TIPO_DOC[d.Tipo] || d.Tipo }}</p>
|
||||||
<p class="doc-sub">
|
<p class="doc-sub">{{ TIPO_DOC[d.Tipo] || d.Tipo }} · {{ formatDate(d.DataVencimento!) }}</p>
|
||||||
{{ d.status === 'vencida' ? 'Vencida' : 'Vencendo em breve' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="doc-badge" :style="{ color: docStatusCor(d.status), background: docStatusCor(d.status) + '18' }">
|
|
||||||
{{ d.status === 'vencida' ? 'Vencida' : 'Vencendo' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
class="doc-badge"
|
||||||
|
:style="{ color: docStatus(d).color, background: docStatus(d).bg }"
|
||||||
|
>{{ docStatus(d).label }}</span>
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -117,57 +300,134 @@ const modalidadeLabel: Record<string, string> = {
|
|||||||
<h3>Editais Recentes</h3>
|
<h3>Editais Recentes</h3>
|
||||||
<NuxtLink to="/oportunidades" class="card-link">Ver todos →</NuxtLink>
|
<NuxtLink to="/oportunidades" class="card-link">Ver todos →</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<UTable
|
<div v-if="editaisRecentes.length === 0" class="empty-section">
|
||||||
:data="editaisRecentes"
|
<p>Nenhum edital cadastrado ainda</p>
|
||||||
:columns="[
|
</div>
|
||||||
{ id: 'numero', accessorKey: 'numero', header: 'Nº Edital' },
|
<table v-else class="tbl">
|
||||||
{ id: 'objeto', accessorKey: 'objeto', header: 'Objeto' },
|
<thead>
|
||||||
{ id: 'orgao', accessorKey: 'orgao', header: 'Órgão' },
|
<tr>
|
||||||
{ id: 'modalidade', accessorKey: 'modalidade', header: 'Modalidade' },
|
<th>Nº Edital</th>
|
||||||
{ id: 'valorEstimado', accessorKey: 'valorEstimado', header: 'Valor Est.' },
|
<th>Órgão</th>
|
||||||
{ id: 'status', accessorKey: 'status', header: 'Status' },
|
<th>Objeto</th>
|
||||||
{ id: 'dataAbertura', accessorKey: 'dataAbertura', header: 'Abertura' },
|
<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}`)"
|
||||||
>
|
>
|
||||||
<template #modalidade-cell="{ row }">
|
<td class="td-num">{{ e.Numero }}</td>
|
||||||
{{ modalidadeLabel[row.original.modalidade] }}
|
<td>{{ e.Orgao }}</td>
|
||||||
</template>
|
<td class="td-obj">{{ e.Objeto }}</td>
|
||||||
<template #valorEstimado-cell="{ row }">
|
<td>{{ MODALIDADE[e.Modalidade] ?? e.Modalidade }}</td>
|
||||||
{{ new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(row.original.valorEstimado) }}
|
<td class="td-val">{{ formatBRL(e.ValorEstimado) }}</td>
|
||||||
</template>
|
<td>{{ formatDate(e.DataAbertura) }}</td>
|
||||||
<template #status-cell="{ row }">
|
<td>
|
||||||
<StatusChip :status="row.original.status" />
|
<span
|
||||||
</template>
|
v-if="STATUS_CFG[e.Status]"
|
||||||
<template #dataAbertura-cell="{ row }">
|
class="badge"
|
||||||
{{ row.original.dataAbertura.toLocaleDateString('pt-BR') }}
|
:style="{ background: STATUS_CFG[e.Status].bg, color: STATUS_CFG[e.Status].color }"
|
||||||
</template>
|
>{{ STATUS_CFG[e.Status].label }}</span>
|
||||||
</UTable>
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page { display: flex; flex-direction: column; height: 100vh; }
|
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300..700&family=JetBrains+Mono:wght@400;600&display=swap');
|
||||||
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
|
|
||||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 16px; }
|
.page { display: flex; flex-direction: column; height: 100vh; font-family: 'DM Sans', system-ui, sans-serif; }
|
||||||
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
|
.content { padding: 24px 28px 40px; flex: 1; overflow-y: auto; }
|
||||||
.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; }
|
.btn-outline-sm {
|
||||||
.card-header h3 { font-size: 13px; font-weight: 700; color: #0f172a; }
|
padding: 6px 14px; border-radius: 7px; border: 1px solid #e2e8f0;
|
||||||
.card-link { font-size: 11px; color: #667eea; text-decoration: none; font-weight: 500; }
|
background: white; font-size: 12px; font-weight: 600; color: #475569;
|
||||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
text-decoration: none; transition: all .15s;
|
||||||
.alert-item { display: flex; align-items: center; gap: 10px; padding: 10px 18px; border-bottom: 1px solid #f8fafc; }
|
}
|
||||||
|
.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:last-child { border-bottom: none; }
|
||||||
|
.alert-item:hover { background: #fafbfd; }
|
||||||
.alert-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
.alert-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
.at { font-size: 12px; font-weight: 600; color: #0f172a; }
|
.alert-text { flex: 1; min-width: 0; }
|
||||||
.as { font-size: 11px; color: #94a3b8; }
|
.at { font-size: 13px; font-weight: 600; color: #0f172a; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.alert-date { font-size: 11px; font-weight: 600; flex-shrink: 0; }
|
.as { font-size: 11.5px; color: #94a3b8; margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.doc-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 18px; border-bottom: 1px solid #f8fafc; }
|
.alert-right { text-align: right; flex-shrink: 0; }
|
||||||
|
.alert-date { font-size: 12px; font-weight: 700; display: block; }
|
||||||
|
.alert-date-full { font-size: 10.5px; color: #94a3b8; display: block; margin-top: 1px; }
|
||||||
|
|
||||||
|
/* ── Docs ── */
|
||||||
|
.doc-item {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 12px 20px; border-bottom: 1px solid #f5f7fa;
|
||||||
|
text-decoration: none; transition: background .12s;
|
||||||
|
}
|
||||||
.doc-item:last-child { border-bottom: none; }
|
.doc-item:last-child { border-bottom: none; }
|
||||||
.doc-nome { font-size: 12px; font-weight: 500; color: #0f172a; }
|
.doc-item:hover { background: #fafbfd; }
|
||||||
.doc-sub { font-size: 10.5px; color: #94a3b8; }
|
.doc-nome { font-size: 13px; font-weight: 600; color: #0f172a; }
|
||||||
.doc-badge { font-size: 10.5px; font-weight: 600; padding: 2px 8px; border-radius: 20px; }
|
.doc-sub { font-size: 11.5px; color: #94a3b8; margin-top: 1px; }
|
||||||
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2) !important; }
|
.doc-badge { font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 20px; flex-shrink: 0; }
|
||||||
.px-4 { padding: 0 18px; }
|
|
||||||
|
/* ── 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>
|
</style>
|
||||||
|
|||||||
@@ -1,23 +1,50 @@
|
|||||||
<!-- front-end/app/pages/login.vue -->
|
<!-- front-end/app/pages/login.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { TenantOption } from '~/composables/useAuth'
|
||||||
|
|
||||||
definePageMeta({ layout: 'auth' })
|
definePageMeta({ layout: 'auth' })
|
||||||
|
|
||||||
const { login } = useAuth()
|
const { login, selectTenant } = useAuth()
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const slug = ref('')
|
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const loading = ref(false)
|
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() {
|
async function handleSubmit() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const result = await login(email.value, password.value, slug.value)
|
const result = await login(email.value, password.value)
|
||||||
loading.value = false
|
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) {
|
if (result.success) {
|
||||||
navigateTo('/')
|
navigateTo('/')
|
||||||
} else {
|
} else {
|
||||||
error.value = result.error ?? 'Erro ao autenticar.'
|
tenantError.value = result.error ?? 'Erro ao selecionar empresa.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -52,17 +79,6 @@ async function handleSubmit() {
|
|||||||
/>
|
/>
|
||||||
</UFormField>
|
</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">
|
<div class="forgot">
|
||||||
<a href="#">Esqueceu a senha?</a>
|
<a href="#">Esqueceu a senha?</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,6 +100,29 @@ async function handleSubmit() {
|
|||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -108,4 +147,30 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
.register-link { text-align: center; font-size: 13px; color: #64748b; margin-top: 16px; }
|
.register-link { text-align: center; font-size: 13px; color: #64748b; margin-top: 16px; }
|
||||||
.register-link a { color: #667eea; font-weight: 600; text-decoration: none; }
|
.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>
|
</style>
|
||||||
|
|||||||
1097
front-end/app/pages/oportunidades/[id].vue
Normal file
1097
front-end/app/pages/oportunidades/[id].vue
Normal file
File diff suppressed because it is too large
Load Diff
17
front-end/app/pages/oportunidades/edital-publicado.vue
Normal file
17
front-end/app/pages/oportunidades/edital-publicado.vue
Normal 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>
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { editais } from '~/data/mock/editais'
|
const { apiFetch } = useApi()
|
||||||
const filtrados = computed(() => editais.filter(e => e.status === 'elaborando_proposta'))
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<AppTopbar title="Elaborando Proposta" breadcrumb="Oportunidades · Elaborando Proposta" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { editais } from '~/data/mock/editais'
|
const { apiFetch } = useApi()
|
||||||
const filtrados = computed(() => editais.filter(e => e.status === 'em_analise'))
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<AppTopbar title="Em Análise" breadcrumb="Oportunidades · Em Análise" />
|
<AppTopbar title="Em Análise" breadcrumb="Oportunidades · Em Análise" />
|
||||||
<div class="content">
|
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
|
||||||
<div class="card">
|
|
||||||
<EditaisTable :editais="filtrados" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
17
front-end/app/pages/oportunidades/fase-lances.vue
Normal file
17
front-end/app/pages/oportunidades/fase-lances.vue
Normal 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>
|
||||||
@@ -1,19 +1,451 @@
|
|||||||
|
<!-- front-end/app/pages/oportunidades/index.vue -->
|
||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<AppTopbar title="Todos os Editais" breadcrumb="Oportunidades · Todos">
|
<AppTopbar title="Todos os Editais" breadcrumb="Oportunidades · Todos">
|
||||||
<template #actions>
|
<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>
|
</template>
|
||||||
</AppTopbar>
|
</AppTopbar>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
<p v-if="importError" class="import-error">{{ importError }}</p>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<EditaisTable :editais="editais" />
|
<table class="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nº Edital</th>
|
||||||
|
<th>Órgão</th>
|
||||||
|
<th>Objeto</th>
|
||||||
|
<th>Modalidade</th>
|
||||||
|
<th>Valor Est.</th>
|
||||||
|
<th>Abertura</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<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>
|
||||||
</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>Nº 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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -21,5 +453,119 @@ import { editais } from '~/data/mock/editais'
|
|||||||
.page { display: flex; flex-direction: column; height: 100vh; }
|
.page { display: flex; flex-direction: column; height: 100vh; }
|
||||||
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
|
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
|
||||||
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { editais } from '~/data/mock/editais'
|
const { apiFetch } = useApi()
|
||||||
const filtrados = computed(() => editais.filter(e => e.status === 'participando'))
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<AppTopbar title="Participando" breadcrumb="Oportunidades · Participando" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { editais } from '~/data/mock/editais'
|
const { apiFetch } = useApi()
|
||||||
const filtrados = computed(() => editais.filter(e => e.status === 'perdida'))
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<AppTopbar title="Perdidas" breadcrumb="Oportunidades · Perdidas" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { editais } from '~/data/mock/editais'
|
const { apiFetch } = useApi()
|
||||||
const filtrados = computed(() => editais.filter(e => e.status === 'recurso'))
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<AppTopbar title="Recurso" breadcrumb="Oportunidades · Recurso" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { editais } from '~/data/mock/editais'
|
const { apiFetch } = useApi()
|
||||||
const filtrados = computed(() => editais.filter(e => e.status === 'vencida'))
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<AppTopbar title="Vencidas" breadcrumb="Oportunidades · Vencidas" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,14 +1,43 @@
|
|||||||
<!-- front-end/app/pages/pipeline/index.vue -->
|
<!-- front-end/app/pages/pipeline/index.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { editais } from '~/data/mock/editais'
|
|
||||||
import { PIPELINE_ETAPAS } from '~/types'
|
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> = {
|
const statusParaEtapa: Record<string, number> = {
|
||||||
em_analise: 1,
|
em_analise: 1,
|
||||||
elaborando_proposta: 3,
|
elaborando_proposta: 2,
|
||||||
impugnacao: 2,
|
edital_publicado: 3,
|
||||||
participando: 4,
|
fase_lances: 4,
|
||||||
|
habilitacao: 5,
|
||||||
recurso: 6,
|
recurso: 6,
|
||||||
|
adjudicado: 7,
|
||||||
|
contrato: 8,
|
||||||
vencida: 8,
|
vencida: 8,
|
||||||
perdida: 8,
|
perdida: 8,
|
||||||
deserta: 8,
|
deserta: 8,
|
||||||
@@ -18,7 +47,7 @@ const colunas = computed(() =>
|
|||||||
PIPELINE_ETAPAS.map((etapa, idx) => ({
|
PIPELINE_ETAPAS.map((etapa, idx) => ({
|
||||||
etapa,
|
etapa,
|
||||||
numero: idx + 1,
|
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',
|
dispensa: 'DI',
|
||||||
inexigibilidade: 'IN',
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -36,24 +138,41 @@ const modalidadeAbrev: Record<string, string> = {
|
|||||||
<AppTopbar title="Kanban de Processos" breadcrumb="Pipeline" />
|
<AppTopbar title="Kanban de Processos" breadcrumb="Pipeline" />
|
||||||
<div class="kanban-scroll">
|
<div class="kanban-scroll">
|
||||||
<div class="kanban-board">
|
<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">
|
<div class="col-header">
|
||||||
<span class="col-num">{{ col.numero }}</span>
|
<span class="col-num">{{ col.numero }}</span>
|
||||||
<span class="col-title">{{ col.etapa }}</span>
|
<span class="col-title">{{ col.etapa }}</span>
|
||||||
<UBadge :label="String(col.cards.length)" variant="soft" color="neutral" size="xs" />
|
<UBadge :label="String(col.cards.length)" variant="soft" color="neutral" size="xs" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-cards">
|
<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">
|
<div class="card-top">
|
||||||
<span class="card-num">{{ card.numero }}</span>
|
<span class="card-num">{{ card.Numero }}</span>
|
||||||
<StatusChip :status="card.status" />
|
<StatusChip :status="(card.Status as any)" />
|
||||||
</div>
|
</div>
|
||||||
<p class="card-objeto">{{ card.objeto }}</p>
|
<p class="card-objeto">{{ card.Objeto }}</p>
|
||||||
<p class="card-orgao">{{ card.orgao }}</p>
|
<p class="card-orgao">{{ card.Orgao }}</p>
|
||||||
<div class="card-bottom">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,12 +188,47 @@ const modalidadeAbrev: Record<string, string> = {
|
|||||||
.page { display: flex; flex-direction: column; height: 100vh; }
|
.page { display: flex; flex-direction: column; height: 100vh; }
|
||||||
.kanban-scroll { flex: 1; overflow-x: auto; padding: 20px 22px; }
|
.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-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-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-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-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; }
|
.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-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||||
.card-num { font-size: 11px; font-weight: 700; color: #0f172a; }
|
.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; }
|
.card-objeto { font-size: 12px; color: #374151; font-weight: 500; margin-bottom: 3px; line-height: 1.3; }
|
||||||
|
|||||||
@@ -8,28 +8,24 @@ const refreshToken = useCookie<string | null>('refresh_token', { maxAge: 60 * 60
|
|||||||
|
|
||||||
const documentType = ref<'pf' | 'pj'>('pj')
|
const documentType = ref<'pf' | 'pj'>('pj')
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
companyName: '',
|
|
||||||
legalName: '',
|
legalName: '',
|
||||||
document: '',
|
document: '',
|
||||||
slug: '',
|
|
||||||
adminName: '',
|
|
||||||
adminEmail: '',
|
adminEmail: '',
|
||||||
adminPassword: '',
|
adminPassword: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const documentLabel = computed(() => documentType.value === 'pj' ? 'CNPJ' : 'CPF')
|
const isPJ = computed(() => documentType.value === 'pj')
|
||||||
const documentPlaceholder = computed(() => documentType.value === 'pj' ? '00.000.000/0001-00' : '000.000.000-00')
|
const documentLabel = computed(() => isPJ.value ? 'CNPJ' : 'CPF')
|
||||||
const nameLabel = computed(() => documentType.value === 'pj' ? 'Razão Social' : 'Nome Completo')
|
const documentPlaceholder = computed(() => isPJ.value ? '00.000.000/0001-00' : '000.000.000-00')
|
||||||
|
|
||||||
// Auto-gera slug a partir do nome fantasia
|
function toSlug(val: string) {
|
||||||
watch(() => form.companyName, (val) => {
|
return val.toLowerCase()
|
||||||
form.slug = val.toLowerCase()
|
|
||||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||||
.replace(/[^a-z0-9\s-]/g, '')
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/\s+/g, '-')
|
.replace(/\s+/g, '-')
|
||||||
.slice(0, 50)
|
.slice(0, 50)
|
||||||
})
|
}
|
||||||
|
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -41,12 +37,12 @@ async function handleSubmit() {
|
|||||||
const res = await $fetch<{ access_token: string; refresh_token: string }>(`${apiBase}/auth/register`, {
|
const res = await $fetch<{ access_token: string; refresh_token: string }>(`${apiBase}/auth/register`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
company_name: form.companyName,
|
company_name: form.legalName,
|
||||||
legal_name: form.legalName,
|
legal_name: form.legalName,
|
||||||
document_type: documentType.value,
|
document_type: documentType.value,
|
||||||
document: form.document,
|
document: form.document,
|
||||||
slug: form.slug,
|
slug: toSlug(form.legalName),
|
||||||
admin_name: form.adminName,
|
admin_name: form.legalName,
|
||||||
admin_email: form.adminEmail,
|
admin_email: form.adminEmail,
|
||||||
admin_password: form.adminPassword,
|
admin_password: form.adminPassword,
|
||||||
},
|
},
|
||||||
@@ -88,33 +84,14 @@ async function handleSubmit() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-label">Dados da Empresa</div>
|
<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 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>
|
</UFormField>
|
||||||
|
|
||||||
<UFormField :label="documentLabel" class="field">
|
<UFormField :label="documentLabel" class="field">
|
||||||
<UInput v-model="form.document" :placeholder="documentPlaceholder" class="w-full" required />
|
<UInput v-model="form.document" :placeholder="documentPlaceholder" class="w-full" required />
|
||||||
</UFormField>
|
</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">
|
<UFormField label="E-mail" class="field">
|
||||||
<UInput v-model="form.adminEmail" type="email" placeholder="voce@empresa.com.br" class="w-full" required />
|
<UInput v-model="form.adminEmail" type="email" placeholder="voce@empresa.com.br" class="w-full" required />
|
||||||
</UFormField>
|
</UFormField>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface ApiUser {
|
|||||||
IsActive: boolean
|
IsActive: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: usuarios, pending } = await useAsyncData('usuarios', () =>
|
const { data: usuarios, refresh } = await useAsyncData('usuarios', () =>
|
||||||
apiFetch<ApiUser[]>('/users')
|
apiFetch<ApiUser[]>('/users')
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,34 +19,185 @@ const roleLabel: Record<string, string> = {
|
|||||||
member: 'Membro',
|
member: 'Membro',
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
// --- Modal Criar ---
|
||||||
{ id: 'Name', accessorKey: 'Name', header: 'Nome' },
|
const showCreate = ref(false)
|
||||||
{ id: 'Email', accessorKey: 'Email', header: 'E-mail' },
|
const createForm = reactive({ name: '', email: '', password: '', role: 'member' })
|
||||||
{ id: 'Role', accessorKey: 'Role', header: 'Perfil' },
|
const createError = ref('')
|
||||||
{ id: 'IsActive', accessorKey: 'IsActive', header: 'Status' },
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<AppTopbar title="Usuários" breadcrumb="Sistema · Usuários">
|
<AppTopbar title="Usuários" breadcrumb="Sistema · Usuários">
|
||||||
<template #actions>
|
<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>
|
</template>
|
||||||
</AppTopbar>
|
</AppTopbar>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<UTable :data="usuarios ?? []" :columns="columns" :loading="pending">
|
<table class="tbl">
|
||||||
<template #Role-cell="{ row }">{{ roleLabel[row.original.Role] ?? row.original.Role }}</template>
|
<thead>
|
||||||
<template #IsActive-cell="{ row }">
|
<tr>
|
||||||
<UBadge
|
<th>Nome</th>
|
||||||
:label="row.original.IsActive ? 'Ativo' : 'Inativo'"
|
<th>E-mail</th>
|
||||||
:color="row.original.IsActive ? 'success' : 'neutral'"
|
<th>Perfil</th>
|
||||||
variant="soft"
|
<th>Status</th>
|
||||||
size="xs"
|
<th></th>
|
||||||
/>
|
</tr>
|
||||||
</template>
|
</thead>
|
||||||
</UTable>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,4 +208,38 @@ const columns = [
|
|||||||
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
|
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
|
||||||
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
|
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
|
||||||
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2) !important; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
export type StatusEdital =
|
export type StatusEdital =
|
||||||
| 'em_analise'
|
| 'em_analise'
|
||||||
| 'elaborando_proposta'
|
| 'elaborando_proposta'
|
||||||
| 'impugnacao'
|
| 'edital_publicado'
|
||||||
|
| 'fase_lances'
|
||||||
|
| 'habilitacao'
|
||||||
| 'recurso'
|
| 'recurso'
|
||||||
| 'participando'
|
| 'adjudicado'
|
||||||
|
| 'contrato'
|
||||||
| 'vencida'
|
| 'vencida'
|
||||||
| 'perdida'
|
| 'perdida'
|
||||||
| 'deserta'
|
| 'deserta'
|
||||||
@@ -90,23 +93,26 @@ export interface Usuario {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const STATUS_EDITAL_CONFIG: Record<StatusEdital, { label: string; bg: string; color: string }> = {
|
export const STATUS_EDITAL_CONFIG: Record<StatusEdital, { label: string; bg: string; color: string }> = {
|
||||||
em_analise: { label: 'Em Análise', bg: '#eff6ff', color: '#0284c7' },
|
em_analise: { label: 'Mapeamento', bg: '#eff6ff', color: '#0284c7' },
|
||||||
elaborando_proposta: { label: 'Elaborando Proposta', bg: '#faf5ff', color: '#7c3aed' },
|
elaborando_proposta: { label: 'Termo de Referência', bg: '#faf5ff', color: '#7c3aed' },
|
||||||
impugnacao: { label: 'Impugnação', bg: '#fff7ed', color: '#ea580c' },
|
edital_publicado: { label: 'Edital Publicado', bg: '#fff7ed', color: '#ea580c' },
|
||||||
recurso: { label: 'Recurso', bg: '#fffbeb', color: '#d97706' },
|
fase_lances: { label: 'Fase de Lances', bg: '#eff6ff', color: '#3b82f6' },
|
||||||
participando: { label: 'Participando', 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' },
|
vencida: { label: 'Vencida', bg: '#f0fdf4', color: '#16a34a' },
|
||||||
perdida: { label: 'Perdida', bg: '#fef2f2', color: '#dc2626' },
|
perdida: { label: 'Perdida', bg: '#fef2f2', color: '#dc2626' },
|
||||||
deserta: { label: 'Deserta / Fracassada', bg: '#f8fafc', color: '#64748b' },
|
deserta: { label: 'Deserta / Fracassada', bg: '#f8fafc', color: '#64748b' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PIPELINE_ETAPAS = [
|
export const PIPELINE_ETAPAS = [
|
||||||
'Identificado',
|
'Mapeamento',
|
||||||
'Análise de Viabilidade',
|
'Termo de Referência',
|
||||||
'Elaborando Proposta',
|
'Edital Publicado',
|
||||||
'Disputa / Lances',
|
'Fase de Lances',
|
||||||
'Habilitação',
|
'Habilitação',
|
||||||
'Recurso / Contrarrazões',
|
'Recursos',
|
||||||
'Adjudicação',
|
'Adjudicado',
|
||||||
'Contrato',
|
'Contrato',
|
||||||
] as const
|
] as const
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default defineNuxtConfig({
|
|||||||
css: ['~/assets/css/main.css'],
|
css: ['~/assets/css/main.css'],
|
||||||
|
|
||||||
routeRules: {
|
routeRules: {
|
||||||
'/': { prerender: true }
|
'/': { prerender: process.env.NODE_ENV !== 'production' }
|
||||||
},
|
},
|
||||||
|
|
||||||
runtimeConfig: {
|
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',
|
compatibilityDate: '2025-01-15',
|
||||||
|
|
||||||
eslint: {
|
eslint: {
|
||||||
|
|||||||
35
nginx.conf
Normal file
35
nginx.conf
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
112
scripts/deploy.sh
Executable file
112
scripts/deploy.sh
Executable 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
BIN
visualizar-pdf-btn.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Reference in New Issue
Block a user