feat(equipe): implementar montagem de equipe interdisciplinar com PDF

- Adicionar seleção múltipla de consultores entre diferentes buscas
- Criar endpoint POST /api/v1/equipe/pdf para gerar documento da equipe
- Implementar template HTML profissional para PDF da equipe
- Exibir estatísticas: total, ativos, coordenadores, premiados, IES
- Persistir seleção ao trocar termos de busca (equipe interdisciplinar)
- Mostrar temas combinados na barra flutuante e no PDF
- Corrigir renderização de emojis no PDF (substituir por texto)
- Melhorar contraste da cor do ranking no frontend (roxo → ciano)
- Corrigir validação Pydantic para ignorar campos extras do .env
This commit is contained in:
Frederico Castro
2025-12-20 18:30:37 -03:00
parent 81acf1895f
commit bb36961b4c
8 changed files with 1040 additions and 33 deletions

View File

@@ -165,6 +165,49 @@ class PDFService:
return result return result
def gerar_pdf_equipe(self, tema: str, area_avaliacao: str, consultores: list) -> bytes:
from weasyprint import HTML, CSS
template = self.env.get_template("equipe_consultores.html")
ies_set = set()
areas_set = set()
for c in consultores:
if c.get('ies'):
ies_set.add(c['ies'])
for area in c.get('areas_avaliacao', []):
areas_set.add(area)
estatisticas = {
'total': len(consultores),
'ativos': sum(1 for c in consultores if c.get('situacao', '').lower().find('atividade') >= 0),
'coordenadores': sum(1 for c in consultores if c.get('foi_coordenador')),
'premiados': sum(1 for c in consultores if c.get('foi_premiado')),
'ies_distintas': len(ies_set),
'areas_cobertas': len(areas_set),
'lista_ies': sorted(list(ies_set)),
'lista_areas': sorted(list(areas_set)),
}
html_content = template.render(
tema=tema,
area_avaliacao=area_avaliacao,
consultores=consultores,
estatisticas=estatisticas,
data_geracao=datetime.now().strftime("%d/%m/%Y às %H:%M"),
)
css_path = self.template_dir / "styles.css"
pdf_bytes = HTML(
string=html_content,
base_url=str(self.template_dir)
).write_pdf(
stylesheets=[CSS(filename=str(css_path))]
)
return pdf_bytes
class ConsultorWrapper: class ConsultorWrapper:
def __init__(self, data: Dict): def __init__(self, data: Dict):

View File

