feat(export): adicionar exportação Excel do ranking com barra de progresso
- Novo endpoint GET /api/v1/ranking/exportar/excel - Exporta apenas consultores com pontuação > 0 - Usa xlsxwriter para geração rápida (~40s para 300k registros) - Layout profissional com formatação, filtros e cores condicionais - Barra de progresso real no frontend com dois estados: - Animação indeterminada durante geração no servidor - Progresso real durante download do arquivo - Botão de exportação integrado ao layout do sistema - Suporte a cancelamento da exportação
This commit is contained in:
@@ -9,3 +9,4 @@ rich==13.7.0
|
|||||||
oracledb==2.5.1
|
oracledb==2.5.1
|
||||||
weasyprint>=62.3
|
weasyprint>=62.3
|
||||||
jinja2==3.1.2
|
jinja2==3.1.2
|
||||||
|
xlsxwriter==3.2.0
|
||||||
|
|||||||
216
backend/src/application/services/excel_service.py
Normal file
216
backend/src/application/services/excel_service.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
import json
|
||||||
|
|
||||||
|
import xlsxwriter
|
||||||
|
|
||||||
|
|
||||||
|
class ExcelService:
|
||||||
|
def gerar_ranking_excel(
|
||||||
|
self,
|
||||||
|
consultores: List[Dict[str, Any]],
|
||||||
|
filtros_aplicados: Optional[Dict[str, Any]] = None
|
||||||
|
) -> bytes:
|
||||||
|
output = BytesIO()
|
||||||
|
wb = xlsxwriter.Workbook(output, {'in_memory': True, 'constant_memory': True})
|
||||||
|
ws = wb.add_worksheet('Ranking Consultores')
|
||||||
|
|
||||||
|
title_fmt = wb.add_format({
|
||||||
|
'font_name': 'Calibri', 'font_size': 16, 'bold': True,
|
||||||
|
'font_color': '#1F4E79', 'align': 'center', 'valign': 'vcenter'
|
||||||
|
})
|
||||||
|
subtitle_fmt = wb.add_format({
|
||||||
|
'font_name': 'Calibri', 'font_size': 11,
|
||||||
|
'font_color': '#666666', 'align': 'center', 'valign': 'vcenter'
|
||||||
|
})
|
||||||
|
header_fmt = wb.add_format({
|
||||||
|
'font_name': 'Calibri', 'font_size': 11, 'bold': True,
|
||||||
|
'font_color': '#FFFFFF', 'bg_color': '#1F4E79',
|
||||||
|
'align': 'center', 'valign': 'vcenter', 'text_wrap': True,
|
||||||
|
'border': 1, 'border_color': '#D9D9D9'
|
||||||
|
})
|
||||||
|
data_fmt = wb.add_format({
|
||||||
|
'font_name': 'Calibri', 'font_size': 10,
|
||||||
|
'border': 1, 'border_color': '#D9D9D9'
|
||||||
|
})
|
||||||
|
data_center_fmt = wb.add_format({
|
||||||
|
'font_name': 'Calibri', 'font_size': 10,
|
||||||
|
'align': 'center', 'valign': 'vcenter',
|
||||||
|
'border': 1, 'border_color': '#D9D9D9'
|
||||||
|
})
|
||||||
|
data_number_fmt = wb.add_format({
|
||||||
|
'font_name': 'Calibri', 'font_size': 10,
|
||||||
|
'align': 'center', 'valign': 'vcenter',
|
||||||
|
'border': 1, 'border_color': '#D9D9D9',
|
||||||
|
'num_format': '#,##0'
|
||||||
|
})
|
||||||
|
data_decimal_fmt = wb.add_format({
|
||||||
|
'font_name': 'Calibri', 'font_size': 10,
|
||||||
|
'align': 'center', 'valign': 'vcenter',
|
||||||
|
'border': 1, 'border_color': '#D9D9D9',
|
||||||
|
'num_format': '#,##0.0'
|
||||||
|
})
|
||||||
|
ativo_fmt = wb.add_format({
|
||||||
|
'font_name': 'Calibri', 'font_size': 10, 'bold': True,
|
||||||
|
'align': 'center', 'valign': 'vcenter',
|
||||||
|
'bg_color': '#C6EFCE', 'font_color': '#006100',
|
||||||
|
'border': 1, 'border_color': '#D9D9D9'
|
||||||
|
})
|
||||||
|
inativo_fmt = wb.add_format({
|
||||||
|
'font_name': 'Calibri', 'font_size': 10, 'bold': True,
|
||||||
|
'align': 'center', 'valign': 'vcenter',
|
||||||
|
'bg_color': '#FFC7CE', 'font_color': '#9C0006',
|
||||||
|
'border': 1, 'border_color': '#D9D9D9'
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.merge_range('A1:O1', 'RANKING DE CONSULTORES CAPES', title_fmt)
|
||||||
|
ws.set_row(0, 30)
|
||||||
|
|
||||||
|
data_geracao = datetime.now().strftime('%d/%m/%Y às %H:%M')
|
||||||
|
total = len(consultores)
|
||||||
|
filtros_texto = self._formatar_filtros(filtros_aplicados) if filtros_aplicados else ""
|
||||||
|
subtitulo = f"Gerado em {data_geracao} | Total: {total:,} consultores com pontuação"
|
||||||
|
if filtros_texto:
|
||||||
|
subtitulo += f" | Filtros: {filtros_texto}"
|
||||||
|
ws.merge_range('A2:O2', subtitulo, subtitle_fmt)
|
||||||
|
ws.set_row(1, 20)
|
||||||
|
|
||||||
|
headers = [
|
||||||
|
("Posição", 10),
|
||||||
|
("ID", 12),
|
||||||
|
("Nome", 40),
|
||||||
|
("Pontuação Total", 15),
|
||||||
|
("Bloco A\n(Coord. CAPES)", 14),
|
||||||
|
("Bloco C\n(Consultoria)", 14),
|
||||||
|
("Bloco D\n(Prêmios/Aval.)", 14),
|
||||||
|
("Bloco E\n(Coord. PPG)", 14),
|
||||||
|
("Status", 10),
|
||||||
|
("Anos Atuação", 12),
|
||||||
|
("Selos", 30),
|
||||||
|
("Coord. CAPES", 25),
|
||||||
|
("Situação Consultoria", 18),
|
||||||
|
("Prêmios", 25),
|
||||||
|
("Titulação", 35),
|
||||||
|
]
|
||||||
|
|
||||||
|
header_row = 3
|
||||||
|
for col_idx, (header_text, width) in enumerate(headers):
|
||||||
|
ws.write(header_row, col_idx, header_text, header_fmt)
|
||||||
|
ws.set_column(col_idx, col_idx, width)
|
||||||
|
ws.set_row(header_row, 35)
|
||||||
|
|
||||||
|
for row_idx, consultor in enumerate(consultores):
|
||||||
|
excel_row = header_row + 1 + row_idx
|
||||||
|
|
||||||
|
detalhes = self._parse_json_detalhes(consultor.get("JSON_DETALHES"))
|
||||||
|
selos = consultor.get("SELOS") or ""
|
||||||
|
is_ativo = consultor.get("ATIVO") == "S"
|
||||||
|
|
||||||
|
coord_capes = self._extrair_coordenacoes_resumo(detalhes)
|
||||||
|
situacao_cons = self._extrair_situacao_consultoria(detalhes)
|
||||||
|
premios = self._extrair_premios_resumo(detalhes)
|
||||||
|
titulacao = self._extrair_titulacao(detalhes)
|
||||||
|
|
||||||
|
ws.write_number(excel_row, 0, consultor.get("POSICAO") or 0, data_center_fmt)
|
||||||
|
ws.write_number(excel_row, 1, consultor.get("ID_PESSOA") or 0, data_center_fmt)
|
||||||
|
ws.write_string(excel_row, 2, consultor.get("NOME") or "", data_fmt)
|
||||||
|
ws.write_number(excel_row, 3, float(consultor.get("PONTUACAO_TOTAL") or 0), data_number_fmt)
|
||||||
|
ws.write_number(excel_row, 4, float(consultor.get("COMPONENTE_A") or 0), data_number_fmt)
|
||||||
|
ws.write_number(excel_row, 5, float(consultor.get("COMPONENTE_C") or 0), data_number_fmt)
|
||||||
|
ws.write_number(excel_row, 6, float(consultor.get("COMPONENTE_D") or 0), data_number_fmt)
|
||||||
|
ws.write_number(excel_row, 7, float(consultor.get("COMPONENTE_E") or 0), data_number_fmt)
|
||||||
|
ws.write_string(excel_row, 8, "Ativo" if is_ativo else "Inativo", ativo_fmt if is_ativo else inativo_fmt)
|
||||||
|
ws.write_number(excel_row, 9, float(consultor.get("ANOS_ATUACAO") or 0), data_decimal_fmt)
|
||||||
|
ws.write_string(excel_row, 10, selos.replace(",", ", "), data_fmt)
|
||||||
|
ws.write_string(excel_row, 11, coord_capes, data_fmt)
|
||||||
|
ws.write_string(excel_row, 12, situacao_cons, data_fmt)
|
||||||
|
ws.write_string(excel_row, 13, premios, data_fmt)
|
||||||
|
ws.write_string(excel_row, 14, titulacao, data_fmt)
|
||||||
|
|
||||||
|
last_row = header_row + len(consultores)
|
||||||
|
ws.autofilter(header_row, 0, last_row, 14)
|
||||||
|
ws.freeze_panes(header_row + 1, 0)
|
||||||
|
|
||||||
|
wb.close()
|
||||||
|
output.seek(0)
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
def _parse_json_detalhes(self, json_str) -> Dict[str, Any]:
|
||||||
|
if not json_str:
|
||||||
|
return {}
|
||||||
|
if hasattr(json_str, "read"):
|
||||||
|
json_str = json_str.read()
|
||||||
|
try:
|
||||||
|
return json.loads(json_str) if isinstance(json_str, str) else json_str
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _formatar_filtros(self, filtros: Dict[str, Any]) -> str:
|
||||||
|
partes = []
|
||||||
|
if filtros.get("ativo") is not None:
|
||||||
|
partes.append("Ativos" if filtros["ativo"] else "Inativos")
|
||||||
|
if filtros.get("selos"):
|
||||||
|
partes.append(f"Selos: {', '.join(filtros['selos'])}")
|
||||||
|
return " | ".join(partes)
|
||||||
|
|
||||||
|
def _extrair_coordenacoes_resumo(self, detalhes: Dict[str, Any]) -> str:
|
||||||
|
coords = detalhes.get("coordenacoes_capes", [])
|
||||||
|
if not coords:
|
||||||
|
return ""
|
||||||
|
resumos = []
|
||||||
|
for c in coords[:3]:
|
||||||
|
tipo = c.get("tipo", "")
|
||||||
|
area = c.get("area", "")
|
||||||
|
if tipo and area:
|
||||||
|
resumos.append(f"{tipo}: {area}")
|
||||||
|
elif tipo:
|
||||||
|
resumos.append(tipo)
|
||||||
|
return "; ".join(resumos)
|
||||||
|
|
||||||
|
def _extrair_situacao_consultoria(self, detalhes: Dict[str, Any]) -> str:
|
||||||
|
cons = detalhes.get("consultoria")
|
||||||
|
if not cons:
|
||||||
|
return ""
|
||||||
|
return cons.get("situacao", "")
|
||||||
|
|
||||||
|
def _extrair_premios_resumo(self, detalhes: Dict[str, Any]) -> str:
|
||||||
|
premios = detalhes.get("premiacoes", [])
|
||||||
|
if not premios:
|
||||||
|
return ""
|
||||||
|
resumos = []
|
||||||
|
for p in premios[:3]:
|
||||||
|
premio = p.get("premio", "")
|
||||||
|
tipo = p.get("tipo", "")
|
||||||
|
ano = p.get("ano", "")
|
||||||
|
if premio:
|
||||||
|
texto = f"{premio}"
|
||||||
|
if tipo:
|
||||||
|
texto += f" ({tipo})"
|
||||||
|
if ano:
|
||||||
|
texto += f" - {ano}"
|
||||||
|
resumos.append(texto)
|
||||||
|
if len(premios) > 3:
|
||||||
|
resumos.append(f"+{len(premios) - 3} outros")
|
||||||
|
return "; ".join(resumos)
|
||||||
|
|
||||||
|
def _extrair_titulacao(self, detalhes: Dict[str, Any]) -> str:
|
||||||
|
titulacao = detalhes.get("titulacao")
|
||||||
|
if titulacao and isinstance(titulacao, str):
|
||||||
|
return titulacao
|
||||||
|
|
||||||
|
lattes = detalhes.get("lattes", {})
|
||||||
|
titulacoes = lattes.get("titulacoes", []) if lattes else []
|
||||||
|
if titulacoes:
|
||||||
|
primeira = titulacoes[0]
|
||||||
|
grau = primeira.get("grau", "")
|
||||||
|
ies = primeira.get("ies_sigla", "")
|
||||||
|
ano = primeira.get("ano", "")
|
||||||
|
if grau:
|
||||||
|
texto = grau
|
||||||
|
if ies:
|
||||||
|
texto += f" - {ies}"
|
||||||
|
if ano:
|
||||||
|
texto += f" ({ano})"
|
||||||
|
return texto
|
||||||
|
return ""
|
||||||
@@ -394,3 +394,99 @@ class RankingOracleRepository:
|
|||||||
raise RuntimeError(f"Erro ao limpar tabela: {e2}")
|
raise RuntimeError(f"Erro ao limpar tabela: {e2}")
|
||||||
finally:
|
finally:
|
||||||
cursor.close()
|
cursor.close()
|
||||||
|
|
||||||
|
def buscar_para_exportacao(
|
||||||
|
self,
|
||||||
|
filtro_ativo: Optional[bool] = None,
|
||||||
|
filtro_selos: Optional[List[str]] = None,
|
||||||
|
batch_size: int = 5000
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Busca todos os consultores com pontuação > 0 para exportação.
|
||||||
|
Otimizado para alto volume usando fetch em batches.
|
||||||
|
Retorna dicts crus do Oracle para processamento direto.
|
||||||
|
"""
|
||||||
|
where_clauses = ["PONTUACAO_TOTAL > 0"]
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
if filtro_ativo is not None:
|
||||||
|
where_clauses.append("ATIVO = :ativo")
|
||||||
|
params["ativo"] = "S" if filtro_ativo else "N"
|
||||||
|
|
||||||
|
if filtro_selos:
|
||||||
|
for i, selo in enumerate(filtro_selos):
|
||||||
|
param_name = f"selo_{i}"
|
||||||
|
where_clauses.append(f"((',' || UPPER(SELOS) || ',') LIKE '%,' || :{param_name} || ',%')")
|
||||||
|
params[param_name] = str(selo).upper()
|
||||||
|
|
||||||
|
where_clause = " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
SELECT
|
||||||
|
ID_PESSOA,
|
||||||
|
NOME,
|
||||||
|
POSICAO,
|
||||||
|
PONTUACAO_TOTAL,
|
||||||
|
COMPONENTE_A,
|
||||||
|
COMPONENTE_B,
|
||||||
|
COMPONENTE_C,
|
||||||
|
COMPONENTE_D,
|
||||||
|
COMPONENTE_E,
|
||||||
|
ATIVO,
|
||||||
|
ANOS_ATUACAO,
|
||||||
|
SELOS,
|
||||||
|
JSON_DETALHES
|
||||||
|
FROM TB_RANKING_CONSULTOR
|
||||||
|
WHERE {where_clause}
|
||||||
|
ORDER BY POSICAO NULLS LAST, PONTUACAO_TOTAL DESC
|
||||||
|
"""
|
||||||
|
|
||||||
|
resultados = []
|
||||||
|
with self.client.get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
try:
|
||||||
|
cursor.arraysize = batch_size
|
||||||
|
cursor.execute(query, params)
|
||||||
|
|
||||||
|
colunas = [col[0] for col in cursor.description]
|
||||||
|
|
||||||
|
while True:
|
||||||
|
rows = cursor.fetchmany(batch_size)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
for row in rows:
|
||||||
|
registro = dict(zip(colunas, row))
|
||||||
|
json_det = registro.get("JSON_DETALHES")
|
||||||
|
if hasattr(json_det, "read"):
|
||||||
|
registro["JSON_DETALHES"] = json_det.read()
|
||||||
|
resultados.append(registro)
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
return resultados
|
||||||
|
|
||||||
|
def contar_para_exportacao(
|
||||||
|
self,
|
||||||
|
filtro_ativo: Optional[bool] = None,
|
||||||
|
filtro_selos: Optional[List[str]] = None
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Conta consultores com pontuação > 0 que seriam exportados.
|
||||||
|
"""
|
||||||
|
where_clauses = ["PONTUACAO_TOTAL > 0"]
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
if filtro_ativo is not None:
|
||||||
|
where_clauses.append("ATIVO = :ativo")
|
||||||
|
params["ativo"] = "S" if filtro_ativo else "N"
|
||||||
|
|
||||||
|
if filtro_selos:
|
||||||
|
for i, selo in enumerate(filtro_selos):
|
||||||
|
param_name = f"selo_{i}"
|
||||||
|
where_clauses.append(f"((',' || UPPER(SELOS) || ',') LIKE '%,' || :{param_name} || ',%')")
|
||||||
|
params[param_name] = str(selo).upper()
|
||||||
|
|
||||||
|
where_clause = " AND ".join(where_clauses)
|
||||||
|
query = f"SELECT COUNT(*) AS TOTAL FROM TB_RANKING_CONSULTOR WHERE {where_clause}"
|
||||||
|
results = self.client.executar_query(query, params)
|
||||||
|
return results[0]["TOTAL"] if results else 0
|
||||||
|
|||||||
@@ -992,3 +992,78 @@ async def listar_areas_avaliacao(
|
|||||||
return [AreaAvaliacaoSchema(**a) for a in areas]
|
return [AreaAvaliacaoSchema(**a) for a in areas]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Erro ao listar areas de avaliacao: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Erro ao listar areas de avaliacao: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ranking/exportar/excel")
|
||||||
|
async def exportar_ranking_excel(
|
||||||
|
ativo: Optional[bool] = Query(default=None, description="Filtrar por status ativo"),
|
||||||
|
selos: Optional[str] = Query(default=None, description="Filtrar por selos (separados por vírgula)"),
|
||||||
|
oracle_repo = Depends(get_ranking_oracle_repo),
|
||||||
|
):
|
||||||
|
from ...application.services.excel_service import ExcelService
|
||||||
|
|
||||||
|
if not oracle_repo:
|
||||||
|
raise HTTPException(status_code=503, detail="Oracle não configurado")
|
||||||
|
|
||||||
|
selos_lista = (
|
||||||
|
[s.strip().upper() for s in selos.split(",") if s.strip()]
|
||||||
|
if selos
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
total = oracle_repo.contar_para_exportacao(filtro_ativo=ativo, filtro_selos=selos_lista)
|
||||||
|
if total == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Nenhum consultor com pontuação encontrado para os filtros aplicados."
|
||||||
|
)
|
||||||
|
|
||||||
|
consultores = oracle_repo.buscar_para_exportacao(
|
||||||
|
filtro_ativo=ativo,
|
||||||
|
filtro_selos=selos_lista
|
||||||
|
)
|
||||||
|
|
||||||
|
filtros = {"ativo": ativo, "selos": selos_lista}
|
||||||
|
excel_service = ExcelService()
|
||||||
|
excel_bytes = excel_service.gerar_ranking_excel(consultores, filtros)
|
||||||
|
|
||||||
|
data_atual = datetime.now().strftime('%Y%m%d_%H%M')
|
||||||
|
nome_arquivo = f"ranking_consultores_capes_{data_atual}.xlsx"
|
||||||
|
|
||||||
|
from fastapi.responses import Response
|
||||||
|
return Response(
|
||||||
|
content=excel_bytes,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'attachment; filename="{nome_arquivo}"',
|
||||||
|
"Content-Length": str(len(excel_bytes)),
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ranking/exportar/info")
|
||||||
|
async def info_exportacao(
|
||||||
|
ativo: Optional[bool] = Query(default=None, description="Filtrar por status ativo"),
|
||||||
|
selos: Optional[str] = Query(default=None, description="Filtrar por selos (separados por vírgula)"),
|
||||||
|
oracle_repo = Depends(get_ranking_oracle_repo),
|
||||||
|
):
|
||||||
|
if not oracle_repo:
|
||||||
|
raise HTTPException(status_code=503, detail="Oracle não configurado")
|
||||||
|
|
||||||
|
selos_lista = (
|
||||||
|
[s.strip().upper() for s in selos.split(",") if s.strip()]
|
||||||
|
if selos
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
total = oracle_repo.contar_para_exportacao(filtro_ativo=ativo, filtro_selos=selos_lista)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_consultores": total,
|
||||||
|
"filtros": {
|
||||||
|
"ativo": ativo,
|
||||||
|
"selos": selos_lista
|
||||||
|
},
|
||||||
|
"estimativa_tamanho_mb": round(total * 0.003, 2)
|
||||||
|
}
|
||||||
|
|||||||
@@ -145,6 +145,34 @@
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-exportar {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.15));
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.4);
|
||||||
|
color: #86efac;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-exportar:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, rgba(34, 197, 94, 0.3), rgba(16, 185, 129, 0.2));
|
||||||
|
border-color: rgba(34, 197, 94, 0.6);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-exportar:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ConsultorCard from './components/ConsultorCard';
|
|||||||
import CompararModal from './components/CompararModal';
|
import CompararModal from './components/CompararModal';
|
||||||
import FiltroSelos from './components/FiltroSelos';
|
import FiltroSelos from './components/FiltroSelos';
|
||||||
import SugerirConsultores from './components/SugerirConsultores';
|
import SugerirConsultores from './components/SugerirConsultores';
|
||||||
|
import ExportProgress from './components/ExportProgress';
|
||||||
import { rankingService } from './services/api';
|
import { rankingService } from './services/api';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -25,6 +26,10 @@ function App() {
|
|||||||
const [modalAberto, setModalAberto] = useState(false);
|
const [modalAberto, setModalAberto] = useState(false);
|
||||||
const [filtroSelos, setFiltroSelos] = useState([]);
|
const [filtroSelos, setFiltroSelos] = useState([]);
|
||||||
const [sugerirAberto, setSugerirAberto] = useState(false);
|
const [sugerirAberto, setSugerirAberto] = useState(false);
|
||||||
|
const [exportando, setExportando] = useState(false);
|
||||||
|
const [exportProgress, setExportProgress] = useState({ loaded: 0, total: 0, percent: 0 });
|
||||||
|
const [exportStatus, setExportStatus] = useState('preparing');
|
||||||
|
const abortControllerRef = useRef(null);
|
||||||
|
|
||||||
const toggleSelecionado = (consultor) => {
|
const toggleSelecionado = (consultor) => {
|
||||||
setSelecionados((prev) => {
|
setSelecionados((prev) => {
|
||||||
@@ -60,6 +65,45 @@ function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExportarExcel = async () => {
|
||||||
|
if (exportando) return;
|
||||||
|
try {
|
||||||
|
setExportando(true);
|
||||||
|
setExportStatus('preparing');
|
||||||
|
setExportProgress({ loaded: 0, total: 0, percent: 0 });
|
||||||
|
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
await rankingService.downloadRankingExcel(
|
||||||
|
filtroSelos,
|
||||||
|
(progress) => {
|
||||||
|
setExportStatus('downloading');
|
||||||
|
setExportProgress(progress);
|
||||||
|
},
|
||||||
|
abortControllerRef.current
|
||||||
|
);
|
||||||
|
|
||||||
|
setExportStatus('complete');
|
||||||
|
setTimeout(() => setExportando(false), 1000);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'CanceledError' || err.code === 'ERR_CANCELED') {
|
||||||
|
console.log('Exportação cancelada pelo usuário');
|
||||||
|
} else {
|
||||||
|
console.error('Erro ao exportar Excel:', err);
|
||||||
|
setExportStatus('error');
|
||||||
|
setTimeout(() => setExportando(false), 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelExport = () => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
setExportando(false);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRanking();
|
loadRanking();
|
||||||
}, [page, pageSize, filtroSelos]);
|
}, [page, pageSize, filtroSelos]);
|
||||||
@@ -220,6 +264,15 @@ function App() {
|
|||||||
Sugerir por Tema
|
Sugerir por Tema
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn-exportar"
|
||||||
|
onClick={handleExportarExcel}
|
||||||
|
disabled={exportando || loading}
|
||||||
|
title="Exportar ranking para Excel (apenas consultores com pontuação)"
|
||||||
|
>
|
||||||
|
{exportando ? 'Exportando...' : '📊 Exportar Excel'}
|
||||||
|
</button>
|
||||||
|
|
||||||
<form className="search-box" onSubmit={handleSubmitBuscar}>
|
<form className="search-box" onSubmit={handleSubmitBuscar}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -290,6 +343,14 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{exportando && (
|
||||||
|
<ExportProgress
|
||||||
|
progress={exportProgress}
|
||||||
|
status={exportStatus}
|
||||||
|
onCancel={handleCancelExport}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>Dados: ATUACAPES (Elasticsearch) + Oracle</p>
|
<p>Dados: ATUACAPES (Elasticsearch) + Oracle</p>
|
||||||
<p>Clique em qualquer consultor para ver detalhes</p>
|
<p>Clique em qualquer consultor para ver detalhes</p>
|
||||||
|
|||||||
196
frontend/src/components/ExportProgress.css
Normal file
196
frontend/src/components/ExportProgress.css
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
.export-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1100;
|
||||||
|
animation: fadeIn 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-modal {
|
||||||
|
background: linear-gradient(155deg, rgba(15, 23, 42, 0.98), rgba(30, 41, 59, 0.95));
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2rem 2.5rem;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 25px 80px rgba(0, 0, 0, 0.6), 0 0 40px rgba(34, 197, 94, 0.1);
|
||||||
|
animation: slideUp 300ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
background: linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(16, 185, 129, 0.15));
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-icon.generating {
|
||||||
|
animation: spin 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
background: linear-gradient(120deg, #86efac, #22c55e);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-status {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-container {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #22c55e, #16a34a);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 150ms ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.3),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.complete {
|
||||||
|
background: linear-gradient(90deg, #22c55e, #16a34a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.complete::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.error {
|
||||||
|
background: linear-gradient(90deg, #ef4444, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill.indeterminate {
|
||||||
|
width: 30%;
|
||||||
|
animation: indeterminate 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes indeterminate {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateX(230%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-generating {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
animation: blink 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 0.5; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-percent {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bytes {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-cancel {
|
||||||
|
padding: 0.6rem 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-cancel:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
border-color: rgba(239, 68, 68, 0.4);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
82
frontend/src/components/ExportProgress.jsx
Normal file
82
frontend/src/components/ExportProgress.jsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import './ExportProgress.css';
|
||||||
|
|
||||||
|
function ExportProgress({ progress, status, onCancel }) {
|
||||||
|
const formatBytes = (bytes) => {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const isGenerating = status === 'preparing' || (status === 'downloading' && progress.total === 0);
|
||||||
|
const isDownloading = status === 'downloading' && progress.total > 0;
|
||||||
|
|
||||||
|
const getStatusText = () => {
|
||||||
|
if (isGenerating) {
|
||||||
|
return 'Gerando arquivo no servidor...';
|
||||||
|
}
|
||||||
|
switch (status) {
|
||||||
|
case 'downloading':
|
||||||
|
return 'Baixando arquivo...';
|
||||||
|
case 'complete':
|
||||||
|
return 'Concluído!';
|
||||||
|
case 'error':
|
||||||
|
return 'Erro na exportação';
|
||||||
|
default:
|
||||||
|
return 'Exportando...';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const percent = Math.min(Math.round(progress.percent || 0), 100);
|
||||||
|
const downloaded = progress.loaded || 0;
|
||||||
|
const total = progress.total || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="export-overlay">
|
||||||
|
<div className="export-modal">
|
||||||
|
<div className={`export-icon ${isGenerating ? 'generating' : ''}`}>
|
||||||
|
{status === 'complete' ? '✓' : status === 'error' ? '✕' : '📊'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="export-title">Exportação Excel</h3>
|
||||||
|
<p className="export-status">{getStatusText()}</p>
|
||||||
|
|
||||||
|
<div className="progress-container">
|
||||||
|
<div className="progress-bar">
|
||||||
|
{isGenerating ? (
|
||||||
|
<div className="progress-fill indeterminate" />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`progress-fill ${status === 'complete' ? 'complete' : ''} ${status === 'error' ? 'error' : ''}`}
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="progress-info">
|
||||||
|
{isGenerating ? (
|
||||||
|
<span className="progress-generating">Processando ~300k registros...</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="progress-percent">{percent}%</span>
|
||||||
|
{total > 0 && (
|
||||||
|
<span className="progress-bytes">
|
||||||
|
{formatBytes(downloaded)} / {formatBytes(total)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status !== 'complete' && status !== 'error' && (
|
||||||
|
<button className="export-cancel" onClick={onCancel}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ExportProgress;
|
||||||
@@ -186,6 +186,73 @@ export const rankingService = {
|
|||||||
const response = await api.get(`/consultor/${idPessoa}/lattes`);
|
const response = await api.get(`/consultor/${idPessoa}/lattes`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getExportInfo(selos = []) {
|
||||||
|
const params = {};
|
||||||
|
if (selos && selos.length > 0) {
|
||||||
|
const normalizados = selos
|
||||||
|
.map((s) => String(s || '').trim().toUpperCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (normalizados.length > 0) {
|
||||||
|
params.selos = normalizados.join(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const response = await api.get('/ranking/exportar/info', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async downloadRankingExcel(selos = [], onProgress = null, abortController = null) {
|
||||||
|
const params = {};
|
||||||
|
if (selos && selos.length > 0) {
|
||||||
|
const normalizados = selos
|
||||||
|
.map((s) => String(s || '').trim().toUpperCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (normalizados.length > 0) {
|
||||||
|
params.selos = normalizados.join(',');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
params,
|
||||||
|
responseType: 'blob',
|
||||||
|
timeout: 300000,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
config.onDownloadProgress = (progressEvent) => {
|
||||||
|
const { loaded, total } = progressEvent;
|
||||||
|
const percent = total ? Math.round((loaded * 100) / total) : 0;
|
||||||
|
onProgress({ loaded, total, percent });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abortController) {
|
||||||
|
config.signal = abortController.signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.get('/ranking/exportar/excel', config);
|
||||||
|
|
||||||
|
const contentDisposition = response.headers['content-disposition'];
|
||||||
|
let nomeArquivo = 'ranking_consultores_capes.xlsx';
|
||||||
|
if (contentDisposition) {
|
||||||
|
const match = contentDisposition.match(/filename="(.+)"/);
|
||||||
|
if (match) {
|
||||||
|
nomeArquivo = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([response.data], {
|
||||||
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = nomeArquivo;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
Reference in New Issue
Block a user