feat(frontend): adicionar modal de insights explicando posicao no ranking

- Criar funcao gerarInsights() com regras baseadas nos criterios oficiais
- Adicionar botao "?" junto a posicao para abrir modal de insights
- Mostrar percentil, bloco dominante, coordenacoes, premiacoes, etc
- Calcular aproveitamento dos tetos por bloco
This commit is contained in:
Frederico Castro
2025-12-23 22:11:40 -03:00
parent 26a478be05
commit ab1cb1ba8c
3 changed files with 229 additions and 1 deletions

View File

@@ -285,6 +285,80 @@
transform: scale(0.95);
}
.rank {
position: relative;
}
.btn-insights {
position: absolute;
top: -6px;
right: -6px;
width: 18px;
height: 18px;
padding: 0;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
border: none;
border-radius: 50%;
color: white;
font-size: 0.7rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
z-index: 1;
}
.btn-insights:hover {
transform: scale(1.15);
box-shadow: 0 3px 8px rgba(99, 102, 241, 0.4);
}
.btn-insights:active {
transform: scale(0.95);
}
.insights-modal {
max-width: 420px;
}
.insights-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.insight-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.6rem 0.8rem;
background: rgba(255,255,255,0.03);
border-radius: 8px;
border-left: 3px solid var(--accent);
}
.insight-icone {
font-size: 1.1rem;
flex-shrink: 0;
}
.insight-texto {
font-size: 0.9rem;
color: var(--text-secondary);
line-height: 1.4;
}
.insights-footer {
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--stroke);
text-align: center;
color: var(--muted);
}
.expand-icon {
margin-left: 0.5rem;
color: var(--muted);

View File

@@ -878,6 +878,138 @@ const PONTOS_BASE = {
EVENTO: 1, PROJ: 10,
};
const TETOS_BLOCO = { A: 450, B: 230, C: 500, D: 300 };
const gerarInsights = (consultor, totalConsultores) => {
const insights = [];
const posicao = consultor.posicao || consultor.rank || 0;
const pontuacao = consultor.pontuacao_total || 0;
const blocoA = consultor.bloco_a || 0;
const blocoB = consultor.bloco_b || 0;
const blocoC = consultor.bloco_c || 0;
const blocoD = consultor.bloco_d || 0;
if (totalConsultores > 0 && posicao > 0) {
const percentil = ((totalConsultores - posicao) / totalConsultores * 100);
if (percentil >= 99) {
insights.push({ icone: '🏆', texto: `Top 1% - Elite dos ${totalConsultores.toLocaleString('pt-BR')} consultores` });
} else if (percentil >= 95) {
insights.push({ icone: '⭐', texto: `Top 5% entre ${totalConsultores.toLocaleString('pt-BR')} consultores` });
} else if (percentil >= 90) {
insights.push({ icone: '📈', texto: `Top 10% no ranking geral` });
} else if (percentil >= 75) {
insights.push({ icone: '✓', texto: `Acima de 75% dos consultores` });
} else if (percentil >= 50) {
insights.push({ icone: '→', texto: `Metade superior do ranking` });
}
}
const blocos = [
{ nome: 'Coordenação CAPES', letra: 'A', valor: blocoA, teto: TETOS_BLOCO.A },
{ nome: 'Consultoria', letra: 'B', valor: blocoB, teto: TETOS_BLOCO.B },
{ nome: 'Avaliações/Premiações', letra: 'C', valor: blocoC, teto: TETOS_BLOCO.C },
{ nome: 'Indicadores', letra: 'D', valor: blocoD, teto: TETOS_BLOCO.D },
];
const blocosAtivos = blocos.filter(b => b.valor > 0);
if (blocosAtivos.length > 0 && pontuacao > 0) {
const maiorBloco = blocosAtivos.reduce((a, b) => a.valor > b.valor ? a : b);
const pct = Math.round(maiorBloco.valor / pontuacao * 100);
if (pct >= 50) {
insights.push({ icone: '📊', texto: `${pct}% da pontuação vem de ${maiorBloco.nome} (Bloco ${maiorBloco.letra})` });
}
}
const coords = consultor.coordenacoes_capes || [];
const coordAtiva = coords.find(c => c.ativo ?? !c.fim);
if (coordAtiva) {
const labels = { CA: 'Coordenador de Área', CAJ: 'Coordenador Adjunto', CAJ_MP: 'Coord. Adjunto MP', CAM: 'Câmara Temática' };
insights.push({ icone: '🎯', texto: `${labels[coordAtiva.codigo] || coordAtiva.codigo} em exercício` });
} else if (coords.length > 0) {
insights.push({ icone: '📜', texto: `Histórico de ${coords.length} coordenação(ões) CAPES` });
}
if (blocoA >= 300) {
insights.push({ icone: '🌟', texto: 'Destaque em Coordenação CAPES' });
}
const consultoria = consultor.consultoria || {};
if (consultor.ativo && consultoria.anos_consecutivos >= 8) {
insights.push({ icone: '💎', texto: `${consultoria.anos_consecutivos} anos consecutivos de consultoria (+bônus continuidade)` });
} else if (consultor.ativo && consultoria.anos_consecutivos >= 5) {
insights.push({ icone: '🔷', texto: `${consultoria.anos_consecutivos} anos consecutivos de consultoria` });
}
if (consultoria.retornos > 0) {
insights.push({ icone: '🔄', texto: `Retorno à consultoria (+bônus reativação)` });
}
const premiacoes = consultor.premiacoes || [];
const gps = premiacoes.filter(p => p.codigo === 'PREMIACAO_GP_AUTOR');
const premios = premiacoes.filter(p => p.codigo === 'PREMIACAO_AUTOR');
if (gps.length > 0) {
insights.push({ icone: '🏆', texto: `${gps.length}x Grande Prêmio CAPES` });
} else if (premios.length > 0) {
insights.push({ icone: '🥇', texto: `${premios.length}x Prêmio CAPES` });
}
const anos = consultor.anos_atuacao || 0;
if (anos >= 15) {
insights.push({ icone: '👑', texto: `${anos} anos de contribuição ao SNPG` });
} else if (anos >= 10) {
insights.push({ icone: '🎖️', texto: `Veterano com ${anos} anos de atuação` });
}
blocosAtivos.forEach(b => {
const aproveitamento = Math.round(b.valor / b.teto * 100);
if (aproveitamento >= 80) {
insights.push({ icone: '✅', texto: `${b.nome}: ${aproveitamento}% do teto (${b.valor}/${b.teto})` });
}
});
if (insights.length === 0) {
insights.push({ icone: '📋', texto: `Pontuação total: ${pontuacao} pontos` });
if (posicao > 0) {
insights.push({ icone: '📍', texto: `Posição #${posicao.toLocaleString('pt-BR')} no ranking` });
}
}
return insights;
};
const InsightsModal = ({ consultor, totalConsultores, onClose }) => {
const insights = useMemo(() => gerarInsights(consultor, totalConsultores), [consultor, totalConsultores]);
const posicao = consultor.posicao || consultor.rank || 0;
return createPortal(
<div className="tipo-modal-overlay" onClick={onClose}>
<div className="tipo-modal insights-modal" onClick={(e) => e.stopPropagation()}>
<div className="tipo-modal-header">
<span className="modal-titulo-item">
<span className="modal-titulo-icone">💡</span>
<span>Por que estou na posição #{posicao}?</span>
</span>
<button className="tipo-modal-close" onClick={onClose}></button>
</div>
<div className="tipo-modal-body">
<div className="insights-list">
{insights.map((insight, idx) => (
<div key={idx} className="insight-item">
<span className="insight-icone">{insight.icone}</span>
<span className="insight-texto">{insight.texto}</span>
</div>
))}
</div>
<div className="insights-footer">
<small>Análise baseada nos critérios oficiais do Ranking CAPES V1.0</small>
</div>
</div>
</div>
</div>,
document.body
);
};
const TETOS = {
INSC_AUTOR: { teto: 20, doc: '3.3 Inscrições', bonus: '+2/participação (max 10)' },
INSC_INST_AUTOR: { teto: 50, doc: '3.3 Inscrições', bonus: '+5/participação (max 10)' },
@@ -1081,13 +1213,14 @@ const ScoreItemClickable = ({ value, label, formula, style, onClick }) => (
</div>
);
const ConsultorCard = memo(({ consultor, highlight, selecionado, onToggleSelecionado }) => {
const ConsultorCard = memo(({ consultor, highlight, selecionado, onToggleSelecionado, totalConsultores = 0 }) => {
const [expanded, setExpanded] = useState(false);
const [showRawModal, setShowRawModal] = useState(false);
const [tipoAtuacaoModal, setTipoAtuacaoModal] = useState(null);
const [seloModal, setSeloModal] = useState(null);
const [itemDetalhe, setItemDetalhe] = useState(null);
const [pontuacaoModal, setPontuacaoModal] = useState(null);
const [showInsights, setShowInsights] = useState(false);
const cardRef = useRef(null);
useEffect(() => {
@@ -1121,6 +1254,11 @@ const ConsultorCard = memo(({ consultor, highlight, selecionado, onToggleSelecio
setShowRawModal(true);
};
const handleInsightsClick = (e) => {
e.stopPropagation();
setShowInsights(true);
};
const { consultoria, pontuacao } = consultor;
const blocoA = pontuacao?.bloco_a || { total: consultor.bloco_a || 0 };
const blocoB = pontuacao?.bloco_b || { total: consultor.bloco_b || 0 };
@@ -1145,6 +1283,13 @@ const ConsultorCard = memo(({ consultor, highlight, selecionado, onToggleSelecio
<span className={`rank-number rank-digits-${String(consultor.posicao || consultor.rank).length}`}>
#{consultor.posicao || consultor.rank}
</span>
<button
className="btn-insights"
onClick={handleInsightsClick}
title="Por que estou nesta posição?"
>
?
</button>
</div>
<div className="card-info">
@@ -1556,6 +1701,14 @@ const ConsultorCard = memo(({ consultor, highlight, selecionado, onToggleSelecio
onClose={() => setPontuacaoModal(null)}
/>
)}
{showInsights && (
<InsightsModal
consultor={consultor}
totalConsultores={totalConsultores}
onClose={() => setShowInsights(false)}
/>
)}
</div>
);
});