ajustes
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1 +1,9 @@
|
||||
.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">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
import { prazos } from '~/data/mock/prazos'
|
||||
import { documentos } from '~/data/mock/documentos'
|
||||
|
||||
const { user, logout } = useAuth()
|
||||
const route = useRoute()
|
||||
const { apiFetch } = useApi()
|
||||
|
||||
const { data: editais } = await useAsyncData('sidebar-editais', () =>
|
||||
apiFetch<{ Status: string }[]>('/editais'), { server: false }
|
||||
)
|
||||
const { data: documentos } = await useAsyncData('sidebar-documentos', () =>
|
||||
apiFetch<{ DataVencimento: string | null }[]>('/documents'), { server: false }
|
||||
)
|
||||
|
||||
const contagemPorStatus = computed(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const e of editais) {
|
||||
counts[e.status] = (counts[e.status] ?? 0) + 1
|
||||
for (const e of editais.value ?? []) {
|
||||
counts[e.Status] = (counts[e.Status] ?? 0) + 1
|
||||
}
|
||||
return counts
|
||||
})
|
||||
|
||||
const alertasPrazos = computed(() => prazos.filter(p => p.urgencia === 'critico' || p.urgencia === 'urgente').length)
|
||||
const docsVencendo = computed(() => documentos.filter(d => d.status === 'vencendo' || d.status === 'vencida').length)
|
||||
const alertasPrazos = computed(() => 0)
|
||||
const docsVencendo = computed(() => {
|
||||
const hoje = new Date()
|
||||
const limite = new Date(hoje.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
return (documentos.value ?? []).filter(d => {
|
||||
if (!d.DataVencimento) return false
|
||||
const venc = new Date(d.DataVencimento)
|
||||
return venc <= limite
|
||||
}).length
|
||||
})
|
||||
|
||||
const navItems = computed(() => [
|
||||
{
|
||||
@@ -27,10 +39,11 @@ const navItems = computed(() => [
|
||||
{
|
||||
label: 'Oportunidades',
|
||||
items: [
|
||||
{ label: 'Todos os Editais', icon: 'i-heroicons-clipboard-document-list', to: '/oportunidades', badge: editais.length, badgeVariant: 'default' },
|
||||
{ label: 'Em Análise', icon: 'i-heroicons-magnifying-glass', to: '/oportunidades/em-analise', badge: contagemPorStatus.value.em_analise ?? 0, badgeVariant: 'default' },
|
||||
{ label: 'Elaborando Proposta', icon: 'i-heroicons-pencil-square', to: '/oportunidades/elaborando-proposta', badge: contagemPorStatus.value.elaborando_proposta ?? 0, badgeVariant: 'warning' },
|
||||
{ label: 'Participando', icon: 'i-heroicons-play', to: '/oportunidades/participando', badge: contagemPorStatus.value.participando ?? 0, badgeVariant: 'default' },
|
||||
{ label: 'Todos os Editais', icon: 'i-heroicons-clipboard-document-list', to: '/oportunidades', badge: (editais.value ?? []).length, badgeVariant: 'default' },
|
||||
{ label: 'Mapeamento', icon: 'i-heroicons-magnifying-glass', to: '/oportunidades/em-analise', badge: contagemPorStatus.value.em_analise ?? 0, badgeVariant: 'default' },
|
||||
{ label: 'Termo de Referência', icon: 'i-heroicons-pencil-square', to: '/oportunidades/elaborando-proposta', badge: contagemPorStatus.value.elaborando_proposta ?? 0, badgeVariant: 'warning' },
|
||||
{ label: 'Edital Publicado', icon: 'i-heroicons-megaphone', to: '/oportunidades/edital-publicado', badge: contagemPorStatus.value.edital_publicado ?? 0, badgeVariant: 'default' },
|
||||
{ label: 'Fase de Lances', icon: 'i-heroicons-play', to: '/oportunidades/fase-lances', badge: contagemPorStatus.value.fase_lances ?? 0, badgeVariant: 'default' },
|
||||
{ label: 'Recurso', icon: 'i-heroicons-scale', to: '/oportunidades/recurso', badge: contagemPorStatus.value.recurso ?? 0, badgeVariant: 'warning' },
|
||||
{ label: 'Vencidas', icon: 'i-heroicons-trophy', to: '/oportunidades/vencidas', badge: contagemPorStatus.value.vencida ?? 0, badgeVariant: 'success' },
|
||||
{ label: 'Perdidas', icon: 'i-heroicons-x-circle', to: '/oportunidades/perdidas', badge: contagemPorStatus.value.perdida ?? 0, badgeVariant: 'neutral' },
|
||||
|
||||
@@ -1,41 +1,99 @@
|
||||
<!-- front-end/app/components/EditaisTable.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { Edital } from '~/types'
|
||||
|
||||
defineProps<{ editais: Edital[] }>()
|
||||
|
||||
const modalidadeLabel: Record<string, string> = {
|
||||
pregao_eletronico: 'Pregão Eletrônico',
|
||||
pregao_presencial: 'Pregão Presencial',
|
||||
concorrencia: 'Concorrência',
|
||||
dispensa: 'Dispensa',
|
||||
inexigibilidade: 'Inexigibilidade',
|
||||
interface ApiEdital {
|
||||
ID: string
|
||||
Numero: string
|
||||
Orgao: string
|
||||
Modalidade: string
|
||||
Objeto: string
|
||||
Plataforma: string
|
||||
ValorEstimado: number
|
||||
DataPublicacao: string
|
||||
DataAbertura: string
|
||||
Status: string
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ id: 'numero', accessorKey: 'numero', header: 'Nº Edital' },
|
||||
{ id: 'objeto', accessorKey: 'objeto', header: 'Objeto' },
|
||||
{ id: 'orgao', accessorKey: 'orgao', header: 'Órgão' },
|
||||
{ id: 'modalidade', accessorKey: 'modalidade', header: 'Modalidade' },
|
||||
{ id: 'valorEstimado', accessorKey: 'valorEstimado', header: 'Valor Est.' },
|
||||
{ id: 'status', accessorKey: 'status', header: 'Status' },
|
||||
{ id: 'dataAbertura', accessorKey: 'dataAbertura', header: 'Abertura' },
|
||||
]
|
||||
defineProps<{ editais: ApiEdital[] }>()
|
||||
|
||||
const MODALIDADE_LABEL: Record<string, string> = {
|
||||
pregao_eletronico: 'Pregão Eletrônico',
|
||||
pregao_presencial: 'Pregão Presencial',
|
||||
concorrencia: 'Concorrência',
|
||||
dispensa: 'Dispensa',
|
||||
inexigibilidade: 'Inexigibilidade',
|
||||
}
|
||||
|
||||
const STATUS_CFG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
em_analise: { label: 'Mapeamento', color: '#0284c7', bg: '#eff6ff' },
|
||||
elaborando_proposta: { label: 'Termo de Referência', color: '#7c3aed', bg: '#faf5ff' },
|
||||
edital_publicado: { label: 'Edital Publicado', color: '#ea580c', bg: '#fff7ed' },
|
||||
fase_lances: { label: 'Fase de Lances', color: '#3b82f6', bg: '#eff6ff' },
|
||||
habilitacao: { label: 'Habilitação', color: '#d97706', bg: '#fef3c7' },
|
||||
recurso: { label: 'Recursos', color: '#d97706', bg: '#fffbeb' },
|
||||
adjudicado: { label: 'Adjudicado', color: '#059669', bg: '#ecfdf5' },
|
||||
contrato: { label: 'Contrato', color: '#16a34a', bg: '#f0fdf4' },
|
||||
vencida: { label: 'Vencida', color: '#16a34a', bg: '#f0fdf4' },
|
||||
perdida: { label: 'Perdida', color: '#dc2626', bg: '#fef2f2' },
|
||||
deserta: { label: 'Deserta/Fracassada', color: '#64748b', bg: '#f8fafc' },
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '—'
|
||||
const [y, m, d] = iso.split('T')[0].split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
|
||||
function formatBRL(value: number): string {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTable :data="editais" :columns="columns">
|
||||
<template #modalidade-cell="{ row }">
|
||||
{{ modalidadeLabel[row.original.modalidade] }}
|
||||
</template>
|
||||
<template #valorEstimado-cell="{ row }">
|
||||
{{ new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(row.original.valorEstimado) }}
|
||||
</template>
|
||||
<template #status-cell="{ row }">
|
||||
<StatusChip :status="row.original.status" />
|
||||
</template>
|
||||
<template #dataAbertura-cell="{ row }">
|
||||
{{ row.original.dataAbertura.toLocaleDateString('pt-BR') }}
|
||||
</template>
|
||||
</UTable>
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nº Edital</th>
|
||||
<th>Órgão</th>
|
||||
<th>Objeto</th>
|
||||
<th>Modalidade</th>
|
||||
<th>Valor Est.</th>
|
||||
<th>Abertura</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!editais || editais.length === 0">
|
||||
<td colspan="7" class="empty">Nenhum edital encontrado.</td>
|
||||
</tr>
|
||||
<tr v-for="e in editais" :key="e.ID">
|
||||
<td class="numero">{{ e.Numero }}</td>
|
||||
<td>{{ e.Orgao }}</td>
|
||||
<td class="objeto">{{ e.Objeto }}</td>
|
||||
<td>{{ MODALIDADE_LABEL[e.Modalidade] ?? e.Modalidade }}</td>
|
||||
<td>{{ formatBRL(e.ValorEstimado) }}</td>
|
||||
<td>{{ formatDate(e.DataAbertura) }}</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="STATUS_CFG[e.Status]"
|
||||
class="badge"
|
||||
:style="{ background: STATUS_CFG[e.Status].bg, color: STATUS_CFG[e.Status].color }"
|
||||
>{{ STATUS_CFG[e.Status].label }}</span>
|
||||
<span v-else class="badge">{{ e.Status }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.tbl thead tr { border-bottom: 1px solid #e2e8f0; background: #f8fafc; }
|
||||
.tbl th { padding: 10px 14px; text-align: left; font-weight: 600; color: #64748b; font-size: 12px; text-transform: uppercase; letter-spacing: .4px; white-space: nowrap; }
|
||||
.tbl td { padding: 11px 14px; color: #1e293b; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
|
||||
.tbl tbody tr:last-child td { border-bottom: none; }
|
||||
.tbl tbody tr:hover td { background: #f8fafc; }
|
||||
.numero { font-weight: 600; white-space: nowrap; }
|
||||
.objeto { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.empty { text-align: center; color: #94a3b8; padding: 40px; }
|
||||
.badge { display: inline-block; padding: 3px 9px; border-radius: 20px; font-size: 11.5px; font-weight: 600; }
|
||||
</style>
|
||||
|
||||
@@ -1,16 +1,45 @@
|
||||
// Wrapper sobre $fetch que injeta o Authorization header automaticamente.
|
||||
// Em caso de 401, tenta renovar o token via refresh e repete a requisição.
|
||||
export function useApi() {
|
||||
const { public: { apiBase } } = useRuntimeConfig()
|
||||
const token = useCookie<string | null>('auth_token')
|
||||
const refreshToken = useCookie<string | null>('refresh_token')
|
||||
|
||||
function apiFetch<T>(path: string, options: Parameters<typeof $fetch>[1] = {}): Promise<T> {
|
||||
return $fetch<T>(`${apiBase}${path}`, {
|
||||
async function tryRefresh(): Promise<boolean> {
|
||||
if (!refreshToken.value) return false
|
||||
try {
|
||||
const res = await $fetch<{ access_token: string; refresh_token: string }>(
|
||||
`${apiBase}/auth/refresh`,
|
||||
{ method: 'POST', body: { refresh_token: refreshToken.value } }
|
||||
)
|
||||
token.value = res.access_token
|
||||
refreshToken.value = res.refresh_token
|
||||
return true
|
||||
} catch {
|
||||
token.value = null
|
||||
refreshToken.value = null
|
||||
navigateTo('/login')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, options: Parameters<typeof $fetch>[1] = {}): Promise<T> {
|
||||
const doFetch = () => $fetch<T>(`${apiBase}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
...(token.value ? { Authorization: `Bearer ${token.value}` } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
return await doFetch()
|
||||
} catch (err: any) {
|
||||
if (err?.status === 401 && await tryRefresh()) {
|
||||
return await doFetch()
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
return { apiFetch }
|
||||
|
||||
@@ -4,6 +4,17 @@ interface AuthUser {
|
||||
papel: string
|
||||
}
|
||||
|
||||
export interface TenantOption {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export type LoginResult =
|
||||
| { success: true }
|
||||
| { success: false; error: string }
|
||||
| { needsTenantSelect: true; tenants: TenantOption[]; email: string; password: string }
|
||||
|
||||
export function useAuth() {
|
||||
const { public: { apiBase } } = useRuntimeConfig()
|
||||
|
||||
@@ -23,23 +34,48 @@ export function useAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
async function login(email: string, password: string, slug: string): Promise<{ success: boolean; error?: string }> {
|
||||
function _applyTokens(access: string, refresh: string) {
|
||||
token.value = access
|
||||
refreshToken.value = refresh
|
||||
const payload = JSON.parse(atob(access.split('.')[1]))
|
||||
user.value = { nome: payload.email.split('@')[0], email: payload.email, papel: payload.role }
|
||||
}
|
||||
|
||||
async function login(email: string, password: string): Promise<LoginResult> {
|
||||
try {
|
||||
const res = await $fetch<{ access_token: string; refresh_token: string }>(`${apiBase}/auth/login`, {
|
||||
const res = await $fetch<{
|
||||
tokens?: { access_token: string; refresh_token: string }
|
||||
tenants?: TenantOption[]
|
||||
}>(`${apiBase}/auth/login`, {
|
||||
method: 'POST',
|
||||
body: { email, password, slug },
|
||||
body: { email, password },
|
||||
})
|
||||
|
||||
token.value = res.access_token
|
||||
refreshToken.value = res.refresh_token
|
||||
if (res.tokens) {
|
||||
_applyTokens(res.tokens.access_token, res.tokens.refresh_token)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const payload = JSON.parse(atob(res.access_token.split('.')[1]))
|
||||
user.value = { nome: payload.email.split('@')[0], email: payload.email, papel: payload.role }
|
||||
if (res.tenants && res.tenants.length > 0) {
|
||||
return { needsTenantSelect: true, tenants: res.tenants, email, password }
|
||||
}
|
||||
|
||||
return { success: false, error: 'Resposta inesperada do servidor.' }
|
||||
} catch (err: any) {
|
||||
return { success: false, error: err?.data?.error || 'E-mail ou senha incorretos.' }
|
||||
}
|
||||
}
|
||||
|
||||
async function selectTenant(email: string, password: string, tenantId: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const res = await $fetch<{ access_token: string; refresh_token: string }>(`${apiBase}/auth/login/select-tenant`, {
|
||||
method: 'POST',
|
||||
body: { email, password, tenant_id: tenantId },
|
||||
})
|
||||
_applyTokens(res.access_token, res.refresh_token)
|
||||
return { success: true }
|
||||
} catch (err: any) {
|
||||
const msg = err?.data?.error || 'E-mail, senha ou organização incorretos.'
|
||||
return { success: false, error: msg }
|
||||
return { success: false, error: err?.data?.error || 'Erro ao selecionar empresa.' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,5 +93,5 @@ export function useAuth() {
|
||||
navigateTo('/login')
|
||||
}
|
||||
|
||||
return { user, isAuthenticated, login, logout }
|
||||
return { user, isAuthenticated, login, selectTenant, logout }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<!-- front-end/app/pages/gestao/documentos.vue -->
|
||||
<script setup lang="ts">
|
||||
const { apiFetch } = useApi()
|
||||
const { public: { apiBase } } = useRuntimeConfig()
|
||||
const token = useCookie<string | null>('auth_token')
|
||||
|
||||
interface ApiDocument {
|
||||
ID: string
|
||||
@@ -10,6 +12,13 @@ interface ApiDocument {
|
||||
Observacoes: string
|
||||
}
|
||||
|
||||
interface ApiFile {
|
||||
ID: string
|
||||
Nome: string
|
||||
Size: number
|
||||
CreatedAt: string
|
||||
}
|
||||
|
||||
const { data: documentos, refresh } = await useAsyncData('documentos', () =>
|
||||
apiFetch<ApiDocument[]>('/documents')
|
||||
)
|
||||
@@ -56,6 +65,12 @@ function formatDate(iso: string | null): string {
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
// ---------- menu ⋯ ----------
|
||||
const menuAberto = ref<string | null>(null)
|
||||
const menuPos = ref({ top: 0, left: 0 })
|
||||
@@ -69,6 +84,65 @@ function abrirMenu(id: string, event: MouseEvent) {
|
||||
|
||||
function fecharMenu() { menuAberto.value = null }
|
||||
|
||||
// ---------- modal visualizar ----------
|
||||
const showVisualizar = ref(false)
|
||||
const viewDoc = ref<ApiDocument | null>(null)
|
||||
const viewFiles = ref<ApiFile[]>([])
|
||||
const viewFilesLoading = ref(false)
|
||||
const uploadLoading = ref(false)
|
||||
|
||||
async function abrirVisualizar(doc: ApiDocument) {
|
||||
fecharMenu()
|
||||
viewDoc.value = doc
|
||||
showVisualizar.value = true
|
||||
viewFilesLoading.value = true
|
||||
try {
|
||||
viewFiles.value = await apiFetch<ApiFile[]>(`/documents/${doc.ID}/files`)
|
||||
} catch { viewFiles.value = [] }
|
||||
viewFilesLoading.value = false
|
||||
}
|
||||
|
||||
async function uploadArquivos(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (!input.files?.length || !viewDoc.value) return
|
||||
uploadLoading.value = true
|
||||
const formData = new FormData()
|
||||
for (const file of Array.from(input.files)) formData.append('file', file)
|
||||
try {
|
||||
await $fetch(`${apiBase}/documents/${viewDoc.value.ID}/files`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token.value}` },
|
||||
body: formData,
|
||||
})
|
||||
viewFiles.value = await apiFetch<ApiFile[]>(`/documents/${viewDoc.value.ID}/files`)
|
||||
} catch {}
|
||||
uploadLoading.value = false
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
async function excluirArquivo(fileId: string) {
|
||||
if (!viewDoc.value) return
|
||||
try {
|
||||
await apiFetch(`/documents/${viewDoc.value.ID}/files/${fileId}`, { method: 'DELETE' })
|
||||
viewFiles.value = viewFiles.value.filter(f => f.ID !== fileId)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function downloadArquivo(docId: string, fileId: string, nome: string) {
|
||||
try {
|
||||
const blob = await $fetch<Blob>(`${apiBase}/documents/${docId}/files/${fileId}/download`, {
|
||||
headers: { Authorization: `Bearer ${token.value}` },
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = nome
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ---------- modal criar ----------
|
||||
const showCriar = ref(false)
|
||||
const criando = ref(false)
|
||||
@@ -221,8 +295,69 @@ async function confirmarExclusao() {
|
||||
:style="{ top: menuPos.top + 'px', left: menuPos.left + 'px' }"
|
||||
@click.stop
|
||||
>
|
||||
<button @click="abrirVisualizar(documentos!.find(d => d.ID === menuAberto)!); menuAberto = null">Visualizar</button>
|
||||
<button @click="abrirEditar(documentos!.find(d => d.ID === menuAberto)!)">Editar</button>
|
||||
<button @click="abrirExcluir(documentos!.find(d => d.ID === menuAberto)!)">Excluir</button>
|
||||
<button class="drop-danger" @click="abrirExcluir(documentos!.find(d => d.ID === menuAberto)!)">Excluir</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Modal Visualizar -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showVisualizar" class="modal-overlay" @click.self="showVisualizar = false">
|
||||
<div class="modal modal-view">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2>{{ viewDoc?.Nome || tipoLabel(viewDoc?.Tipo ?? '') }}</h2>
|
||||
<span
|
||||
class="doc-status"
|
||||
:style="{ color: STATUS_CFG[calcStatus(viewDoc?.DataVencimento ?? null)].color, background: STATUS_CFG[calcStatus(viewDoc?.DataVencimento ?? null)].bg }"
|
||||
>
|
||||
{{ STATUS_CFG[calcStatus(viewDoc?.DataVencimento ?? null)].label }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="showVisualizar = false">✕</button>
|
||||
</div>
|
||||
<div class="view-grid">
|
||||
<div class="view-field">
|
||||
<label>Tipo</label>
|
||||
<span>{{ tipoLabel(viewDoc?.Tipo ?? '') }}</span>
|
||||
</div>
|
||||
<div class="view-field">
|
||||
<label>Vencimento</label>
|
||||
<span>{{ formatDate(viewDoc?.DataVencimento ?? null) }}</span>
|
||||
</div>
|
||||
<div class="view-field view-full">
|
||||
<label>Observações</label>
|
||||
<span>{{ viewDoc?.Observacoes || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="files-section">
|
||||
<div class="files-header">
|
||||
<h3>Arquivos</h3>
|
||||
<label class="upload-btn" :class="{ loading: uploadLoading }">
|
||||
<input type="file" multiple @change="uploadArquivos" style="display:none" :disabled="uploadLoading" />
|
||||
{{ uploadLoading ? 'Enviando...' : '+ Anexar' }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="viewFilesLoading" class="files-empty">Carregando arquivos...</div>
|
||||
<div v-else-if="viewFiles.length === 0" class="files-empty">Nenhum arquivo anexado.</div>
|
||||
<div v-else class="files-list">
|
||||
<div v-for="f in viewFiles" :key="f.ID" class="file-item">
|
||||
<span class="file-icon">📄</span>
|
||||
<span class="file-name">{{ f.Nome }}</span>
|
||||
<span class="file-size">{{ formatFileSize(f.Size) }}</span>
|
||||
<button class="file-act" title="Download" @click="downloadArquivo(viewDoc!.ID, f.ID, f.Nome)">⬇</button>
|
||||
<button class="file-act file-del" title="Remover" @click="excluirArquivo(f.ID)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn-cancel" @click="showVisualizar = false">Fechar</button>
|
||||
<button class="btn-save" @click="showVisualizar = false; abrirEditar(viewDoc!)">Editar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
@@ -348,9 +483,11 @@ async function confirmarExclusao() {
|
||||
.btn-menu:hover { background: #f1f5f9; color: #334155; }
|
||||
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2); color: white; border: none; border-radius: 8px; padding: 7px 14px; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 20px 12px; border-bottom: 1px solid #f1f5f9; }
|
||||
.modal-header h2 { font-size: 16px; font-weight: 700; color: #0f172a; margin: 0; }
|
||||
.modal-header { display: flex; align-items: flex-start; justify-content: space-between; padding: 18px 20px 12px; border-bottom: 1px solid #f1f5f9; }
|
||||
.modal-header h2 { font-size: 16px; font-weight: 700; color: #0f172a; margin: 0 0 6px; }
|
||||
.modal-header button { background: none; border: none; font-size: 18px; cursor: pointer; color: #94a3b8; }
|
||||
.close-btn { background: none; border: none; font-size: 16px; color: #94a3b8; cursor: pointer; padding: 4px 8px; border-radius: 6px; }
|
||||
.close-btn:hover { background: #f1f5f9; color: #475569; }
|
||||
.modal-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 12px 20px 18px; border-top: 1px solid #f1f5f9; }
|
||||
|
||||
@@ -365,6 +502,32 @@ async function confirmarExclusao() {
|
||||
.btn-danger { background: #dc2626; color: white; border: none; border-radius: 8px; padding: 7px 16px; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||
.btn-danger:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.erro { color: #dc2626; font-size: 12px; }
|
||||
|
||||
/* Modal visualizar */
|
||||
.modal-view { max-width: 620px !important; }
|
||||
.view-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; padding: 16px 20px 4px; }
|
||||
.view-full { grid-column: 1 / -1; }
|
||||
.view-field { display: flex; flex-direction: column; gap: 3px; }
|
||||
.view-field label { font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.view-field span { font-size: 13px; color: #1e293b; }
|
||||
|
||||
/* Arquivos */
|
||||
.files-section { border-top: 1px solid #f1f5f9; padding: 16px 20px 4px; margin-top: 8px; }
|
||||
.files-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.files-header h3 { font-size: 11px; font-weight: 700; color: #94a3b8; margin: 0; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.upload-btn { font-size: 12px; font-weight: 600; padding: 5px 12px; border-radius: 8px; border: 1px dashed #667eea; color: #667eea; cursor: pointer; transition: all 0.15s; }
|
||||
.upload-btn:hover { background: #f0f3ff; }
|
||||
.upload-btn.loading { opacity: 0.6; cursor: not-allowed; }
|
||||
.files-empty { font-size: 13px; color: #94a3b8; text-align: center; padding: 16px 0; }
|
||||
.files-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.file-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; }
|
||||
.file-icon { font-size: 16px; flex-shrink: 0; }
|
||||
.file-name { flex: 1; font-size: 13px; color: #1e293b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-size { font-size: 11px; color: #94a3b8; white-space: nowrap; flex-shrink: 0; }
|
||||
.file-act { font-size: 13px; padding: 3px 7px; background: none; border: none; cursor: pointer; color: #667eea; border-radius: 4px; flex-shrink: 0; }
|
||||
.file-act:hover { background: #f0f3ff; }
|
||||
.file-del { color: #dc2626; }
|
||||
.file-del:hover { background: #fff1f1 !important; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
@@ -387,4 +550,6 @@ async function confirmarExclusao() {
|
||||
font-size: 13px; background: none; border: none; cursor: pointer; color: #374151;
|
||||
}
|
||||
.dropdown-fixed button:hover { background: #f8fafc; }
|
||||
.dropdown-fixed .drop-danger { color: #dc2626; }
|
||||
.dropdown-fixed .drop-danger:hover { background: #fff1f1; }
|
||||
</style>
|
||||
|
||||
@@ -1,57 +1,416 @@
|
||||
<!-- front-end/app/pages/gestao/prazos.vue -->
|
||||
<script setup lang="ts">
|
||||
import { prazos } from '~/data/mock/prazos'
|
||||
const { apiFetch } = useApi()
|
||||
|
||||
const hoje = new Date()
|
||||
|
||||
const urgenciaConfig = {
|
||||
critico: { label: 'Crítico — Hoje', color: '#dc2626', bg: '#fef2f2' },
|
||||
urgente: { label: 'Urgente', color: '#d97706', bg: '#fffbeb' },
|
||||
normal: { label: 'Normal', color: '#667eea', bg: '#eff6ff' },
|
||||
interface ApiEdital {
|
||||
ID: string; Numero: string; Orgao: string; Modalidade: string
|
||||
Objeto: string; ValorEstimado: number; DataPublicacao: string
|
||||
DataAbertura: string; Status: string
|
||||
}
|
||||
interface ApiDocument {
|
||||
ID: string; Tipo: string; Nome: string
|
||||
DataVencimento: string | null; Observacoes: string
|
||||
}
|
||||
interface ApiContract {
|
||||
ID: string; Numero: string; Orgao: string; Objeto: string
|
||||
DataInicio: string; DataFim: string; Status: string
|
||||
}
|
||||
|
||||
function diasRestantes(data: Date) {
|
||||
const diff = Math.ceil((data.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (diff <= 0) return 'Hoje'
|
||||
const { data: editais } = await useAsyncData('prazos-editais', () =>
|
||||
apiFetch<ApiEdital[]>('/editais'), { server: false }
|
||||
)
|
||||
const { data: documentos } = await useAsyncData('prazos-docs', () =>
|
||||
apiFetch<ApiDocument[]>('/documents'), { server: false }
|
||||
)
|
||||
const { data: contratos } = await useAsyncData('prazos-contratos', () =>
|
||||
apiFetch<ApiContract[]>('/contracts'), { server: false, default: () => [] }
|
||||
)
|
||||
|
||||
// ---------- helpers ----------
|
||||
const TIPO_DOC: Record<string, string> = {
|
||||
cnpj: 'CNPJ', contrato_social: 'Contrato Social', balanco: 'Balanço',
|
||||
certidao: 'Certidão', atestado: 'Atestado Técnico', procuracao: 'Procuração', outro: 'Outro',
|
||||
}
|
||||
const MODALIDADE: Record<string, string> = {
|
||||
pregao_eletronico: 'Pregão Eletrônico', pregao_presencial: 'Pregão Presencial',
|
||||
concorrencia: 'Concorrência', dispensa: 'Dispensa', inexigibilidade: 'Inexigibilidade',
|
||||
}
|
||||
|
||||
function toDate(iso: string): Date {
|
||||
const [y, m, d] = iso.split('T')[0].split('-').map(Number)
|
||||
return new Date(y, m - 1, d)
|
||||
}
|
||||
function formatDate(iso: string): string {
|
||||
const [y, m, d] = iso.split('T')[0].split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
function diffDays(iso: string): number {
|
||||
const hoje = new Date()
|
||||
const hojeMs = Date.UTC(hoje.getFullYear(), hoje.getMonth(), hoje.getDate())
|
||||
const [y, m, d] = iso.split('T')[0].split('-').map(Number)
|
||||
const tMs = Date.UTC(y, m - 1, d)
|
||||
return Math.ceil((tMs - hojeMs) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
function diasLabel(diff: number): string {
|
||||
if (diff < 0) return `${Math.abs(diff)}d atrás`
|
||||
if (diff === 0) return 'Hoje'
|
||||
if (diff === 1) return 'Amanhã'
|
||||
return `${diff} dias`
|
||||
return `em ${diff}d`
|
||||
}
|
||||
|
||||
// ---------- prazo items ----------
|
||||
interface PrazoItem {
|
||||
id: string
|
||||
titulo: string
|
||||
subtitulo: string
|
||||
dataIso: string
|
||||
diff: number
|
||||
tipo: 'edital' | 'documento' | 'contrato'
|
||||
link?: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const tipoIcone: Record<string, string> = {
|
||||
edital: '📋', documento: '📄', contrato: '📝',
|
||||
}
|
||||
const tipoCor: Record<string, { color: string; bg: string }> = {
|
||||
edital: { color: '#3b82f6', bg: '#eff6ff' },
|
||||
documento: { color: '#8b5cf6', bg: '#f5f3ff' },
|
||||
contrato: { color: '#059669', bg: '#ecfdf5' },
|
||||
}
|
||||
|
||||
const prazos = computed<PrazoItem[]>(() => {
|
||||
const items: PrazoItem[] = []
|
||||
|
||||
// Editais — data de abertura (só status ativos)
|
||||
const statusAtivos = new Set(['em_analise', 'elaborando_proposta', 'edital_publicado', 'fase_lances', 'habilitacao', 'recurso'])
|
||||
for (const e of editais.value ?? []) {
|
||||
if (!statusAtivos.has(e.Status)) continue
|
||||
const d = diffDays(e.DataAbertura)
|
||||
items.push({
|
||||
id: `e-${e.ID}`,
|
||||
titulo: `Abertura — ${e.Numero}`,
|
||||
subtitulo: `${MODALIDADE[e.Modalidade] ?? e.Modalidade} · ${e.Orgao}`,
|
||||
dataIso: e.DataAbertura,
|
||||
diff: d,
|
||||
tipo: 'edital',
|
||||
link: `/oportunidades/${e.ID}`,
|
||||
icon: tipoIcone.edital,
|
||||
})
|
||||
}
|
||||
|
||||
// Documentos — data de vencimento
|
||||
for (const doc of documentos.value ?? []) {
|
||||
if (!doc.DataVencimento) continue
|
||||
const d = diffDays(doc.DataVencimento)
|
||||
items.push({
|
||||
id: `d-${doc.ID}`,
|
||||
titulo: `Vencimento — ${doc.Nome || TIPO_DOC[doc.Tipo] || doc.Tipo}`,
|
||||
subtitulo: `${TIPO_DOC[doc.Tipo] || doc.Tipo}${doc.Observacoes ? ' · ' + doc.Observacoes : ''}`,
|
||||
dataIso: doc.DataVencimento,
|
||||
diff: d,
|
||||
tipo: 'documento',
|
||||
link: '/gestao/documentos',
|
||||
icon: tipoIcone.documento,
|
||||
})
|
||||
}
|
||||
|
||||
// Contratos — data fim
|
||||
for (const c of contratos.value ?? []) {
|
||||
if (!c.DataFim || c.Status === 'encerrado') continue
|
||||
const d = diffDays(c.DataFim)
|
||||
items.push({
|
||||
id: `c-${c.ID}`,
|
||||
titulo: `Fim vigência — ${c.Numero}`,
|
||||
subtitulo: `Contrato · ${c.Orgao}`,
|
||||
dataIso: c.DataFim,
|
||||
diff: d,
|
||||
tipo: 'contrato',
|
||||
link: '/gestao/contratos',
|
||||
icon: tipoIcone.contrato,
|
||||
})
|
||||
}
|
||||
|
||||
// Ordenar por data (mais próximo primeiro)
|
||||
items.sort((a, b) => a.diff - b.diff)
|
||||
return items
|
||||
})
|
||||
|
||||
// ---------- filtros ----------
|
||||
type FiltroTipo = 'todos' | 'edital' | 'documento' | 'contrato'
|
||||
type FiltroPeriodo = 'todos' | 'vencido' | '7dias' | '30dias' | '90dias'
|
||||
|
||||
const filtroTipo = ref<FiltroTipo>('todos')
|
||||
const filtroPeriodo = ref<FiltroPeriodo>('todos')
|
||||
|
||||
const filtroTipoOptions: { value: FiltroTipo; label: string }[] = [
|
||||
{ value: 'todos', label: 'Todos' },
|
||||
{ value: 'edital', label: 'Editais' },
|
||||
{ value: 'documento', label: 'Documentos' },
|
||||
{ value: 'contrato', label: 'Contratos' },
|
||||
]
|
||||
const filtroPeriodoOptions: { value: FiltroPeriodo; label: string }[] = [
|
||||
{ value: 'todos', label: 'Todos' },
|
||||
{ value: 'vencido', label: 'Vencidos' },
|
||||
{ value: '7dias', label: 'Próx. 7 dias' },
|
||||
{ value: '30dias', label: 'Próx. 30 dias' },
|
||||
{ value: '90dias', label: 'Próx. 90 dias' },
|
||||
]
|
||||
|
||||
const prazosFiltrados = computed(() => {
|
||||
return prazos.value.filter(p => {
|
||||
if (filtroTipo.value !== 'todos' && p.tipo !== filtroTipo.value) return false
|
||||
if (filtroPeriodo.value === 'vencido' && p.diff >= 0) return false
|
||||
if (filtroPeriodo.value === '7dias' && (p.diff < 0 || p.diff > 7)) return false
|
||||
if (filtroPeriodo.value === '30dias' && (p.diff < 0 || p.diff > 30)) return false
|
||||
if (filtroPeriodo.value === '90dias' && (p.diff < 0 || p.diff > 90)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- stats ----------
|
||||
const stats = computed(() => {
|
||||
const all = prazos.value
|
||||
return {
|
||||
total: all.length,
|
||||
vencidos: all.filter(p => p.diff < 0).length,
|
||||
hoje: all.filter(p => p.diff === 0).length,
|
||||
semana: all.filter(p => p.diff > 0 && p.diff <= 7).length,
|
||||
mes: all.filter(p => p.diff > 0 && p.diff <= 30).length,
|
||||
}
|
||||
})
|
||||
|
||||
function urgencia(diff: number): 'vencido' | 'critico' | 'urgente' | 'normal' | 'folgado' {
|
||||
if (diff < 0) return 'vencido'
|
||||
if (diff === 0) return 'critico'
|
||||
if (diff <= 7) return 'urgente'
|
||||
if (diff <= 30) return 'normal'
|
||||
return 'folgado'
|
||||
}
|
||||
const urgenciaCfg = {
|
||||
vencido: { label: 'Vencido', color: '#dc2626', bg: '#fef2f2', border: '#fecaca' },
|
||||
critico: { label: 'Hoje', color: '#dc2626', bg: '#fef2f2', border: '#fecaca' },
|
||||
urgente: { label: 'Urgente', color: '#ea580c', bg: '#fff7ed', border: '#fed7aa' },
|
||||
normal: { label: 'Normal', color: '#d97706', bg: '#fffbeb', border: '#fde68a' },
|
||||
folgado: { label: 'Folgado', color: '#16a34a', bg: '#f0fdf4', border: '#bbf7d0' },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Prazos" breadcrumb="Gestão · Prazos" />
|
||||
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div v-for="prazo in prazos" :key="prazo.id" class="prazo-row">
|
||||
<div class="prazo-dot" :style="{ background: urgenciaConfig[prazo.urgencia].color }" />
|
||||
<div class="prazo-info">
|
||||
<p class="prazo-titulo">{{ prazo.titulo }}</p>
|
||||
<p class="prazo-desc">{{ prazo.descricao }}</p>
|
||||
</div>
|
||||
<div class="prazo-right">
|
||||
<span class="prazo-badge" :style="{ color: urgenciaConfig[prazo.urgencia].color, background: urgenciaConfig[prazo.urgencia].bg }">
|
||||
{{ urgenciaConfig[prazo.urgencia].label }}
|
||||
</span>
|
||||
<p class="prazo-data">{{ prazo.dataLimite.toLocaleDateString('pt-BR') }} · {{ diasRestantes(prazo.dataLimite) }}</p>
|
||||
</div>
|
||||
<!-- Stats cards -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card stat-danger" @click="filtroPeriodo = 'vencido'; filtroTipo = 'todos'">
|
||||
<span class="stat-num">{{ stats.vencidos }}</span>
|
||||
<span class="stat-label">Vencidos</span>
|
||||
</div>
|
||||
<div class="stat-card stat-critical" @click="filtroPeriodo = 'todos'; filtroTipo = 'todos'">
|
||||
<span class="stat-num">{{ stats.hoje }}</span>
|
||||
<span class="stat-label">Hoje</span>
|
||||
</div>
|
||||
<div class="stat-card stat-warning" @click="filtroPeriodo = '7dias'; filtroTipo = 'todos'">
|
||||
<span class="stat-num">{{ stats.semana }}</span>
|
||||
<span class="stat-label">Próx. 7 dias</span>
|
||||
</div>
|
||||
<div class="stat-card stat-info" @click="filtroPeriodo = '30dias'; filtroTipo = 'todos'">
|
||||
<span class="stat-num">{{ stats.mes }}</span>
|
||||
<span class="stat-label">Próx. 30 dias</span>
|
||||
</div>
|
||||
<div class="stat-card stat-total">
|
||||
<span class="stat-num">{{ stats.total }}</span>
|
||||
<span class="stat-label">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters-row">
|
||||
<div class="filter-group">
|
||||
<button
|
||||
v-for="opt in filtroTipoOptions" :key="opt.value"
|
||||
class="filter-btn"
|
||||
:class="{ active: filtroTipo === opt.value }"
|
||||
@click="filtroTipo = opt.value"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<button
|
||||
v-for="opt in filtroPeriodoOptions" :key="opt.value"
|
||||
class="filter-btn"
|
||||
:class="{ active: filtroPeriodo === opt.value }"
|
||||
@click="filtroPeriodo = opt.value"
|
||||
>{{ opt.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="timeline-card">
|
||||
<div v-if="prazosFiltrados.length === 0" class="timeline-empty">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#cbd5e1" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
<p>Nenhum prazo encontrado para os filtros selecionados.</p>
|
||||
</div>
|
||||
|
||||
<NuxtLink
|
||||
v-for="(prazo, idx) in prazosFiltrados"
|
||||
:key="prazo.id"
|
||||
:to="prazo.link ?? '#'"
|
||||
class="timeline-item"
|
||||
:class="urgencia(prazo.diff)"
|
||||
>
|
||||
<div class="tl-left">
|
||||
<div class="tl-dot" :style="{ background: urgenciaCfg[urgencia(prazo.diff)].color }" />
|
||||
<div v-if="idx < prazosFiltrados.length - 1" class="tl-line" />
|
||||
</div>
|
||||
|
||||
<div class="tl-content">
|
||||
<div class="tl-header">
|
||||
<div class="tl-icon-badge" :style="{ background: tipoCor[prazo.tipo].bg, color: tipoCor[prazo.tipo].color }">
|
||||
{{ prazo.icon }}
|
||||
</div>
|
||||
<div class="tl-title-area">
|
||||
<span class="tl-title">{{ prazo.titulo }}</span>
|
||||
<span class="tl-sub">{{ prazo.subtitulo }}</span>
|
||||
</div>
|
||||
<div class="tl-right-area">
|
||||
<span
|
||||
class="tl-urgencia"
|
||||
:style="{
|
||||
color: urgenciaCfg[urgencia(prazo.diff)].color,
|
||||
background: urgenciaCfg[urgencia(prazo.diff)].bg,
|
||||
borderColor: urgenciaCfg[urgencia(prazo.diff)].border,
|
||||
}"
|
||||
>{{ urgenciaCfg[urgencia(prazo.diff)].label }}</span>
|
||||
<span class="tl-date">{{ formatDate(prazo.dataIso) }}</span>
|
||||
<span class="tl-diff" :style="{ color: urgenciaCfg[urgencia(prazo.diff)].color }">
|
||||
{{ diasLabel(prazo.diff) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: flex; flex-direction: column; height: 100vh; }
|
||||
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
|
||||
.prazo-row { display: flex; align-items: center; gap: 12px; padding: 14px 18px; border-bottom: 1px solid #f8fafc; }
|
||||
.prazo-row:last-child { border-bottom: none; }
|
||||
.prazo-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.prazo-info { flex: 1; }
|
||||
.prazo-titulo { font-size: 13px; font-weight: 600; color: #0f172a; }
|
||||
.prazo-desc { font-size: 11px; color: #94a3b8; margin-top: 2px; }
|
||||
.prazo-right { text-align: right; flex-shrink: 0; }
|
||||
.prazo-badge { font-size: 10.5px; font-weight: 600; padding: 2px 8px; border-radius: 20px; display: inline-block; margin-bottom: 4px; }
|
||||
.prazo-data { font-size: 11px; color: #64748b; }
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300..700&family=JetBrains+Mono:wght@400;600&display=swap');
|
||||
|
||||
.page { display: flex; flex-direction: column; height: 100vh; font-family: 'DM Sans', system-ui, sans-serif; }
|
||||
.content { padding: 24px 28px 40px; flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 18px; }
|
||||
|
||||
/* ── Stats ── */
|
||||
.stats-row { display: flex; gap: 12px; }
|
||||
.stat-card {
|
||||
flex: 1; background: white; border-radius: 12px;
|
||||
border: 1px solid #e2e8f0; padding: 16px 18px;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
cursor: pointer; transition: all .15s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
||||
}
|
||||
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,.08); }
|
||||
.stat-num { font-size: 28px; font-weight: 800; font-family: 'JetBrains Mono', monospace; line-height: 1; }
|
||||
.stat-label { font-size: 11.5px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: .5px; }
|
||||
.stat-danger .stat-num { color: #dc2626; }
|
||||
.stat-danger { border-left: 3px solid #dc2626; }
|
||||
.stat-critical .stat-num { color: #ea580c; }
|
||||
.stat-critical { border-left: 3px solid #ea580c; }
|
||||
.stat-warning .stat-num { color: #d97706; }
|
||||
.stat-warning { border-left: 3px solid #d97706; }
|
||||
.stat-info .stat-num { color: #3b82f6; }
|
||||
.stat-info { border-left: 3px solid #3b82f6; }
|
||||
.stat-total .stat-num { color: #0f172a; }
|
||||
.stat-total { border-left: 3px solid #667eea; }
|
||||
|
||||
/* ── Filters ── */
|
||||
.filters-row { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
|
||||
.filter-group { display: flex; gap: 4px; background: #f1f5f9; border-radius: 9px; padding: 3px; }
|
||||
.filter-btn {
|
||||
padding: 6px 14px; border-radius: 7px; border: none;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
color: #64748b; background: transparent; transition: all .15s;
|
||||
}
|
||||
.filter-btn:hover { color: #334155; }
|
||||
.filter-btn.active { background: white; color: #0f172a; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||
|
||||
/* ── Timeline ── */
|
||||
.timeline-card {
|
||||
background: white; border-radius: 14px;
|
||||
border: 1px solid #e2e8f0; overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
||||
}
|
||||
.timeline-empty {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 10px; padding: 48px 20px; color: #94a3b8;
|
||||
}
|
||||
.timeline-empty p { font-size: 13px; margin: 0; }
|
||||
|
||||
.timeline-item {
|
||||
display: flex; gap: 0; text-decoration: none;
|
||||
transition: background .12s;
|
||||
}
|
||||
.timeline-item:hover { background: #fafbfd; }
|
||||
.timeline-item:not(:last-child) { border-bottom: 1px solid #f5f7fa; }
|
||||
|
||||
/* Urgency left accent */
|
||||
.timeline-item.vencido { border-left: 3px solid #dc2626; }
|
||||
.timeline-item.critico { border-left: 3px solid #dc2626; }
|
||||
.timeline-item.urgente { border-left: 3px solid #ea580c; }
|
||||
.timeline-item.normal { border-left: 3px solid #d97706; }
|
||||
.timeline-item.folgado { border-left: 3px solid #16a34a; }
|
||||
|
||||
/* Timeline dots + line */
|
||||
.tl-left {
|
||||
width: 32px; display: flex; flex-direction: column;
|
||||
align-items: center; padding-top: 22px; flex-shrink: 0;
|
||||
}
|
||||
.tl-dot {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
flex-shrink: 0; position: relative; z-index: 1;
|
||||
}
|
||||
.tl-line {
|
||||
width: 2px; flex: 1; background: #e9edf3; margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.tl-content { flex: 1; padding: 14px 18px 14px 6px; min-width: 0; }
|
||||
.tl-header { display: flex; align-items: center; gap: 12px; }
|
||||
.tl-icon-badge {
|
||||
width: 36px; height: 36px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 16px; flex-shrink: 0;
|
||||
}
|
||||
.tl-title-area { flex: 1; min-width: 0; }
|
||||
.tl-title {
|
||||
display: block; font-size: 13.5px; font-weight: 700; color: #0f172a;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.tl-sub {
|
||||
display: block; font-size: 12px; color: #94a3b8; margin-top: 2px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.tl-right-area {
|
||||
display: flex; flex-direction: column; align-items: flex-end;
|
||||
gap: 3px; flex-shrink: 0;
|
||||
}
|
||||
.tl-urgencia {
|
||||
font-size: 10.5px; font-weight: 700; padding: 2px 9px;
|
||||
border-radius: 20px; border: 1px solid;
|
||||
}
|
||||
.tl-date {
|
||||
font-size: 12px; font-weight: 600; color: #475569;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.tl-diff { font-size: 11px; font-weight: 700; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 768px) {
|
||||
.stats-row { flex-wrap: wrap; }
|
||||
.stat-card { min-width: calc(50% - 8px); }
|
||||
.filters-row { flex-direction: column; }
|
||||
.tl-header { flex-wrap: wrap; }
|
||||
.tl-right-area { flex-direction: row; gap: 8px; align-items: center; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,65 +1,232 @@
|
||||
<!-- front-end/app/pages/index.vue -->
|
||||
<script setup lang="ts">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
import { prazos } from '~/data/mock/prazos'
|
||||
import { documentos } from '~/data/mock/documentos'
|
||||
import { dashboardStats } from '~/data/mock/stats'
|
||||
import { PIPELINE_ETAPAS } from '~/types'
|
||||
|
||||
const { apiFetch } = useApi()
|
||||
|
||||
interface ApiEdital {
|
||||
ID: string; Numero: string; Orgao: string; Modalidade: string
|
||||
Objeto: string; Plataforma: string; ValorEstimado: number
|
||||
DataPublicacao: string; DataAbertura: string; Status: string
|
||||
}
|
||||
interface ApiDocument {
|
||||
ID: string; Tipo: string; Nome: string
|
||||
DataVencimento: string | null; Observacoes: string
|
||||
}
|
||||
interface ApiContract {
|
||||
ID: string; Numero: string; Orgao: string; Objeto: string
|
||||
Valor: number; DataInicio: string; DataFim: string; Status: string
|
||||
}
|
||||
|
||||
const { data: editais } = await useAsyncData('dash-editais', () =>
|
||||
apiFetch<ApiEdital[]>('/editais'), { server: false, default: () => [] }
|
||||
)
|
||||
const { data: documentos } = await useAsyncData('dash-docs', () =>
|
||||
apiFetch<ApiDocument[]>('/documents'), { server: false, default: () => [] }
|
||||
)
|
||||
const { data: contratos } = await useAsyncData('dash-contratos', () =>
|
||||
apiFetch<ApiContract[]>('/contracts'), { server: false, default: () => [] }
|
||||
)
|
||||
|
||||
// ---------- helpers ----------
|
||||
const hoje = new Date()
|
||||
const dataFormatada = hoje.toLocaleDateString('pt-BR', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
|
||||
const valorFormatado = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(dashboardStats.valorGanho)
|
||||
|
||||
const editaisRecentes = computed(() => [...editais].sort((a, b) => b.dataAbertura.getTime() - a.dataAbertura.getTime()).slice(0, 5))
|
||||
|
||||
const prazosUrgentes = computed(() => prazos.filter(p => p.urgencia === 'critico' || p.urgencia === 'urgente'))
|
||||
|
||||
const docsAlerta = computed(() => documentos.filter(d => d.status === 'vencendo' || d.status === 'vencida'))
|
||||
|
||||
function urgenciaCor(urgencia: string) {
|
||||
if (urgencia === 'critico') return '#ef4444'
|
||||
if (urgencia === 'urgente') return '#f59e0b'
|
||||
return '#667eea'
|
||||
const MODALIDADE: Record<string, string> = {
|
||||
pregao_eletronico: 'Pregão Eletrônico', pregao_presencial: 'Pregão Presencial',
|
||||
concorrencia: 'Concorrência', dispensa: 'Dispensa', inexigibilidade: 'Inexigibilidade',
|
||||
}
|
||||
const STATUS_CFG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
em_analise: { label: 'Mapeamento', color: '#0284c7', bg: '#eff6ff' },
|
||||
elaborando_proposta: { label: 'Termo de Referência', color: '#7c3aed', bg: '#faf5ff' },
|
||||
edital_publicado: { label: 'Edital Publicado', color: '#ea580c', bg: '#fff7ed' },
|
||||
fase_lances: { label: 'Fase de Lances', color: '#3b82f6', bg: '#eff6ff' },
|
||||
habilitacao: { label: 'Habilitação', color: '#d97706', bg: '#fef3c7' },
|
||||
recurso: { label: 'Recursos', color: '#d97706', bg: '#fffbeb' },
|
||||
adjudicado: { label: 'Adjudicado', color: '#059669', bg: '#ecfdf5' },
|
||||
contrato: { label: 'Contrato', color: '#16a34a', bg: '#f0fdf4' },
|
||||
vencida: { label: 'Vencida', color: '#16a34a', bg: '#f0fdf4' },
|
||||
perdida: { label: 'Perdida', color: '#dc2626', bg: '#fef2f2' },
|
||||
deserta: { label: 'Deserta/Fracassada', color: '#64748b', bg: '#f8fafc' },
|
||||
}
|
||||
const TIPO_DOC: Record<string, string> = {
|
||||
cnpj: 'CNPJ', contrato_social: 'Contrato Social', balanco: 'Balanço',
|
||||
certidao: 'Certidão', atestado: 'Atestado Técnico', procuracao: 'Procuração', outro: 'Outro',
|
||||
}
|
||||
|
||||
function urgenciaLabel(p: typeof prazos[0]) {
|
||||
const diff = Math.ceil((p.dataLimite.getTime() - hoje.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (diff <= 0) return 'Hoje'
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) return '—'
|
||||
const [y, m, d] = iso.split('T')[0].split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
function formatBRL(v: number): string {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(v)
|
||||
}
|
||||
function diffDays(iso: string): number {
|
||||
const hojeMs = Date.UTC(hoje.getFullYear(), hoje.getMonth(), hoje.getDate())
|
||||
const [y, m, d] = iso.split('T')[0].split('-').map(Number)
|
||||
return Math.ceil((Date.UTC(y, m - 1, d) - hojeMs) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
function diasLabel(diff: number): string {
|
||||
if (diff < 0) return `${Math.abs(diff)}d atrás`
|
||||
if (diff === 0) return 'Hoje'
|
||||
if (diff === 1) return 'Amanhã'
|
||||
return p.dataLimite.toLocaleDateString('pt-BR')
|
||||
return `em ${diff}d`
|
||||
}
|
||||
|
||||
function docStatusCor(status: string) {
|
||||
if (status === 'vencida') return '#dc2626'
|
||||
if (status === 'vencendo') return '#d97706'
|
||||
return '#16a34a'
|
||||
// ---------- stats ----------
|
||||
const totalEditais = computed(() => editais.value.length)
|
||||
const vencidas = computed(() => editais.value.filter(e => e.Status === 'vencida').length)
|
||||
const taxaVitoria = computed(() => {
|
||||
const finalizados = editais.value.filter(e => ['vencida', 'perdida', 'deserta'].includes(e.Status))
|
||||
if (finalizados.length === 0) return 0
|
||||
return Math.round((vencidas.value / finalizados.length) * 100)
|
||||
})
|
||||
const valorContratosAtivos = computed(() =>
|
||||
contratos.value.filter(c => c.Status === 'ativo').reduce((sum, c) => sum + (c.Valor ?? 0), 0)
|
||||
)
|
||||
|
||||
// Alertas = docs vencendo/vencidos + prazos editais próximos
|
||||
const alertasAtivos = computed(() => {
|
||||
let count = 0
|
||||
for (const d of documentos.value) {
|
||||
if (!d.DataVencimento) continue
|
||||
const diff = diffDays(d.DataVencimento)
|
||||
if (diff <= 30) count++
|
||||
}
|
||||
const statusAtivos = new Set(['em_analise', 'elaborando_proposta', 'edital_publicado', 'fase_lances', 'habilitacao', 'recurso'])
|
||||
for (const e of editais.value) {
|
||||
if (!statusAtivos.has(e.Status)) continue
|
||||
const diff = diffDays(e.DataAbertura)
|
||||
if (diff >= 0 && diff <= 7) count++
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
// ---------- pipeline ----------
|
||||
const statusParaEtapa: Record<string, number> = {
|
||||
em_analise: 1, elaborando_proposta: 2, edital_publicado: 3,
|
||||
fase_lances: 4, habilitacao: 5, recurso: 6,
|
||||
adjudicado: 7, contrato: 8, vencida: 8, perdida: 8, deserta: 8,
|
||||
}
|
||||
|
||||
const modalidadeLabel: Record<string, string> = {
|
||||
pregao_eletronico: 'Pregão Eletrônico',
|
||||
pregao_presencial: 'Pregão Presencial',
|
||||
concorrencia: 'Concorrência',
|
||||
dispensa: 'Dispensa',
|
||||
inexigibilidade: 'Inexigibilidade',
|
||||
const contagemPorEtapa = computed(() => {
|
||||
const counts: Record<number, number> = {}
|
||||
for (const e of editais.value) {
|
||||
const etapa = statusParaEtapa[e.Status] ?? 1
|
||||
counts[etapa] = (counts[etapa] ?? 0) + 1
|
||||
}
|
||||
return counts
|
||||
})
|
||||
|
||||
// ---------- prazos urgentes (próx 30 dias) ----------
|
||||
interface PrazoItem {
|
||||
id: string; titulo: string; sub: string; dataIso: string
|
||||
diff: number; tipo: 'edital' | 'documento' | 'contrato'; link: string
|
||||
}
|
||||
|
||||
const prazosUrgentes = computed<PrazoItem[]>(() => {
|
||||
const items: PrazoItem[] = []
|
||||
const statusAtivos = new Set(['em_analise', 'elaborando_proposta', 'edital_publicado', 'fase_lances', 'habilitacao', 'recurso'])
|
||||
for (const e of editais.value) {
|
||||
if (!statusAtivos.has(e.Status)) continue
|
||||
const d = diffDays(e.DataAbertura)
|
||||
if (d > 30) continue
|
||||
items.push({
|
||||
id: `e-${e.ID}`, titulo: `Abertura — ${e.Numero}`,
|
||||
sub: `${e.Orgao}`, dataIso: e.DataAbertura, diff: d,
|
||||
tipo: 'edital', link: `/oportunidades/${e.ID}`,
|
||||
})
|
||||
}
|
||||
for (const doc of documentos.value) {
|
||||
if (!doc.DataVencimento) continue
|
||||
const d = diffDays(doc.DataVencimento)
|
||||
if (d > 30) continue
|
||||
items.push({
|
||||
id: `d-${doc.ID}`, titulo: `Vencimento — ${doc.Nome || TIPO_DOC[doc.Tipo] || doc.Tipo}`,
|
||||
sub: TIPO_DOC[doc.Tipo] || doc.Tipo, dataIso: doc.DataVencimento, diff: d,
|
||||
tipo: 'documento', link: '/gestao/documentos',
|
||||
})
|
||||
}
|
||||
for (const c of contratos.value) {
|
||||
if (!c.DataFim || c.Status === 'encerrado') continue
|
||||
const d = diffDays(c.DataFim)
|
||||
if (d > 30) continue
|
||||
items.push({
|
||||
id: `c-${c.ID}`, titulo: `Fim vigência — ${c.Numero}`,
|
||||
sub: c.Orgao, dataIso: c.DataFim, diff: d,
|
||||
tipo: 'contrato', link: '/gestao/contratos',
|
||||
})
|
||||
}
|
||||
items.sort((a, b) => a.diff - b.diff)
|
||||
return items.slice(0, 6)
|
||||
})
|
||||
|
||||
function urgenciaCor(diff: number): string {
|
||||
if (diff < 0) return '#dc2626'
|
||||
if (diff === 0) return '#dc2626'
|
||||
if (diff <= 7) return '#ea580c'
|
||||
return '#d97706'
|
||||
}
|
||||
|
||||
// ---------- docs com alerta ----------
|
||||
const docsAlerta = computed(() => {
|
||||
return documentos.value
|
||||
.filter(d => {
|
||||
if (!d.DataVencimento) return false
|
||||
return diffDays(d.DataVencimento) <= 30
|
||||
})
|
||||
.sort((a, b) => diffDays(a.DataVencimento!) - diffDays(b.DataVencimento!))
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
function docStatus(d: ApiDocument) {
|
||||
if (!d.DataVencimento) return { label: 'OK', color: '#64748b', bg: '#f8fafc' }
|
||||
const diff = diffDays(d.DataVencimento)
|
||||
if (diff < 0) return { label: 'Vencida', color: '#dc2626', bg: '#fef2f2' }
|
||||
if (diff <= 30) return { label: 'Vencendo', color: '#d97706', bg: '#fffbeb' }
|
||||
return { label: 'Válida', color: '#16a34a', bg: '#f0fdf4' }
|
||||
}
|
||||
|
||||
// ---------- editais recentes ----------
|
||||
const editaisRecentes = computed(() =>
|
||||
[...editais.value]
|
||||
.sort((a, b) => new Date(b.DataAbertura).getTime() - new Date(a.DataAbertura).getTime())
|
||||
.slice(0, 5)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Dashboard" :breadcrumb="`Visão geral · ${dataFormatada}`">
|
||||
<template #actions>
|
||||
<UButton variant="outline" color="neutral" size="sm">Importar Edital</UButton>
|
||||
<UButton size="sm" class="btn-primary">+ Nova Oportunidade</UButton>
|
||||
<NuxtLink to="/oportunidades" class="btn-outline-sm">Ver Oportunidades</NuxtLink>
|
||||
</template>
|
||||
</AppTopbar>
|
||||
|
||||
<div class="content">
|
||||
<!-- Stats -->
|
||||
<div class="stats-grid">
|
||||
<StatCard label="Total de Editais" :value="dashboardStats.totalEditais" sub="Este ano" />
|
||||
<StatCard label="Taxa de Vitória" :value="`${dashboardStats.taxaVitoria}%`" sub="Processos ganhos" color="#10b981" />
|
||||
<StatCard label="Valor Ganho" :value="valorFormatado" sub="Em contratos ativos" color="#667eea" />
|
||||
<StatCard label="Alertas Ativos" :value="dashboardStats.alertasAtivos" sub="Requerem atenção" color="#f59e0b" />
|
||||
<div class="stat-card">
|
||||
<p class="stat-label">Total de Editais</p>
|
||||
<p class="stat-value">{{ totalEditais }}</p>
|
||||
<p class="stat-sub">Cadastrados</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<p class="stat-label">Taxa de Vitória</p>
|
||||
<p class="stat-value" style="color: #10b981">{{ taxaVitoria }}%</p>
|
||||
<p class="stat-sub">Processos finalizados</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<p class="stat-label">Contratos Ativos</p>
|
||||
<p class="stat-value" style="color: #667eea">{{ formatBRL(valorContratosAtivos) }}</p>
|
||||
<p class="stat-sub">{{ contratos.filter(c => c.Status === 'ativo').length }} contratos</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<p class="stat-label">Alertas Ativos</p>
|
||||
<p class="stat-value" :style="{ color: alertasAtivos > 0 ? '#f59e0b' : '#94a3b8' }">{{ alertasAtivos }}</p>
|
||||
<p class="stat-sub">Requerem atenção</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline -->
|
||||
@@ -68,8 +235,8 @@ const modalidadeLabel: Record<string, string> = {
|
||||
<h3>Pipeline de Oportunidades</h3>
|
||||
<NuxtLink to="/pipeline" class="card-link">Ver kanban →</NuxtLink>
|
||||
</div>
|
||||
<div class="px-4">
|
||||
<PipelineBar :contagem-por-etapa="dashboardStats.editalsPorEtapaPipeline" />
|
||||
<div class="pipeline-wrap">
|
||||
<PipelineBar :contagem-por-etapa="contagemPorEtapa" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -81,33 +248,49 @@ const modalidadeLabel: Record<string, string> = {
|
||||
<h3>Alertas de Prazo</h3>
|
||||
<NuxtLink to="/gestao/prazos" class="card-link">Ver todos →</NuxtLink>
|
||||
</div>
|
||||
<div v-for="p in prazosUrgentes" :key="p.id" class="alert-item">
|
||||
<div class="alert-dot" :style="{ background: urgenciaCor(p.urgencia) }" />
|
||||
<div v-if="prazosUrgentes.length === 0" class="empty-section">
|
||||
<p>Nenhum prazo urgente nos próximos 30 dias</p>
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-for="p in prazosUrgentes" :key="p.id"
|
||||
:to="p.link"
|
||||
class="alert-item"
|
||||
>
|
||||
<div class="alert-dot" :style="{ background: urgenciaCor(p.diff) }" />
|
||||
<div class="alert-text">
|
||||
<p class="at">{{ p.titulo }}</p>
|
||||
<p class="as">{{ p.descricao }}</p>
|
||||
<p class="as">{{ p.sub }}</p>
|
||||
</div>
|
||||
<span class="alert-date" :style="{ color: urgenciaCor(p.urgencia) }">{{ urgenciaLabel(p) }}</span>
|
||||
</div>
|
||||
<div class="alert-right">
|
||||
<span class="alert-date" :style="{ color: urgenciaCor(p.diff) }">{{ diasLabel(p.diff) }}</span>
|
||||
<span class="alert-date-full">{{ formatDate(p.dataIso) }}</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Documentos -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Documentos da Empresa</h3>
|
||||
<h3>Documentos com Alerta</h3>
|
||||
<NuxtLink to="/gestao/documentos" class="card-link">Gerenciar →</NuxtLink>
|
||||
</div>
|
||||
<div v-for="d in docsAlerta" :key="d.id" class="doc-item">
|
||||
<div>
|
||||
<p class="doc-nome">{{ d.nome }}</p>
|
||||
<p class="doc-sub">
|
||||
{{ d.status === 'vencida' ? 'Vencida' : 'Vencendo em breve' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="doc-badge" :style="{ color: docStatusCor(d.status), background: docStatusCor(d.status) + '18' }">
|
||||
{{ d.status === 'vencida' ? 'Vencida' : 'Vencendo' }}
|
||||
</span>
|
||||
<div v-if="docsAlerta.length === 0" class="empty-section">
|
||||
<p>Todos os documentos estão em dia</p>
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-for="d in docsAlerta" :key="d.ID"
|
||||
to="/gestao/documentos"
|
||||
class="doc-item"
|
||||
>
|
||||
<div>
|
||||
<p class="doc-nome">{{ d.Nome || TIPO_DOC[d.Tipo] || d.Tipo }}</p>
|
||||
<p class="doc-sub">{{ TIPO_DOC[d.Tipo] || d.Tipo }} · {{ formatDate(d.DataVencimento!) }}</p>
|
||||
</div>
|
||||
<span
|
||||
class="doc-badge"
|
||||
:style="{ color: docStatus(d).color, background: docStatus(d).bg }"
|
||||
>{{ docStatus(d).label }}</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -117,57 +300,134 @@ const modalidadeLabel: Record<string, string> = {
|
||||
<h3>Editais Recentes</h3>
|
||||
<NuxtLink to="/oportunidades" class="card-link">Ver todos →</NuxtLink>
|
||||
</div>
|
||||
<UTable
|
||||
:data="editaisRecentes"
|
||||
:columns="[
|
||||
{ id: 'numero', accessorKey: 'numero', header: 'Nº Edital' },
|
||||
{ id: 'objeto', accessorKey: 'objeto', header: 'Objeto' },
|
||||
{ id: 'orgao', accessorKey: 'orgao', header: 'Órgão' },
|
||||
{ id: 'modalidade', accessorKey: 'modalidade', header: 'Modalidade' },
|
||||
{ id: 'valorEstimado', accessorKey: 'valorEstimado', header: 'Valor Est.' },
|
||||
{ id: 'status', accessorKey: 'status', header: 'Status' },
|
||||
{ id: 'dataAbertura', accessorKey: 'dataAbertura', header: 'Abertura' },
|
||||
]"
|
||||
>
|
||||
<template #modalidade-cell="{ row }">
|
||||
{{ modalidadeLabel[row.original.modalidade] }}
|
||||
</template>
|
||||
<template #valorEstimado-cell="{ row }">
|
||||
{{ new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(row.original.valorEstimado) }}
|
||||
</template>
|
||||
<template #status-cell="{ row }">
|
||||
<StatusChip :status="row.original.status" />
|
||||
</template>
|
||||
<template #dataAbertura-cell="{ row }">
|
||||
{{ row.original.dataAbertura.toLocaleDateString('pt-BR') }}
|
||||
</template>
|
||||
</UTable>
|
||||
<div v-if="editaisRecentes.length === 0" class="empty-section">
|
||||
<p>Nenhum edital cadastrado ainda</p>
|
||||
</div>
|
||||
<table v-else class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nº Edital</th>
|
||||
<th>Órgão</th>
|
||||
<th>Objeto</th>
|
||||
<th>Modalidade</th>
|
||||
<th>Valor Est.</th>
|
||||
<th>Abertura</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="e in editaisRecentes" :key="e.ID"
|
||||
class="row-click"
|
||||
@click="navigateTo(`/oportunidades/${e.ID}`)"
|
||||
>
|
||||
<td class="td-num">{{ e.Numero }}</td>
|
||||
<td>{{ e.Orgao }}</td>
|
||||
<td class="td-obj">{{ e.Objeto }}</td>
|
||||
<td>{{ MODALIDADE[e.Modalidade] ?? e.Modalidade }}</td>
|
||||
<td class="td-val">{{ formatBRL(e.ValorEstimado) }}</td>
|
||||
<td>{{ formatDate(e.DataAbertura) }}</td>
|
||||
<td>
|
||||
<span
|
||||
v-if="STATUS_CFG[e.Status]"
|
||||
class="badge"
|
||||
:style="{ background: STATUS_CFG[e.Status].bg, color: STATUS_CFG[e.Status].color }"
|
||||
>{{ STATUS_CFG[e.Status].label }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { display: flex; flex-direction: column; height: 100vh; }
|
||||
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 16px; }
|
||||
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
|
||||
.mb-4 { margin-bottom: 14px; }
|
||||
.card-header { padding: 14px 18px 10px; border-bottom: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: space-between; }
|
||||
.card-header h3 { font-size: 13px; font-weight: 700; color: #0f172a; }
|
||||
.card-link { font-size: 11px; color: #667eea; text-decoration: none; font-weight: 500; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.alert-item { display: flex; align-items: center; gap: 10px; padding: 10px 18px; border-bottom: 1px solid #f8fafc; }
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300..700&family=JetBrains+Mono:wght@400;600&display=swap');
|
||||
|
||||
.page { display: flex; flex-direction: column; height: 100vh; font-family: 'DM Sans', system-ui, sans-serif; }
|
||||
.content { padding: 24px 28px 40px; flex: 1; overflow-y: auto; }
|
||||
|
||||
.btn-outline-sm {
|
||||
padding: 6px 14px; border-radius: 7px; border: 1px solid #e2e8f0;
|
||||
background: white; font-size: 12px; font-weight: 600; color: #475569;
|
||||
text-decoration: none; transition: all .15s;
|
||||
}
|
||||
.btn-outline-sm:hover { background: #f8fafc; border-color: #cbd5e1; }
|
||||
|
||||
/* ── Stats ── */
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 18px; }
|
||||
.stat-card {
|
||||
background: white; border-radius: 12px; padding: 18px 20px;
|
||||
border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
||||
}
|
||||
.stat-label { font-size: 11px; color: #94a3b8; text-transform: uppercase; letter-spacing: .5px; font-weight: 700; margin-bottom: 8px; }
|
||||
.stat-value { font-size: 28px; font-weight: 800; color: #0f172a; line-height: 1; font-family: 'JetBrains Mono', monospace; }
|
||||
.stat-sub { font-size: 11px; color: #64748b; margin-top: 6px; }
|
||||
|
||||
/* ── Cards ── */
|
||||
.card { background: white; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
|
||||
.mb-4 { margin-bottom: 18px; }
|
||||
.card-header {
|
||||
padding: 16px 20px 12px; border-bottom: 1px solid #f1f5f9;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.card-header h3 { font-size: 14px; font-weight: 700; color: #0f172a; margin: 0; }
|
||||
.card-link { font-size: 12px; color: #667eea; text-decoration: none; font-weight: 600; }
|
||||
.card-link:hover { text-decoration: underline; }
|
||||
.pipeline-wrap { padding: 0 20px; }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
|
||||
.empty-section { padding: 28px 20px; text-align: center; }
|
||||
.empty-section p { font-size: 13px; color: #94a3b8; margin: 0; }
|
||||
|
||||
/* ── Alertas ── */
|
||||
.alert-item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 20px; border-bottom: 1px solid #f5f7fa;
|
||||
text-decoration: none; transition: background .12s;
|
||||
}
|
||||
.alert-item:last-child { border-bottom: none; }
|
||||
.alert-item:hover { background: #fafbfd; }
|
||||
.alert-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.at { font-size: 12px; font-weight: 600; color: #0f172a; }
|
||||
.as { font-size: 11px; color: #94a3b8; }
|
||||
.alert-date { font-size: 11px; font-weight: 600; flex-shrink: 0; }
|
||||
.doc-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 18px; border-bottom: 1px solid #f8fafc; }
|
||||
.alert-text { flex: 1; min-width: 0; }
|
||||
.at { font-size: 13px; font-weight: 600; color: #0f172a; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.as { font-size: 11.5px; color: #94a3b8; margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.alert-right { text-align: right; flex-shrink: 0; }
|
||||
.alert-date { font-size: 12px; font-weight: 700; display: block; }
|
||||
.alert-date-full { font-size: 10.5px; color: #94a3b8; display: block; margin-top: 1px; }
|
||||
|
||||
/* ── Docs ── */
|
||||
.doc-item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 20px; border-bottom: 1px solid #f5f7fa;
|
||||
text-decoration: none; transition: background .12s;
|
||||
}
|
||||
.doc-item:last-child { border-bottom: none; }
|
||||
.doc-nome { font-size: 12px; font-weight: 500; color: #0f172a; }
|
||||
.doc-sub { font-size: 10.5px; color: #94a3b8; }
|
||||
.doc-badge { font-size: 10.5px; font-weight: 600; padding: 2px 8px; border-radius: 20px; }
|
||||
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2) !important; }
|
||||
.px-4 { padding: 0 18px; }
|
||||
.doc-item:hover { background: #fafbfd; }
|
||||
.doc-nome { font-size: 13px; font-weight: 600; color: #0f172a; }
|
||||
.doc-sub { font-size: 11.5px; color: #94a3b8; margin-top: 1px; }
|
||||
.doc-badge { font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 20px; flex-shrink: 0; }
|
||||
|
||||
/* ── Table ── */
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.tbl thead tr { border-bottom: 1px solid #e2e8f0; background: #f8fafc; }
|
||||
.tbl th {
|
||||
padding: 10px 16px; text-align: left; font-weight: 700; color: #64748b;
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: .4px; white-space: nowrap;
|
||||
}
|
||||
.tbl td { padding: 12px 16px; color: #1e293b; border-bottom: 1px solid #f5f7fa; vertical-align: middle; }
|
||||
.tbl tbody tr:last-child td { border-bottom: none; }
|
||||
.row-click { cursor: pointer; transition: background .12s; }
|
||||
.row-click:hover td { background: #fafbfd; }
|
||||
.td-num { font-weight: 700; white-space: nowrap; }
|
||||
.td-obj { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.td-val { font-family: 'JetBrains Mono', monospace; font-size: 12px; white-space: nowrap; }
|
||||
.badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 700; }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 900px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid-2 { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,23 +1,50 @@
|
||||
<!-- front-end/app/pages/login.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { TenantOption } from '~/composables/useAuth'
|
||||
|
||||
definePageMeta({ layout: 'auth' })
|
||||
|
||||
const { login } = useAuth()
|
||||
const { login, selectTenant } = useAuth()
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const slug = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
// Estado do modal multi-tenant
|
||||
const tenants = ref<TenantOption[]>([])
|
||||
const pendingEmail = ref('')
|
||||
const pendingPassword = ref('')
|
||||
const showTenantModal = ref(false)
|
||||
const tenantError = ref('')
|
||||
const tenantLoading = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
const result = await login(email.value, password.value, slug.value)
|
||||
const result = await login(email.value, password.value)
|
||||
loading.value = false
|
||||
|
||||
if ('success' in result && result.success) {
|
||||
navigateTo('/')
|
||||
} else if ('needsTenantSelect' in result) {
|
||||
tenants.value = result.tenants
|
||||
pendingEmail.value = result.email
|
||||
pendingPassword.value = result.password
|
||||
showTenantModal.value = true
|
||||
} else if ('error' in result) {
|
||||
error.value = result.error ?? 'Erro ao autenticar.'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectTenant(tenantId: string) {
|
||||
tenantError.value = ''
|
||||
tenantLoading.value = true
|
||||
const result = await selectTenant(pendingEmail.value, pendingPassword.value, tenantId)
|
||||
tenantLoading.value = false
|
||||
if (result.success) {
|
||||
navigateTo('/')
|
||||
} else {
|
||||
error.value = result.error ?? 'Erro ao autenticar.'
|
||||
tenantError.value = result.error ?? 'Erro ao selecionar empresa.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -52,17 +79,6 @@ async function handleSubmit() {
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Organização" class="field">
|
||||
<UInput
|
||||
v-model="slug"
|
||||
type="text"
|
||||
placeholder="minha-empresa"
|
||||
size="md"
|
||||
class="w-full"
|
||||
required
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="forgot">
|
||||
<a href="#">Esqueceu a senha?</a>
|
||||
</div>
|
||||
@@ -84,6 +100,29 @@ async function handleSubmit() {
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Modal seleção de empresa -->
|
||||
<div v-if="showTenantModal" class="modal-overlay">
|
||||
<div class="modal-card">
|
||||
<h2>Selecione a empresa</h2>
|
||||
<p>Seu acesso está vinculado a mais de uma empresa.</p>
|
||||
|
||||
<div class="tenant-list">
|
||||
<button
|
||||
v-for="t in tenants"
|
||||
:key="t.id"
|
||||
class="tenant-btn"
|
||||
:disabled="tenantLoading"
|
||||
@click="handleSelectTenant(t.id)"
|
||||
>
|
||||
<span class="tenant-name">{{ t.name }}</span>
|
||||
<span class="tenant-slug">{{ t.slug }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="tenantError" class="error-msg">{{ tenantError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -108,4 +147,30 @@ async function handleSubmit() {
|
||||
}
|
||||
.register-link { text-align: center; font-size: 13px; color: #64748b; margin-top: 16px; }
|
||||
.register-link a { color: #667eea; font-weight: 600; text-decoration: none; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal-card {
|
||||
background: white; border-radius: 16px; padding: 32px;
|
||||
width: 100%; max-width: 380px;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.3);
|
||||
}
|
||||
.modal-card h2 { font-size: 18px; font-weight: 700; color: #0f172a; margin-bottom: 6px; }
|
||||
.modal-card p { font-size: 13px; color: #64748b; margin-bottom: 20px; }
|
||||
.tenant-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.tenant-btn {
|
||||
display: flex; flex-direction: column; align-items: flex-start;
|
||||
padding: 14px 16px; border-radius: 10px;
|
||||
border: 1.5px solid #e2e8f0; background: white;
|
||||
cursor: pointer; transition: all 0.15s; text-align: left;
|
||||
}
|
||||
.tenant-btn:hover { border-color: #667eea; background: #f0f3ff; }
|
||||
.tenant-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.tenant-name { font-size: 14px; font-weight: 600; color: #0f172a; }
|
||||
.tenant-slug { font-size: 12px; color: #94a3b8; margin-top: 2px; }
|
||||
</style>
|
||||
|
||||
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">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
const filtrados = computed(() => editais.filter(e => e.status === 'elaborando_proposta'))
|
||||
const { apiFetch } = useApi()
|
||||
interface ApiEdital { ID: string; Numero: string; Orgao: string; Modalidade: string; Objeto: string; Plataforma: string; ValorEstimado: number; DataPublicacao: string; DataAbertura: string; Status: string }
|
||||
const { data: todos } = await useAsyncData('editais-elaborando', () => apiFetch<ApiEdital[]>('/editais'))
|
||||
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'elaborando_proposta'))
|
||||
</script>
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Elaborando Proposta" breadcrumb="Oportunidades · Elaborando Proposta" />
|
||||
<div class="content"><div class="card"><EditaisTable :editais="filtrados" /></div></div>
|
||||
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
const filtrados = computed(() => editais.filter(e => e.status === 'em_analise'))
|
||||
const { apiFetch } = useApi()
|
||||
interface ApiEdital { ID: string; Numero: string; Orgao: string; Modalidade: string; Objeto: string; Plataforma: string; ValorEstimado: number; DataPublicacao: string; DataAbertura: string; Status: string }
|
||||
const { data: todos } = await useAsyncData('editais-em-analise', () => apiFetch<ApiEdital[]>('/editais'))
|
||||
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'em_analise'))
|
||||
</script>
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Em Análise" breadcrumb="Oportunidades · Em Análise" />
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<EditaisTable :editais="filtrados" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
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">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
const { apiFetch } = useApi()
|
||||
const { public: { apiBase } } = useRuntimeConfig()
|
||||
const token = useCookie<string | null>('auth_token')
|
||||
|
||||
interface ApiEdital {
|
||||
ID: string
|
||||
Numero: string
|
||||
Orgao: string
|
||||
Modalidade: string
|
||||
Objeto: string
|
||||
Plataforma: string
|
||||
ValorEstimado: number
|
||||
DataPublicacao: string
|
||||
DataAbertura: string
|
||||
Status: string
|
||||
}
|
||||
|
||||
interface ApiOrgao {
|
||||
ID: string
|
||||
Nome: string
|
||||
Esfera: string
|
||||
Estado: string
|
||||
}
|
||||
|
||||
const { data: editais, refresh } = await useAsyncData('editais', () =>
|
||||
apiFetch<ApiEdital[]>('/editais')
|
||||
)
|
||||
|
||||
const { data: orgaos } = await useAsyncData('orgaos-oportunidades', () =>
|
||||
apiFetch<ApiOrgao[]>('/organs'), { server: false }
|
||||
)
|
||||
|
||||
// ---------- labels ----------
|
||||
const MODALIDADE_LABEL: Record<string, string> = {
|
||||
pregao_eletronico: 'Pregão Eletrônico',
|
||||
pregao_presencial: 'Pregão Presencial',
|
||||
concorrencia: 'Concorrência',
|
||||
dispensa: 'Dispensa',
|
||||
inexigibilidade: 'Inexigibilidade',
|
||||
}
|
||||
|
||||
const STATUS_CFG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
em_analise: { label: 'Mapeamento', color: '#0284c7', bg: '#eff6ff' },
|
||||
elaborando_proposta: { label: 'Termo de Referência', color: '#7c3aed', bg: '#faf5ff' },
|
||||
edital_publicado: { label: 'Edital Publicado', color: '#ea580c', bg: '#fff7ed' },
|
||||
fase_lances: { label: 'Fase de Lances', color: '#3b82f6', bg: '#eff6ff' },
|
||||
habilitacao: { label: 'Habilitação', color: '#d97706', bg: '#fef3c7' },
|
||||
recurso: { label: 'Recursos', color: '#d97706', bg: '#fffbeb' },
|
||||
adjudicado: { label: 'Adjudicado', color: '#059669', bg: '#ecfdf5' },
|
||||
contrato: { label: 'Contrato', color: '#16a34a', bg: '#f0fdf4' },
|
||||
vencida: { label: 'Vencida', color: '#16a34a', bg: '#f0fdf4' },
|
||||
perdida: { label: 'Perdida', color: '#dc2626', bg: '#fef2f2' },
|
||||
deserta: { label: 'Deserta/Fracassada', color: '#64748b', bg: '#f8fafc' },
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const [y, m, d] = iso.split('T')[0].split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
|
||||
function formatBRL(value: number): string {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }).format(value)
|
||||
}
|
||||
|
||||
|
||||
// ---------- menu ⋯ ----------
|
||||
const menuAberto = ref<string | null>(null)
|
||||
const menuPos = ref({ top: 0, left: 0 })
|
||||
|
||||
function abrirMenu(id: string, event: MouseEvent) {
|
||||
const btn = event.currentTarget as HTMLElement
|
||||
const rect = btn.getBoundingClientRect()
|
||||
menuPos.value = { top: rect.bottom + 4, left: rect.left - 130 }
|
||||
menuAberto.value = menuAberto.value === id ? null : id
|
||||
}
|
||||
function fecharMenu() { menuAberto.value = null }
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function abrirVisualizar(edital: ApiEdital) {
|
||||
fecharMenu()
|
||||
router.push(`/oportunidades/${edital.ID}`)
|
||||
}
|
||||
|
||||
|
||||
// ---------- modal criar / editar ----------
|
||||
const showModal = ref(false)
|
||||
const editando = ref<ApiEdital | null>(null)
|
||||
const saving = ref(false)
|
||||
const erroModal = ref('')
|
||||
|
||||
const STATUS_OPTIONS = Object.entries(STATUS_CFG).map(([value, cfg]) => ({ value, label: cfg.label }))
|
||||
const MODALIDADE_OPTIONS = Object.entries(MODALIDADE_LABEL).map(([value, label]) => ({ value, label }))
|
||||
|
||||
const form = ref({
|
||||
Numero: '',
|
||||
Orgao: '',
|
||||
Modalidade: 'pregao_eletronico',
|
||||
Objeto: '',
|
||||
Plataforma: '',
|
||||
ValorEstimado: 0,
|
||||
DataPublicacao: '',
|
||||
DataAbertura: '',
|
||||
Status: 'em_analise',
|
||||
})
|
||||
|
||||
function abrirCriar() {
|
||||
editando.value = null
|
||||
erroModal.value = ''
|
||||
importedFile.value = null
|
||||
importedFileName.value = ''
|
||||
importError.value = ''
|
||||
form.value = {
|
||||
Numero: '', Orgao: '', Modalidade: 'pregao_eletronico',
|
||||
Objeto: '', Plataforma: '', ValorEstimado: 0,
|
||||
DataPublicacao: '', DataAbertura: '', Status: 'em_analise',
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function abrirEditar(edital: ApiEdital) {
|
||||
fecharMenu()
|
||||
editando.value = edital
|
||||
erroModal.value = ''
|
||||
form.value = {
|
||||
Numero: edital.Numero,
|
||||
Orgao: edital.Orgao,
|
||||
Modalidade: edital.Modalidade,
|
||||
Objeto: edital.Objeto,
|
||||
Plataforma: edital.Plataforma,
|
||||
ValorEstimado: edital.ValorEstimado,
|
||||
DataPublicacao: edital.DataPublicacao.split('T')[0],
|
||||
DataAbertura: edital.DataAbertura.split('T')[0],
|
||||
Status: edital.Status,
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function salvar() {
|
||||
erroModal.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
let editalId: string | null = null
|
||||
|
||||
if (editando.value) {
|
||||
await apiFetch(`/editais/${editando.value.ID}`, { method: 'PUT', body: form.value })
|
||||
editalId = editando.value.ID
|
||||
} else {
|
||||
const created = await apiFetch<ApiEdital>('/editais', { method: 'POST', body: form.value })
|
||||
editalId = created?.ID ?? null
|
||||
}
|
||||
|
||||
// If this was a partial import, attach the PDF file to the created edital
|
||||
if (importedFile.value && editalId) {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', importedFile.value)
|
||||
await $fetch(`${apiBase}/editais/${editalId}/files`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token.value}` },
|
||||
body: formData,
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal: edital was created, file attach failed silently
|
||||
}
|
||||
importedFile.value = null
|
||||
importedFileName.value = ''
|
||||
}
|
||||
|
||||
showModal.value = false
|
||||
importError.value = ''
|
||||
await refresh()
|
||||
} catch {
|
||||
erroModal.value = 'Erro ao salvar edital.'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- importar PDF ----------
|
||||
const importLoading = ref(false)
|
||||
const importError = ref('')
|
||||
const importedFile = ref<File | null>(null)
|
||||
const importedFileName = ref('')
|
||||
|
||||
async function importarEdital(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (!input.files?.length) return
|
||||
|
||||
const file = input.files[0]
|
||||
if (file.type !== 'application/pdf') {
|
||||
importError.value = 'Selecione um arquivo PDF.'
|
||||
return
|
||||
}
|
||||
|
||||
importLoading.value = true
|
||||
importError.value = ''
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await apiFetch<any>('/editais/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
// If backend returned partial data, open modal for manual completion
|
||||
if (res?.partial) {
|
||||
importedFile.value = file
|
||||
importedFileName.value = res.file_name || file.name
|
||||
editando.value = null
|
||||
erroModal.value = ''
|
||||
form.value = {
|
||||
Numero: res.numero || '',
|
||||
Orgao: res.orgao || '',
|
||||
Modalidade: res.modalidade || 'pregao_eletronico',
|
||||
Objeto: res.objeto || '',
|
||||
Plataforma: res.plataforma || '',
|
||||
ValorEstimado: res.valor_estimado || 0,
|
||||
DataPublicacao: res.data_publicacao || '',
|
||||
DataAbertura: res.data_abertura || '',
|
||||
Status: res.status || 'em_analise',
|
||||
}
|
||||
showModal.value = true
|
||||
const tipoLabel = res.tipo_documento === 'edital' ? 'Edital' : res.tipo_documento === 'termo_referencia' ? 'Termo de Referência' : 'Documento'
|
||||
importError.value = `⚠️ ${tipoLabel} detectado. Alguns campos precisam de revisão. Confira e complete manualmente.`
|
||||
return
|
||||
}
|
||||
|
||||
await refresh()
|
||||
} catch (err: any) {
|
||||
importError.value = err?.data?.error || 'Erro ao importar edital do PDF.'
|
||||
} finally {
|
||||
importLoading.value = false
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- excluir ----------
|
||||
const showDeleteConfirm = ref(false)
|
||||
const deleteTarget = ref<ApiEdital | null>(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
function confirmarExclusao(edital: ApiEdital) {
|
||||
fecharMenu()
|
||||
deleteTarget.value = edital
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
function cancelarExclusao() {
|
||||
showDeleteConfirm.value = false
|
||||
deleteTarget.value = null
|
||||
}
|
||||
|
||||
async function executarExclusao() {
|
||||
if (!deleteTarget.value) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await apiFetch(`/editais/${deleteTarget.value.ID}`, { method: 'DELETE' })
|
||||
showDeleteConfirm.value = false
|
||||
deleteTarget.value = null
|
||||
await refresh()
|
||||
} catch {
|
||||
// keep modal open on error
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Todos os Editais" breadcrumb="Oportunidades · Todos">
|
||||
<template #actions>
|
||||
<UButton size="sm" class="btn-primary">+ Nova Oportunidade</UButton>
|
||||
<label class="btn-import" :class="{ disabled: importLoading }">
|
||||
<input type="file" accept=".pdf" style="display:none" @change="importarEdital" />
|
||||
{{ importLoading ? '⏳ Importando…' : '📄 Importar Edital' }}
|
||||
</label>
|
||||
<button class="btn-primary" @click="abrirCriar">+ Nova Oportunidade</button>
|
||||
</template>
|
||||
</AppTopbar>
|
||||
|
||||
<div class="content">
|
||||
<p v-if="importError" class="import-error">{{ importError }}</p>
|
||||
<div class="card">
|
||||
<EditaisTable :editais="editais" />
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>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>
|
||||
|
||||
<!-- 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>
|
||||
</template>
|
||||
|
||||
@@ -21,5 +453,119 @@ import { editais } from '~/data/mock/editais'
|
||||
.page { display: flex; flex-direction: column; height: 100vh; }
|
||||
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
|
||||
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2) !important; }
|
||||
|
||||
.btn-import {
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
color: white; border: none; border-radius: 7px;
|
||||
padding: 7px 16px; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.btn-import:hover { opacity: 0.9; }
|
||||
.btn-import.disabled { opacity: 0.6; pointer-events: none; }
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white; border: none; border-radius: 7px;
|
||||
padding: 7px 16px; font-size: 13px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.9; }
|
||||
|
||||
.import-error {
|
||||
background: #fef2f2; color: #dc2626; border: 1px solid #fecaca;
|
||||
border-radius: 8px; padding: 10px 16px; font-size: 13px; margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* table */
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.tbl thead tr { border-bottom: 1px solid #e2e8f0; background: #f8fafc; }
|
||||
.tbl th { padding: 10px 14px; text-align: left; font-weight: 600; color: #64748b; font-size: 12px; text-transform: uppercase; letter-spacing: .4px; white-space: nowrap; }
|
||||
.tbl td { padding: 11px 14px; color: #1e293b; border-bottom: 1px solid #f1f5f9; vertical-align: middle; }
|
||||
.tbl tbody tr:last-child td { border-bottom: none; }
|
||||
.tbl tbody tr:hover td { background: #f8fafc; }
|
||||
.numero { font-weight: 600; white-space: nowrap; }
|
||||
.numero.link { color: #667eea; cursor: pointer; }
|
||||
.numero.link:hover { color: #764ba2; text-decoration: underline; }
|
||||
.objeto { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.empty { text-align: center; color: #94a3b8; padding: 40px; }
|
||||
.actions-cell { text-align: right; white-space: nowrap; }
|
||||
.btn-menu { background: none; border: none; cursor: pointer; font-size: 18px; color: #94a3b8; padding: 2px 6px; border-radius: 4px; }
|
||||
.btn-menu:hover { background: #f1f5f9; color: #475569; }
|
||||
.badge { display: inline-block; padding: 3px 9px; border-radius: 20px; font-size: 11.5px; font-weight: 600; }
|
||||
|
||||
/* dropdown */
|
||||
.overlay-menu { position: fixed; inset: 0; z-index: 999; }
|
||||
.dropdown { position: fixed; z-index: 1000; background: white; border: 1px solid #e2e8f0; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,.12); min-width: 140px; overflow: hidden; }
|
||||
.dropdown button { display: block; width: 100%; padding: 9px 14px; text-align: left; background: none; border: none; cursor: pointer; font-size: 13px; color: #334155; }
|
||||
.dropdown button:hover { background: #f8fafc; }
|
||||
.drop-danger { color: #dc2626 !important; }
|
||||
|
||||
/* modal */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.35); z-index: 1000; display: flex; align-items: center; justify-content: center; }
|
||||
.modal { background: white; border-radius: 12px; width: 600px; max-width: 95vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 40px rgba(0,0,0,.2); }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid #e2e8f0; }
|
||||
.modal-header h3 { margin: 0; font-size: 15px; font-weight: 700; color: #1e293b; }
|
||||
.close-btn { background: none; border: none; cursor: pointer; font-size: 16px; color: #64748b; }
|
||||
.modal-body { padding: 20px 22px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 22px; border-top: 1px solid #f1f5f9; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.field label { font-size: 12px; font-weight: 600; color: #475569; }
|
||||
.inp { border: 1px solid #e2e8f0; border-radius: 7px; padding: 8px 10px; font-size: 13px; color: #1e293b; width: 100%; box-sizing: border-box; }
|
||||
.inp:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,.1); }
|
||||
.btn-cancel { background: #f1f5f9; color: #475569; border: none; border-radius: 7px; padding: 8px 18px; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||
.btn-save { background: linear-gradient(135deg, #667eea, #764ba2); color: white; border: none; border-radius: 7px; padding: 8px 18px; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||
.btn-save:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.erro-msg { color: #dc2626; font-size: 12px; margin: 0; }
|
||||
|
||||
/* delete confirm modal */
|
||||
.modal-delete {
|
||||
background: white; border-radius: 16px;
|
||||
width: 400px; max-width: 90vw;
|
||||
padding: 32px 28px 24px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.25);
|
||||
text-align: center;
|
||||
animation: modalPopIn .2s ease;
|
||||
}
|
||||
@keyframes modalPopIn {
|
||||
from { opacity: 0; transform: scale(.92); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.modal-delete-icon {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: #fef2f2; color: #dc2626;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
.modal-delete-title {
|
||||
font-size: 16px; font-weight: 700; color: #0f172a;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.modal-delete-text {
|
||||
font-size: 13.5px; color: #64748b; line-height: 1.6;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
.modal-delete-text strong { color: #1e293b; }
|
||||
.modal-delete-actions {
|
||||
display: flex; gap: 10px; justify-content: center;
|
||||
}
|
||||
.btn-delete {
|
||||
background: #dc2626; color: white;
|
||||
border: none; border-radius: 9px;
|
||||
padding: 9px 22px; font-size: 13px; font-weight: 700;
|
||||
cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.btn-delete:hover { background: #b91c1c; }
|
||||
.btn-delete:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
/* import notice */
|
||||
.import-notice {
|
||||
display: flex; align-items: flex-start; gap: 12px;
|
||||
padding: 14px 16px; border-radius: 10px;
|
||||
background: #eff6ff; border: 1px solid #bfdbfe;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.import-notice-icon { font-size: 24px; flex-shrink: 0; margin-top: 2px; }
|
||||
.import-notice strong { font-size: 13px; color: #1e40af; }
|
||||
.import-notice p { font-size: 12px; color: #3b82f6; margin: 4px 0 0; line-height: 1.4; }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
const filtrados = computed(() => editais.filter(e => e.status === 'participando'))
|
||||
const { apiFetch } = useApi()
|
||||
interface ApiEdital { ID: string; Numero: string; Orgao: string; Modalidade: string; Objeto: string; Plataforma: string; ValorEstimado: number; DataPublicacao: string; DataAbertura: string; Status: string }
|
||||
const { data: todos } = await useAsyncData('editais-participando', () => apiFetch<ApiEdital[]>('/editais'))
|
||||
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'participando'))
|
||||
</script>
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Participando" breadcrumb="Oportunidades · Participando" />
|
||||
<div class="content"><div class="card"><EditaisTable :editais="filtrados" /></div></div>
|
||||
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
const filtrados = computed(() => editais.filter(e => e.status === 'perdida'))
|
||||
const { apiFetch } = useApi()
|
||||
interface ApiEdital { ID: string; Numero: string; Orgao: string; Modalidade: string; Objeto: string; Plataforma: string; ValorEstimado: number; DataPublicacao: string; DataAbertura: string; Status: string }
|
||||
const { data: todos } = await useAsyncData('editais-perdidas', () => apiFetch<ApiEdital[]>('/editais'))
|
||||
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'perdida'))
|
||||
</script>
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Perdidas" breadcrumb="Oportunidades · Perdidas" />
|
||||
<div class="content"><div class="card"><EditaisTable :editais="filtrados" /></div></div>
|
||||
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
const filtrados = computed(() => editais.filter(e => e.status === 'recurso'))
|
||||
const { apiFetch } = useApi()
|
||||
interface ApiEdital { ID: string; Numero: string; Orgao: string; Modalidade: string; Objeto: string; Plataforma: string; ValorEstimado: number; DataPublicacao: string; DataAbertura: string; Status: string }
|
||||
const { data: todos } = await useAsyncData('editais-recurso', () => apiFetch<ApiEdital[]>('/editais'))
|
||||
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'recurso'))
|
||||
</script>
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Recurso" breadcrumb="Oportunidades · Recurso" />
|
||||
<div class="content"><div class="card"><EditaisTable :editais="filtrados" /></div></div>
|
||||
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
const filtrados = computed(() => editais.filter(e => e.status === 'vencida'))
|
||||
const { apiFetch } = useApi()
|
||||
interface ApiEdital { ID: string; Numero: string; Orgao: string; Modalidade: string; Objeto: string; Plataforma: string; ValorEstimado: number; DataPublicacao: string; DataAbertura: string; Status: string }
|
||||
const { data: todos } = await useAsyncData('editais-vencidas', () => apiFetch<ApiEdital[]>('/editais'))
|
||||
const editais = computed(() => (todos.value ?? []).filter(e => e.Status === 'vencida'))
|
||||
</script>
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Vencidas" breadcrumb="Oportunidades · Vencidas" />
|
||||
<div class="content"><div class="card"><EditaisTable :editais="filtrados" /></div></div>
|
||||
<div class="content"><div class="card"><EditaisTable :editais="editais" /></div></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,14 +1,43 @@
|
||||
<!-- front-end/app/pages/pipeline/index.vue -->
|
||||
<script setup lang="ts">
|
||||
import { editais } from '~/data/mock/editais'
|
||||
import { PIPELINE_ETAPAS } from '~/types'
|
||||
|
||||
const { apiFetch } = useApi()
|
||||
|
||||
interface ApiEdital {
|
||||
ID: string; Numero: string; Orgao: string; Modalidade: string
|
||||
Objeto: string; Plataforma: string; ValorEstimado: number
|
||||
DataPublicacao: string; DataAbertura: string; Status: string
|
||||
}
|
||||
|
||||
const { data: rawEditais } = await useAsyncData('pipeline-editais', () =>
|
||||
apiFetch<ApiEdital[]>('/editais')
|
||||
)
|
||||
|
||||
// Cópia local reativa — permite optimistic updates que o computed detecta
|
||||
const items = ref<ApiEdital[]>([])
|
||||
watchEffect(() => { items.value = [...(rawEditais.value ?? [])] })
|
||||
|
||||
const etapaParaStatus: Record<number, string> = {
|
||||
1: 'em_analise',
|
||||
2: 'elaborando_proposta',
|
||||
3: 'edital_publicado',
|
||||
4: 'fase_lances',
|
||||
5: 'habilitacao',
|
||||
6: 'recurso',
|
||||
7: 'adjudicado',
|
||||
8: 'contrato',
|
||||
}
|
||||
|
||||
const statusParaEtapa: Record<string, number> = {
|
||||
em_analise: 1,
|
||||
elaborando_proposta: 3,
|
||||
impugnacao: 2,
|
||||
participando: 4,
|
||||
elaborando_proposta: 2,
|
||||
edital_publicado: 3,
|
||||
fase_lances: 4,
|
||||
habilitacao: 5,
|
||||
recurso: 6,
|
||||
adjudicado: 7,
|
||||
contrato: 8,
|
||||
vencida: 8,
|
||||
perdida: 8,
|
||||
deserta: 8,
|
||||
@@ -18,7 +47,7 @@ const colunas = computed(() =>
|
||||
PIPELINE_ETAPAS.map((etapa, idx) => ({
|
||||
etapa,
|
||||
numero: idx + 1,
|
||||
cards: editais.filter(e => statusParaEtapa[e.status] === idx + 1),
|
||||
cards: items.value.filter(e => statusParaEtapa[e.Status] === idx + 1),
|
||||
}))
|
||||
)
|
||||
|
||||
@@ -29,6 +58,79 @@ const modalidadeAbrev: Record<string, string> = {
|
||||
dispensa: 'DI',
|
||||
inexigibilidade: 'IN',
|
||||
}
|
||||
|
||||
/* ── Drag & Drop ── */
|
||||
const draggedId = ref<string | null>(null)
|
||||
const dragOverCol = ref<number | null>(null)
|
||||
const movingCardId = ref<string | null>(null)
|
||||
|
||||
function onDragStart(e: DragEvent, card: ApiEdital) {
|
||||
draggedId.value = card.ID
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', card.ID)
|
||||
}
|
||||
const el = e.target as HTMLElement
|
||||
requestAnimationFrame(() => el.classList.add('dragging'))
|
||||
}
|
||||
|
||||
function onDragEnd(e: DragEvent) {
|
||||
draggedId.value = null
|
||||
dragOverCol.value = null
|
||||
const el = e.target as HTMLElement
|
||||
el.classList.remove('dragging')
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent, colNum: number) {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
||||
dragOverCol.value = colNum
|
||||
}
|
||||
|
||||
function onDragLeave(_e: DragEvent, colNum: number) {
|
||||
if (dragOverCol.value === colNum) dragOverCol.value = null
|
||||
}
|
||||
|
||||
function draggedStatus() {
|
||||
const card = items.value.find(e => e.ID === draggedId.value)
|
||||
return card?.Status ?? ''
|
||||
}
|
||||
|
||||
async function onDrop(e: DragEvent, colNum: number) {
|
||||
e.preventDefault()
|
||||
dragOverCol.value = null
|
||||
|
||||
const cardId = draggedId.value
|
||||
draggedId.value = null
|
||||
if (!cardId) return
|
||||
|
||||
const idx = items.value.findIndex(e => e.ID === cardId)
|
||||
if (idx === -1) return
|
||||
|
||||
const card = items.value[idx]
|
||||
const currentEtapa = statusParaEtapa[card.Status]
|
||||
if (currentEtapa === colNum) return
|
||||
|
||||
const newStatus = etapaParaStatus[colNum]
|
||||
if (!newStatus) return
|
||||
|
||||
// Optimistic update — substitui o objeto no array para triggerar reatividade
|
||||
const oldStatus = card.Status
|
||||
items.value[idx] = { ...card, Status: newStatus }
|
||||
movingCardId.value = cardId
|
||||
|
||||
try {
|
||||
await apiFetch(`/editais/${cardId}`, {
|
||||
method: 'PUT',
|
||||
body: { Status: newStatus },
|
||||
})
|
||||
} catch {
|
||||
// Reverte
|
||||
items.value[idx] = { ...items.value[idx], Status: oldStatus }
|
||||
} finally {
|
||||
setTimeout(() => { movingCardId.value = null }, 400)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,24 +138,41 @@ const modalidadeAbrev: Record<string, string> = {
|
||||
<AppTopbar title="Kanban de Processos" breadcrumb="Pipeline" />
|
||||
<div class="kanban-scroll">
|
||||
<div class="kanban-board">
|
||||
<div v-for="col in colunas" :key="col.numero" class="kanban-col">
|
||||
<div
|
||||
v-for="col in colunas"
|
||||
:key="col.numero"
|
||||
class="kanban-col"
|
||||
:class="{ 'drop-target': dragOverCol === col.numero && statusParaEtapa[draggedStatus()] !== col.numero }"
|
||||
@dragover="onDragOver($event, col.numero)"
|
||||
@dragleave="onDragLeave($event, col.numero)"
|
||||
@drop="onDrop($event, col.numero)"
|
||||
>
|
||||
<div class="col-header">
|
||||
<span class="col-num">{{ col.numero }}</span>
|
||||
<span class="col-title">{{ col.etapa }}</span>
|
||||
<UBadge :label="String(col.cards.length)" variant="soft" color="neutral" size="xs" />
|
||||
</div>
|
||||
<div class="col-cards">
|
||||
<div v-for="card in col.cards" :key="card.id" class="kanban-card">
|
||||
<div
|
||||
v-for="card in col.cards"
|
||||
:key="card.ID"
|
||||
class="kanban-card"
|
||||
:class="{ 'card-just-moved': movingCardId === card.ID }"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, card)"
|
||||
@dragend="onDragEnd"
|
||||
@click="navigateTo(`/oportunidades/${card.ID}`)"
|
||||
>
|
||||
<div class="card-top">
|
||||
<span class="card-num">{{ card.numero }}</span>
|
||||
<StatusChip :status="card.status" />
|
||||
<span class="card-num">{{ card.Numero }}</span>
|
||||
<StatusChip :status="(card.Status as any)" />
|
||||
</div>
|
||||
<p class="card-objeto">{{ card.objeto }}</p>
|
||||
<p class="card-orgao">{{ card.orgao }}</p>
|
||||
<p class="card-objeto">{{ card.Objeto }}</p>
|
||||
<p class="card-orgao">{{ card.Orgao }}</p>
|
||||
<div class="card-bottom">
|
||||
<span class="card-modal">{{ modalidadeAbrev[card.modalidade] }}</span>
|
||||
<span class="card-modal">{{ modalidadeAbrev[card.Modalidade] ?? card.Modalidade }}</span>
|
||||
<span class="card-valor">
|
||||
{{ new Intl.NumberFormat('pt-BR', { notation: 'compact', currency: 'BRL', style: 'currency' }).format(card.valorEstimado) }}
|
||||
{{ new Intl.NumberFormat('pt-BR', { notation: 'compact', currency: 'BRL', style: 'currency' }).format(card.ValorEstimado) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,12 +188,47 @@ const modalidadeAbrev: Record<string, string> = {
|
||||
.page { display: flex; flex-direction: column; height: 100vh; }
|
||||
.kanban-scroll { flex: 1; overflow-x: auto; padding: 20px 22px; }
|
||||
.kanban-board { display: flex; gap: 12px; min-width: max-content; align-items: flex-start; }
|
||||
.kanban-col { width: 220px; background: #f8fafc; border-radius: 10px; border: 1px solid #e2e8f0; flex-shrink: 0; }
|
||||
.kanban-col {
|
||||
width: 220px;
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
flex-shrink: 0;
|
||||
transition: border-color .2s, background .2s, box-shadow .2s;
|
||||
}
|
||||
.kanban-col.drop-target {
|
||||
border-color: #667eea;
|
||||
background: #eef2ff;
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
.col-header { display: flex; align-items: center; gap: 6px; padding: 10px 12px; border-bottom: 1px solid #e2e8f0; }
|
||||
.col-num { width: 20px; height: 20px; background: #667eea; color: white; border-radius: 50%; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.col-title { font-size: 11.5px; font-weight: 600; color: #374151; flex: 1; }
|
||||
.col-cards { padding: 8px; display: flex; flex-direction: column; gap: 8px; min-height: 80px; }
|
||||
.kanban-card { background: white; border-radius: 8px; padding: 10px 12px; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
|
||||
.kanban-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
cursor: grab;
|
||||
transition: border-color .15s, box-shadow .15s, opacity .15s, transform .2s;
|
||||
user-select: none;
|
||||
}
|
||||
.kanban-card:hover { border-color: #667eea; box-shadow: 0 2px 8px rgba(102,126,234,.15); }
|
||||
.kanban-card:active { cursor: grabbing; }
|
||||
.kanban-card.dragging {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.kanban-card.card-just-moved {
|
||||
animation: card-pop 0.4s ease;
|
||||
}
|
||||
@keyframes card-pop {
|
||||
0% { transform: scale(0.9); opacity: 0.5; }
|
||||
50% { transform: scale(1.03); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
.card-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
|
||||
.card-num { font-size: 11px; font-weight: 700; color: #0f172a; }
|
||||
.card-objeto { font-size: 12px; color: #374151; font-weight: 500; margin-bottom: 3px; line-height: 1.3; }
|
||||
|
||||
@@ -8,28 +8,24 @@ const refreshToken = useCookie<string | null>('refresh_token', { maxAge: 60 * 60
|
||||
|
||||
const documentType = ref<'pf' | 'pj'>('pj')
|
||||
const form = reactive({
|
||||
companyName: '',
|
||||
legalName: '',
|
||||
document: '',
|
||||
slug: '',
|
||||
adminName: '',
|
||||
adminEmail: '',
|
||||
adminPassword: '',
|
||||
})
|
||||
|
||||
const documentLabel = computed(() => documentType.value === 'pj' ? 'CNPJ' : 'CPF')
|
||||
const documentPlaceholder = computed(() => documentType.value === 'pj' ? '00.000.000/0001-00' : '000.000.000-00')
|
||||
const nameLabel = computed(() => documentType.value === 'pj' ? 'Razão Social' : 'Nome Completo')
|
||||
const isPJ = computed(() => documentType.value === 'pj')
|
||||
const documentLabel = computed(() => isPJ.value ? 'CNPJ' : 'CPF')
|
||||
const documentPlaceholder = computed(() => isPJ.value ? '00.000.000/0001-00' : '000.000.000-00')
|
||||
|
||||
// Auto-gera slug a partir do nome fantasia
|
||||
watch(() => form.companyName, (val) => {
|
||||
form.slug = val.toLowerCase()
|
||||
function toSlug(val: string) {
|
||||
return val.toLowerCase()
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.slice(0, 50)
|
||||
})
|
||||
}
|
||||
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
@@ -41,12 +37,12 @@ async function handleSubmit() {
|
||||
const res = await $fetch<{ access_token: string; refresh_token: string }>(`${apiBase}/auth/register`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
company_name: form.companyName,
|
||||
company_name: form.legalName,
|
||||
legal_name: form.legalName,
|
||||
document_type: documentType.value,
|
||||
document: form.document,
|
||||
slug: form.slug,
|
||||
admin_name: form.adminName,
|
||||
slug: toSlug(form.legalName),
|
||||
admin_name: form.legalName,
|
||||
admin_email: form.adminEmail,
|
||||
admin_password: form.adminPassword,
|
||||
},
|
||||
@@ -88,33 +84,14 @@ async function handleSubmit() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Dados da Empresa</div>
|
||||
|
||||
<UFormField label="Nome Fantasia / Apelido" class="field">
|
||||
<UInput v-model="form.companyName" placeholder="Ex: Tech Gov" class="w-full" required />
|
||||
</UFormField>
|
||||
|
||||
<UFormField :label="nameLabel" class="field">
|
||||
<UInput v-model="form.legalName" :placeholder="documentType === 'pj' ? 'Razão Social completa' : 'Nome completo'" class="w-full" required />
|
||||
<UFormField :label="isPJ ? 'Razão Social' : 'Nome Completo'" class="field">
|
||||
<UInput v-model="form.legalName" :placeholder="isPJ ? 'Razão Social completa' : 'Nome completo'" class="w-full" required />
|
||||
</UFormField>
|
||||
|
||||
<UFormField :label="documentLabel" class="field">
|
||||
<UInput v-model="form.document" :placeholder="documentPlaceholder" class="w-full" required />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Identificador único (slug)" class="field">
|
||||
<UInput v-model="form.slug" placeholder="minha-empresa" class="w-full" required />
|
||||
<template #hint>
|
||||
<span class="slug-hint">Usado para fazer login. Ex: <strong>{{ form.slug || 'minha-empresa' }}</strong></span>
|
||||
</template>
|
||||
</UFormField>
|
||||
|
||||
<div class="section-label">Dados de Acesso</div>
|
||||
|
||||
<UFormField label="Seu nome" class="field">
|
||||
<UInput v-model="form.adminName" placeholder="Nome do responsável" class="w-full" required />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="E-mail" class="field">
|
||||
<UInput v-model="form.adminEmail" type="email" placeholder="voce@empresa.com.br" class="w-full" required />
|
||||
</UFormField>
|
||||
|
||||
@@ -10,7 +10,7 @@ interface ApiUser {
|
||||
IsActive: boolean
|
||||
}
|
||||
|
||||
const { data: usuarios, pending } = await useAsyncData('usuarios', () =>
|
||||
const { data: usuarios, refresh } = await useAsyncData('usuarios', () =>
|
||||
apiFetch<ApiUser[]>('/users')
|
||||
)
|
||||
|
||||
@@ -19,34 +19,185 @@ const roleLabel: Record<string, string> = {
|
||||
member: 'Membro',
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ id: 'Name', accessorKey: 'Name', header: 'Nome' },
|
||||
{ id: 'Email', accessorKey: 'Email', header: 'E-mail' },
|
||||
{ id: 'Role', accessorKey: 'Role', header: 'Perfil' },
|
||||
{ id: 'IsActive', accessorKey: 'IsActive', header: 'Status' },
|
||||
]
|
||||
// --- Modal Criar ---
|
||||
const showCreate = ref(false)
|
||||
const createForm = reactive({ name: '', email: '', password: '', role: 'member' })
|
||||
const createError = ref('')
|
||||
const createLoading = ref(false)
|
||||
|
||||
async function criarUsuario() {
|
||||
createError.value = ''
|
||||
createLoading.value = true
|
||||
try {
|
||||
await apiFetch('/users', {
|
||||
method: 'POST',
|
||||
body: { name: createForm.name, email: createForm.email, password: createForm.password, role: createForm.role },
|
||||
})
|
||||
showCreate.value = false
|
||||
createForm.name = ''
|
||||
createForm.email = ''
|
||||
createForm.password = ''
|
||||
createForm.role = 'member'
|
||||
await refresh()
|
||||
} catch (err: any) {
|
||||
createError.value = err?.data?.error || 'Erro ao criar usuário.'
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Modal Editar ---
|
||||
const showEdit = ref(false)
|
||||
const editUser = ref<ApiUser | null>(null)
|
||||
const editForm = reactive({ name: '', role: 'member' })
|
||||
const editError = ref('')
|
||||
const editLoading = ref(false)
|
||||
|
||||
function abrirEditar(u: ApiUser) {
|
||||
editUser.value = u
|
||||
editForm.name = u.Name
|
||||
editForm.role = u.Role
|
||||
editError.value = ''
|
||||
showEdit.value = true
|
||||
}
|
||||
|
||||
async function salvarEdicao() {
|
||||
if (!editUser.value) return
|
||||
editError.value = ''
|
||||
editLoading.value = true
|
||||
try {
|
||||
await apiFetch(`/users/${editUser.value.ID}`, {
|
||||
method: 'PUT',
|
||||
body: { name: editForm.name, role: editForm.role },
|
||||
})
|
||||
showEdit.value = false
|
||||
await refresh()
|
||||
} catch (err: any) {
|
||||
editError.value = err?.data?.error || 'Erro ao salvar.'
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- Toggle status ---
|
||||
const togglingId = ref<string | null>(null)
|
||||
|
||||
async function toggleStatus(u: ApiUser) {
|
||||
togglingId.value = u.ID
|
||||
try {
|
||||
if (u.IsActive) {
|
||||
await apiFetch(`/users/${u.ID}`, { method: 'DELETE' })
|
||||
} else {
|
||||
const isActive = true
|
||||
await apiFetch(`/users/${u.ID}`, { method: 'PUT', body: { is_active: isActive } })
|
||||
}
|
||||
await refresh()
|
||||
} catch {}
|
||||
togglingId.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<AppTopbar title="Usuários" breadcrumb="Sistema · Usuários">
|
||||
<template #actions>
|
||||
<UButton size="sm" class="btn-primary">+ Novo Usuário</UButton>
|
||||
<UButton size="sm" class="btn-primary" @click="showCreate = true">+ Novo Usuário</UButton>
|
||||
</template>
|
||||
</AppTopbar>
|
||||
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<UTable :data="usuarios ?? []" :columns="columns" :loading="pending">
|
||||
<template #Role-cell="{ row }">{{ roleLabel[row.original.Role] ?? row.original.Role }}</template>
|
||||
<template #IsActive-cell="{ row }">
|
||||
<UBadge
|
||||
:label="row.original.IsActive ? 'Ativo' : 'Inativo'"
|
||||
:color="row.original.IsActive ? 'success' : 'neutral'"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
/>
|
||||
</template>
|
||||
</UTable>
|
||||
<table class="tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nome</th>
|
||||
<th>E-mail</th>
|
||||
<th>Perfil</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in (usuarios ?? [])" :key="u.ID">
|
||||
<td>{{ u.Name }}</td>
|
||||
<td class="email">{{ u.Email }}</td>
|
||||
<td>{{ roleLabel[u.Role] ?? u.Role }}</td>
|
||||
<td>
|
||||
<span :class="['badge', u.IsActive ? 'badge-active' : 'badge-inactive']">
|
||||
{{ u.IsActive ? 'Ativo' : 'Inativo' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<button class="act-btn" @click="abrirEditar(u)">Editar</button>
|
||||
<button
|
||||
class="act-btn"
|
||||
:class="u.IsActive ? 'act-danger' : 'act-success'"
|
||||
:disabled="togglingId === u.ID"
|
||||
@click="toggleStatus(u)"
|
||||
>
|
||||
{{ u.IsActive ? 'Desativar' : 'Ativar' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!usuarios?.length">
|
||||
<td colspan="5" class="empty">Nenhum usuário encontrado.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Criar -->
|
||||
<div v-if="showCreate" class="overlay">
|
||||
<div class="modal">
|
||||
<h2>Novo Usuário</h2>
|
||||
<div class="field">
|
||||
<label>Nome</label>
|
||||
<UInput v-model="createForm.name" placeholder="Nome completo" class="w-full" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>E-mail</label>
|
||||
<UInput v-model="createForm.email" type="email" placeholder="email@empresa.com" class="w-full" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Senha</label>
|
||||
<UInput v-model="createForm.password" type="password" placeholder="Mínimo 8 caracteres" class="w-full" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Perfil</label>
|
||||
<select v-model="createForm.role" class="sel">
|
||||
<option value="member">Membro</option>
|
||||
<option value="admin">Administrador</option>
|
||||
</select>
|
||||
</div>
|
||||
<p v-if="createError" class="err">{{ createError }}</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" @click="showCreate = false">Cancelar</button>
|
||||
<UButton class="btn-primary" size="sm" :loading="createLoading" @click="criarUsuario">Criar</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Editar -->
|
||||
<div v-if="showEdit" class="overlay">
|
||||
<div class="modal">
|
||||
<h2>Editar Usuário</h2>
|
||||
<div class="field">
|
||||
<label>Nome</label>
|
||||
<UInput v-model="editForm.name" class="w-full" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Perfil</label>
|
||||
<select v-model="editForm.role" class="sel">
|
||||
<option value="member">Membro</option>
|
||||
<option value="admin">Administrador</option>
|
||||
</select>
|
||||
</div>
|
||||
<p v-if="editError" class="err">{{ editError }}</p>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" @click="showEdit = false">Cancelar</button>
|
||||
<UButton class="btn-primary" size="sm" :loading="editLoading" @click="salvarEdicao">Salvar</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,4 +208,38 @@ const columns = [
|
||||
.content { padding: 20px 22px; flex: 1; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 11px; border: 1px solid #e2e8f0; overflow: hidden; }
|
||||
.btn-primary { background: linear-gradient(135deg, #667eea, #764ba2) !important; }
|
||||
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.tbl thead tr { background: #f8fafc; border-bottom: 1px solid #e2e8f0; }
|
||||
.tbl th { padding: 10px 14px; text-align: left; font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.tbl td { padding: 12px 14px; border-bottom: 1px solid #f1f5f9; color: #1e293b; }
|
||||
.tbl tbody tr:last-child td { border-bottom: none; }
|
||||
.tbl tbody tr:hover { background: #fafafa; }
|
||||
.email { color: #64748b; }
|
||||
.empty { text-align: center; color: #94a3b8; padding: 32px !important; }
|
||||
|
||||
.badge { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; }
|
||||
.badge-active { background: #dcfce7; color: #16a34a; }
|
||||
.badge-inactive { background: #f1f5f9; color: #94a3b8; }
|
||||
|
||||
.actions-cell { display: flex; gap: 6px; }
|
||||
.act-btn { font-size: 12px; font-weight: 500; padding: 4px 10px; border-radius: 6px; border: 1px solid #e2e8f0; background: white; cursor: pointer; color: #667eea; transition: all 0.15s; }
|
||||
.act-btn:hover { background: #f0f3ff; border-color: #667eea; }
|
||||
.act-danger { color: #dc2626; }
|
||||
.act-danger:hover { background: #fff1f1; border-color: #dc2626; }
|
||||
.act-success { color: #16a34a; }
|
||||
.act-success:hover { background: #dcfce7; border-color: #16a34a; }
|
||||
.act-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Modal */
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal { background: white; border-radius: 14px; padding: 28px; width: 100%; max-width: 400px; box-shadow: 0 20px 50px rgba(0,0,0,0.2); }
|
||||
.modal h2 { font-size: 16px; font-weight: 700; color: #0f172a; margin-bottom: 18px; }
|
||||
.field { margin-bottom: 14px; }
|
||||
.field label { display: block; font-size: 12px; font-weight: 600; color: #475569; margin-bottom: 5px; }
|
||||
.sel { width: 100%; padding: 8px 10px; border: 1px solid #e2e8f0; border-radius: 8px; font-size: 13px; color: #0f172a; background: white; }
|
||||
.err { color: #dc2626; font-size: 12px; margin-bottom: 10px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
|
||||
.btn-cancel { font-size: 13px; padding: 6px 14px; border-radius: 8px; border: 1px solid #e2e8f0; background: white; cursor: pointer; color: #64748b; }
|
||||
.btn-cancel:hover { background: #f8fafc; }
|
||||
</style>
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
export type StatusEdital =
|
||||
| 'em_analise'
|
||||
| 'elaborando_proposta'
|
||||
| 'impugnacao'
|
||||
| 'edital_publicado'
|
||||
| 'fase_lances'
|
||||
| 'habilitacao'
|
||||
| 'recurso'
|
||||
| 'participando'
|
||||
| 'adjudicado'
|
||||
| 'contrato'
|
||||
| 'vencida'
|
||||
| 'perdida'
|
||||
| 'deserta'
|
||||
@@ -90,23 +93,26 @@ export interface Usuario {
|
||||
}
|
||||
|
||||
export const STATUS_EDITAL_CONFIG: Record<StatusEdital, { label: string; bg: string; color: string }> = {
|
||||
em_analise: { label: 'Em Análise', bg: '#eff6ff', color: '#0284c7' },
|
||||
elaborando_proposta: { label: 'Elaborando Proposta', bg: '#faf5ff', color: '#7c3aed' },
|
||||
impugnacao: { label: 'Impugnação', bg: '#fff7ed', color: '#ea580c' },
|
||||
recurso: { label: 'Recurso', bg: '#fffbeb', color: '#d97706' },
|
||||
participando: { label: 'Participando', bg: '#eff6ff', color: '#3b82f6' },
|
||||
em_analise: { label: 'Mapeamento', bg: '#eff6ff', color: '#0284c7' },
|
||||
elaborando_proposta: { label: 'Termo de Referência', bg: '#faf5ff', color: '#7c3aed' },
|
||||
edital_publicado: { label: 'Edital Publicado', bg: '#fff7ed', color: '#ea580c' },
|
||||
fase_lances: { label: 'Fase de Lances', bg: '#eff6ff', color: '#3b82f6' },
|
||||
habilitacao: { label: 'Habilitação', bg: '#fef3c7', color: '#d97706' },
|
||||
recurso: { label: 'Recursos', bg: '#fffbeb', color: '#d97706' },
|
||||
adjudicado: { label: 'Adjudicado', bg: '#ecfdf5', color: '#059669' },
|
||||
contrato: { label: 'Contrato', bg: '#f0fdf4', color: '#16a34a' },
|
||||
vencida: { label: 'Vencida', bg: '#f0fdf4', color: '#16a34a' },
|
||||
perdida: { label: 'Perdida', bg: '#fef2f2', color: '#dc2626' },
|
||||
deserta: { label: 'Deserta / Fracassada', bg: '#f8fafc', color: '#64748b' },
|
||||
}
|
||||
|
||||
export const PIPELINE_ETAPAS = [
|
||||
'Identificado',
|
||||
'Análise de Viabilidade',
|
||||
'Elaborando Proposta',
|
||||
'Disputa / Lances',
|
||||
'Mapeamento',
|
||||
'Termo de Referência',
|
||||
'Edital Publicado',
|
||||
'Fase de Lances',
|
||||
'Habilitação',
|
||||
'Recurso / Contrarrazões',
|
||||
'Adjudicação',
|
||||
'Recursos',
|
||||
'Adjudicado',
|
||||
'Contrato',
|
||||
] as const
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineNuxtConfig({
|
||||
css: ['~/assets/css/main.css'],
|
||||
|
||||
routeRules: {
|
||||
'/': { prerender: true }
|
||||
'/': { prerender: process.env.NODE_ENV !== 'production' }
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
@@ -21,6 +21,19 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
|
||||
devServer: {
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
|
||||
vite: {
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
hmr: {
|
||||
protocol: 'ws',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
compatibilityDate: '2025-01-15',
|
||||
|
||||
eslint: {
|
||||
|
||||
35
nginx.conf
Normal file
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