Files
ranking/frontend/src/App.jsx
Frederico Castro e0692ee49c fix(frontend): evitar requisições duplicadas causadas pelo React StrictMode
- 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
2025-12-29 03:29:42 -03:00

377 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;