- Adicionar ref para controlar requisições já feitas no App.jsx - Adicionar ref para controlar fetch no RawDataModal.jsx - Adicionar componente FiltroAtivo para filtrar consultores
377 lines
12 KiB
JavaScript
377 lines
12 KiB
JavaScript
import { useState, useEffect, useRef } from 'react';
|
||
import Header from './components/Header';
|
||
import ConsultorCard from './components/ConsultorCard';
|
||
import CompararModal from './components/CompararModal';
|
||
import FiltroSelos from './components/FiltroSelos';
|
||
import FiltroAtivo from './components/FiltroAtivo';
|
||
import SugerirConsultores from './components/SugerirConsultores';
|
||
import ExportProgress from './components/ExportProgress';
|
||
import { rankingService } from './services/api';
|
||
import './App.css';
|
||
|
||
function App() {
|
||
const [consultores, setConsultores] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState(null);
|
||
const [processing, setProcessing] = useState(false);
|
||
const [processMessage, setProcessMessage] = useState('');
|
||
const processStartedRef = useRef(false);
|
||
const [total, setTotal] = useState(0);
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(50);
|
||
const [totalPages, setTotalPages] = useState(0);
|
||
const [highlightId, setHighlightId] = useState(null);
|
||
const [busca, setBusca] = useState('');
|
||
const [buscando, setBuscando] = useState(false);
|
||
const [selecionados, setSelecionados] = useState([]);
|
||
const [modalAberto, setModalAberto] = useState(false);
|
||
const [filtroSelos, setFiltroSelos] = useState([]);
|
||
const [filtroAtivo, setFiltroAtivo] = useState(null);
|
||
const [sugerirAberto, setSugerirAberto] = useState(false);
|
||
const [exportando, setExportando] = useState(false);
|
||
const [exportProgress, setExportProgress] = useState({ loaded: 0, total: 0, percent: 0 });
|
||
const [exportStatus, setExportStatus] = useState('preparing');
|
||
const abortControllerRef = useRef(null);
|
||
const loadingRef = useRef(false);
|
||
|
||
const toggleSelecionado = (consultor) => {
|
||
setSelecionados((prev) => {
|
||
const existe = prev.find((c) => c.id_pessoa === consultor.id_pessoa);
|
||
if (existe) {
|
||
return prev.filter((c) => c.id_pessoa !== consultor.id_pessoa);
|
||
}
|
||
if (prev.length >= 2) {
|
||
return [prev[1], consultor];
|
||
}
|
||
return [...prev, consultor];
|
||
});
|
||
};
|
||
|
||
const limparSelecao = () => {
|
||
setSelecionados([]);
|
||
setModalAberto(false);
|
||
};
|
||
|
||
const handleSugestaoSelecionada = async (idPessoa) => {
|
||
try {
|
||
const response = await fetch(`/api/v1/ranking/posicao/${idPessoa}`);
|
||
if (response.ok) {
|
||
const alvo = await response.json();
|
||
if (alvo.encontrado && alvo.posicao) {
|
||
const pagina = Math.ceil(alvo.posicao / pageSize);
|
||
setHighlightId(alvo.id_pessoa);
|
||
setPage(pagina);
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Erro ao navegar para consultor:', err);
|
||
}
|
||
};
|
||
|
||
const handleExportarExcel = async () => {
|
||
if (exportando) return;
|
||
try {
|
||
setExportando(true);
|
||
setExportStatus('preparing');
|
||
setExportProgress({ loaded: 0, total: 0, percent: 0 });
|
||
|
||
abortControllerRef.current = new AbortController();
|
||
|
||
await rankingService.downloadRankingExcel(
|
||
filtroSelos,
|
||
filtroAtivo,
|
||
(progress) => {
|
||
setExportStatus('downloading');
|
||
setExportProgress(progress);
|
||
},
|
||
abortControllerRef.current
|
||
);
|
||
|
||
setExportStatus('complete');
|
||
setTimeout(() => setExportando(false), 1000);
|
||
} catch (err) {
|
||
if (err.name === 'CanceledError' || err.code === 'ERR_CANCELED') {
|
||
console.log('Exportação cancelada pelo usuário');
|
||
} else {
|
||
console.error('Erro ao exportar Excel:', err);
|
||
setExportStatus('error');
|
||
setTimeout(() => setExportando(false), 2000);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleCancelExport = () => {
|
||
if (abortControllerRef.current) {
|
||
abortControllerRef.current.abort();
|
||
abortControllerRef.current = null;
|
||
}
|
||
setExportando(false);
|
||
};
|
||
|
||
useEffect(() => {
|
||
const requestKey = `${page}-${pageSize}-${filtroSelos.join(',')}-${filtroAtivo}`;
|
||
if (loadingRef.current === requestKey) {
|
||
return;
|
||
}
|
||
loadingRef.current = requestKey;
|
||
loadRanking();
|
||
}, [page, pageSize, filtroSelos, filtroAtivo]);
|
||
|
||
const loadRanking = async (retryCount = 0) => {
|
||
const MAX_RETRIES = 10;
|
||
const RETRY_DELAY = 2000;
|
||
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
setProcessMessage('');
|
||
const response = await rankingService.getRanking(page, pageSize, filtroSelos, filtroAtivo);
|
||
setConsultores(response.consultores);
|
||
setTotal(response.total);
|
||
setTotalPages(response.total_pages || 0);
|
||
setPage(response.page || page);
|
||
} catch (err) {
|
||
const status = err?.response?.status;
|
||
const isNetworkError = !err?.response || err?.code === 'ERR_NETWORK';
|
||
|
||
if (isNetworkError && retryCount < MAX_RETRIES) {
|
||
setProcessMessage(`Aguardando API... (tentativa ${retryCount + 1}/${MAX_RETRIES})`);
|
||
await new Promise((r) => setTimeout(r, RETRY_DELAY));
|
||
return loadRanking(retryCount + 1);
|
||
}
|
||
|
||
if (status === 503) {
|
||
try {
|
||
setProcessing(true);
|
||
if (!processStartedRef.current) {
|
||
processStartedRef.current = true;
|
||
setProcessMessage('Ranking ainda não processado. Iniciando processamento...');
|
||
try {
|
||
await rankingService.processarRanking(true);
|
||
} catch (e) {
|
||
const st = e?.response?.status;
|
||
if (st !== 409) throw e; // 409 = job já em execução (ex.: StrictMode)
|
||
}
|
||
} else {
|
||
setProcessMessage('Processamento do ranking já iniciado. Aguardando...');
|
||
}
|
||
|
||
const MAX_POLLING_TIME = 45 * 60 * 1000;
|
||
const POLLING_INTERVAL = 4000;
|
||
const startTime = Date.now();
|
||
|
||
while (Date.now() - startTime < MAX_POLLING_TIME) {
|
||
try {
|
||
const st = await rankingService.getStatus();
|
||
setProcessMessage(st.mensagem || `Processando... ${st.progress || 0}%`);
|
||
if (!st.running) {
|
||
if (st.erro) throw new Error(st.erro);
|
||
break;
|
||
}
|
||
} catch (e) {
|
||
setProcessMessage('Aguardando status do processamento...');
|
||
}
|
||
await new Promise((r) => setTimeout(r, POLLING_INTERVAL));
|
||
}
|
||
|
||
if (Date.now() - startTime >= MAX_POLLING_TIME) {
|
||
throw new Error('Timeout: processamento demorou mais que 45 minutos');
|
||
}
|
||
|
||
const response = await rankingService.getRanking(page, pageSize, filtroSelos, filtroAtivo);
|
||
setConsultores(response.consultores);
|
||
setTotal(response.total);
|
||
setTotalPages(response.total_pages || 0);
|
||
setPage(response.page || page);
|
||
} catch (e) {
|
||
console.error('Erro ao processar ranking:', e);
|
||
setError('Erro ao processar ranking. Verifique a conexão com o Elasticsearch.');
|
||
} finally {
|
||
setProcessing(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
console.error('Erro ao carregar ranking:', err);
|
||
setError('Erro ao carregar ranking. Verifique se a API está rodando.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleBuscar = async () => {
|
||
if (busca.trim().length < 3) return;
|
||
try {
|
||
setBuscando(true);
|
||
const resultados = await rankingService.searchConsultor(busca.trim(), 5);
|
||
if (resultados && resultados.length > 0) {
|
||
const alvo = resultados[0];
|
||
const pos = alvo.posicao || 1;
|
||
const pagina = Math.ceil(pos / pageSize);
|
||
setHighlightId(alvo.id_pessoa);
|
||
setPage(pagina);
|
||
} else {
|
||
alert('Nenhum consultor encontrado.');
|
||
}
|
||
} catch (err) {
|
||
alert('Erro ao buscar consultor.');
|
||
} finally {
|
||
setBuscando(false);
|
||
}
|
||
};
|
||
|
||
const handleSubmitBuscar = (e) => {
|
||
e.preventDefault();
|
||
if (buscando) return;
|
||
handleBuscar();
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="container">
|
||
<div className="loading">
|
||
{processMessage || (processing ? 'Processando ranking...' : 'Carregando ranking...')}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="container">
|
||
<div className="error">
|
||
<h2>Erro</h2>
|
||
<p>{error}</p>
|
||
<button onClick={loadRanking}>Tentar novamente</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="container">
|
||
<Header total={total} />
|
||
|
||
<div className="controls">
|
||
<div className="control-group">
|
||
<span className="control-group-label">Exibir:</span>
|
||
<select value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}>
|
||
<option value={10}>10</option>
|
||
<option value={50}>50</option>
|
||
<option value={100}>100</option>
|
||
<option value={200}>200</option>
|
||
<option value={500}>500</option>
|
||
</select>
|
||
</div>
|
||
|
||
<FiltroSelos
|
||
selecionados={filtroSelos}
|
||
onChange={(selos) => { setFiltroSelos(selos); setPage(1); }}
|
||
/>
|
||
|
||
<FiltroAtivo
|
||
valor={filtroAtivo}
|
||
onChange={(valor) => { setFiltroAtivo(valor); setPage(1); }}
|
||
/>
|
||
|
||
<button className="btn-sugerir" onClick={() => setSugerirAberto(true)}>
|
||
Sugerir por Tema
|
||
</button>
|
||
|
||
<button
|
||
className="btn-exportar"
|
||
onClick={handleExportarExcel}
|
||
disabled={exportando || loading}
|
||
title="Exportar ranking para Excel (apenas consultores com pontuação)"
|
||
>
|
||
{exportando ? 'Exportando...' : '📊 Exportar Excel'}
|
||
</button>
|
||
|
||
<div className="pagination">
|
||
<button onClick={() => setPage(1)} disabled={page <= 1}>«</button>
|
||
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>‹</button>
|
||
<span className="page-info">{page} / {totalPages || '?'}</span>
|
||
<button onClick={() => setPage((p) => (totalPages ? Math.min(totalPages, p + 1) : p + 1))} disabled={totalPages && page >= totalPages}>›</button>
|
||
<button onClick={() => totalPages && setPage(totalPages)} disabled={totalPages && page >= totalPages}>»</button>
|
||
</div>
|
||
|
||
<form className="search-box" onSubmit={handleSubmitBuscar}>
|
||
<input
|
||
type="text"
|
||
placeholder="Buscar por nome..."
|
||
value={busca}
|
||
onChange={(e) => setBusca(e.target.value)}
|
||
/>
|
||
<button type="submit" disabled={buscando || busca.length < 3}>
|
||
{buscando ? '...' : 'Buscar'}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div className="ranking-list">
|
||
{consultores.map((consultor) => (
|
||
<ConsultorCard
|
||
key={consultor.id_pessoa}
|
||
consultor={consultor}
|
||
highlight={consultor.id_pessoa === highlightId}
|
||
selecionado={selecionados.some((c) => c.id_pessoa === consultor.id_pessoa)}
|
||
onToggleSelecionado={toggleSelecionado}
|
||
totalConsultores={total}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{selecionados.length > 0 && (
|
||
<div className="selecao-flutuante">
|
||
<div className="selecao-info">
|
||
<span>{selecionados.length}/2 selecionados</span>
|
||
{selecionados.map((c) => (
|
||
<span key={c.id_pessoa} className="selecao-nome">{c.nome.split(' ')[0]}</span>
|
||
))}
|
||
</div>
|
||
<div className="selecao-acoes">
|
||
<button className="btn-limpar" onClick={limparSelecao}>Limpar</button>
|
||
<button
|
||
className="btn-comparar"
|
||
disabled={selecionados.length < 2}
|
||
onClick={() => setModalAberto(true)}
|
||
>
|
||
Comparar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{modalAberto && (
|
||
<CompararModal
|
||
consultor1={selecionados[0]}
|
||
consultor2={selecionados[1]}
|
||
onClose={() => setModalAberto(false)}
|
||
/>
|
||
)}
|
||
|
||
{sugerirAberto && (
|
||
<SugerirConsultores
|
||
onClose={() => setSugerirAberto(false)}
|
||
onSelectConsultor={handleSugestaoSelecionada}
|
||
/>
|
||
)}
|
||
|
||
{exportando && (
|
||
<ExportProgress
|
||
progress={exportProgress}
|
||
status={exportStatus}
|
||
onCancel={handleCancelExport}
|
||
/>
|
||
)}
|
||
|
||
<footer>
|
||
<p>Dados: ATUACAPES (Elasticsearch) + Oracle</p>
|
||
<p>Clique em qualquer consultor para ver detalhes</p>
|
||
</footer>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App;
|