feat(filtros): adicionar filtro multi-select por selos no ranking

- Backend: extrair selos de detalhes e filtrar por eles
- API: endpoint /ranking/selos e parâmetro selos em /ranking/paginado
- Frontend: componente FiltroSelos com dropdown e seleção múltipla
- Selos disponíveis: funções, premiações, orientações
This commit is contained in:
Frederico Castro
2025-12-15 12:32:24 -03:00
parent d215e9ac76
commit c294d4cc77
6 changed files with 527 additions and 11 deletions

View File

@@ -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]
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

View File

@@ -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

View File

@@ -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() {
</select>
</label>
<FiltroSelos
selecionados={filtroSelos}
onChange={(selos) => { setFiltroSelos(selos); setPage(1); }}
/>
<form className="search-box" onSubmit={handleSubmitBuscar}>
<input
type="text"

View File

@@ -0,0 +1,244 @@
.filtro-selos {
position: relative;
}
.filtro-selos-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.9rem;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--stroke);
border-radius: 8px;
color: var(--text);
font-size: 0.9rem;
cursor: pointer;
transition: all 200ms ease;
white-space: nowrap;
}
.filtro-selos-trigger:hover {
border-color: var(--accent-2);
background: rgba(255, 255, 255, 0.08);
}
.filtro-selos-trigger.ativo {
border-color: var(--accent);
background: rgba(79, 70, 229, 0.15);
}
.filtro-icone {
font-size: 1rem;
}
.filtro-label {
font-weight: 500;
}
.filtro-seta {
font-size: 0.7rem;
color: var(--muted);
transition: transform 200ms ease;
}
.filtro-seta.aberto {
transform: rotate(180deg);
}
.filtro-limpar {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 0.7rem;
background: rgba(255, 59, 48, 0.2);
color: #ff6b6b;
border-radius: 50%;
margin-left: 0.25rem;
transition: all 150ms ease;
}
.filtro-limpar:hover {
background: rgba(255, 59, 48, 0.4);
}
.filtro-selos-dropdown {
position: absolute;
top: calc(100% + 8px);
left: 0;
min-width: 340px;
max-width: 420px;
background: linear-gradient(165deg, rgba(15, 23, 42, 0.98), rgba(30, 41, 59, 0.98));
border: 1px solid var(--stroke);
border-radius: 12px;
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4), 0 0 20px rgba(79, 70, 229, 0.1);
z-index: 100;
backdrop-filter: blur(12px);
animation: dropdownSlide 200ms ease;
overflow: hidden;
}
@keyframes dropdownSlide {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.filtro-selos-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--stroke);
font-size: 0.85rem;
color: var(--muted);
}
.filtro-limpar-todos {
padding: 0.3rem 0.6rem;
background: rgba(255, 59, 48, 0.15);
border: 1px solid rgba(255, 59, 48, 0.3);
border-radius: 6px;
color: #ff6b6b;
font-size: 0.75rem;
cursor: pointer;
transition: all 150ms ease;
}
.filtro-limpar-todos:hover {
background: rgba(255, 59, 48, 0.25);
}
.filtro-selos-grupos {
max-height: 320px;
overflow-y: auto;
padding: 0.5rem;
}
.filtro-selos-grupos::-webkit-scrollbar {
width: 6px;
}
.filtro-selos-grupos::-webkit-scrollbar-track {
background: transparent;
}
.filtro-selos-grupos::-webkit-scrollbar-thumb {
background: var(--stroke);
border-radius: 3px;
}
.filtro-grupo {
margin-bottom: 0.75rem;
}
.filtro-grupo:last-child {
margin-bottom: 0;
}
.filtro-grupo-titulo {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent-2);
padding: 0.4rem 0.5rem 0.3rem;
margin-bottom: 0.25rem;
}
.filtro-grupo-selos {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.filtro-selo-item {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.65rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
transition: all 150ms ease;
font-size: 0.8rem;
}
.filtro-selo-item:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--stroke);
}
.filtro-selo-item.selecionado {
background: rgba(79, 70, 229, 0.2);
border-color: var(--accent);
}
.filtro-selo-item input {
display: none;
}
.filtro-selo-item .selo-icone {
font-size: 0.9rem;
}
.filtro-selo-item .selo-label {
color: var(--text);
white-space: nowrap;
}
.filtro-selos-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-top: 1px solid var(--stroke);
background: rgba(0, 0, 0, 0.2);
}
.filtro-info {
font-size: 0.8rem;
color: var(--muted);
}
.filtro-aplicar {
padding: 0.45rem 1rem;
background: linear-gradient(145deg, var(--accent), var(--accent-2));
border: none;
border-radius: 6px;
color: white;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 150ms ease;
}
.filtro-aplicar:hover {
filter: brightness(1.1);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
}
@media (max-width: 480px) {
.filtro-selos-dropdown {
position: fixed;
top: auto;
bottom: 0;
left: 0;
right: 0;
min-width: 100%;
max-width: 100%;
border-radius: 16px 16px 0 0;
max-height: 70vh;
}
.filtro-selos-grupos {
max-height: 50vh;
}
}