@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<title>Equipe de Consultores - {{ tema }}</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="documento">
<section class="cover">
<div>
<table class="cover-header">
<tr>
<td>
<strong>MINISTÉRIO DA EDUCAÇÃO</strong><br>
Coordenação de Aperfeiçoamento de Pessoal de Nível Superior
</td>
<td class="right small">Sugestão de Equipe</td>
</tr>
</table>
<div class="cover-title">Equipe de Consultores</div>
<div class="cover-subtitle">Sistema de Ranking AtuaCAPES · Seleção Inteligente</div>
<div class="cover-name">{{ tema }}</div>
{% if area_avaliacao %}
<div class="tag-list">
<span class="badge">{{ area_avaliacao }}</span>
</div>
{% endif %}
</div>
<div class="cover-stats">
<table class="stats-table">
<tr>
<td class="stat-cell">
<div class="stat-value">{{ estatisticas.total }}</div>
<div class="stat-label">Consultores</div>
</td>
<td class="stat-cell">
<div class="stat-value">{{ estatisticas.ativos }}</div>
<div class="stat-label">Ativos</div>
</td>
<td class="stat-cell">
<div class="stat-value">{{ estatisticas.coordenadores }}</div>
<div class="stat-label">Coordenadores</div>
</td>
<td class="stat-cell">
<div class="stat-value">{{ estatisticas.premiados }}</div>
<div class="stat-label">Premiados</div>
</td>
</tr>
<tr>
<td class="stat-cell" colspan="2">
<div class="stat-value">{{ estatisticas.ies_distintas }}</div>
<div class="stat-label">IES Distintas</div>
</td>
<td class="stat-cell" colspan="2">
<div class="stat-value">{{ estatisticas.areas_cobertas }}</div>
<div class="stat-label">Áreas Cobertas</div>
</td>
</tr>
</table>
</div>
<div class="cover-footer">
<p>Documento gerado em {{ data_geracao }}</p>
<p class="small">Ranking de Consultores CAPES · Geração Automática</p>
</div>
</section>
<section class="summary-section">
<h2>Resumo da Equipe</h2>
<div class="summary-grid">
<div class="summary-box">
<h4>Instituições Representadas</h4>
<div class="tag-list">
{% for ies in estatisticas.lista_ies %}
<span class="badge">{{ ies }}</span>
{% endfor %}
</div>
</div>
<div class="summary-box">
<h4>Áreas de Avaliação</h4>
<div class="tag-list">
{% for area in estatisticas.lista_areas %}
<span class="badge info">{{ area }}</span>
{% endfor %}
</div>
</div>
</div>
</section>
<section class="team-section">
<h2>Membros da Equipe</h2>
{% for consultor in consultores %}
<div class="team-member">
<div class="member-header">
<div class="member-rank">#{{ loop.index }}</div>
<div class="member-info">
<div class="member-name">{{ consultor.nome }}</div>
<div class="member-ies">{{ consultor.ies or '-' }}</div>
</div>
<div class="member-badges">
{% if consultor.foi_coordenador %}
<span class="badge success">Coordenador</span>
{% endif %}
{% if consultor.foi_premiado %}
<span class="badge warning">Premiado</span>
{% endif %}
{% if consultor.situacao and 'atividade' in consultor.situacao.lower() %}
<span class="badge">Ativo</span>
{% else %}
<span class="badge muted">Histórico</span>
{% endif %}
</div>
</div>
<div class="member-stats">
{% if consultor.posicao_ranking %}
<div class="stat-inline">
<span class="stat-label">Ranking:</span>
<span class="stat-value-inline">#{{ consultor.posicao_ranking }}</span>
</div>
{% endif %}
{% if consultor.pontuacao_ranking %}
<div class="stat-inline">
<span class="stat-label">Pontos:</span>
<span class="stat-value-inline">{{ consultor.pontuacao_ranking|int }}</span>
</div>
{% endif %}
</div>
{% if consultor.motivos_match %}
<div class="member-motivos">
<strong>Por que este consultor:</strong>
<div class="tag-list">
{% for motivo in consultor.motivos_match %}
<span class="badge info small">{{ motivo }}</span>
{% endfor %}
</div>
</div>
{% endif %}
{% if consultor.areas_avaliacao %}
<div class="member-areas">
<strong>Áreas de Avaliação:</strong>
<span>{{ consultor.areas_avaliacao | join(', ') }}</span>
</div>
{% endif %}
{% if consultor.linhas_pesquisa %}
<div class="member-pesquisa">
<strong>Linhas de Pesquisa:</strong>
<ul>
{% for linha in consultor.linhas_pesquisa[:5] %}
<li>{{ linha }}</li>
{% endfor %}
{% if consultor.linhas_pesquisa|length > 5 %}
<li class="muted">... e mais {{ consultor.linhas_pesquisa|length - 5 }} linhas</li>
{% endif %}
</ul>
</div>
{% endif %}
</div>
{% endfor %}
</section>
<section class="footer-section">
<p class="disclaimer">
Este documento foi gerado automaticamente pelo Sistema de Ranking de Consultores CAPES.
As sugestões são baseadas em análise de relevância temática, posição no ranking geral e diversidade institucional.
</p>
<p class="generation-info">
Gerado em {{ data_geracao }} · Tema: "{{ tema }}"
{% if area_avaliacao %} · Área: {{ area_avaliacao }}{% endif %}
</p>
</section>
</div>
</body>
</html>

View File

