From 47f0a80f3f6734280c63ed48cbf3a13fc4de0014 Mon Sep 17 00:00:00 2001 From: Frederico Castro Date: Thu, 18 Dec 2025 22:43:42 -0300 Subject: [PATCH] =?UTF-8?q?feat(pdf):=20adicionar=20exporta=C3=A7=C3=A3o?= =?UTF-8?q?=20de=20ficha=20do=20consultor=20em=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Novo endpoint GET /api/v1/consultor/{id}/pdf para download - Serviço PDFService usando WeasyPrint para geração - Template HTML com layout padrão governo federal - Botão de exportar PDF no card e modal de dados brutos --- backend/Dockerfile | 9 + backend/requirements.txt | 2 + backend/src/application/services/__init__.py | 0 .../src/application/services/pdf_service.py | 145 +++ backend/src/infrastructure/pdf/__init__.py | 0 .../pdf/templates/ficha_consultor.html | 941 ++++++++++++++++++ .../infrastructure/pdf/templates/styles.css | 307 ++++++ .../repositories/consultor_repository_impl.py | 19 +- backend/src/interface/api/routes.py | 45 + frontend/src/components/ConsultorCard.jsx | 1 + frontend/src/components/RawDataModal.css | 96 ++ frontend/src/components/RawDataModal.jsx | 28 + frontend/src/services/api.js | 23 + 13 files changed, 1607 insertions(+), 9 deletions(-) create mode 100644 backend/src/application/services/__init__.py create mode 100644 backend/src/application/services/pdf_service.py create mode 100644 backend/src/infrastructure/pdf/__init__.py create mode 100644 backend/src/infrastructure/pdf/templates/ficha_consultor.html create mode 100644 backend/src/infrastructure/pdf/templates/styles.css diff --git a/backend/Dockerfile b/backend/Dockerfile index 37c6a11..1252ff6 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,6 +2,15 @@ FROM python:3.11-slim WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libgdk-pixbuf-2.0-0 \ + libffi-dev \ + shared-mime-info \ + fonts-liberation \ + && rm -rf /var/lib/apt/lists/* + RUN pip install --upgrade pip COPY requirements.txt ./ diff --git a/backend/requirements.txt b/backend/requirements.txt index 8aefcac..90c20a8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,3 +7,5 @@ httpx==0.26.0 python-dotenv==1.0.0 rich==13.7.0 oracledb==2.5.1 +weasyprint>=62.3 +jinja2==3.1.2 diff --git a/backend/src/application/services/__init__.py b/backend/src/application/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/application/services/pdf_service.py b/backend/src/application/services/pdf_service.py new file mode 100644 index 0000000..f730b65 --- /dev/null +++ b/backend/src/application/services/pdf_service.py @@ -0,0 +1,145 @@ +from pathlib import Path +from datetime import datetime +from typing import Any, Dict +from dataclasses import is_dataclass, asdict + +from jinja2 import Environment, FileSystemLoader + + +class PDFService: + def __init__(self): + self.template_dir = Path(__file__).parent.parent.parent / "infrastructure" / "pdf" / "templates" + self.env = Environment( + loader=FileSystemLoader(str(self.template_dir)), + autoescape=True + ) + self.env.filters['format_date'] = self._format_date + self.env.filters['format_date_short'] = self._format_date_short + self.template = self.env.get_template("ficha_consultor.html") + + def _format_date(self, date_str: str) -> str: + if not date_str: + return "-" + try: + if "/" in date_str: + return date_str + dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) + return dt.strftime("%d/%m/%Y") + except (ValueError, AttributeError): + return str(date_str) + + def _format_date_short(self, date_str: str) -> str: + if not date_str: + return "-" + try: + meses = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', + 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'] + if "/" in date_str: + parts = date_str.split("/") + if len(parts) >= 2: + mes = int(parts[1]) if len(parts) == 3 else int(parts[0]) + ano = parts[2] if len(parts) == 3 else parts[1] + return f"{meses[mes-1]}/{ano}" + return date_str + dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) + return f"{meses[dt.month-1]}/{dt.year}" + except (ValueError, AttributeError, IndexError): + return str(date_str)[:10] if date_str else "-" + + def _consultor_to_dict(self, consultor: Any) -> Dict: + if is_dataclass(consultor) and not isinstance(consultor, type): + return asdict(consultor) + elif hasattr(consultor, 'model_dump'): + return consultor.model_dump() + elif hasattr(consultor, 'dict'): + return consultor.dict() + elif isinstance(consultor, dict): + return consultor + else: + return vars(consultor) + + def gerar_ficha_consultor(self, consultor: Any, raw_document: Any = None) -> bytes: + from weasyprint import HTML, CSS + + consultor_data = self._consultor_to_dict(consultor) + raw_data = self._consultor_to_dict(raw_document) if raw_document else {} + + html_content = self.template.render( + consultor=ConsultorWrapper(consultor_data), + raw=DictWrapper(raw_data) if raw_data else DictWrapper({}), + data_geracao=datetime.now().strftime("%d/%m/%Y às %H:%M"), + pontuacao_coord=self._extrair_pontuacao_coord(consultor_data), + ) + + 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 + + def _extrair_pontuacao_coord(self, consultor_data: Dict) -> Dict: + result = {} + pontuacao = consultor_data.get('pontuacao') + if not pontuacao: + return result + + bloco_a = pontuacao.get('bloco_a', {}) + atuacoes = bloco_a.get('atuacoes', []) + + for atuacao in atuacoes: + codigo = atuacao.get('codigo', '') + result[codigo] = { + 'base': atuacao.get('base', 0), + 'tempo': atuacao.get('tempo', 0), + 'bonus': atuacao.get('bonus', 0), + 'total': atuacao.get('total', 0), + } + + return result + + +class ConsultorWrapper: + def __init__(self, data: Dict): + self._data = data + + def __getattr__(self, name: str): + value = self._data.get(name) + if value is None: + return None + if isinstance(value, dict): + return DictWrapper(value) + if isinstance(value, list): + return [DictWrapper(item) if isinstance(item, dict) else item for item in value] + return value + + def __bool__(self): + return bool(self._data) + + +class DictWrapper: + def __init__(self, data: Dict): + self._data = data + + def __getattr__(self, name: str): + value = self._data.get(name) + if value is None: + return None + if isinstance(value, dict): + return DictWrapper(value) + if isinstance(value, list): + return [DictWrapper(item) if isinstance(item, dict) else item for item in value] + return value + + def __bool__(self): + return bool(self._data) + + def __str__(self): + return str(self._data) + + def get(self, key: str, default=None): + return self._data.get(key, default) diff --git a/backend/src/infrastructure/pdf/__init__.py b/backend/src/infrastructure/pdf/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/infrastructure/pdf/templates/ficha_consultor.html b/backend/src/infrastructure/pdf/templates/ficha_consultor.html new file mode 100644 index 0000000..05ab016 --- /dev/null +++ b/backend/src/infrastructure/pdf/templates/ficha_consultor.html @@ -0,0 +1,941 @@ + + + + + Cadastro do Consultor - {{ consultor.nome }} + + + + {% set dp = raw.dadosPessoais if raw.dadosPessoais else {} %} + {% set emails = raw.emails if raw.emails else [] %} + {% set telefones = raw.telefones if raw.telefones else [] %} + {% set enderecos = raw.enderecos if raw.enderecos else [] %} + {% set identificadores = raw.identificadoresRegistrados if raw.identificadoresRegistrados else [] %} + {% set identificador_lattes = raw.identificadorLattes if raw.identificadorLattes else None %} + {% set titulacoes = raw.titulacoes if raw.titulacoes else [] %} + {% set idiomas = raw.idiomas if raw.idiomas else [] %} + {% set areas = raw.areasConhecimento if raw.areasConhecimento else [] %} + {% set papeis = raw.papeis if raw.papeis else [] %} + {% set bolsas = raw.bolsas if raw.bolsas else [] %} + {% set dados_bancarios = raw.dadosBancarios if raw.dadosBancarios else [] %} + {% set documentos = raw.documento if raw.documento else [] %} + {% set atuacoes_raw = raw.atuacoes if raw.atuacoes else [] %} + + {% set total_coordenacoes = consultor.coordenacoes_capes|length if consultor.coordenacoes_capes else 0 %} + {% set total_inscricoes = consultor.inscricoes|length if consultor.inscricoes else 0 %} + {% set total_avaliacoes = consultor.avaliacoes_comissao|length if consultor.avaliacoes_comissao else 0 %} + {% set total_premiacoes = consultor.premiacoes|length if consultor.premiacoes else 0 %} + {% set total_bolsas_cnpq = consultor.bolsas_cnpq|length if consultor.bolsas_cnpq else 0 %} + {% set total_participacoes = consultor.participacoes|length if consultor.participacoes else 0 %} + {% set total_orientacoes = consultor.orientacoes|length if consultor.orientacoes else 0 %} + {% set total_bancas = consultor.membros_banca|length if consultor.membros_banca else 0 %} + +
+
+
+ + + + + +
+ MINISTÉRIO DA EDUCAÇÃO
+ Coordenação de Aperfeiçoamento de Pessoal de Nível Superior +
Relatório Executivo
+ +
Cadastro do Consultor
+
Sistema de Ranking AtuaCAPES · Documento de Alta Gestão
+ +
{{ consultor.nome }}
+
+ {{ 'Ativo' if consultor.ativo else 'Histórico' }} + {% if consultor.veterano %} + Veterano + {% endif %} + {% if consultor.coordenador_ppg %} + Coordenação PPG + {% endif %} +
+
+ + + + + + + + + + + + + + +
ID Pessoa: {{ consultor.id_pessoa }}Posição no Ranking: {{ consultor.rank or '-' }}º
Pontuação Total: {{ consultor.pontuacao.pontuacao_total }}Anos de Atuação: {{ "%.1f"|format(consultor.anos_atuacao) }} anos
Data de Geração: {{ data_geracao }}Origem: Elasticsearch / ATUACAPES
+ +
+ +
Resumo Executivo
+

Visão consolidada dos indicadores do consultor, com foco em desempenho, volume e posicionamento estratégico.

+ + + + + + + + +
+
Pontuação total
+
{{ consultor.pontuacao.pontuacao_total }}
+
Blocos A+C+D consolidados
+
+
Ranking nacional
+
{{ consultor.rank or '-' }}º
+
Posição atual no ranking
+
+
Anos de atuação
+
{{ "%.1f"|format(consultor.anos_atuacao) }}
+
Tempo de atuação CAPES
+
+
Status
+
{{ 'Ativo' if consultor.ativo else 'Histórico' }}
+
{{ 'Consultoria em vigor' if consultor.ativo else 'Sem consultoria ativa' }}
+
+ +
Indicadores de Volume
+ + + + + + + +
+
Coordenações CAPES
+
{{ total_coordenacoes }}
+
Bloco A
+
+
Consultorias & Vínculos
+
{{ consultor.consultoria.vinculos|length if consultor.consultoria and consultor.consultoria.vinculos else 0 }}
+
Bloco C
+
+
Premiações & Avaliações
+
{{ total_premiacoes + total_avaliacoes + total_inscricoes }}
+
Bloco D
+
+
Atuações no ES
+
{{ atuacoes_raw|length }}
+
Base completa
+
+
+ +
+
Identificação e Perfil
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Nome{{ dp.nome or consultor.nome }}
Nascimento{{ dp.nascimento or '-' }}
Gênero{{ dp.genero or '-' }}
Raça/Cor{{ dp.raca or '-' }}
Nacionalidade{{ dp.nacionalidade or '-' }}
Município/UF{{ dp.municipio or '-' }}{% if dp.uf %} / {{ dp.uf }}{% endif %}
Estado civil{{ dp.estadoCivil or '-' }}
Nome da mãe{{ dp.nomeMae or '-' }}
Nome do pai{{ dp.nomePai or '-' }}
Tipo de pessoa{{ dp.tipo or '-' }}
Ano de óbito{{ dp.anoObito or '-' }}
+
+ +
+
Contato e Identificadores
+ +
E-mails
+ {% if emails %} + + + + + + + + + + + {% for e in emails %} + + + + + + + {% endfor %} + +
E-mailTipoPrincipalÚltima alteração
{{ e.email }}{{ e.tipo or '-' }}{{ e.principalFinalidade or '-' }}{{ e.ultimaAlteracao or '-' }}
+ {% else %} +

Sem registros de e-mail.

+ {% endif %} + +
Telefones
+ {% if telefones %} + + + + + + + + + + + {% for t in telefones %} + + + + + + + {% endfor %} + +
TelefoneTipoFinalidadePrincipal
({{ t.codigo }}) {{ t.numero }}{{ t.tipo or '-' }}{{ t.finalidade or '-' }}{{ t.principalFinalidade or '-' }}
+ {% else %} +

Sem registros de telefone.

+ {% endif %} + +
Endereços
+ {% if enderecos %} + + + + + + + + + + + + {% for e in enderecos %} + + + + + + + + {% endfor %} + +
TipoEndereçoCidadeCEPPrincipal
{{ e.tipo or '-' }}{{ e.endereco or '-' }}{% if e.numero %}, {{ e.numero }}{% endif %}{% if e.complemento %} - {{ e.complemento }}{% endif %}{{ e.cidadeExterior or '-' }}{{ e.cep or '-' }}{{ e.principalFinalidade or '-' }}
+ {% else %} +

Sem registros de endereço.

+ {% endif %} + +
Identificadores e Documentos
+ {% if identificadores or documentos or identificador_lattes %} + + + + + + + + + + + {% for i in identificadores %} + + + + + + + {% endfor %} + {% if identificador_lattes %} + + + + + + + {% endif %} + {% for d in documentos %} + + + + + + + {% endfor %} + +
TipoDescriçãoÓrgão expedidorValidade
{{ i.tipo or '-' }}{{ i.descricao or '-' }}{{ i.orgaoExpedidor or '-' }}{{ i.inicioValidade or '-' }}{% if i.fimValidade %} a {{ i.fimValidade }}{% endif %}
{{ identificador_lattes.tipo or 'Identificador Lattes' }}{{ identificador_lattes.descricao or '-' }}{{ identificador_lattes.orgaoExpedidor or '-' }}{{ identificador_lattes.inicioValidade or '-' }}{% if identificador_lattes.fimValidade %} a {{ identificador_lattes.fimValidade }}{% endif %}
{{ d.tipo or '-' }}{{ d.descricao or '-' }}{{ d.orgaoExpedidor or '-' }}{{ d.inicioValidade or '-' }}{% if d.fimValidade %} a {{ d.fimValidade }}{% endif %}
+ {% else %} +

Sem registros de identificadores/documentos.

+ {% endif %} +
+ +
+
Formação, Áreas e Competências
+ +
Titulações
+ {% if titulacoes %} + + + + + + + + + + + + {% for t in titulacoes %} + + + + + + + + {% endfor %} + +
GrauIESÁreaProgramaPeríodo
{{ t.grauAcademico.nome if t.grauAcademico else '-' }}{{ t.ies.nome if t.ies else '-' }}{% if t.ies and t.ies.sigla %} ({{ t.ies.sigla }}){% endif %}{{ t.areaConhecimento.nome if t.areaConhecimento else '-' }}{{ t.programa.nome if t.programa else '-' }}{{ t.inicio|format_date_short if t.inicio else '-' }}{% if t.fim %} a {{ t.fim|format_date_short }}{% endif %}
+ {% else %} +

Sem registros de titulação.

+ {% endif %} + +
Áreas de conhecimento
+ {% if areas %} +
+ {% for a in areas %} + {{ a.nome or '-' }} + {% endfor %} +
+ {% else %} +

Sem áreas de conhecimento registradas.

+ {% endif %} + +
Idiomas
+ {% if idiomas %} +
+ {% for i in idiomas %} + {{ i.idioma or i.nome or '-' }}{% if i.nivel %} · {{ i.nivel }}{% endif %} + {% endfor %} +
+ {% else %} +

Sem idiomas registrados.

+ {% endif %} + +
Bolsas (base ES)
+ {% if bolsas %} + + + + + + + + + + {% for b in bolsas %} + + + + + + {% endfor %} + +
ProgramaInstituiçãoPeríodo
{{ b.programa or b.tipo or '-' }}{{ b.ies.nome if b.ies else '-' }}{{ b.inicio|format_date_short if b.inicio else '-' }}{% if b.fim %} a {{ b.fim|format_date_short }}{% endif %}
+ {% else %} +

Sem bolsas registradas na base ES.

+ {% endif %} + +
Bolsas CNPq (pontuação)
+ {% if consultor.bolsas_cnpq %} + + + + + + + + + + {% for b in consultor.bolsas_cnpq %} + + + + + + {% endfor %} + +
CódigoNívelÁrea
{{ b.codigo }}{{ b.nivel }}{{ b.area or '-' }}
+ {% else %} +

Sem bolsas CNPq associadas.

+ {% endif %} +
+ +
+
Governança, Papéis e Consultoria
+ +
Papéis cadastrados
+ {% if papeis %} + + + + + + + + + + {% for p in papeis %} + + + + + + {% endfor %} + +
TipoStatusOrigem
{{ p.tipo or '-' }}{{ p.status or '-' }}{{ p.procedencia.origem if p.procedencia else '-' }}
+ {% else %} +

Sem papéis registrados na base ES.

+ {% endif %} + +
Consultoria CAPES
+ {% if consultor.consultoria %} + + + + + + + + + + + + + + + + + + + + + + + + + +
Código{{ consultor.consultoria.codigo }}
Situação{{ consultor.consultoria.situacao }}
Período{{ consultor.consultoria.periodo.inicio|format_date if consultor.consultoria.periodo else '-' }} a {{ consultor.consultoria.periodo.fim|format_date if consultor.consultoria.periodo and consultor.consultoria.periodo.fim else 'Atual' }}
Áreas{{ consultor.consultoria.areas|join(', ') if consultor.consultoria.areas else '-' }}
Anos consecutivos{{ consultor.consultoria.anos_consecutivos }}
Retornos{{ consultor.consultoria.retornos }}
+ + {% if consultor.consultoria.vinculos %} +
Vínculos institucionais
+ + + + + + + + + + {% for v in consultor.consultoria.vinculos %} + + + + + + {% endfor %} + +
IESPeríodoSituação
{{ v.ies.nome if v.ies else '-' }}{% if v.ies and v.ies.sigla %} ({{ v.ies.sigla }}){% endif %}{{ v.periodo.inicio|format_date_short if v.periodo else '-' }} a {{ v.periodo.fim|format_date_short if v.periodo and v.periodo.fim else 'Atual' }}{{ v.situacao or '-' }}
+ {% endif %} + {% else %} +

Sem registros de consultoria ativa ou histórica.

+ {% endif %} + +
Coordenação de PPG
+

{{ 'Possui atuação de coordenação de PPG.' if consultor.coordenador_ppg else 'Sem indicação de coordenação de PPG nos registros.' }}

+
+ +
+
Pontuação Consolidada
+

Resumo dos blocos de pontuação conforme metodologia do Ranking AtuaCAPES.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BlocoDescriçãoPontos
ACoordenação CAPES{{ consultor.pontuacao.bloco_a.total }}
BCoordenação PPG (reservado)0
CConsultoria CAPES{{ consultor.pontuacao.bloco_c.total }}
DPremiações, Avaliações e Participações{{ consultor.pontuacao.bloco_d.total }}
Pontuação total{{ consultor.pontuacao.pontuacao_total }}
+
+ +
+
Bloco A — Coordenações CAPES
+ {% if consultor.coordenacoes_capes %} + + + + + + + + + + + + {% for coord in consultor.coordenacoes_capes %} + + + + + + + + {% endfor %} + +
CódigoÁrea de avaliaçãoPeríodoAnosPresidente
{{ coord.codigo }}{{ coord.area_avaliacao or '-' }}{{ coord.periodo.inicio|format_date_short if coord.periodo else '-' }} a {{ coord.periodo.fim|format_date_short if coord.periodo and coord.periodo.fim else 'Atual' }}{{ "%.1f"|format(coord.periodo.anos_decorridos) if coord.periodo else '-' }}{{ 'Sim' if coord.presidente else 'Não' }}
+ + {% if consultor.pontuacao.bloco_a.atuacoes %} +
Pontuação detalhada
+ + + + + + + + + + + + + {% for at in consultor.pontuacao.bloco_a.atuacoes %} + + + + + + + + + {% endfor %} + +
CódigoQuantidadeBaseTempoBônusTotal
{{ at.codigo }}{{ at.quantidade }}{{ at.base }}{{ at.tempo }}{{ at.bonus }}{{ at.total }}
+ {% endif %} + {% else %} +

Nenhuma coordenação CAPES registrada para este consultor.

+ {% endif %} +
+ +
+
Bloco C — Consultoria CAPES
+ {% if consultor.consultoria %} + + + + + + + + + + + + + + + + + + + + + +
Código{{ consultor.consultoria.codigo }}
Situação{{ consultor.consultoria.situacao }}
Período{{ consultor.consultoria.periodo.inicio|format_date if consultor.consultoria.periodo else '-' }} a {{ consultor.consultoria.periodo.fim|format_date if consultor.consultoria.periodo and consultor.consultoria.periodo.fim else 'Atual' }}
Anos consecutivos{{ consultor.consultoria.anos_consecutivos }}
Retornos{{ consultor.consultoria.retornos }}
+ + {% if consultor.consultoria.vinculos %} +
Vínculos institucionais
+ + + + + + + + + + {% for v in consultor.consultoria.vinculos %} + + + + + + {% endfor %} + +
IESPeríodoSituação
{{ v.ies.nome if v.ies else '-' }}{% if v.ies and v.ies.sigla %} ({{ v.ies.sigla }}){% endif %}{{ v.periodo.inicio|format_date_short if v.periodo else '-' }} a {{ v.periodo.fim|format_date_short if v.periodo and v.periodo.fim else 'Atual' }}{{ v.situacao or '-' }}
+ {% endif %} + + {% if consultor.pontuacao.bloco_c.atuacoes %} +
Pontuação detalhada
+ + + + + + + + + + + + + {% for at in consultor.pontuacao.bloco_c.atuacoes %} + + + + + + + + + {% endfor %} + +
CódigoQuantidadeBaseTempoBônusTotal
{{ at.codigo }}{{ at.quantidade }}{{ at.base }}{{ at.tempo }}{{ at.bonus }}{{ at.total }}
+ {% endif %} + {% else %} +

Nenhum registro de consultoria encontrado para este consultor.

+ {% endif %} +
+ +
+
Bloco D — Premiações, Avaliações e Participações
+ + {% if consultor.avaliacoes_comissao %} +
Avaliações em comissões ({{ consultor.avaliacoes_comissao|length }})
+ + + + + + + + + + + + {% for a in consultor.avaliacoes_comissao %} + + + + + + + + {% endfor %} + +
CódigoTipoPrêmioAnoComissão
{{ a.codigo }}{{ a.tipo }}{{ a.premio }}{{ a.ano }}{{ a.nome_comissao or '-' }}{% if a.comissao_tipo %} ({{ a.comissao_tipo }}){% endif %}
+ {% endif %} + + {% if consultor.premiacoes %} +
Premiações recebidas ({{ consultor.premiacoes|length }})
+ + + + + + + + + + + + {% for p in consultor.premiacoes %} + + + + + + + + {% endfor %} + +
CódigoTipoPrêmioAnoPapel
{{ p.codigo }}{{ p.tipo }}{{ p.nome_premio }}{{ p.ano }}{{ p.papel or '-' }}
+ {% endif %} + + {% if consultor.inscricoes %} +
Inscrições em prêmios ({{ consultor.inscricoes|length }})
+ + + + + + + + + + + + {% for i in consultor.inscricoes %} + + + + + + + + {% endfor %} + +
CódigoTipoPrêmioAnoSituação
{{ i.codigo }}{{ i.tipo }}{{ i.premio }}{{ i.ano }}{{ i.situacao }}
+ {% endif %} + + {% if consultor.orientacoes %} +
Orientações acadêmicas ({{ consultor.orientacoes|length }})
+ + + + + + + + + + + + + {% for o in consultor.orientacoes %} + + + + + + + + + {% endfor %} + +
CódigoTipoNívelAnoCo-orient.Premiada
{{ o.codigo }}{{ o.tipo }}{{ o.nivel }}{{ o.ano or '-' }}{{ 'Sim' if o.coorientacao else 'Não' }}{{ 'Sim' if o.premiada else 'Não' }}
+ {% endif %} + + {% if consultor.membros_banca %} +
Bancas examinadoras ({{ consultor.membros_banca|length }})
+ + + + + + + + + + + {% for b in consultor.membros_banca %} + + + + + + + {% endfor %} + +
CódigoTipoNívelAno
{{ b.codigo }}{{ b.tipo }}{{ b.nivel }}{{ b.ano or '-' }}
+ {% endif %} + + {% if consultor.participacoes %} +
Participações ({{ consultor.participacoes|length }})
+ + + + + + + + + + + {% for p in consultor.participacoes %} + + + + + + + {% endfor %} + +
CódigoTipoDescriçãoAno
{{ p.codigo }}{{ p.tipo }}{{ p.descricao }}{{ p.ano or '-' }}
+ {% endif %} + + {% if consultor.pontuacao.bloco_d.atuacoes %} +
Pontuação detalhada
+ + + + + + + + + + + + + {% for at in consultor.pontuacao.bloco_d.atuacoes %} + + + + + + + + + {% endfor %} + +
CódigoQuantidadeBaseTempoBônusTotal
{{ at.codigo }}{{ at.quantidade }}{{ at.base }}{{ at.tempo }}{{ at.bonus }}{{ at.total }}
+ {% endif %} +
+ +
+
Atuações no Elasticsearch
+

Base completa de atuações registradas no ATUACAPES. Esta seção consolida todas as entradas encontradas no índice.

+ {% if atuacoes_raw %} + + + + + + + + + + + {% for a in atuacoes_raw %} + + + + + + + {% endfor %} + +
TipoDescriçãoPeríodoProcedência
{{ a.tipo or '-' }}{{ a.descricao or a.nome or '-' }}{{ a.inicio or '-' }}{% if a.fim %} a {{ a.fim }}{% endif %}{{ a.procedencia.origem if a.procedencia else '-' }}
+ {% else %} +

Sem atuações registradas no índice.

+ {% endif %} +
+ +
+
Apêndice Técnico
+
Dados bancários
+ {% if dados_bancarios %} + + + + + + + + + + + {% for b in dados_bancarios %} + + + + + + + {% endfor %} + +
BancoAgênciaContaTipo
{{ b.banco or '-' }}{{ b.agencia or '-' }}{{ b.conta or '-' }}{{ b.tipoConta or '-' }}
+ {% else %} +

Sem dados bancários registrados.

+ {% endif %} + +
Metadados do documento
+ + + + + + + + + + + + + +
Consultor ID{{ consultor.id_pessoa }}
FonteElasticsearch ATUACAPES
Data de geração{{ data_geracao }}
+ + +
+
+ + diff --git a/backend/src/infrastructure/pdf/templates/styles.css b/backend/src/infrastructure/pdf/templates/styles.css new file mode 100644 index 0000000..ef60fab --- /dev/null +++ b/backend/src/infrastructure/pdf/templates/styles.css @@ -0,0 +1,307 @@ +@page { + size: A4; + margin: 1.6cm 1.4cm 1.8cm 1.4cm; + + @bottom-left { + content: "Sistema de Ranking AtuaCAPES"; + font-family: "Helvetica Neue", Arial, sans-serif; + font-size: 8pt; + color: #64748b; + } + + @bottom-right { + content: "Página " counter(page) " de " counter(pages); + font-family: "Helvetica Neue", Arial, sans-serif; + font-size: 8pt; + color: #64748b; + } +} + +:root { + --ink: #0f172a; + --muted: #475569; + --accent: #0f3d68; + --accent-2: #1d4ed8; + --border: #e2e8f0; + --bg-soft: #f8fafc; + --bg-strong: #eef2f7; + --success: #15803d; + --danger: #b91c1c; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: "Helvetica Neue", Arial, sans-serif; + font-size: 10pt; + line-height: 1.45; + color: var(--ink); + background: #ffffff; +} + +.documento { + width: 100%; +} + +.force-page-break { + page-break-before: always !important; + break-before: page !important; +} + +.no-break { + page-break-inside: avoid !important; + break-inside: avoid !important; +} + +h1, h2, h3, h4 { + page-break-after: avoid; + break-after: avoid; +} + +.cover { + border: 2px solid var(--accent); + padding: 28px 32px; + min-height: 23cm; + display: flex; + flex-direction: column; + justify-content: space-between; + background: var(--bg-soft); +} + +.cover-header { + width: 100%; + border-bottom: 1px solid var(--border); + padding-bottom: 12px; + margin-bottom: 10px; + border-collapse: collapse; +} + +.cover-header td { + vertical-align: top; +} + +.cover-title { + font-size: 20pt; + font-weight: 700; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 1.5px; + margin-top: 22px; +} + +.cover-subtitle { + color: var(--muted); + font-size: 11pt; + margin-top: 6px; +} + +.cover-name { + font-size: 16pt; + font-weight: 700; + margin: 18px 0 8px; +} + +.cover-meta { + width: 100%; + border-top: 1px solid var(--border); + padding-top: 10px; + border-collapse: collapse; + font-size: 9.5pt; + color: var(--muted); +} + +.cover-meta td { + padding: 4px 0; +} + +.cover-meta strong { + color: var(--ink); +} + +.badge { + display: inline-block; + padding: 4px 10px; + border-radius: 999px; + background: #e2e8f0; + color: var(--ink); + font-size: 8.5pt; + font-weight: 600; + margin-right: 6px; +} + +.badge.success { + background: #dcfce7; + color: var(--success); +} + +.badge.danger { + background: #fee2e2; + color: var(--danger); +} + +.section { + padding: 10px 0 4px; +} + +.section-title { + font-size: 13pt; + font-weight: 700; + color: var(--accent); + text-transform: uppercase; + border-bottom: 2px solid var(--accent); + padding-bottom: 6px; + margin-bottom: 12px; +} + +.subsection-title { + font-size: 11pt; + font-weight: 600; + color: var(--ink); + margin: 16px 0 8px; +} + +.lead { + color: var(--muted); + margin-bottom: 10px; +} + +.kpi-table { + width: 100%; + border-collapse: separate; + border-spacing: 8px; + margin-bottom: 4px; +} + +.kpi-table td { + background: var(--bg-strong); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; + vertical-align: top; +} + +.kpi-label { + font-size: 8.5pt; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.6px; + margin-bottom: 6px; +} + +.kpi-value { + font-size: 14pt; + font-weight: 700; + color: var(--accent); +} + +.kpi-sub { + font-size: 9pt; + color: var(--muted); +} + +.data-table { + width: 100%; + border-collapse: collapse; + border: 1px solid var(--border); + margin-bottom: 10px; +} + +.data-table thead { + background: var(--accent); + color: #ffffff; +} + +.data-table th, +.data-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: top; +} + +.data-table th { + font-size: 8.5pt; + letter-spacing: 0.5px; + text-transform: uppercase; +} + +.data-table tbody tr:nth-child(even) { + background: #f8fafc; +} + +.label { + color: var(--muted); + font-weight: 600; + width: 35%; +} + +.value { + color: var(--ink); +} + +.meta-table { + width: 100%; + border-collapse: collapse; + border: 1px solid var(--border); +} + +.meta-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--border); +} + +.meta-table tr:nth-child(even) { + background: var(--bg-soft); +} + +.tag-list { + margin-top: 6px; +} + +.tag { + display: inline-block; + padding: 4px 8px; + margin: 0 6px 6px 0; + background: var(--bg-strong); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 8.5pt; +} + +.note { + background: var(--bg-soft); + border: 1px dashed var(--border); + padding: 10px 12px; + color: var(--muted); + font-size: 9pt; + margin-top: 10px; +} + +.section-divider { + height: 1px; + background: var(--border); + margin: 16px 0; +} + +.footer { + margin-top: 18px; + font-size: 8.5pt; + color: var(--muted); +} + +.small { + font-size: 8.5pt; + color: var(--muted); +} + +.right { + text-align: right; +} + +.center { + text-align: center; +} diff --git a/backend/src/infrastructure/repositories/consultor_repository_impl.py b/backend/src/infrastructure/repositories/consultor_repository_impl.py index b43c751..a58415f 100644 --- a/backend/src/infrastructure/repositories/consultor_repository_impl.py +++ b/backend/src/infrastructure/repositories/consultor_repository_impl.py @@ -141,19 +141,20 @@ class ConsultorRepositoryImpl(ConsultorRepository): if not consultorias: return None - visto_ids: Dict[int, Dict[str, Any]] = {} + vistos: Dict[tuple, Dict[str, Any]] = {} for c in consultorias: - cid = c.get("id") - if cid is None: - continue - if cid not in visto_ids: - visto_ids[cid] = c + dc = c.get("dadosConsultoria", {}) or {} + ies_id = (dc.get("ies", {}) or {}).get("id") + inicio = c.get("inicio") or dc.get("inicioVinculacao") or dc.get("inicioSituacao") + chave = (ies_id, inicio) + if chave not in vistos: + vistos[chave] = c else: - existente_fim = visto_ids[cid].get("fim") + existente_fim = vistos[chave].get("fim") novo_fim = c.get("fim") if existente_fim and not novo_fim: - visto_ids[cid] = c - consultorias = list(visto_ids.values()) if visto_ids else consultorias + vistos[chave] = c + consultorias = list(vistos.values()) if vistos else consultorias periodos: List[Periodo] = [] vinculos: List[VinculoConsultoria] = [] diff --git a/backend/src/interface/api/routes.py b/backend/src/interface/api/routes.py index cbddaa6..ff69084 100644 --- a/backend/src/interface/api/routes.py +++ b/backend/src/interface/api/routes.py @@ -1,6 +1,9 @@ import asyncio +from io import BytesIO +from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse from typing import Optional, List from ...application.use_cases.obter_ranking import ObterRankingUseCase @@ -365,3 +368,45 @@ async def obter_consultor_raw( return documento except RuntimeError as e: raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/consultor/{id_pessoa}/pdf") +async def exportar_ficha_pdf( + id_pessoa: int, + repository: ConsultorRepositoryImpl = Depends(get_repository), + es_client: ElasticsearchClient = Depends(get_es_client), + store = Depends(get_ranking_store), +): + from ...application.services.pdf_service import PDFService + + use_case = ObterConsultorUseCase(repository=repository) + rank = None + if store.is_ready(): + found = store.get_by_id(id_pessoa) + rank = found.posicao if found else None + + consultor = await use_case.executar(id_pessoa=id_pessoa, rank=rank) + + if not consultor: + raise HTTPException(status_code=404, detail=f"Consultor {id_pessoa} não encontrado") + + try: + pdf_service = PDFService() + raw_documento = await es_client.buscar_documento_completo(id_pessoa) + raw_source = raw_documento.get("_source") if raw_documento else {} + pdf_bytes = pdf_service.gerar_ficha_consultor(consultor, raw_source) + except Exception as e: + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"Erro ao gerar PDF: {str(e)}") + + nome_sanitizado = "".join(c if c.isalnum() or c in " -_" else "_" for c in consultor.nome) + nome_arquivo = f"ficha_consultor_{id_pessoa}_{nome_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}"' + } + ) diff --git a/frontend/src/components/ConsultorCard.jsx b/frontend/src/components/ConsultorCard.jsx index 4507d8f..3ed39d6 100644 --- a/frontend/src/components/ConsultorCard.jsx +++ b/frontend/src/components/ConsultorCard.jsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect, useMemo, memo } from 'react'; import './ConsultorCard.css'; import RawDataModal from './RawDataModal'; +import { rankingService } from '../services/api'; const SELOS = { PRESID_CAMARA: { label: 'Presidente Camara', cor: 'selo-camara', icone: '👑' }, diff --git a/frontend/src/components/RawDataModal.css b/frontend/src/components/RawDataModal.css index 81b3095..db01081 100644 --- a/frontend/src/components/RawDataModal.css +++ b/frontend/src/components/RawDataModal.css @@ -80,6 +80,94 @@ color: #c7d2fe; } +.btn-export-pdf { + appearance: none; + -webkit-appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + align-self: center; + gap: 0.4rem; + padding: 0.5rem 0.9rem; + width: 80px; + min-width: 80px; + max-width: 80px; + height: 36px; + min-height: 36px; + max-height: 36px; + flex: 0 0 80px; + position: relative; + font-size: 0.85rem; + font-weight: 600; + line-height: 1; + background: linear-gradient(135deg, rgba(220, 38, 38, 0.2), rgba(185, 28, 28, 0.3)); + border: 1px solid rgba(220, 38, 38, 0.5); + border-radius: 8px; + color: #fca5a5; + cursor: pointer; + transition: background 200ms ease, border-color 200ms ease; + white-space: nowrap; + overflow: hidden; + box-sizing: border-box; +} + +.btn-export-pdf-wrap { + width: 80px; + height: 36px; + flex: 0 0 80px; + display: flex; + align-items: center; + justify-content: center; + align-self: center; + overflow: hidden; +} + +.btn-export-pdf-wrap .btn-export-pdf { + width: 100%; + height: 100%; +} + +.btn-export-pdf:hover:not(:disabled) { + background: linear-gradient(135deg, rgba(220, 38, 38, 0.3), rgba(185, 28, 28, 0.4)); + border-color: rgba(220, 38, 38, 0.7); + color: #fecaca; +} + +.btn-export-pdf:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.btn-export-pdf .pdf-icon { + width: 18px; + height: 18px; +} + +.btn-export-pdf.loading .pdf-icon { + opacity: 0; +} + +.btn-export-pdf.loading span { + opacity: 0; +} + +.btn-export-pdf.loading::after { + content: ""; + position: absolute; + width: 16px; + height: 16px; + border: 2px solid rgba(252, 165, 165, 0.35); + border-top-color: #fecaca; + border-radius: 50%; + animation: spin-pdf 0.9s linear infinite; +} + +@keyframes spin-pdf { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + .raw-modal-close { background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2); @@ -505,6 +593,14 @@ .raw-modal-header-actions { width: 100%; justify-content: space-between; + align-items: center; + } + + .btn-export-pdf { + width: 80px !important; + height: 36px !important; + flex: 0 0 auto !important; + align-self: center !important; } .dados-pessoais-grid { diff --git a/frontend/src/components/RawDataModal.jsx b/frontend/src/components/RawDataModal.jsx index 1f59872..0452635 100644 --- a/frontend/src/components/RawDataModal.jsx +++ b/frontend/src/components/RawDataModal.jsx @@ -240,6 +240,7 @@ const RawDataModal = ({ idPessoa, nome, onClose }) => { const [viewMode, setViewMode] = useState('formatted'); const [filterType, setFilterType] = useState('all'); const [copyFeedback, setCopyFeedback] = useState(false); + const [downloadingPDF, setDownloadingPDF] = useState(false); useEffect(() => { const fetchData = async () => { @@ -280,6 +281,19 @@ const RawDataModal = ({ idPessoa, nome, onClose }) => { } }; + const handleDownloadPDF = async () => { + if (downloadingPDF) return; + setDownloadingPDF(true); + try { + await rankingService.downloadFichaPDF(idPessoa, nome); + } catch (err) { + console.error('Erro ao baixar PDF:', err); + alert('Erro ao gerar PDF. Tente novamente.'); + } finally { + setDownloadingPDF(false); + } + }; + const source = data?._source || {}; const dadosPessoais = source.dadosPessoais || {}; const atuacoes = source.atuacoes || []; @@ -303,6 +317,20 @@ const RawDataModal = ({ idPessoa, nome, onClose }) => { {nome} (ID: {idPessoa})
+
+ +