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:
@@ -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 ./
|
||||
|
||||
@@ -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
|
||||
|
||||
0
backend/src/application/services/__init__.py
Normal file
0
backend/src/application/services/__init__.py
Normal file
145
backend/src/application/services/pdf_service.py
Normal file
145
backend/src/application/services/pdf_service.py
Normal 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)
|
||||
0
backend/src/infrastructure/pdf/__init__.py
Normal file
0
backend/src/infrastructure/pdf/__init__.py
Normal file
941
backend/src/infrastructure/pdf/templates/ficha_consultor.html
Normal file
941
backend/src/infrastructure/pdf/templates/ficha_consultor.html
Normal 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>
|
||||
307
backend/src/infrastructure/pdf/templates/styles.css
Normal file
307
backend/src/infrastructure/pdf/templates/styles.css
Normal 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;
|
||||
}
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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}"'
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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: '👑' },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }) => {
|
||||
<span className="raw-modal-subtitle">{nome} (ID: {idPessoa})</span>
|
||||
</div>
|
||||
<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">
|
||||
<button
|
||||
className={viewMode === 'formatted' ? 'active' : ''}
|
||||
|
||||
@@ -133,6 +133,29 @@ export const rankingService = {
|
||||
const response = await api.get(`/consultor/${idPessoa}/raw`);
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user