@@ -305,3 +305,206 @@ h1, h2, h3, h4 {
.center { .center {
text-align: center; text-align: center;
} }
.cover-stats {
margin: 24px 0;
}
.stats-table {
width: 100%;
border-collapse: collapse;
background: white;
border: 1px solid var(--border);
border-radius: 8px;
}
.stat-cell {
padding: 16px 12px;
text-align: center;
border: 1px solid var(--border);
}
.stat-value {
font-size: 24pt;
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: 9pt;
color: var(--muted);
margin-top: 4px;
}
.summary-section {
page-break-before: always;
padding-top: 20px;
}
.summary-section h2 {
color: var(--accent);
font-size: 14pt;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid var(--accent);
}
.summary-grid {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.summary-box {
flex: 1;
min-width: 200px;
background: var(--bg-soft);
border: 1px solid var(--border);
padding: 16px;
border-radius: 8px;
}
.summary-box h4 {
font-size: 10pt;
color: var(--muted);
margin-bottom: 12px;
}
.team-section {
margin-top: 24px;
}
.team-section h2 {
color: var(--accent);
font-size: 14pt;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid var(--accent);
}
.team-member {
background: var(--bg-soft);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
page-break-inside: avoid;
}
.member-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.member-rank {
background: var(--accent);
color: white;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 11pt;
}
.member-info {
flex: 1;
}
.member-name {
font-size: 12pt;
font-weight: 700;
color: var(--ink);
}
.member-ies {
font-size: 9pt;
color: var(--muted);
}
.member-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.member-stats {
display: flex;
gap: 16px;
margin-bottom: 10px;
}
.stat-inline {
display: flex;
align-items: center;
gap: 4px;
font-size: 9pt;
color: var(--muted);
background: var(--bg-soft);
padding: 4px 8px;
border-radius: 4px;
border: 1px solid var(--border);
}
.stat-label {
font-weight: 600;
color: var(--muted);
}
.stat-value-inline {
font-weight: 700;
color: var(--accent);
}
.member-motivos {
background: white;
border: 1px solid var(--border);
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 10px;
font-size: 9pt;
}
.member-areas {
font-size: 9pt;
margin-bottom: 8px;
}
.member-pesquisa {
font-size: 9pt;
}
.member-pesquisa ul {
margin-left: 16px;
margin-top: 4px;
}
.member-pesquisa li {
margin-bottom: 2px;
}
.footer-section {
margin-top: 32px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.disclaimer {
font-size: 8pt;
color: var(--muted);
font-style: italic;
}
.generation-info {
font-size: 8pt;
color: var(--muted);
margin-top: 8px;
}
.muted {
color: var(--muted);
}

View File

@@ -3,7 +3,7 @@ from typing import List
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
ES_URL: str = "http://localhost:9200" ES_URL: str = "http://localhost:9200"
ES_INDEX: str = "atuacapes" ES_INDEX: str = "atuacapes"

View File

@@ -1,11 +1,23 @@
import asyncio import asyncio
import html
import unicodedata
from io import BytesIO from io import BytesIO
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List
def normalizar_texto(texto: str) -> str:
if not texto:
return ""
texto = html.unescape(texto)
texto = unicodedata.normalize('NFD', texto)
texto = ''.join(c for c in texto if unicodedata.category(c) != 'Mn')
return texto.lower()
from ...application.use_cases.obter_ranking import ObterRankingUseCase from ...application.use_cases.obter_ranking import ObterRankingUseCase
from ...application.use_cases.obter_consultor import ObterConsultorUseCase from ...application.use_cases.obter_consultor import ObterConsultorUseCase
from ...infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl from ...infrastructure.repositories.consultor_repository_impl import ConsultorRepositoryImpl
@@ -415,6 +427,61 @@ async def exportar_ficha_pdf(
) )
class ConsultorEquipeSchema(BaseModel):
id_pessoa: int
nome: str
ies: Optional[str] = None
areas_avaliacao: List[str] = []
areas_conhecimento: List[str] = []
linhas_pesquisa: List[str] = []
situacao: str = ""
foi_coordenador: bool = False
foi_premiado: bool = False
posicao_ranking: Optional[int] = None
pontuacao_ranking: float = 0
motivos_match: List[str] = []
class GerarEquipePdfRequest(BaseModel):
tema: str
area_avaliacao: Optional[str] = None
consultores: List[ConsultorEquipeSchema]
@router.post("/equipe/pdf")
async def gerar_pdf_equipe(
request: GerarEquipePdfRequest,
):
from ...application.services.pdf_service import PDFService
if not request.consultores:
raise HTTPException(status_code=400, detail="Nenhum consultor selecionado")
try:
pdf_service = PDFService()
consultores_dict = [c.model_dump() for c in request.consultores]
pdf_bytes = pdf_service.gerar_pdf_equipe(
tema=request.tema,
area_avaliacao=request.area_avaliacao,
consultores=consultores_dict
)
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Erro ao gerar PDF: {str(e)}")
tema_sanitizado = "".join(c if c.isalnum() or c in " -_" else "_" for c in request.tema)
nome_arquivo = f"equipe_{tema_sanitizado[:30]}_{datetime.now().strftime('%Y%m%d')}.pdf"
return StreamingResponse(
BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f'attachment; filename="{nome_arquivo}"'
}
)
@router.get("/consultores/sugerir", response_model=SugerirConsultoresResponseSchema) @router.get("/consultores/sugerir", response_model=SugerirConsultoresResponseSchema)
async def sugerir_consultores( async def sugerir_consultores(
tema: str = Query(..., min_length=2, description="Tema ou assunto para buscar consultores"), tema: str = Query(..., min_length=2, description="Tema ou assunto para buscar consultores"),
@@ -429,10 +496,10 @@ async def sugerir_consultores(
tema=tema, tema=tema,
area_avaliacao=area_avaliacao, area_avaliacao=area_avaliacao,
apenas_ativos=apenas_ativos, apenas_ativos=apenas_ativos,
size=quantidade size=quantidade * 3
) )
consultores = [] consultores_raw = []
for doc in resultados: for doc in resultados:
id_pessoa = doc.get("id") id_pessoa = doc.get("id")
nome = doc.get("dadosPessoais", {}).get("nome", "") nome = doc.get("dadosPessoais", {}).get("nome", "")
@@ -445,6 +512,10 @@ async def sugerir_consultores(
ies = None ies = None
foi_coordenador = False foi_coordenador = False
foi_premiado = False foi_premiado = False
motivos_match = set()
tema_norm = normalizar_texto(tema)
tema_palavras = tema_norm.split()
for atuacao in doc.get("atuacoes", []): for atuacao in doc.get("atuacoes", []):
tipo = atuacao.get("tipo", "") tipo = atuacao.get("tipo", "")
@@ -457,14 +528,26 @@ async def sugerir_consultores(
for area in dados.get("areaConhecimentoPos", []): for area in dados.get("areaConhecimentoPos", []):
if area.get("nome"): if area.get("nome"):
areas_conhecimento.add(area["nome"]) area_nome = html.unescape(area["nome"])
areas_conhecimento.add(area_nome)
area_norm = normalizar_texto(area["nome"])
if tema_norm in area_norm or any(p in area_norm for p in tema_palavras if len(p) > 3):
motivos_match.add(f"Area: {area_nome}")
area_aval = area.get("areaAvaliacao", {}) area_aval = area.get("areaAvaliacao", {})
if area_aval and area_aval.get("nome"): if area_aval and area_aval.get("nome"):
areas_avaliacao.add(area_aval["nome"]) aval_nome = html.unescape(area_aval["nome"])
areas_avaliacao.add(aval_nome)
aval_norm = normalizar_texto(area_aval["nome"])
if tema_norm in aval_norm or any(p in aval_norm for p in tema_palavras if len(p) > 3):
motivos_match.add(f"Area Avaliacao: {aval_nome}")
for pesq in dados.get("areaPesquisa", []): for pesq in dados.get("areaPesquisa", []):
if pesq.get("descricao"): if pesq.get("descricao"):
linhas_pesquisa.add(pesq["descricao"]) pesq_desc = html.unescape(pesq["descricao"])
linhas_pesquisa.add(pesq_desc)
pesq_norm = normalizar_texto(pesq["descricao"])
if tema_norm in pesq_norm or any(p in pesq_norm for p in tema_palavras if len(p) > 3):
motivos_match.add(f"Pesquisa: {pesq_desc[:50]}...")
elif "Coordenação" in tipo: elif "Coordenação" in tipo:
foi_coordenador = True foi_coordenador = True
@@ -473,23 +556,79 @@ async def sugerir_consultores(
foi_premiado = True foi_premiado = True
posicao_ranking = None posicao_ranking = None
pontuacao_ranking = 0
if store.is_ready(): if store.is_ready():
entry = store.get_by_id(id_pessoa) entry = store.get_by_id(id_pessoa)
if entry: if entry:
posicao_ranking = entry.posicao posicao_ranking = entry.posicao
pontuacao_ranking = entry.pontuacao_total
consultores.append(SugestaoConsultorSchema( score_final = score_match
id_pessoa=id_pessoa, if posicao_ranking:
nome=nome, bonus_ranking = max(0, (10000 - posicao_ranking) / 100)
score_match=score_match, score_final += bonus_ranking
areas_avaliacao=list(areas_avaliacao), if foi_coordenador:
areas_conhecimento=list(areas_conhecimento), score_final += 20
linhas_pesquisa=list(linhas_pesquisa), motivos_match.add("Foi Coordenador de Area")
situacao=situacao, if foi_premiado:
ies=ies, score_final += 10
foi_coordenador=foi_coordenador, motivos_match.add("Foi Premiado")
foi_premiado=foi_premiado,
)) consultores_raw.append({
"id_pessoa": id_pessoa,
"nome": nome,
"score_match": score_match,
"score_final": score_final,
"posicao_ranking": posicao_ranking,
"pontuacao_ranking": pontuacao_ranking,
"areas_avaliacao": list(areas_avaliacao),
"areas_conhecimento": list(areas_conhecimento),
"linhas_pesquisa": list(linhas_pesquisa),
"situacao": situacao,
"ies": ies,
"foi_coordenador": foi_coordenador,
"foi_premiado": foi_premiado,
"motivos_match": list(motivos_match)[:5],
})
consultores_raw.sort(key=lambda x: x["score_final"], reverse=True)
ies_selecionadas = set()
consultores_finais = []
for c in consultores_raw:
if len(consultores_finais) >= quantidade:
break
if c["ies"] and c["ies"] in ies_selecionadas and len(consultores_finais) < quantidade // 2:
continue
consultores_finais.append(c)
if c["ies"]:
ies_selecionadas.add(c["ies"])
while len(consultores_finais) < quantidade and len(consultores_finais) < len(consultores_raw):
for c in consultores_raw:
if c not in consultores_finais:
consultores_finais.append(c)
if len(consultores_finais) >= quantidade:
break
consultores = [
SugestaoConsultorSchema(
id_pessoa=c["id_pessoa"],
nome=c["nome"],
score_match=c["score_final"],
areas_avaliacao=c["areas_avaliacao"],
areas_conhecimento=c["areas_conhecimento"],
linhas_pesquisa=c["linhas_pesquisa"],
situacao=c["situacao"],
ies=c["ies"],
foi_coordenador=c["foi_coordenador"],
foi_premiado=c["foi_premiado"],
posicao_ranking=c["posicao_ranking"],
pontuacao_ranking=c["pontuacao_ranking"],
motivos_match=c["motivos_match"],
)
for c in consultores_finais
]
return SugerirConsultoresResponseSchema( return SugerirConsultoresResponseSchema(
tema_buscado=tema, tema_buscado=tema,

View File

@@ -104,6 +104,9 @@ class SugestaoConsultorSchema(BaseModel):
ies: Optional[str] = None ies: Optional[str] = None
foi_coordenador: bool = False foi_coordenador: bool = False
foi_premiado: bool = False foi_premiado: bool = False
posicao_ranking: Optional[int] = None
pontuacao_ranking: float = 0
motivos_match: List[str] = []
class SugerirConsultoresRequestSchema(BaseModel): class SugerirConsultoresRequestSchema(BaseModel):

View File

@@ -32,6 +32,10 @@
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(96, 165, 250, 0.05)); background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(96, 165, 250, 0.05));
} }
.sugerir-header-content {
flex: 1;
}
.sugerir-header h2 { .sugerir-header h2 {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
@@ -39,6 +43,13 @@
color: var(--accent-2); color: var(--accent-2);
} }
.sugerir-subtitle {
margin: 0.25rem 0 0 0;
font-size: 0.75rem;
color: var(--muted);
font-weight: 400;
}
.sugerir-close { .sugerir-close {
background: none; background: none;
border: none; border: none;
@@ -173,13 +184,25 @@
flex-direction: column; flex-direction: column;
} }
.sugerir-resultados h3 { .sugerir-resultados-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-bottom: 1px solid var(--stroke);
}
.sugerir-resultados h3 {
margin: 0; margin: 0;
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
color: var(--silver); color: var(--silver);
border-bottom: 1px solid var(--stroke); }
.sugerir-hint {
font-size: 0.75rem;
color: var(--muted);
font-style: italic;
} }
.sugerir-lista { .sugerir-lista {
@@ -192,16 +215,56 @@
padding: 1rem; padding: 1rem;
margin: 0.5rem; margin: 0.5rem;
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--stroke); border: 2px solid var(--stroke);
border-radius: 10px; border-radius: 10px;
cursor: pointer; cursor: pointer;
transition: background 0.2s, border-color 0.2s, transform 0.2s; transition: background 0.2s, border-color 0.2s, transform 0.2s, box-shadow 0.2s;
} }
.sugerir-item:hover { .sugerir-item:hover {
background: rgba(99, 102, 241, 0.1); background: rgba(99, 102, 241, 0.1);
border-color: rgba(99, 102, 241, 0.3); border-color: rgba(99, 102, 241, 0.3);
transform: translateX(4px); }
.sugerir-item.selecionado {
background: rgba(16, 185, 129, 0.1);
border-color: rgba(16, 185, 129, 0.5);
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.3);
}
.sugerir-item.selecionado:hover {
background: rgba(16, 185, 129, 0.15);
}
.sugerir-checkbox-item {
display: flex;
align-items: center;
margin-right: 0.5rem;
}
.sugerir-checkbox-item input[type="checkbox"] {
width: 1.2rem;
height: 1.2rem;
accent-color: var(--accent);
cursor: pointer;
}
.btn-ver-ranking {
background: rgba(255, 255, 255, 0.1);
border: 1px solid var(--stroke);
color: var(--silver);
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
margin-left: auto;
}
.btn-ver-ranking:hover {
background: var(--accent);
color: white;
border-color: var(--accent);
} }
.sugerir-item-header { .sugerir-item-header {
@@ -262,6 +325,72 @@
border: 1px solid rgba(107, 114, 128, 0.4); border: 1px solid rgba(107, 114, 128, 0.4);
} }
.sugerir-item-stats {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.stat-ranking {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: #22d3ee;
font-weight: 600;
background: rgba(34, 211, 238, 0.15);
padding: 0.25rem 0.6rem;
border-radius: 6px;
border: 1px solid rgba(34, 211, 238, 0.3);
}
.stat-icon {
font-size: 0.9rem;
}
.stat-pontuacao {
font-size: 0.75rem;
color: #fcd34d;
font-weight: 600;
background: rgba(234, 179, 8, 0.15);
padding: 0.2rem 0.5rem;
border-radius: 4px;
border: 1px solid rgba(234, 179, 8, 0.3);
}
.sugerir-motivos {
background: rgba(16, 185, 129, 0.05);
border: 1px solid rgba(16, 185, 129, 0.15);
border-radius: 8px;
padding: 0.6rem 0.8rem;
margin-bottom: 0.5rem;
}
.motivos-label {
font-size: 0.7rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
display: block;
margin-bottom: 0.4rem;
}
.motivos-lista {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.tag.motivo {
background: rgba(16, 185, 129, 0.15);
color: #6ee7b7;
border: 1px solid rgba(16, 185, 129, 0.3);
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
}
.sugerir-item-details { .sugerir-item-details {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -271,8 +400,11 @@
.sugerir-ies { .sugerir-ies {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--muted); color: var(--silver);
font-weight: 500; font-weight: 500;
background: rgba(255, 255, 255, 0.05);
padding: 0.2rem 0.5rem;
border-radius: 4px;
} }
.sugerir-areas { .sugerir-areas {
@@ -316,6 +448,126 @@
white-space: nowrap; white-space: nowrap;
} }
.equipe-flutuante {
position: sticky;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, rgba(16, 185, 129, 0.95), rgba(5, 150, 105, 0.95));
backdrop-filter: blur(8px);
padding: 1rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
}
.equipe-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.equipe-count {
font-size: 1rem;
font-weight: 700;
color: white;
}
.equipe-nomes {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.equipe-nome {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.15);
padding: 0.15rem 0.5rem;
border-radius: 4px;
}
.equipe-acoes {
display: flex;
gap: 0.75rem;
}
.btn-limpar {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 0.6rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s;
}
.btn-limpar:hover {
background: rgba(255, 255, 255, 0.25);
}
.btn-gerar-pdf {
background: white;
border: none;
color: #059669;
padding: 0.6rem 1.25rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 700;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.btn-gerar-pdf:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.btn-gerar-pdf:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.equipe-header-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.equipe-interdisciplinar {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
background: rgba(255, 255, 255, 0.25);
color: white;
padding: 0.2rem 0.6rem;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.4);
}
.equipe-temas {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.25rem;
}
.equipe-tema {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.9);
background: rgba(0, 0, 0, 0.2);
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-style: italic;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.sugerir-modal { .sugerir-modal {
max-height: 95vh; max-height: 95vh;
@@ -334,4 +586,18 @@
width: 100%; width: 100%;
margin-top: 0.5rem; margin-top: 0.5rem;
} }
.equipe-flutuante {
flex-direction: column;
padding: 1rem;
}
.equipe-acoes {
width: 100%;
justify-content: stretch;
}
.btn-gerar-pdf {
flex: 1;
}
} }

