diff --git a/backend/src/infrastructure/ranking_store.py b/backend/src/infrastructure/ranking_store.py index 4ca3e66..677888d 100644 --- a/backend/src/infrastructure/ranking_store.py +++ b/backend/src/infrastructure/ranking_store.py @@ -3,7 +3,82 @@ from __future__ import annotations import asyncio from dataclasses import dataclass from datetime import datetime -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Set, Tuple + + +SELOS_DISPONIVEIS = [ + "PRESID_CAMARA", + "COORD_PPG", + "BPQ", + "AUTOR_GP", + "AUTOR_PREMIO", + "AUTOR_MENCAO", + "ORIENT_GP", + "ORIENT_PREMIO", + "ORIENT_MENCAO", + "COORIENT_GP", + "COORIENT_PREMIO", + "COORIENT_MENCAO", + "ORIENT_POS_DOC", + "ORIENT_TESE", + "ORIENT_DISS", + "CO_ORIENT_POS_DOC", + "CO_ORIENT_TESE", + "CO_ORIENT_DISS", +] + + +def extrair_selos_entry(detalhes: Dict[str, Any]) -> Set[str]: + selos = set() + + for c in detalhes.get("coordenacoes_capes", []): + if c.get("presidente"): + selos.add("PRESID_CAMARA") + + if detalhes.get("coordenador_ppg"): + selos.add("COORD_PPG") + + if detalhes.get("bolsas_cnpq"): + selos.add("BPQ") + + for prem in detalhes.get("premiacoes", []): + papel = (prem.get("papel") or "").lower() + codigo = prem.get("codigo", "") + + if "GP" in codigo or "grande" in codigo.lower(): + tipo_prem = "GP" + elif "MENCAO" in codigo or "menção" in codigo.lower(): + tipo_prem = "MENCAO" + else: + tipo_prem = "PREMIO" + + if "autor" in papel: + selos.add(f"AUTOR_{tipo_prem}") + elif "orientador" in papel: + selos.add(f"ORIENT_{tipo_prem}") + elif "coorientador" in papel or "co-orientador" in papel: + selos.add(f"COORIENT_{tipo_prem}") + + for orient in detalhes.get("orientacoes", []): + codigo = orient.get("codigo", "") + is_coorient = orient.get("coorientacao", False) + + if is_coorient: + if "POS_DOC" in codigo: + selos.add("CO_ORIENT_POS_DOC") + elif "TESE" in codigo: + selos.add("CO_ORIENT_TESE") + elif "DISS" in codigo: + selos.add("CO_ORIENT_DISS") + else: + if "POS_DOC" in codigo: + selos.add("ORIENT_POS_DOC") + elif "TESE" in codigo: + selos.add("ORIENT_TESE") + elif "DISS" in codigo: + selos.add("ORIENT_DISS") + + return selos @dataclass(frozen=True) @@ -51,17 +126,28 @@ class RankingStore: return sum(1 for e in self._entries if e.ativo == filtro_ativo) def get_page( - self, page: int, size: int, filtro_ativo: Optional[bool] = None + self, + page: int, + size: int, + filtro_ativo: Optional[bool] = None, + filtro_selos: Optional[List[str]] = None, ) -> Tuple[int, List[RankingEntry]]: if page < 1: page = 1 if size < 1: size = 1 - if filtro_ativo is None: - entries = self._entries - else: - entries = [e for e in self._entries if e.ativo == filtro_ativo] + entries = self._entries + + if filtro_ativo is not None: + entries = [e for e in entries if e.ativo == filtro_ativo] + + if filtro_selos: + selos_set = set(filtro_selos) + entries = [ + e for e in entries + if selos_set & extrair_selos_entry(e.detalhes) + ] total = len(entries) start = (page - 1) * size diff --git a/backend/src/interface/api/routes.py b/backend/src/interface/api/routes.py index f8b343c..0d72a6e 100644 --- a/backend/src/interface/api/routes.py +++ b/backend/src/interface/api/routes.py @@ -110,11 +110,43 @@ async def health_check(): return {"status": "ok", "message": "API Ranking CAPES funcionando"} +@router.get("/ranking/selos") +async def listar_selos(): + from ...infrastructure.ranking_store import SELOS_DISPONIVEIS + selos_info = { + "PRESID_CAMARA": {"label": "Presidente Câmara", "icone": "👑", "grupo": "funcoes"}, + "COORD_PPG": {"label": "Coord. PPG", "icone": "🎓", "grupo": "funcoes"}, + "BPQ": {"label": "Bolsista PQ", "icone": "🏅", "grupo": "funcoes"}, + "AUTOR_GP": {"label": "Autor Grande Prêmio", "icone": "🏆", "grupo": "premiacoes"}, + "AUTOR_PREMIO": {"label": "Autor Prêmio", "icone": "🥇", "grupo": "premiacoes"}, + "AUTOR_MENCAO": {"label": "Autor Menção", "icone": "🥈", "grupo": "premiacoes"}, + "ORIENT_GP": {"label": "Orientador GP", "icone": "🏆", "grupo": "premiacoes"}, + "ORIENT_PREMIO": {"label": "Orientador Prêmio", "icone": "🎖️", "grupo": "premiacoes"}, + "ORIENT_MENCAO": {"label": "Orientador Menção", "icone": "📜", "grupo": "premiacoes"}, + "COORIENT_GP": {"label": "Coorientador GP", "icone": "🏆", "grupo": "premiacoes"}, + "COORIENT_PREMIO": {"label": "Coorientador Prêmio", "icone": "🎖️", "grupo": "premiacoes"}, + "COORIENT_MENCAO": {"label": "Coorientador Menção", "icone": "📜", "grupo": "premiacoes"}, + "ORIENT_POS_DOC": {"label": "Orient. Pós-Doc", "icone": "🔬", "grupo": "orientacoes"}, + "ORIENT_TESE": {"label": "Orient. Tese", "icone": "📚", "grupo": "orientacoes"}, + "ORIENT_DISS": {"label": "Orient. Dissertação", "icone": "📄", "grupo": "orientacoes"}, + "CO_ORIENT_POS_DOC": {"label": "Coorient. Pós-Doc", "icone": "🔬", "grupo": "coorientacoes"}, + "CO_ORIENT_TESE": {"label": "Coorient. Tese", "icone": "📚", "grupo": "coorientacoes"}, + "CO_ORIENT_DISS": {"label": "Coorient. Dissertação", "icone": "📄", "grupo": "coorientacoes"}, + } + return { + "selos": [ + {"codigo": s, **selos_info.get(s, {"label": s, "icone": "🏷️", "grupo": "outros"})} + for s in SELOS_DISPONIVEIS + ] + } + + @router.get("/ranking/paginado", response_model=RankingPaginadoResponseSchema) async def ranking_paginado( page: int = Query(default=1, ge=1, description="Número da página"), size: int = Query(default=50, ge=1, le=1000, description="Tamanho da página (máx 1000)"), 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)"), store = Depends(get_ranking_store), ): if not store.is_ready(): @@ -123,7 +155,8 @@ async def ranking_paginado( detail="Ranking ainda não foi processado. Execute POST /api/v1/ranking/processar.", ) - total, entries = store.get_page(page=page, size=size, filtro_ativo=ativo) + filtro_selos = [s.strip() for s in selos.split(",") if s.strip()] if selos else None + total, entries = store.get_page(page=page, size=size, filtro_ativo=ativo, filtro_selos=filtro_selos) total_pages = (total + size - 1) // size diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d7259e5..32ce983 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import Header from './components/Header'; import ConsultorCard from './components/ConsultorCard'; import CompararModal from './components/CompararModal'; +import FiltroSelos from './components/FiltroSelos'; import { rankingService } from './services/api'; import './App.css'; @@ -21,6 +22,7 @@ function App() { const [buscando, setBuscando] = useState(false); const [selecionados, setSelecionados] = useState([]); const [modalAberto, setModalAberto] = useState(false); + const [filtroSelos, setFiltroSelos] = useState([]); const toggleSelecionado = (consultor) => { setSelecionados((prev) => { @@ -42,7 +44,7 @@ function App() { useEffect(() => { loadRanking(); - }, [page, pageSize]); + }, [page, pageSize, filtroSelos]); const loadRanking = async (retryCount = 0) => { const MAX_RETRIES = 10; @@ -52,7 +54,7 @@ function App() { setLoading(true); setError(null); setProcessMessage(''); - const response = await rankingService.getRanking(page, pageSize); + const response = await rankingService.getRanking(page, pageSize, filtroSelos); setConsultores(response.consultores); setTotal(response.total); setTotalPages(response.total_pages || 0); @@ -105,7 +107,7 @@ function App() { throw new Error('Timeout: processamento demorou mais que 45 minutos'); } - const response = await rankingService.getRanking(page, pageSize); + const response = await rankingService.getRanking(page, pageSize, filtroSelos); setConsultores(response.consultores); setTotal(response.total); setTotalPages(response.total_pages || 0); @@ -191,6 +193,11 @@ function App() { + { setFiltroSelos(selos); setPage(1); }} + /> +
{ + const handleClickOutside = (e) => { + if (ref.current && !ref.current.contains(e.target)) { + setAberto(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const toggleSelo = (codigo) => { + if (selecionados.includes(codigo)) { + onChange(selecionados.filter((s) => s !== codigo)); + } else { + onChange([...selecionados, codigo]); + } + }; + + const limparFiltros = (e) => { + e.stopPropagation(); + onChange([]); + }; + + const totalSelos = Object.values(SELOS_CONFIG).reduce( + (acc, g) => acc + g.selos.length, + 0 + ); + + return ( +
+ + + {aberto && ( +
+
+ Selecione os selos para filtrar + {selecionados.length > 0 && ( + + )} +
+ +
+ {Object.entries(SELOS_CONFIG).map(([grupoKey, grupo]) => ( +
+
{grupo.label}
+
+ {grupo.selos.map((selo) => ( + + ))} +
+
+ ))} +
+ +
+ + {selecionados.length} de {totalSelos} selecionado{selecionados.length !== 1 ? 's' : ''} + + +
+
+ )} +
+ ); +} + +export default FiltroSelos; diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 685236e..0d91235 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -9,8 +9,11 @@ const api = axios.create({ }); export const rankingService = { - async getRanking(page = 1, size = 100) { + async getRanking(page = 1, size = 100, selos = []) { const params = { page, size }; + if (selos && selos.length > 0) { + params.selos = selos.join(','); + } const response = await api.get('/ranking/paginado', { params }); const data = response.data; @@ -111,6 +114,11 @@ export const rankingService = { const response = await api.get('/ranking/status'); return response.data; }, + + async getSelos() { + const response = await api.get('/ranking/selos'); + return response.data.selos; + }, }; export default api;