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

@@ -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: '👑' },

View File

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

View File

@@ -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' : ''}

View File

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