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:
Frederico Castro
2025-12-28 01:45:52 -03:00
parent 015c8f5741
commit 840934a187
9 changed files with 822 additions and 0 deletions

View File

@@ -9,3 +9,4 @@ rich==13.7.0
oracledb==2.5.1
weasyprint>=62.3
jinja2==3.1.2
xlsxwriter==3.2.0

View 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 ""

View File

@@ -394,3 +394,99 @@ class RankingOracleRepository:
raise RuntimeError(f"Erro ao limpar tabela: {e2}")
finally:
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

View File

@@ -992,3 +992,78 @@ async def listar_areas_avaliacao(
return [AreaAvaliacaoSchema(**a) for a in areas]
except Exception as 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)
}

View File

@@ -145,6 +145,34 @@
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 {
display: flex;
align-items: center;

View File

@@ -4,6 +4,7 @@ import ConsultorCard from './components/ConsultorCard';
import CompararModal from './components/CompararModal';
import FiltroSelos from './components/FiltroSelos';
import SugerirConsultores from './components/SugerirConsultores';
import ExportProgress from './components/ExportProgress';
import { rankingService } from './services/api';
import './App.css';
@@ -25,6 +26,10 @@ function App() {
const [modalAberto, setModalAberto] = useState(false);
const [filtroSelos, setFiltroSelos] = useState([]);
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) => {
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(() => {
loadRanking();
}, [page, pageSize, filtroSelos]);
@@ -220,6 +264,15 @@ function App() {
Sugerir por Tema
</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}>
<input
type="text"
@@ -290,6 +343,14 @@ function App() {
/>
)}
{exportando && (
<ExportProgress
progress={exportProgress}
status={exportStatus}
onCancel={handleCancelExport}
/>
)}
<footer>
<p>Dados: ATUACAPES (Elasticsearch) + Oracle</p>
<p>Clique em qualquer consultor para ver detalhes</p>

View 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;
}

View 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;

View File

@@ -186,6 +186,73 @@ export const rankingService = {
const response = await api.get(`/consultor/${idPessoa}/lattes`);
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;