diff --git a/backend/requirements.txt b/backend/requirements.txt index 90c20a8..a0332c6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,3 +9,4 @@ rich==13.7.0 oracledb==2.5.1 weasyprint>=62.3 jinja2==3.1.2 +xlsxwriter==3.2.0 diff --git a/backend/src/application/services/excel_service.py b/backend/src/application/services/excel_service.py new file mode 100644 index 0000000..dba36c9 --- /dev/null +++ b/backend/src/application/services/excel_service.py @@ -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 "" diff --git a/backend/src/infrastructure/oracle/ranking_repository.py b/backend/src/infrastructure/oracle/ranking_repository.py index f83493f..61051ce 100644 --- a/backend/src/infrastructure/oracle/ranking_repository.py +++ b/backend/src/infrastructure/oracle/ranking_repository.py @@ -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 diff --git a/backend/src/interface/api/routes.py b/backend/src/interface/api/routes.py index 5fbcfcb..91c2833 100644 --- a/backend/src/interface/api/routes.py +++ b/backend/src/interface/api/routes.py @@ -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) + } diff --git a/frontend/src/App.css b/frontend/src/App.css index dfaf940..41864a7 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c1dca0e..8e2795c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 + +
)} + {exportando && ( + + )} +