feat(raw-data): adicionar visualização de dados brutos do ATUACAPES
- Novo endpoint GET /api/v1/consultor/{id}/raw para buscar documento completo do ES
- Novo componente RawDataModal com formatação inteligente de campos
- Botão de acesso rápido no ConsultorCard (ícone ⋮)
- Melhorias de estilo no Header e ConsultorCard
This commit is contained in:
420
frontend/src/components/RawDataModal.jsx
Normal file
420
frontend/src/components/RawDataModal.jsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { rankingService } from '../services/api';
|
||||
import './RawDataModal.css';
|
||||
|
||||
const decodeHtmlEntities = (str) => {
|
||||
if (typeof str !== 'string') return str;
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.innerHTML = str;
|
||||
return textarea.value;
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return null;
|
||||
try {
|
||||
if (dateStr.includes('/')) {
|
||||
return dateStr.split(' ')[0];
|
||||
}
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('pt-BR');
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
};
|
||||
|
||||
const formatValue = (value) => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (typeof value === 'boolean') return value ? 'Sim' : 'Não';
|
||||
if (typeof value === 'number') return String(value);
|
||||
if (typeof value === 'string') return decodeHtmlEntities(value);
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return null;
|
||||
return value.map(item => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
const val = item.nome || item.descricao || item.sigla || item.tipo || JSON.stringify(item);
|
||||
return decodeHtmlEntities(val);
|
||||
}
|
||||
return decodeHtmlEntities(String(item));
|
||||
}).join(', ');
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
if (value.nome) return decodeHtmlEntities(value.nome);
|
||||
if (value.descricao) return decodeHtmlEntities(value.descricao);
|
||||
if (value.sigla) return decodeHtmlEntities(value.sigla);
|
||||
return null;
|
||||
}
|
||||
return decodeHtmlEntities(String(value));
|
||||
};
|
||||
|
||||
const LABEL_MAP = {
|
||||
tipo: 'Tipo',
|
||||
nome: 'Nome',
|
||||
sigla: 'Sigla',
|
||||
codigo: 'Código',
|
||||
situacao: 'Situação',
|
||||
situacaoConsultoria: 'Situação',
|
||||
areaAvaliacao: 'Área de Avaliação',
|
||||
areaConhecimento: 'Área de Conhecimento',
|
||||
areaConhecimentoPos: 'Área de Conhecimento Pós',
|
||||
areaPesquisa: 'Área de Pesquisa',
|
||||
colegio: 'Colégio',
|
||||
ies: 'IES',
|
||||
programa: 'Programa',
|
||||
inicioVinculacao: 'Início Vinculação',
|
||||
fimVinculacao: 'Fim Vinculação',
|
||||
inicioSituacao: 'Início Situação',
|
||||
inativacaoSituacao: 'Inativação',
|
||||
portaria: 'Portaria',
|
||||
dataPortaria: 'Data Portaria',
|
||||
premio: 'Prêmio',
|
||||
evento: 'Evento',
|
||||
premiacao: 'Premiação',
|
||||
ano: 'Ano',
|
||||
edicao: 'Edição',
|
||||
papelPessoa: 'Papel',
|
||||
comissao: 'Comissão',
|
||||
produto: 'Produto',
|
||||
nivel: 'Nível',
|
||||
modalidade: 'Modalidade',
|
||||
camaraTematica: 'Câmara Temática',
|
||||
};
|
||||
|
||||
const formatLabel = (key) => LABEL_MAP[key] || key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase());
|
||||
|
||||
const DataField = ({ label, value, className = '' }) => {
|
||||
const formattedValue = formatValue(value);
|
||||
if (formattedValue === null) return null;
|
||||
return (
|
||||
<div className={`data-field ${className}`}>
|
||||
<span className="data-label">{formatLabel(label)}</span>
|
||||
<span className="data-value">{formattedValue}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NestedObjectDisplay = ({ data, depth = 0 }) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
const entries = Object.entries(data).filter(([key, value]) => {
|
||||
if (value === null || value === undefined || value === '') return false;
|
||||
if (Array.isArray(value) && value.length === 0) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`nested-display depth-${depth}`}>
|
||||
{entries.map(([key, value]) => {
|
||||
if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
|
||||
const simpleValue = value.nome || value.descricao || value.sigla;
|
||||
if (simpleValue) {
|
||||
return <DataField key={key} label={key} value={simpleValue} />;
|
||||
}
|
||||
return (
|
||||
<div key={key} className="data-field nested">
|
||||
<span className="data-label">{formatLabel(key)}</span>
|
||||
<NestedObjectDisplay data={value} depth={depth + 1} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const formatted = value.map(item => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
return item.nome || item.descricao || item.sigla || item.tipo || Object.values(item).filter(v => typeof v === 'string')[0] || '';
|
||||
}
|
||||
return String(item);
|
||||
}).filter(Boolean).join(', ');
|
||||
if (!formatted) return null;
|
||||
return <DataField key={key} label={key} value={formatted} />;
|
||||
}
|
||||
return <DataField key={key} label={key} value={value} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Section = ({ title, icon, children, defaultOpen = true, count }) => {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className={`data-section ${isOpen ? 'open' : ''}`}>
|
||||
<div className="section-header" onClick={() => setIsOpen(!isOpen)}>
|
||||
<span className="section-icon">{icon}</span>
|
||||
<h3>{title}</h3>
|
||||
{count !== undefined && <span className="section-count">{count}</span>}
|
||||
<span className="section-toggle">{isOpen ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
{isOpen && <div className="section-content">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AtuacaoCard = ({ atuacao, index }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const tipo = atuacao.tipo || 'Tipo não informado';
|
||||
|
||||
const getAtuacaoColor = (tipo) => {
|
||||
if (tipo.includes('Coordenação')) return 'atuacao-coordenacao';
|
||||
if (tipo.includes('Consultor')) return 'atuacao-consultoria';
|
||||
if (tipo.includes('Premiação')) return 'atuacao-premiacao';
|
||||
if (tipo.includes('Avaliação')) return 'atuacao-avaliacao';
|
||||
if (tipo.includes('Inscrição')) return 'atuacao-inscricao';
|
||||
if (tipo.includes('Bolsista')) return 'atuacao-bolsa';
|
||||
if (tipo.includes('Orientação')) return 'atuacao-orientacao';
|
||||
return 'atuacao-outros';
|
||||
};
|
||||
|
||||
const getAllDados = () => {
|
||||
const allData = {};
|
||||
|
||||
const dataKeys = [
|
||||
'dadosCoordenacaoArea',
|
||||
'dadosHistoricoCoordenacaoArea',
|
||||
'dadosConsultoria',
|
||||
'dadosPremiacaoPremio',
|
||||
'dadosParticipacaoPremio',
|
||||
'dadosParticipacaoInscricaoPremio',
|
||||
'dadosBolsistaCNPq',
|
||||
'dadosOrientacao',
|
||||
'dadosParticipacao',
|
||||
'dadosGestaoPrograma',
|
||||
];
|
||||
|
||||
const dateKeys = ['inicio', 'fim', 'inicioVinculacao', 'fimVinculacao', 'inicioSituacao', 'inativacaoSituacao'];
|
||||
const dateData = {};
|
||||
|
||||
dataKeys.forEach(key => {
|
||||
if (atuacao[key]) {
|
||||
Object.entries(atuacao[key]).forEach(([k, v]) => {
|
||||
if (dateKeys.includes(k)) {
|
||||
dateData[k] = v;
|
||||
} else {
|
||||
allData[k] = v;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(allData).length === 0) {
|
||||
if (atuacao.inicio) allData.inicio = atuacao.inicio;
|
||||
if (atuacao.fim) allData.fim = atuacao.fim;
|
||||
Object.assign(allData, dateData);
|
||||
}
|
||||
|
||||
return allData;
|
||||
};
|
||||
|
||||
const dados = getAllDados();
|
||||
const hasData = Object.keys(dados).length > 0;
|
||||
|
||||
return (
|
||||
<div className={`atuacao-card ${getAtuacaoColor(tipo)}`}>
|
||||
<div className="atuacao-header" onClick={() => setExpanded(!expanded)}>
|
||||
<span className="atuacao-index">#{index + 1}</span>
|
||||
<span className="atuacao-tipo">{tipo}</span>
|
||||
<div className="atuacao-periodo">
|
||||
{atuacao.inicio && <span>{formatDate(atuacao.inicio)}</span>}
|
||||
{atuacao.inicio && atuacao.fim && <span> - </span>}
|
||||
{atuacao.fim ? <span>{formatDate(atuacao.fim)}</span> : atuacao.inicio && <span className="ativo">Atual</span>}
|
||||
</div>
|
||||
<span className="atuacao-toggle">{expanded ? '−' : '+'}</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="atuacao-dados">
|
||||
{hasData ? (
|
||||
<NestedObjectDisplay data={dados} />
|
||||
) : (
|
||||
<p className="empty-message">Sem dados adicionais</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RawDataModal = ({ idPessoa, nome, onClose }) => {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [viewMode, setViewMode] = useState('formatted');
|
||||
const [filterType, setFilterType] = useState('all');
|
||||
const [copyFeedback, setCopyFeedback] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await rankingService.getConsultorRaw(idPessoa);
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || err.message || 'Erro ao carregar dados');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [idPessoa]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify(data, null, 2));
|
||||
setCopyFeedback(true);
|
||||
setTimeout(() => setCopyFeedback(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Erro ao copiar:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const source = data?._source || {};
|
||||
const dadosPessoais = source.dadosPessoais || {};
|
||||
const atuacoes = source.atuacoes || [];
|
||||
|
||||
const tiposAtuacao = [...new Set(atuacoes.map(a => a.tipo))].sort();
|
||||
const atuacoesFiltradas = filterType === 'all'
|
||||
? atuacoes
|
||||
: atuacoes.filter(a => a.tipo === filterType);
|
||||
|
||||
const atuacoesPorTipo = tiposAtuacao.reduce((acc, tipo) => {
|
||||
acc[tipo] = atuacoes.filter(a => a.tipo === tipo).length;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const modalContent = (
|
||||
<div className="raw-modal-overlay" onClick={(e) => e.target.classList.contains('raw-modal-overlay') && onClose()}>
|
||||
<div className="raw-modal">
|
||||
<div className="raw-modal-header">
|
||||
<div className="raw-modal-title">
|
||||
<h2>Dados Completos ATUACAPES</h2>
|
||||
<span className="raw-modal-subtitle">{nome} (ID: {idPessoa})</span>
|
||||
</div>
|
||||
<div className="raw-modal-header-actions">
|
||||
<div className="view-toggle">
|
||||
<button
|
||||
className={viewMode === 'formatted' ? 'active' : ''}
|
||||
onClick={() => setViewMode('formatted')}
|
||||
>
|
||||
Formatado
|
||||
</button>
|
||||
<button
|
||||
className={viewMode === 'json' ? 'active' : ''}
|
||||
onClick={() => setViewMode('json')}
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
</div>
|
||||
<button className="raw-modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="raw-modal-content">
|
||||
{loading && (
|
||||
<div className="raw-modal-loading">
|
||||
<div className="spinner"></div>
|
||||
<span>Carregando dados do Elasticsearch...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="raw-modal-error">
|
||||
<span className="error-icon">⚠</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && data && viewMode === 'formatted' && (
|
||||
<div className="formatted-view">
|
||||
<Section title="Dados Pessoais" icon="👤" defaultOpen={true}>
|
||||
<div className="dados-pessoais-grid">
|
||||
<DataField label="ID" value={source.id} />
|
||||
<DataField label="Nome" value={dadosPessoais.nome} className="destaque" />
|
||||
<DataField label="CPF" value={dadosPessoais.cpf} />
|
||||
<DataField label="Email" value={dadosPessoais.email} />
|
||||
<DataField label="Sexo" value={dadosPessoais.sexo} />
|
||||
<DataField label="Nascimento" value={formatDate(dadosPessoais.nascimento)} />
|
||||
<DataField label="Ano Óbito" value={dadosPessoais.anoObito} />
|
||||
<DataField label="Nacionalidade" value={dadosPessoais.nacionalidade} />
|
||||
<DataField label="País Nascimento" value={dadosPessoais.paisNascimento} />
|
||||
<DataField label="UF Nascimento" value={dadosPessoais.ufNascimento} />
|
||||
<DataField label="Cidade Nascimento" value={dadosPessoais.cidadeNascimento} />
|
||||
<DataField label="Lattes" value={dadosPessoais.lattes} />
|
||||
<DataField label="ORCID" value={dadosPessoais.orcid} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Atuações" icon="📋" defaultOpen={true} count={atuacoes.length}>
|
||||
{atuacoes.length > 0 && (
|
||||
<>
|
||||
<div className="atuacoes-summary">
|
||||
{tiposAtuacao.map(tipo => (
|
||||
<span
|
||||
key={tipo}
|
||||
className={`tipo-badge ${filterType === tipo ? 'active' : ''}`}
|
||||
onClick={() => setFilterType(filterType === tipo ? 'all' : tipo)}
|
||||
>
|
||||
{tipo.replace('Histórico de ', '').substring(0, 25)}
|
||||
<span className="tipo-count">{atuacoesPorTipo[tipo]}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="atuacoes-list">
|
||||
{atuacoesFiltradas.map((atuacao, idx) => (
|
||||
<AtuacaoCard key={idx} atuacao={atuacao} index={idx} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{atuacoes.length === 0 && (
|
||||
<p className="empty-message">Nenhuma atuação registrada</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Metadados Elasticsearch" icon="🔍" defaultOpen={false}>
|
||||
<div className="dados-pessoais-grid">
|
||||
<DataField label="Index" value={data._index} />
|
||||
<DataField label="Document ID" value={data._id} />
|
||||
<DataField label="Score" value={data._score} />
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && data && viewMode === 'json' && (
|
||||
<div className="json-view">
|
||||
<div className="json-toolbar">
|
||||
<button onClick={copyToClipboard} className={copyFeedback ? 'copied' : ''}>
|
||||
{copyFeedback ? '✓ Copiado!' : 'Copiar JSON'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="json-content">{JSON.stringify(data, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="raw-modal-footer">
|
||||
<span className="raw-modal-info">
|
||||
Fonte: Elasticsearch ATUACAPES | Index: {data?._index || 'atuacapes'} | {atuacoes.length} atuações
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return ReactDOM.createPortal(modalContent, document.body);
|
||||
};
|
||||
|
||||
export default RawDataModal;
|
||||
Reference in New Issue
Block a user