feat(pdf): adicionar exportação de ficha do consultor em PDF

- 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
This commit is contained in:
Frederico Castro
2025-12-18 22:43:42 -03:00
parent 9d93e42a12
commit 47f0a80f3f
13 changed files with 1607 additions and 9 deletions

View File

@@ -2,6 +2,15 @@ FROM python:3.11-slim
WORKDIR /app 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 RUN pip install --upgrade pip
COPY requirements.txt ./ COPY requirements.txt ./

View File

@@ -7,3 +7,5 @@ httpx==0.26.0
python-dotenv==1.0.0 python-dotenv==1.0.0
rich==13.7.0 rich==13.7.0
oracledb==2.5.1 oracledb==2.5.1
weasyprint>=62.3
jinja2==3.1.2

View File

@@ -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)

View File

@@ -0,0 +1,941 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<title>Cadastro do Consultor - {{ consultor.nome }}</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
{% 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 %}
<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">Relatório Executivo</td>
</tr>
</table>
<div class="cover-title">Cadastro do Consultor</div>
<div class="cover-subtitle">Sistema de Ranking AtuaCAPES · Documento de Alta Gestão</div>
<div class="cover-name">{{ consultor.nome }}</div>
<div class="tag-list">
<span class="badge {{ 'success' if consultor.ativo else 'danger' }}">{{ 'Ativo' if consultor.ativo else 'Histórico' }}</span>
{% if consultor.veterano %}
<span class="badge">Veterano</span>
{% endif %}
{% if consultor.coordenador_ppg %}
<span class="badge">Coordenação PPG</span>
{% endif %}
</div>
</div>
<table class="cover-meta">
<tr>
<td><strong>ID Pessoa:</strong> {{ consultor.id_pessoa }}</td>
<td><strong>Posição no Ranking:</strong> {{ consultor.rank or '-' }}º</td>
</tr>
<tr>
<td><strong>Pontuação Total:</strong> {{ consultor.pontuacao.pontuacao_total }}</td>
<td><strong>Anos de Atuação:</strong> {{ "%.1f"|format(consultor.anos_atuacao) }} anos</td>
</tr>
<tr>
<td><strong>Data de Geração:</strong> {{ data_geracao }}</td>
<td><strong>Origem:</strong> Elasticsearch / ATUACAPES</td>
</tr>
</table>
<div class="section-divider"></div>
<div class="section-title">Resumo Executivo</div>
<p class="lead">Visão consolidada dos indicadores do consultor, com foco em desempenho, volume e posicionamento estratégico.</p>
<table class="kpi-table">
<tr>
<td>
<div class="kpi-label">Pontuação total</div>
<div class="kpi-value">{{ consultor.pontuacao.pontuacao_total }}</div>
<div class="kpi-sub">Blocos A+C+D consolidados</div>
</td>
<td>
<div class="kpi-label">Ranking nacional</div>
<div class="kpi-value">{{ consultor.rank or '-' }}º</div>
<div class="kpi-sub">Posição atual no ranking</div>
</td>
<td>
<div class="kpi-label">Anos de atuação</div>
<div class="kpi-value">{{ "%.1f"|format(consultor.anos_atuacao) }}</div>
<div class="kpi-sub">Tempo de atuação CAPES</div>
</td>
<td>
<div class="kpi-label">Status</div>
<div class="kpi-value">{{ 'Ativo' if consultor.ativo else 'Histórico' }}</div>
<div class="kpi-sub">{{ 'Consultoria em vigor' if consultor.ativo else 'Sem consultoria ativa' }}</div>
</td>
</tr>
</table>
<div class="subsection-title">Indicadores de Volume</div>
<table class="kpi-table">
<tr>
<td>
<div class="kpi-label">Coordenações CAPES</div>
<div class="kpi-value">{{ total_coordenacoes }}</div>
<div class="kpi-sub">Bloco A</div>
</td>
<td>
<div class="kpi-label">Consultorias & Vínculos</div>
<div class="kpi-value">{{ consultor.consultoria.vinculos|length if consultor.consultoria and consultor.consultoria.vinculos else 0 }}</div>
<div class="kpi-sub">Bloco C</div>
</td>
<td>
<div class="kpi-label">Premiações & Avaliações</div>
<div class="kpi-value">{{ total_premiacoes + total_avaliacoes + total_inscricoes }}</div>
<div class="kpi-sub">Bloco D</div>
</td>
<td>
<div class="kpi-label">Atuações no ES</div>
<div class="kpi-value">{{ atuacoes_raw|length }}</div>
<div class="kpi-sub">Base completa</div>
</td>
</tr>
</table>
</section>
<section class="section force-page-break">
<div class="section-title">Identificação e Perfil</div>
<table class="meta-table">
<tr>
<td class="label">Nome</td>
<td class="value">{{ dp.nome or consultor.nome }}</td>
</tr>
<tr>
<td class="label">Nascimento</td>
<td class="value">{{ dp.nascimento or '-' }}</td>
</tr>
<tr>
<td class="label">Gênero</td>
<td class="value">{{ dp.genero or '-' }}</td>
</tr>
<tr>
<td class="label">Raça/Cor</td>
<td class="value">{{ dp.raca or '-' }}</td>
</tr>
<tr>
<td class="label">Nacionalidade</td>
<td class="value">{{ dp.nacionalidade or '-' }}</td>
</tr>
<tr>
<td class="label">Município/UF</td>
<td class="value">{{ dp.municipio or '-' }}{% if dp.uf %} / {{ dp.uf }}{% endif %}</td>
</tr>
<tr>
<td class="label">Estado civil</td>
<td class="value">{{ dp.estadoCivil or '-' }}</td>
</tr>
<tr>
<td class="label">Nome da mãe</td>
<td class="value">{{ dp.nomeMae or '-' }}</td>
</tr>
<tr>
<td class="label">Nome do pai</td>
<td class="value">{{ dp.nomePai or '-' }}</td>
</tr>
<tr>
<td class="label">Tipo de pessoa</td>
<td class="value">{{ dp.tipo or '-' }}</td>
</tr>
<tr>
<td class="label">Ano de óbito</td>
<td class="value">{{ dp.anoObito or '-' }}</td>
</tr>
</table>
</section>
<section class="section force-page-break">
<div class="section-title">Contato e Identificadores</div>
<div class="subsection-title">E-mails</div>
{% if emails %}
<table class="data-table">
<thead>
<tr>
<th>E-mail</th>
<th>Tipo</th>
<th>Principal</th>
<th>Última alteração</th>
</tr>
</thead>
<tbody>
{% for e in emails %}
<tr>
<td>{{ e.email }}</td>
<td>{{ e.tipo or '-' }}</td>
<td>{{ e.principalFinalidade or '-' }}</td>
<td>{{ e.ultimaAlteracao or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem registros de e-mail.</p>
{% endif %}
<div class="subsection-title">Telefones</div>
{% if telefones %}
<table class="data-table">
<thead>
<tr>
<th>Telefone</th>
<th>Tipo</th>
<th>Finalidade</th>
<th>Principal</th>
</tr>
</thead>
<tbody>
{% for t in telefones %}
<tr>
<td>({{ t.codigo }}) {{ t.numero }}</td>
<td>{{ t.tipo or '-' }}</td>
<td>{{ t.finalidade or '-' }}</td>
<td>{{ t.principalFinalidade or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem registros de telefone.</p>
{% endif %}
<div class="subsection-title">Endereços</div>
{% if enderecos %}
<table class="data-table">
<thead>
<tr>
<th>Tipo</th>
<th>Endereço</th>
<th>Cidade</th>
<th>CEP</th>
<th>Principal</th>
</tr>
</thead>
<tbody>
{% for e in enderecos %}
<tr>
<td>{{ e.tipo or '-' }}</td>
<td>{{ e.endereco or '-' }}{% if e.numero %}, {{ e.numero }}{% endif %}{% if e.complemento %} - {{ e.complemento }}{% endif %}</td>
<td>{{ e.cidadeExterior or '-' }}</td>
<td>{{ e.cep or '-' }}</td>
<td>{{ e.principalFinalidade or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem registros de endereço.</p>
{% endif %}
<div class="subsection-title">Identificadores e Documentos</div>
{% if identificadores or documentos or identificador_lattes %}
<table class="data-table">
<thead>
<tr>
<th>Tipo</th>
<th>Descrição</th>
<th>Órgão expedidor</th>
<th>Validade</th>
</tr>
</thead>
<tbody>
{% for i in identificadores %}
<tr>
<td>{{ i.tipo or '-' }}</td>
<td>{{ i.descricao or '-' }}</td>
<td>{{ i.orgaoExpedidor or '-' }}</td>
<td>{{ i.inicioValidade or '-' }}{% if i.fimValidade %} a {{ i.fimValidade }}{% endif %}</td>
</tr>
{% endfor %}
{% if identificador_lattes %}
<tr>
<td>{{ identificador_lattes.tipo or 'Identificador Lattes' }}</td>
<td>{{ identificador_lattes.descricao or '-' }}</td>
<td>{{ identificador_lattes.orgaoExpedidor or '-' }}</td>
<td>{{ identificador_lattes.inicioValidade or '-' }}{% if identificador_lattes.fimValidade %} a {{ identificador_lattes.fimValidade }}{% endif %}</td>
</tr>
{% endif %}
{% for d in documentos %}
<tr>
<td>{{ d.tipo or '-' }}</td>
<td>{{ d.descricao or '-' }}</td>
<td>{{ d.orgaoExpedidor or '-' }}</td>
<td>{{ d.inicioValidade or '-' }}{% if d.fimValidade %} a {{ d.fimValidade }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem registros de identificadores/documentos.</p>
{% endif %}
</section>
<section class="section force-page-break">
<div class="section-title">Formação, Áreas e Competências</div>
<div class="subsection-title">Titulações</div>
{% if titulacoes %}
<table class="data-table">
<thead>
<tr>
<th>Grau</th>
<th>IES</th>
<th>Área</th>
<th>Programa</th>
<th>Período</th>
</tr>
</thead>
<tbody>
{% for t in titulacoes %}
<tr>
<td>{{ t.grauAcademico.nome if t.grauAcademico else '-' }}</td>
<td>{{ t.ies.nome if t.ies else '-' }}{% if t.ies and t.ies.sigla %} ({{ t.ies.sigla }}){% endif %}</td>
<td>{{ t.areaConhecimento.nome if t.areaConhecimento else '-' }}</td>
<td>{{ t.programa.nome if t.programa else '-' }}</td>
<td>{{ t.inicio|format_date_short if t.inicio else '-' }}{% if t.fim %} a {{ t.fim|format_date_short }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem registros de titulação.</p>
{% endif %}
<div class="subsection-title">Áreas de conhecimento</div>
{% if areas %}
<div class="tag-list">
{% for a in areas %}
<span class="tag">{{ a.nome or '-' }}</span>
{% endfor %}
</div>
{% else %}
<p class="note">Sem áreas de conhecimento registradas.</p>
{% endif %}
<div class="subsection-title">Idiomas</div>
{% if idiomas %}
<div class="tag-list">
{% for i in idiomas %}
<span class="tag">{{ i.idioma or i.nome or '-' }}{% if i.nivel %} · {{ i.nivel }}{% endif %}</span>
{% endfor %}
</div>
{% else %}
<p class="note">Sem idiomas registrados.</p>
{% endif %}
<div class="subsection-title">Bolsas (base ES)</div>
{% if bolsas %}
<table class="data-table">
<thead>
<tr>
<th>Programa</th>
<th>Instituição</th>
<th>Período</th>
</tr>
</thead>
<tbody>
{% for b in bolsas %}
<tr>
<td>{{ b.programa or b.tipo or '-' }}</td>
<td>{{ b.ies.nome if b.ies else '-' }}</td>
<td>{{ b.inicio|format_date_short if b.inicio else '-' }}{% if b.fim %} a {{ b.fim|format_date_short }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem bolsas registradas na base ES.</p>
{% endif %}
<div class="subsection-title">Bolsas CNPq (pontuação)</div>
{% if consultor.bolsas_cnpq %}
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Nível</th>
<th>Área</th>
</tr>
</thead>
<tbody>
{% for b in consultor.bolsas_cnpq %}
<tr>
<td>{{ b.codigo }}</td>
<td>{{ b.nivel }}</td>
<td>{{ b.area or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem bolsas CNPq associadas.</p>
{% endif %}
</section>
<section class="section force-page-break">
<div class="section-title">Governança, Papéis e Consultoria</div>
<div class="subsection-title">Papéis cadastrados</div>
{% if papeis %}
<table class="data-table">
<thead>
<tr>
<th>Tipo</th>
<th>Status</th>
<th>Origem</th>
</tr>
</thead>
<tbody>
{% for p in papeis %}
<tr>
<td>{{ p.tipo or '-' }}</td>
<td>{{ p.status or '-' }}</td>
<td>{{ p.procedencia.origem if p.procedencia else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem papéis registrados na base ES.</p>
{% endif %}
<div class="subsection-title">Consultoria CAPES</div>
{% if consultor.consultoria %}
<table class="meta-table">
<tr>
<td class="label">Código</td>
<td class="value">{{ consultor.consultoria.codigo }}</td>
</tr>
<tr>
<td class="label">Situação</td>
<td class="value">{{ consultor.consultoria.situacao }}</td>
</tr>
<tr>
<td class="label">Período</td>
<td class="value">{{ 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' }}</td>
</tr>
<tr>
<td class="label">Áreas</td>
<td class="value">{{ consultor.consultoria.areas|join(', ') if consultor.consultoria.areas else '-' }}</td>
</tr>
<tr>
<td class="label">Anos consecutivos</td>
<td class="value">{{ consultor.consultoria.anos_consecutivos }}</td>
</tr>
<tr>
<td class="label">Retornos</td>
<td class="value">{{ consultor.consultoria.retornos }}</td>
</tr>
</table>
{% if consultor.consultoria.vinculos %}
<div class="subsection-title">Vínculos institucionais</div>
<table class="data-table">
<thead>
<tr>
<th>IES</th>
<th>Período</th>
<th>Situação</th>
</tr>
</thead>
<tbody>
{% for v in consultor.consultoria.vinculos %}
<tr>
<td>{{ v.ies.nome if v.ies else '-' }}{% if v.ies and v.ies.sigla %} ({{ v.ies.sigla }}){% endif %}</td>
<td>{{ 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' }}</td>
<td>{{ v.situacao or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% else %}
<p class="note">Sem registros de consultoria ativa ou histórica.</p>
{% endif %}
<div class="subsection-title">Coordenação de PPG</div>
<p class="lead">{{ 'Possui atuação de coordenação de PPG.' if consultor.coordenador_ppg else 'Sem indicação de coordenação de PPG nos registros.' }}</p>
</section>
<section class="section force-page-break">
<div class="section-title">Pontuação Consolidada</div>
<p class="lead">Resumo dos blocos de pontuação conforme metodologia do Ranking AtuaCAPES.</p>
<table class="data-table">
<thead>
<tr>
<th>Bloco</th>
<th>Descrição</th>
<th>Pontos</th>
</tr>
</thead>
<tbody>
<tr>
<td>A</td>
<td>Coordenação CAPES</td>
<td>{{ consultor.pontuacao.bloco_a.total }}</td>
</tr>
<tr>
<td>B</td>
<td>Coordenação PPG (reservado)</td>
<td>0</td>
</tr>
<tr>
<td>C</td>
<td>Consultoria CAPES</td>
<td>{{ consultor.pontuacao.bloco_c.total }}</td>
</tr>
<tr>
<td>D</td>
<td>Premiações, Avaliações e Participações</td>
<td>{{ consultor.pontuacao.bloco_d.total }}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="2" class="right"><strong>Pontuação total</strong></td>
<td><strong>{{ consultor.pontuacao.pontuacao_total }}</strong></td>
</tr>
</tfoot>
</table>
</section>
<section class="section force-page-break">
<div class="section-title">Bloco A — Coordenações CAPES</div>
{% if consultor.coordenacoes_capes %}
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Área de avaliação</th>
<th>Período</th>
<th>Anos</th>
<th>Presidente</th>
</tr>
</thead>
<tbody>
{% for coord in consultor.coordenacoes_capes %}
<tr>
<td>{{ coord.codigo }}</td>
<td>{{ coord.area_avaliacao or '-' }}</td>
<td>{{ 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' }}</td>
<td>{{ "%.1f"|format(coord.periodo.anos_decorridos) if coord.periodo else '-' }}</td>
<td>{{ 'Sim' if coord.presidente else 'Não' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if consultor.pontuacao.bloco_a.atuacoes %}
<div class="subsection-title">Pontuação detalhada</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Quantidade</th>
<th>Base</th>
<th>Tempo</th>
<th>Bônus</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for at in consultor.pontuacao.bloco_a.atuacoes %}
<tr>
<td>{{ at.codigo }}</td>
<td>{{ at.quantidade }}</td>
<td>{{ at.base }}</td>
<td>{{ at.tempo }}</td>
<td>{{ at.bonus }}</td>
<td><strong>{{ at.total }}</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% else %}
<p class="note">Nenhuma coordenação CAPES registrada para este consultor.</p>
{% endif %}
</section>
<section class="section force-page-break">
<div class="section-title">Bloco C — Consultoria CAPES</div>
{% if consultor.consultoria %}
<table class="meta-table">
<tr>
<td class="label">Código</td>
<td class="value">{{ consultor.consultoria.codigo }}</td>
</tr>
<tr>
<td class="label">Situação</td>
<td class="value">{{ consultor.consultoria.situacao }}</td>
</tr>
<tr>
<td class="label">Período</td>
<td class="value">{{ 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' }}</td>
</tr>
<tr>
<td class="label">Anos consecutivos</td>
<td class="value">{{ consultor.consultoria.anos_consecutivos }}</td>
</tr>
<tr>
<td class="label">Retornos</td>
<td class="value">{{ consultor.consultoria.retornos }}</td>
</tr>
</table>
{% if consultor.consultoria.vinculos %}
<div class="subsection-title">Vínculos institucionais</div>
<table class="data-table">
<thead>
<tr>
<th>IES</th>
<th>Período</th>
<th>Situação</th>
</tr>
</thead>
<tbody>
{% for v in consultor.consultoria.vinculos %}
<tr>
<td>{{ v.ies.nome if v.ies else '-' }}{% if v.ies and v.ies.sigla %} ({{ v.ies.sigla }}){% endif %}</td>
<td>{{ 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' }}</td>
<td>{{ v.situacao or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if consultor.pontuacao.bloco_c.atuacoes %}
<div class="subsection-title">Pontuação detalhada</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Quantidade</th>
<th>Base</th>
<th>Tempo</th>
<th>Bônus</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for at in consultor.pontuacao.bloco_c.atuacoes %}
<tr>
<td>{{ at.codigo }}</td>
<td>{{ at.quantidade }}</td>
<td>{{ at.base }}</td>
<td>{{ at.tempo }}</td>
<td>{{ at.bonus }}</td>
<td><strong>{{ at.total }}</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% else %}
<p class="note">Nenhum registro de consultoria encontrado para este consultor.</p>
{% endif %}
</section>
<section class="section force-page-break">
<div class="section-title">Bloco D — Premiações, Avaliações e Participações</div>
{% if consultor.avaliacoes_comissao %}
<div class="subsection-title">Avaliações em comissões ({{ consultor.avaliacoes_comissao|length }})</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Tipo</th>
<th>Prêmio</th>
<th>Ano</th>
<th>Comissão</th>
</tr>
</thead>
<tbody>
{% for a in consultor.avaliacoes_comissao %}
<tr>
<td>{{ a.codigo }}</td>
<td>{{ a.tipo }}</td>
<td>{{ a.premio }}</td>
<td>{{ a.ano }}</td>
<td>{{ a.nome_comissao or '-' }}{% if a.comissao_tipo %} ({{ a.comissao_tipo }}){% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if consultor.premiacoes %}
<div class="subsection-title">Premiações recebidas ({{ consultor.premiacoes|length }})</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Tipo</th>
<th>Prêmio</th>
<th>Ano</th>
<th>Papel</th>
</tr>
</thead>
<tbody>
{% for p in consultor.premiacoes %}
<tr>
<td>{{ p.codigo }}</td>
<td>{{ p.tipo }}</td>
<td>{{ p.nome_premio }}</td>
<td>{{ p.ano }}</td>
<td>{{ p.papel or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if consultor.inscricoes %}
<div class="subsection-title">Inscrições em prêmios ({{ consultor.inscricoes|length }})</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Tipo</th>
<th>Prêmio</th>
<th>Ano</th>
<th>Situação</th>
</tr>
</thead>
<tbody>
{% for i in consultor.inscricoes %}
<tr>
<td>{{ i.codigo }}</td>
<td>{{ i.tipo }}</td>
<td>{{ i.premio }}</td>
<td>{{ i.ano }}</td>
<td>{{ i.situacao }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if consultor.orientacoes %}
<div class="subsection-title">Orientações acadêmicas ({{ consultor.orientacoes|length }})</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Tipo</th>
<th>Nível</th>
<th>Ano</th>
<th>Co-orient.</th>
<th>Premiada</th>
</tr>
</thead>
<tbody>
{% for o in consultor.orientacoes %}
<tr>
<td>{{ o.codigo }}</td>
<td>{{ o.tipo }}</td>
<td>{{ o.nivel }}</td>
<td>{{ o.ano or '-' }}</td>
<td>{{ 'Sim' if o.coorientacao else 'Não' }}</td>
<td>{{ 'Sim' if o.premiada else 'Não' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if consultor.membros_banca %}
<div class="subsection-title">Bancas examinadoras ({{ consultor.membros_banca|length }})</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Tipo</th>
<th>Nível</th>
<th>Ano</th>
</tr>
</thead>
<tbody>
{% for b in consultor.membros_banca %}
<tr>
<td>{{ b.codigo }}</td>
<td>{{ b.tipo }}</td>
<td>{{ b.nivel }}</td>
<td>{{ b.ano or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if consultor.participacoes %}
<div class="subsection-title">Participações ({{ consultor.participacoes|length }})</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Tipo</th>
<th>Descrição</th>
<th>Ano</th>
</tr>
</thead>
<tbody>
{% for p in consultor.participacoes %}
<tr>
<td>{{ p.codigo }}</td>
<td>{{ p.tipo }}</td>
<td>{{ p.descricao }}</td>
<td>{{ p.ano or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if consultor.pontuacao.bloco_d.atuacoes %}
<div class="subsection-title">Pontuação detalhada</div>
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Quantidade</th>
<th>Base</th>
<th>Tempo</th>
<th>Bônus</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{% for at in consultor.pontuacao.bloco_d.atuacoes %}
<tr>
<td>{{ at.codigo }}</td>
<td>{{ at.quantidade }}</td>
<td>{{ at.base }}</td>
<td>{{ at.tempo }}</td>
<td>{{ at.bonus }}</td>
<td><strong>{{ at.total }}</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
<section class="section force-page-break">
<div class="section-title">Atuações no Elasticsearch</div>
<p class="lead">Base completa de atuações registradas no ATUACAPES. Esta seção consolida todas as entradas encontradas no índice.</p>
{% if atuacoes_raw %}
<table class="data-table">
<thead>
<tr>
<th>Tipo</th>
<th>Descrição</th>
<th>Período</th>
<th>Procedência</th>
</tr>
</thead>
<tbody>
{% for a in atuacoes_raw %}
<tr>
<td>{{ a.tipo or '-' }}</td>
<td>{{ a.descricao or a.nome or '-' }}</td>
<td>{{ a.inicio or '-' }}{% if a.fim %} a {{ a.fim }}{% endif %}</td>
<td>{{ a.procedencia.origem if a.procedencia else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem atuações registradas no índice.</p>
{% endif %}
</section>
<section class="section force-page-break">
<div class="section-title">Apêndice Técnico</div>
<div class="subsection-title">Dados bancários</div>
{% if dados_bancarios %}
<table class="data-table">
<thead>
<tr>
<th>Banco</th>
<th>Agência</th>
<th>Conta</th>
<th>Tipo</th>
</tr>
</thead>
<tbody>
{% for b in dados_bancarios %}
<tr>
<td>{{ b.banco or '-' }}</td>
<td>{{ b.agencia or '-' }}</td>
<td>{{ b.conta or '-' }}</td>
<td>{{ b.tipoConta or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="note">Sem dados bancários registrados.</p>
{% endif %}
<div class="subsection-title">Metadados do documento</div>
<table class="meta-table">
<tr>
<td class="label">Consultor ID</td>
<td class="value">{{ consultor.id_pessoa }}</td>
</tr>
<tr>
<td class="label">Fonte</td>
<td class="value">Elasticsearch ATUACAPES</td>
</tr>
<tr>
<td class="label">Data de geração</td>
<td class="value">{{ data_geracao }}</td>
</tr>
</table>
<div class="footer">
Documento gerado automaticamente. Em caso de divergência, prevalecem os registros oficiais do sistema.
</div>
</section>
</div>
</body>
</html>

View File

@@ -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;
}

View File

@@ -141,19 +141,20 @@ class ConsultorRepositoryImpl(ConsultorRepository):
if not consultorias: if not consultorias:
return None return None
visto_ids: Dict[int, Dict[str, Any]] = {} vistos: Dict[tuple, Dict[str, Any]] = {}
for c in consultorias: for c in consultorias:
cid = c.get("id") dc = c.get("dadosConsultoria", {}) or {}
if cid is None: ies_id = (dc.get("ies", {}) or {}).get("id")
continue inicio = c.get("inicio") or dc.get("inicioVinculacao") or dc.get("inicioSituacao")
if cid not in visto_ids: chave = (ies_id, inicio)
visto_ids[cid] = c if chave not in vistos:
vistos[chave] = c
else: else:
existente_fim = visto_ids[cid].get("fim") existente_fim = vistos[chave].get("fim")
novo_fim = c.get("fim") novo_fim = c.get("fim")
if existente_fim and not novo_fim: if existente_fim and not novo_fim:
visto_ids[cid] = c vistos[chave] = c
consultorias = list(visto_ids.values()) if visto_ids else consultorias consultorias = list(vistos.values()) if vistos else consultorias
periodos: List[Periodo] = [] periodos: List[Periodo] = []
vinculos: List[VinculoConsultoria] = [] vinculos: List[VinculoConsultoria] = []

View File

@@ -1,6 +1,9 @@
import asyncio import asyncio
from io import BytesIO
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from typing import Optional, List from typing import Optional, List
from ...application.use_cases.obter_ranking import ObterRankingUseCase from ...application.use_cases.obter_ranking import ObterRankingUseCase
@@ -365,3 +368,45 @@ async def obter_consultor_raw(
return documento return documento
except RuntimeError as e: except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(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}"'
}
)

View File

@@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect, useMemo, memo } from 'react'; import React, { useState, useRef, useEffect, useMemo, memo } from 'react';
import './ConsultorCard.css'; import './ConsultorCard.css';
import RawDataModal from './RawDataModal'; import RawDataModal from './RawDataModal';
import { rankingService } from '../services/api';
const SELOS = { const SELOS = {
PRESID_CAMARA: { label: 'Presidente Camara', cor: 'selo-camara', icone: '👑' }, PRESID_CAMARA: { label: 'Presidente Camara', cor: 'selo-camara', icone: '👑' },

View File

@@ -80,6 +80,94 @@
color: #c7d2fe; 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 { .raw-modal-close {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
@@ -505,6 +593,14 @@
.raw-modal-header-actions { .raw-modal-header-actions {
width: 100%; width: 100%;
justify-content: space-between; 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 { .dados-pessoais-grid {

View File

@@ -240,6 +240,7 @@ const RawDataModal = ({ idPessoa, nome, onClose }) => {
const [viewMode, setViewMode] = useState('formatted'); const [viewMode, setViewMode] = useState('formatted');
const [filterType, setFilterType] = useState('all'); const [filterType, setFilterType] = useState('all');
const [copyFeedback, setCopyFeedback] = useState(false); const [copyFeedback, setCopyFeedback] = useState(false);
const [downloadingPDF, setDownloadingPDF] = useState(false);
useEffect(() => { useEffect(() => {
const fetchData = async () => { 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 source = data?._source || {};
const dadosPessoais = source.dadosPessoais || {}; const dadosPessoais = source.dadosPessoais || {};
const atuacoes = source.atuacoes || []; const atuacoes = source.atuacoes || [];
@@ -303,6 +317,20 @@ const RawDataModal = ({ idPessoa, nome, onClose }) => {
<span className="raw-modal-subtitle">{nome} (ID: {idPessoa})</span> <span className="raw-modal-subtitle">{nome} (ID: {idPessoa})</span>
</div> </div>
<div className="raw-modal-header-actions"> <div className="raw-modal-header-actions">
<div className="btn-export-pdf-wrap">
<button
className={`btn-export-pdf ${downloadingPDF ? 'loading' : ''}`}
onClick={handleDownloadPDF}
disabled={downloadingPDF}
title="Exportar ficha completa em PDF"
>
<svg className="pdf-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span>PDF</span>
</button>
</div>
<div className="view-toggle"> <div className="view-toggle">
<button <button
className={viewMode === 'formatted' ? 'active' : ''} className={viewMode === 'formatted' ? 'active' : ''}

View File

@@ -133,6 +133,29 @@ export const rankingService = {
const response = await api.get(`/consultor/${idPessoa}/raw`); const response = await api.get(`/consultor/${idPessoa}/raw`);
return response.data; return response.data;
}, },
async downloadFichaPDF(idPessoa, nomeConsultor = '') {
const response = await api.get(`/consultor/${idPessoa}/pdf`, {
responseType: 'blob',
});
const blob = new Blob([response.data], { type: 'application/pdf' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const nomeSanitizado = nomeConsultor
.replace(/[^a-zA-Z0-9\s\-_]/g, '')
.substring(0, 30)
.trim();
const dataAtual = new Date().toISOString().split('T')[0].replace(/-/g, '');
link.download = `ficha_consultor_${idPessoa}_${nomeSanitizado}_${dataAtual}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
},
}; };
export default api; export default api;