View File

@@ -0,0 +1,138 @@
import { useState, useRef, useEffect } from 'react';
import './FiltroSelos.css';
const SELOS_CONFIG = {
funcoes: {
label: 'Funções',
selos: [
{ codigo: 'PRESID_CAMARA', label: 'Presidente Câmara', icone: '👑' },
{ codigo: 'COORD_PPG', label: 'Coord. PPG', icone: '🎓' },
{ codigo: 'BPQ', label: 'Bolsista PQ', icone: '🏅' },
],
},
premiacoes: {
label: 'Premiações',
selos: [
{ codigo: 'AUTOR_GP', label: 'Autor GP', icone: '🏆' },
{ codigo: 'AUTOR_PREMIO', label: 'Autor Prêmio', icone: '🥇' },
{ codigo: 'AUTOR_MENCAO', label: 'Autor Menção', icone: '🥈' },
{ codigo: 'ORIENT_GP', label: 'Orient. GP', icone: '🏆' },
{ codigo: 'ORIENT_PREMIO', label: 'Orient. Prêmio', icone: '🎖️' },
{ codigo: 'ORIENT_MENCAO', label: 'Orient. Menção', icone: '📜' },
],
},
orientacoes: {
label: 'Orientações',
selos: [
{ codigo: 'ORIENT_POS_DOC', label: 'Pós-Doc', icone: '🔬' },
{ codigo: 'ORIENT_TESE', label: 'Tese', icone: '📚' },
{ codigo: 'ORIENT_DISS', label: 'Dissertação', icone: '📄' },
{ codigo: 'CO_ORIENT_POS_DOC', label: 'Co-orient. Pós-Doc', icone: '🔬' },
{ codigo: 'CO_ORIENT_TESE', label: 'Co-orient. Tese', icone: '📚' },
{ codigo: 'CO_ORIENT_DISS', label: 'Co-orient. Diss.', icone: '📄' },
],
},
};
function FiltroSelos({ selecionados, onChange }) {
const [aberto, setAberto] = useState(false);
const ref = useRef(null);
useEffect(() => {
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 (
<div className="filtro-selos" ref={ref}>
<button
className={`filtro-selos-trigger ${selecionados.length > 0 ? 'ativo' : ''}`}
onClick={() => setAberto(!aberto)}
>
<span className="filtro-icone">🏷</span>
<span className="filtro-label">
{selecionados.length > 0
? `${selecionados.length} selo${selecionados.length > 1 ? 's' : ''}`
: 'Filtrar por selos'}
</span>
<span className={`filtro-seta ${aberto ? 'aberto' : ''}`}></span>
{selecionados.length > 0 && (
<span className="filtro-limpar" onClick={limparFiltros} title="Limpar filtros">
</span>
)}
</button>
{aberto && (
<div className="filtro-selos-dropdown">
<div className="filtro-selos-header">
<span>Selecione os selos para filtrar</span>
{selecionados.length > 0 && (
<button className="filtro-limpar-todos" onClick={limparFiltros}>
Limpar ({selecionados.length})
</button>
)}
</div>
<div className="filtro-selos-grupos">
{Object.entries(SELOS_CONFIG).map(([grupoKey, grupo]) => (
<div key={grupoKey} className="filtro-grupo">
<div className="filtro-grupo-titulo">{grupo.label}</div>
<div className="filtro-grupo-selos">
{grupo.selos.map((selo) => (
<label
key={selo.codigo}
className={`filtro-selo-item ${selecionados.includes(selo.codigo) ? 'selecionado' : ''}`}
>
<input
type="checkbox"
checked={selecionados.includes(selo.codigo)}
onChange={() => toggleSelo(selo.codigo)}
/>
<span className="selo-icone">{selo.icone}</span>
<span className="selo-label">{selo.label}</span>
</label>
))}
</div>
</div>
))}
</div>
<div className="filtro-selos-footer">
<span className="filtro-info">
{selecionados.length} de {totalSelos} selecionado{selecionados.length !== 1 ? 's' : ''}
</span>
<button className="filtro-aplicar" onClick={() => setAberto(false)}>
Aplicar
</button>
</div>
</div>
)}
</div>
);
}
export default FiltroSelos;

View File

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