From bb36961b4c12ce4f70e62aa0546079e3df5383b9 Mon Sep 17 00:00:00 2001 From: Frederico Castro Date: Sat, 20 Dec 2025 18:30:37 -0300 Subject: [PATCH] feat(equipe): implementar montagem de equipe interdisciplinar com PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/application/services/pdf_service.py | 43 +++ .../pdf/templates/equipe_consultores.html | 184 ++++++++++++ .../infrastructure/pdf/templates/styles.css | 203 +++++++++++++ backend/src/interface/api/config.py | 2 +- backend/src/interface/api/routes.py | 173 +++++++++-- .../src/interface/schemas/ranking_schema.py | 3 + .../src/components/SugerirConsultores.css | 278 +++++++++++++++++- .../src/components/SugerirConsultores.jsx | 187 +++++++++++- 8 files changed, 1040 insertions(+), 33 deletions(-) create mode 100644 backend/src/infrastructure/pdf/templates/equipe_consultores.html diff --git a/backend/src/application/services/pdf_service.py b/backend/src/application/services/pdf_service.py index 176670b..97e7b4f 100644 --- a/backend/src/application/services/pdf_service.py +++ b/backend/src/application/services/pdf_service.py @@ -165,6 +165,49 @@ class PDFService: 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: def __init__(self, data: Dict): diff --git a/backend/src/infrastructure/pdf/templates/equipe_consultores.html b/backend/src/infrastructure/pdf/templates/equipe_consultores.html new file mode 100644 index 0000000..076ec6b --- /dev/null +++ b/backend/src/infrastructure/pdf/templates/equipe_consultores.html @@ -0,0 +1,184 @@ + + + + + Equipe de Consultores - {{ tema }} + + + +
+
+
+ + + + + +
+ MINISTÉRIO DA EDUCAÇÃO
+ Coordenação de Aperfeiçoamento de Pessoal de Nível Superior +
Sugestão de Equipe
+ +
Equipe de Consultores
+
Sistema de Ranking AtuaCAPES · Seleção Inteligente
+ +
{{ tema }}
+ {% if area_avaliacao %} +
+ {{ area_avaliacao }} +
+ {% endif %} +
+ +
+ + + + + + + + + + + +
+
{{ estatisticas.total }}
+
Consultores
+
+
{{ estatisticas.ativos }}
+
Ativos
+
+
{{ estatisticas.coordenadores }}
+
Coordenadores
+
+
{{ estatisticas.premiados }}
+
Premiados
+
+
{{ estatisticas.ies_distintas }}
+
IES Distintas
+
+
{{ estatisticas.areas_cobertas }}
+
Áreas Cobertas
+
+
+ + +
+ +
+

Resumo da Equipe

+ +
+
+

Instituições Representadas

+
+ {% for ies in estatisticas.lista_ies %} + {{ ies }} + {% endfor %} +
+
+ +
+

Áreas de Avaliação

+
+ {% for area in estatisticas.lista_areas %} + {{ area }} + {% endfor %} +
+
+
+
+ +
+

Membros da Equipe

+ + {% for consultor in consultores %} +
+
+
#{{ loop.index }}
+
+
{{ consultor.nome }}
+
{{ consultor.ies or '-' }}
+
+
+ {% if consultor.foi_coordenador %} + Coordenador + {% endif %} + {% if consultor.foi_premiado %} + Premiado + {% endif %} + {% if consultor.situacao and 'atividade' in consultor.situacao.lower() %} + Ativo + {% else %} + Histórico + {% endif %} +
+
+ +
+ {% if consultor.posicao_ranking %} +
+ Ranking: + #{{ consultor.posicao_ranking }} +
+ {% endif %} + {% if consultor.pontuacao_ranking %} +
+ Pontos: + {{ consultor.pontuacao_ranking|int }} +
+ {% endif %} +
+ + {% if consultor.motivos_match %} +
+ Por que este consultor: +
+ {% for motivo in consultor.motivos_match %} + {{ motivo }} + {% endfor %} +
+
+ {% endif %} + + {% if consultor.areas_avaliacao %} +
+ Áreas de Avaliação: + {{ consultor.areas_avaliacao | join(', ') }} +
+ {% endif %} + + {% if consultor.linhas_pesquisa %} +
+ Linhas de Pesquisa: +
    + {% for linha in consultor.linhas_pesquisa[:5] %} +
  • {{ linha }}
  • + {% endfor %} + {% if consultor.linhas_pesquisa|length > 5 %} +
  • ... e mais {{ consultor.linhas_pesquisa|length - 5 }} linhas
  • + {% endif %} +
