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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user