feat: Implementa job de ranking para 300k consultores
Backend: - Adiciona Scroll API no cliente Elasticsearch para processar todos os 300k+ consultores - Cria tabela TB_RANKING_CONSULTOR no Oracle para ranking pré-calculado - Implementa job de processamento com APScheduler (diário às 3h) - Adiciona endpoints: /ranking/paginado, /ranking/status, /ranking/processar, /ranking/estatisticas - Repository Oracle com paginação eficiente via ROW_NUMBER - Status do job com progresso em tempo real (polling) - Leitura automática de LOBs no OracleClient Frontend: - Componente RankingPaginado com paginação completa - Barra de progresso do job em tempo real - Botão para reprocessar ranking - Alternância entre Top N (rápido) e Ranking Completo (300k) Infraestrutura: - Docker compose com depends_on para garantir Oracle disponível - Schema SQL com procedure SP_ATUALIZAR_POSICOES - Índices otimizados para paginação
This commit is contained in:
@@ -46,6 +46,36 @@
|
||||
background: var(--accent-2);
|
||||
}
|
||||
|
||||
.mode-selector {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin: 1.5rem 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mode-selector button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 200ms;
|
||||
}
|
||||
|
||||
.mode-selector button:hover {
|
||||
border-color: var(--accent-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.mode-selector button.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin: 1.5rem 0;
|
||||
display: flex;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Header from './components/Header';
|
||||
import ConsultorCard from './components/ConsultorCard';
|
||||
import RankingPaginado from './components/RankingPaginado';
|
||||
import { rankingService } from './services/api';
|
||||
import './App.css';
|
||||
|
||||
@@ -10,6 +11,7 @@ function App() {
|
||||
const [error, setError] = useState(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [limite, setLimite] = useState(10);
|
||||
const [modo, setModo] = useState('completo');
|
||||
|
||||
useEffect(() => {
|
||||
loadRanking();
|
||||
@@ -54,24 +56,45 @@ function App() {
|
||||
<div className="container">
|
||||
<Header total={total} />
|
||||
|
||||
<div className="controls">
|
||||
<label>
|
||||
Limite de consultores:
|
||||
<select value={limite} onChange={(e) => setLimite(Number(e.target.value))}>
|
||||
<option value={10}>Top 10</option>
|
||||
<option value={50}>Top 50</option>
|
||||
<option value={100}>Top 100</option>
|
||||
<option value={200}>Top 200</option>
|
||||
<option value={500}>Top 500</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="mode-selector">
|
||||
<button
|
||||
className={modo === 'top' ? 'active' : ''}
|
||||
onClick={() => setModo('top')}
|
||||
>
|
||||
Top N (Rápido)
|
||||
</button>
|
||||
<button
|
||||
className={modo === 'completo' ? 'active' : ''}
|
||||
onClick={() => setModo('completo')}
|
||||
>
|
||||
Ranking Completo (300k)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ranking-list">
|
||||
{consultores.map((consultor) => (
|
||||
<ConsultorCard key={consultor.id_pessoa} consultor={consultor} />
|
||||
))}
|
||||
</div>
|
||||
{modo === 'top' ? (
|
||||
<>
|
||||
<div className="controls">
|
||||
<label>
|
||||
Limite de consultores:
|
||||
<select value={limite} onChange={(e) => setLimite(Number(e.target.value))}>
|
||||
<option value={10}>Top 10</option>
|
||||
<option value={50}>Top 50</option>
|
||||
<option value={100}>Top 100</option>
|
||||
<option value={200}>Top 200</option>
|
||||
<option value={500}>Top 500</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="ranking-list">
|
||||
{consultores.map((consultor) => (
|
||||
<ConsultorCard key={consultor.id_pessoa} consultor={consultor} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<RankingPaginado />
|
||||
)}
|
||||
|
||||
<footer>
|
||||
<p>Dados: ATUACAPES (Elasticsearch) + SUCUPIRA_PAINEL (Oracle)</p>
|
||||
|
||||
197
frontend/src/components/RankingPaginado.css
Normal file
197
frontend/src/components/RankingPaginado.css
Normal file
@@ -0,0 +1,197 @@
|
||||
.ranking-paginado {
|
||||
padding: 1rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.job-progress {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.job-progress h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4caf50, #66bb6a);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.estatisticas {
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.estatisticas h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-processar {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-processar:hover:not(:disabled) {
|
||||
background: #1976d2;
|
||||
}
|
||||
|
||||
.btn-processar:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.ranking-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ranking-table thead {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ranking-table th,
|
||||
.ranking-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ranking-table th {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ranking-table tbody tr:nth-child(even) {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.ranking-table tbody tr:hover {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.posicao {
|
||||
font-weight: 700;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.nome {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pontuacao-total {
|
||||
font-weight: 700;
|
||||
color: #2e7d32;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.ativo {
|
||||
color: #2e7d32;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inativo {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.paginacao {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.paginacao button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.paginacao button:hover:not(:disabled) {
|
||||
background: #1565c0;
|
||||
}
|
||||
|
||||
.paginacao button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
196
frontend/src/components/RankingPaginado.jsx
Normal file
196
frontend/src/components/RankingPaginado.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import './RankingPaginado.css';
|
||||
|
||||
const RankingPaginado = () => {
|
||||
const [consultores, setConsultores] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [size] = useState(50);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [jobStatus, setJobStatus] = useState(null);
|
||||
const [estatisticas, setEstatisticas] = useState(null);
|
||||
|
||||
const fetchRanking = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/ranking/paginado?page=${page}&size=${size}`);
|
||||
if (!response.ok) throw new Error('Erro ao buscar ranking');
|
||||
const data = await response.json();
|
||||
setConsultores(data.consultores);
|
||||
setTotal(data.total);
|
||||
setTotalPages(data.total_pages);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchJobStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/ranking/status');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
setJobStatus(data);
|
||||
} catch (err) {
|
||||
console.error('Erro ao buscar status do job:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchEstatisticas = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/ranking/estatisticas');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
setEstatisticas(data);
|
||||
} catch (err) {
|
||||
console.error('Erro ao buscar estatísticas:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const processarRanking = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/ranking/processar', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ limpar_antes: true })
|
||||
});
|
||||
if (!response.ok) throw new Error('Erro ao processar ranking');
|
||||
alert('Processamento iniciado! Acompanhe o progresso abaixo.');
|
||||
} catch (err) {
|
||||
alert('Erro: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRanking();
|
||||
fetchEstatisticas();
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobStatus();
|
||||
const interval = setInterval(fetchJobStatus, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const irParaPagina = (novaPagina) => {
|
||||
if (novaPagina >= 1 && novaPagina <= totalPages) {
|
||||
setPage(novaPagina);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ranking-paginado">
|
||||
{jobStatus && jobStatus.running && (
|
||||
<div className="job-progress">
|
||||
<h3>Processamento em andamento...</h3>
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: `${jobStatus.progress}%` }}>
|
||||
{jobStatus.progress}%
|
||||
</div>
|
||||
</div>
|
||||
<p>{jobStatus.mensagem}</p>
|
||||
<p>
|
||||
{jobStatus.processados.toLocaleString('pt-BR')} / {jobStatus.total.toLocaleString('pt-BR')} consultores
|
||||
{jobStatus.tempo_decorrido && ` | Tempo: ${jobStatus.tempo_decorrido}`}
|
||||
{jobStatus.tempo_estimado && ` | Estimado: ${jobStatus.tempo_estimado}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{estatisticas && (
|
||||
<div className="estatisticas">
|
||||
<h3>Estatísticas do Ranking</h3>
|
||||
<div className="stats-grid">
|
||||
<div className="stat">
|
||||
<span className="stat-label">Total:</span>
|
||||
<span className="stat-value">{estatisticas.total_consultores.toLocaleString('pt-BR')}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Ativos:</span>
|
||||
<span className="stat-value">{estatisticas.total_ativos.toLocaleString('pt-BR')}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Pontuação Média:</span>
|
||||
<span className="stat-value">{estatisticas.pontuacao_media.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Atualizado:</span>
|
||||
<span className="stat-value">
|
||||
{estatisticas.ultima_atualizacao
|
||||
? new Date(estatisticas.ultima_atualizacao).toLocaleString('pt-BR')
|
||||
: 'Nunca'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={processarRanking} disabled={jobStatus?.running} className="btn-processar">
|
||||
{jobStatus?.running ? 'Processando...' : 'Reprocessar Ranking'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="loading">Carregando...</div>}
|
||||
{error && <div className="error">Erro: {error}</div>}
|
||||
|
||||
{!loading && !error && consultores.length > 0 && (
|
||||
<>
|
||||
<table className="ranking-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Posição</th>
|
||||
<th>Nome</th>
|
||||
<th>Pontuação Total</th>
|
||||
<th>Comp. A</th>
|
||||
<th>Comp. B</th>
|
||||
<th>Comp. C</th>
|
||||
<th>Comp. D</th>
|
||||
<th>Status</th>
|
||||
<th>Anos</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{consultores.map((consultor) => (
|
||||
<tr key={consultor.id_pessoa}>
|
||||
<td className="posicao">#{consultor.posicao}</td>
|
||||
<td className="nome">{consultor.nome}</td>
|
||||
<td className="pontuacao-total">{consultor.pontuacao_total.toFixed(1)}</td>
|
||||
<td>{consultor.componente_a.toFixed(1)}</td>
|
||||
<td>{consultor.componente_b.toFixed(1)}</td>
|
||||
<td>{consultor.componente_c.toFixed(1)}</td>
|
||||
<td>{consultor.componente_d.toFixed(1)}</td>
|
||||
<td className={consultor.ativo ? 'ativo' : 'inativo'}>
|
||||
{consultor.ativo ? 'Ativo' : 'Inativo'}
|
||||
</td>
|
||||
<td>{consultor.anos_atuacao.toFixed(1)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="paginacao">
|
||||
<button onClick={() => irParaPagina(1)} disabled={page === 1}>
|
||||
Primeira
|
||||
</button>
|
||||
<button onClick={() => irParaPagina(page - 1)} disabled={page === 1}>
|
||||
Anterior
|
||||
</button>
|
||||
<span className="page-info">
|
||||
Página {page} de {totalPages} | Total: {total.toLocaleString('pt-BR')} consultores
|
||||
</span>
|
||||
<button onClick={() => irParaPagina(page + 1)} disabled={page === totalPages}>
|
||||
Próxima
|
||||
</button>
|
||||
<button onClick={() => irParaPagina(totalPages)} disabled={page === totalPages}>
|
||||
Última
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RankingPaginado;
|
||||
Reference in New Issue
Block a user