View File

@@ -13,6 +13,8 @@ const SugerirConsultores = ({ onClose, onSelectConsultor }) => {
const [loadingAreas, setLoadingAreas] = useState(true); const [loadingAreas, setLoadingAreas] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [buscaRealizada, setBuscaRealizada] = useState(false); const [buscaRealizada, setBuscaRealizada] = useState(false);
const [selecionados, setSelecionados] = useState([]);
const [gerandoPdf, setGerandoPdf] = useState(false);
useEffect(() => { useEffect(() => {
const carregarAreas = async () => { const carregarAreas = async () => {
@@ -51,6 +53,17 @@ const SugerirConsultores = ({ onClose, onSelectConsultor }) => {
} }
}; };
const toggleSelecionado = (consultor, e) => {
e.stopPropagation();
setSelecionados((prev) => {
const existe = prev.find((c) => c.id_pessoa === consultor.id_pessoa);
if (existe) {
return prev.filter((c) => c.id_pessoa !== consultor.id_pessoa);
}
return [...prev, { ...consultor, _busca_tema: tema, _busca_area: areaAvaliacao }];
});
};
const handleClickConsultor = (consultor) => { const handleClickConsultor = (consultor) => {
if (onSelectConsultor) { if (onSelectConsultor) {
onSelectConsultor(consultor.id_pessoa); onSelectConsultor(consultor.id_pessoa);
@@ -58,17 +71,81 @@ const SugerirConsultores = ({ onClose, onSelectConsultor }) => {
onClose(); onClose();
}; };
const handleGerarPdf = async () => {
if (selecionados.length === 0) return;
try {
setGerandoPdf(true);
setError(null);
const temasUnicos = [...new Set(selecionados.map((c) => c._busca_tema).filter(Boolean))];
const areasUnicas = [...new Set(selecionados.map((c) => c._busca_area).filter(Boolean))];
const temasCombinados = temasUnicos.length > 0 ? temasUnicos.join(' + ') : 'Equipe Selecionada';
const areasCombinadas = areasUnicas.length > 0 ? areasUnicas.join(', ') : null;
const response = await fetch('/api/v1/equipe/pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tema: temasCombinados,
area_avaliacao: areasCombinadas,
consultores: selecionados.map((c) => ({
id_pessoa: c.id_pessoa,
nome: c.nome,
ies: c.ies,
areas_avaliacao: c.areas_avaliacao,
areas_conhecimento: c.areas_conhecimento,
linhas_pesquisa: c.linhas_pesquisa,
situacao: c.situacao,
foi_coordenador: c.foi_coordenador,
foi_premiado: c.foi_premiado,
posicao_ranking: c.posicao_ranking,
pontuacao_ranking: c.pontuacao_ranking,
motivos_match: c.motivos_match,
})),
}),
});
if (!response.ok) {
throw new Error('Erro ao gerar PDF');
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const nomeArquivo = temasUnicos.length > 1
? `equipe_interdisciplinar_${new Date().toISOString().slice(0, 10)}.pdf`
: `equipe_${temasCombinados.replace(/\s+/g, '_')}_${new Date().toISOString().slice(0, 10)}.pdf`;
a.download = nomeArquivo;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} catch (err) {
console.error('Erro ao gerar PDF:', err);
setError('Erro ao gerar PDF da equipe. Tente novamente.');
} finally {
setGerandoPdf(false);
}
};
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
onClose(); onClose();
} }
}; };
const isSelecionado = (id) => selecionados.some((c) => c.id_pessoa === id);
return ( return (
<div className="sugerir-overlay" onClick={onClose} onKeyDown={handleKeyDown}> <div className="sugerir-overlay" onClick={onClose} onKeyDown={handleKeyDown}>
<div className="sugerir-modal" onClick={(e) => e.stopPropagation()}> <div className="sugerir-modal" onClick={(e) => e.stopPropagation()}>
<div className="sugerir-header"> <div className="sugerir-header">
<h2>Sugerir Consultores por Tema</h2> <div className="sugerir-header-content">
<h2>Montar Equipe de Consultores</h2>
<p className="sugerir-subtitle">Selecione consultores para gerar documento da equipe</p>
</div>
<button className="sugerir-close" onClick={onClose}>×</button> <button className="sugerir-close" onClick={onClose}>×</button>
</div> </div>
@@ -140,21 +217,33 @@ const SugerirConsultores = ({ onClose, onSelectConsultor }) => {
{buscaRealizada && ( {buscaRealizada && (
<div className="sugerir-resultados"> <div className="sugerir-resultados">
<div className="sugerir-resultados-header">
<h3> <h3>
{consultores.length > 0 {consultores.length > 0
? `${consultores.length} consultores encontrados` ? `${consultores.length} consultores para "${tema}"`
: 'Nenhum consultor encontrado'} : 'Nenhum consultor encontrado'}
</h3> </h3>
{consultores.length > 0 && (
<span className="sugerir-hint">Selecione os consultores para a equipe</span>
)}
</div>
{consultores.length > 0 && ( {consultores.length > 0 && (
<div className="sugerir-lista"> <div className="sugerir-lista">
{consultores.map((c, index) => ( {consultores.map((c, index) => (
<div <div
key={c.id_pessoa} key={c.id_pessoa}
className="sugerir-item" className={`sugerir-item ${isSelecionado(c.id_pessoa) ? 'selecionado' : ''}`}
onClick={() => handleClickConsultor(c)} onClick={(e) => toggleSelecionado(c, e)}
> >
<div className="sugerir-item-header"> <div className="sugerir-item-header">
<label className="sugerir-checkbox-item" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelecionado(c.id_pessoa)}
onChange={(e) => toggleSelecionado(c, e)}
/>
</label>
<span className="sugerir-rank">#{index + 1}</span> <span className="sugerir-rank">#{index + 1}</span>
<span className="sugerir-nome">{c.nome}</span> <span className="sugerir-nome">{c.nome}</span>
<div className="sugerir-badges"> <div className="sugerir-badges">
@@ -166,9 +255,45 @@ const SugerirConsultores = ({ onClose, onSelectConsultor }) => {
<span className="badge inativo">Inativo</span> <span className="badge inativo">Inativo</span>
)} )}
</div> </div>
<button
className="btn-ver-ranking"
onClick={(e) => {
e.stopPropagation();
handleClickConsultor(c);
}}
title="Ver no ranking"
>
</button>
</div> </div>
<div className="sugerir-item-details">
<div className="sugerir-item-stats">
{c.posicao_ranking && (
<span className="stat-ranking" title="Posicao no ranking geral">
<span className="stat-icon">📊</span>
#{c.posicao_ranking.toLocaleString()} no ranking
</span>
)}
{c.pontuacao_ranking > 0 && (
<span className="stat-pontuacao" title="Pontuacao no ranking">
{c.pontuacao_ranking.toFixed(0)} pts
</span>
)}
{c.ies && <span className="sugerir-ies">{c.ies}</span>} {c.ies && <span className="sugerir-ies">{c.ies}</span>}
</div>
{c.motivos_match && c.motivos_match.length > 0 && (
<div className="sugerir-motivos">
<span className="motivos-label">Por que este consultor:</span>
<div className="motivos-lista">
{c.motivos_match.map((motivo, idx) => (
<span key={idx} className="tag motivo">{motivo}</span>
))}
</div>
</div>
)}
<div className="sugerir-item-details">
{c.areas_avaliacao.length > 0 && ( {c.areas_avaliacao.length > 0 && (
<div className="sugerir-areas"> <div className="sugerir-areas">
{c.areas_avaliacao.slice(0, 3).map((area) => ( {c.areas_avaliacao.slice(0, 3).map((area) => (
@@ -195,6 +320,50 @@ const SugerirConsultores = ({ onClose, onSelectConsultor }) => {
)} )}
</div> </div>
)} )}
{selecionados.length > 0 && (
<div className="equipe-flutuante">
<div className="equipe-info">
<div className="equipe-header-row">
<span className="equipe-count">{selecionados.length} selecionado{selecionados.length > 1 ? 's' : ''}</span>
{(() => {
const temas = [...new Set(selecionados.map((c) => c._busca_tema).filter(Boolean))];
return temas.length > 1 && (
<span className="equipe-interdisciplinar">Interdisciplinar</span>
);
})()}
</div>
<div className="equipe-temas">
{(() => {
const temas = [...new Set(selecionados.map((c) => c._busca_tema).filter(Boolean))];
return temas.map((t) => (
<span key={t} className="equipe-tema">{t}</span>
));
})()}
</div>
<div className="equipe-nomes">
{selecionados.slice(0, 5).map((c) => (
<span key={c.id_pessoa} className="equipe-nome">{c.nome.split(' ')[0]}</span>
))}
{selecionados.length > 5 && (
<span className="equipe-nome">+{selecionados.length - 5}</span>
)}
</div>
</div>
<div className="equipe-acoes">
<button className="btn-limpar" onClick={() => setSelecionados([])}>
Limpar
</button>
<button
className="btn-gerar-pdf"
onClick={handleGerarPdf}
disabled={gerandoPdf}
>
{gerandoPdf ? 'Gerando...' : 'Gerar PDF da Equipe'}
</button>
</div>
</div>
)}
</div> </div>
</div> </div>
); );