+
+ {% endif %} +
+ {% endfor %} +
+ + +
+ + diff --git a/backend/src/infrastructure/pdf/templates/styles.css b/backend/src/infrastructure/pdf/templates/styles.css index ef60fab..1440cb9 100644 --- a/backend/src/infrastructure/pdf/templates/styles.css +++ b/backend/src/infrastructure/pdf/templates/styles.css @@ -305,3 +305,206 @@ h1, h2, h3, h4 { .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); +} diff --git a/backend/src/interface/api/config.py b/backend/src/interface/api/config.py index f1ee067..60e0c55 100644 --- a/backend/src/interface/api/config.py +++ b/backend/src/interface/api/config.py @@ -3,7 +3,7 @@ from typing import List 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_INDEX: str = "atuacapes" diff --git a/backend/src/interface/api/routes.py b/backend/src/interface/api/routes.py index 980e23a..332c8c2 100644 --- a/backend/src/interface/api/routes.py +++ b/backend/src/interface/api/routes.py @@ -1,11 +1,23 @@ import asyncio +import html +import unicodedata from io import BytesIO from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse +from pydantic import BaseModel 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_consultor import ObterConsultorUseCase 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) async def sugerir_consultores( tema: str = Query(..., min_length=2, description="Tema ou assunto para buscar consultores"), @@ -429,10 +496,10 @@ async def sugerir_consultores( tema=tema, area_avaliacao=area_avaliacao, apenas_ativos=apenas_ativos, - size=quantidade + size=quantidade * 3 ) - consultores = [] + consultores_raw = [] for doc in resultados: id_pessoa = doc.get("id") nome = doc.get("dadosPessoais", {}).get("nome", "") @@ -445,6 +512,10 @@ async def sugerir_consultores( ies = None foi_coordenador = False foi_premiado = False + motivos_match = set() + + tema_norm = normalizar_texto(tema) + tema_palavras = tema_norm.split() for atuacao in doc.get("atuacoes", []): tipo = atuacao.get("tipo", "") @@ -457,14 +528,26 @@ async def sugerir_consultores( for area in dados.get("areaConhecimentoPos", []): 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", {}) 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", []): 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: foi_coordenador = True @@ -473,23 +556,79 @@ async def sugerir_consultores( foi_premiado = True posicao_ranking = None + pontuacao_ranking = 0 if store.is_ready(): entry = store.get_by_id(id_pessoa) if entry: posicao_ranking = entry.posicao + pontuacao_ranking = entry.pontuacao_total - consultores.append(SugestaoConsultorSchema( - id_pessoa=id_pessoa, - nome=nome, - score_match=score_match, - 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, - )) + score_final = score_match + if posicao_ranking: + bonus_ranking = max(0, (10000 - posicao_ranking) / 100) + score_final += bonus_ranking + if foi_coordenador: + score_final += 20 + motivos_match.add("Foi Coordenador de Area") + if foi_premiado: + score_final += 10 + motivos_match.add("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( tema_buscado=tema, diff --git a/backend/src/interface/schemas/ranking_schema.py b/backend/src/interface/schemas/ranking_schema.py index fbc3b4d..32689da 100644 --- a/backend/src/interface/schemas/ranking_schema.py +++ b/backend/src/interface/schemas/ranking_schema.py @@ -104,6 +104,9 @@ class SugestaoConsultorSchema(BaseModel): ies: Optional[str] = None foi_coordenador: bool = False foi_premiado: bool = False + posicao_ranking: Optional[int] = None + pontuacao_ranking: float = 0 + motivos_match: List[str] = [] class SugerirConsultoresRequestSchema(BaseModel): diff --git a/frontend/src/components/SugerirConsultores.css b/frontend/src/components/SugerirConsultores.css index 922ede4..145e98e 100644 --- a/frontend/src/components/SugerirConsultores.css +++ b/frontend/src/components/SugerirConsultores.css @@ -32,6 +32,10 @@ background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(96, 165, 250, 0.05)); } +.sugerir-header-content { + flex: 1; +} + .sugerir-header h2 { margin: 0; font-size: 1.25rem; @@ -39,6 +43,13 @@ color: var(--accent-2); } +.sugerir-subtitle { + margin: 0.25rem 0 0 0; + font-size: 0.75rem; + color: var(--muted); + font-weight: 400; +} + .sugerir-close { background: none; border: none; @@ -173,13 +184,25 @@ flex-direction: column; } -.sugerir-resultados h3 { +.sugerir-resultados-header { + display: flex; + align-items: center; + justify-content: space-between; padding: 1rem 1.5rem; + border-bottom: 1px solid var(--stroke); +} + +.sugerir-resultados h3 { margin: 0; font-size: 0.95rem; font-weight: 600; color: var(--silver); - border-bottom: 1px solid var(--stroke); +} + +.sugerir-hint { + font-size: 0.75rem; + color: var(--muted); + font-style: italic; } .sugerir-lista { @@ -192,16 +215,56 @@ padding: 1rem; margin: 0.5rem; background: rgba(255, 255, 255, 0.02); - border: 1px solid var(--stroke); + border: 2px solid var(--stroke); border-radius: 10px; 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 { background: rgba(99, 102, 241, 0.1); 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 { @@ -262,6 +325,72 @@ 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 { display: flex; align-items: center; @@ -271,8 +400,11 @@ .sugerir-ies { font-size: 0.8rem; - color: var(--muted); + color: var(--silver); font-weight: 500; + background: rgba(255, 255, 255, 0.05); + padding: 0.2rem 0.5rem; + border-radius: 4px; } .sugerir-areas { @@ -316,6 +448,126 @@ 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) { .sugerir-modal { max-height: 95vh; @@ -334,4 +586,18 @@ width: 100%; margin-top: 0.5rem; } + + .equipe-flutuante { + flex-direction: column; + padding: 1rem; + } + + .equipe-acoes { + width: 100%; + justify-content: stretch; + } + + .btn-gerar-pdf { + flex: 1; + } } diff --git a/frontend/src/components/SugerirConsultores.jsx b/frontend/src/components/SugerirConsultores.jsx index 7e0b31f..02181ec 100644 --- a/frontend/src/components/SugerirConsultores.jsx +++ b/frontend/src/components/SugerirConsultores.jsx @@ -13,6 +13,8 @@ const SugerirConsultores = ({ onClose, onSelectConsultor }) => { const [loadingAreas, setLoadingAreas] = useState(true); const [error, setError] = useState(null); const [buscaRealizada, setBuscaRealizada] = useState(false); + const [selecionados, setSelecionados] = useState([]); + const [gerandoPdf, setGerandoPdf] = useState(false); useEffect(() => { 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) => { if (onSelectConsultor) { onSelectConsultor(consultor.id_pessoa); @@ -58,17 +71,81 @@ const SugerirConsultores = ({ onClose, onSelectConsultor }) => { 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) => { if (e.key === 'Escape') { onClose(); } }; + const isSelecionado = (id) => selecionados.some((c) => c.id_pessoa === id); + return (
e.stopPropagation()}>
-

Sugerir Consultores por Tema

+
+

Montar Equipe de Consultores

+

Selecione consultores para gerar documento da equipe

+
@@ -140,21 +217,33 @@ const SugerirConsultores = ({ onClose, onSelectConsultor }) => { {buscaRealizada && (
-

- {consultores.length > 0 - ? `${consultores.length} consultores encontrados` - : 'Nenhum consultor encontrado'} -

+
+

+ {consultores.length > 0 + ? `${consultores.length} consultores para "${tema}"` + : 'Nenhum consultor encontrado'} +

+ {consultores.length > 0 && ( + Selecione os consultores para a equipe + )} +
{consultores.length > 0 && (
{consultores.map((c, index) => (
handleClickConsultor(c)} + className={`sugerir-item ${isSelecionado(c.id_pessoa) ? 'selecionado' : ''}`} + onClick={(e) => toggleSelecionado(c, e)} >
+ #{index + 1} {c.nome}
@@ -166,9 +255,45 @@ const SugerirConsultores = ({ onClose, onSelectConsultor }) => { Inativo )}
+
-
+ +
+ {c.posicao_ranking && ( + + 📊 + #{c.posicao_ranking.toLocaleString()} no ranking + + )} + {c.pontuacao_ranking > 0 && ( + + {c.pontuacao_ranking.toFixed(0)} pts + + )} {c.ies && {c.ies}} +
+ + {c.motivos_match && c.motivos_match.length > 0 && ( +
+ Por que este consultor: +
+ {c.motivos_match.map((motivo, idx) => ( + {motivo} + ))} +
+
+ )} + +
{c.areas_avaliacao.length > 0 && (
{c.areas_avaliacao.slice(0, 3).map((area) => ( @@ -195,6 +320,50 @@ const SugerirConsultores = ({ onClose, onSelectConsultor }) => { )}
)} + + {selecionados.length > 0 && ( +
+
+
+ {selecionados.length} selecionado{selecionados.length > 1 ? 's' : ''} + {(() => { + const temas = [...new Set(selecionados.map((c) => c._busca_tema).filter(Boolean))]; + return temas.length > 1 && ( + Interdisciplinar + ); + })()} +
+
+ {(() => { + const temas = [...new Set(selecionados.map((c) => c._busca_tema).filter(Boolean))]; + return temas.map((t) => ( + {t} + )); + })()} +
+
+ {selecionados.slice(0, 5).map((c) => ( + {c.nome.split(' ')[0]} + ))} + {selecionados.length > 5 && ( + +{selecionados.length - 5} + )} +
+
+
+ + +
+
+ )}
);