fix: busca server-side + top 20 clientes (antes carregava 261K clientes no browser)

- fetchClientList separado em fetchTopClients (top 20 LIMIT) e fetchClientSearch (LIKE server-side)
- Novos endpoints: /admin/api/clientes/top e /admin/api/clientes/search?q=
- Cards clicaveis com data-id/data-nome + event delegation (sem inline onclick)
- Busca agora faz fetch server-side com debounce 300ms (min 2 chars)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-16 15:56:22 -05:00
parent ef80c4185d
commit 6c925db42b
3 changed files with 66 additions and 25 deletions

View File

@@ -11,7 +11,7 @@ const express = require('express');
const session = require('express-session');
const path = require('path');
const { authenticate, requireAuth, requireRole, createAgente, createUser } = require('./src/auth');
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics, fetchBIStrategic, fetchClientList, fetchClientProfile, fetchClientData } = require('./src/queries');
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics, fetchBIStrategic, fetchTopClients, fetchClientSearch, fetchClientProfile, fetchClientData } = require('./src/queries');
const { buildHTML } = require('./src/dashboard');
const { buildAdminHTML } = require('./src/admin-panel');
const { buildAdminHomeHTML } = require('./src/admin-home');
@@ -395,12 +395,24 @@ app.get('/admin/cliente', requireRole('admin'), (req, res) => {
}
});
app.get('/admin/api/clientes', requireRole('admin'), async (req, res) => {
app.get('/admin/api/clientes/top', requireRole('admin'), async (req, res) => {
try {
const data = await cache.getOrFetch('client-list', fetchClientList, 15 * 60 * 1000);
const data = await cache.getOrFetch('top-clients', fetchTopClients, 15 * 60 * 1000);
res.json(data);
} catch (err) {
console.error('Client list API error:', err);
console.error('Top clients API error:', err);
res.status(500).json({ error: err.message });
}
});
app.get('/admin/api/clientes/search', requireRole('admin'), async (req, res) => {
try {
const q = (req.query.q || '').trim();
if (q.length < 2) return res.json([]);
const data = await fetchClientSearch(q);
res.json(data);
} catch (err) {
console.error('Client search API error:', err);
res.status(500).json({ error: err.message });
}
});

View File

@@ -578,7 +578,7 @@ ${buildFooter()}
<script>
// === State ===
var clientList = [], selectedClientId = null, profileData = null, clientData = null;
var selectedClientId = null, profileData = null, clientData = null;
var currentStart = '${thirtyDaysAgo}', currentEnd = '${today}';
var currentPage = 1, pageSize = 25, sortCol = 'date', sortDir = -1, timelineGran = 'D';
@@ -752,26 +752,29 @@ function renderNetting(data) {
document.getElementById('netEffBar').style.width = eff + '%';
}
// === Client Search ===
// === Client Search (server-side) ===
var _searchTimer = null;
function fmtVolShort(v) {
if (v >= 1e6) return '$' + (v/1e6).toFixed(1) + 'M';
if (v >= 1e3) return '$' + (v/1e3).toFixed(0) + 'K';
return '$' + v;
}
function esc(s) { return s.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;'); }
function loadClients() {
fetch('/admin/api/clientes').then(function(r){return r.json();}).then(function(data) {
clientList = data;
renderTopClients(data.slice(0, 20));
fetch('/admin/api/clientes/top').then(function(r){return r.json();}).then(function(data) {
renderTopClients(data);
var params = new URLSearchParams(window.location.search);
var deepId = params.get('id');
if (deepId) { var m = clientList.find(function(c){return String(c.id)===String(deepId);}); if (m) selectClient(m.id, m.nome); }
if (deepId) {
var m = data.find(function(c){return String(c.id)===String(deepId);});
if (m) { selectClient(m.id, m.nome); }
else { fetch('/admin/api/cliente/' + deepId + '/profile').then(function(r){return r.json();}).then(function(p){ if(p&&p.nome) selectClient(deepId, p.nome); }).catch(function(){}); }
}
}).catch(function(e){ console.error('loadClients:', e); });
}
function renderTopClients(top) {
var el = document.getElementById('topClientsSection');
if (!el || !top.length) return;
// Group by volume tier
var high = [], mid = [], low = [];
top.forEach(function(c, i) { c._rank = i + 1; });
var maxVol = top[0] ? top[0].vol : 1;
@@ -784,26 +787,32 @@ function renderTopClients(top) {
function buildCards(list) {
return '<div class="top-clients-grid">' + list.map(function(c) {
var avgMonth = c.months > 0 ? c.vol / c.months : c.vol;
return '<div class="top-client-card" onclick="selectClient(\\''+c.id+'\\',\\''+c.nome.replace(/'/g,"\\\\'")+'\\')"><span class="tc-rank">#'+c._rank+'</span><div class="tc-name" title="'+c.nome.replace(/"/g,'&quot;')+'">'+c.nome+'</div><div class="tc-stats"><span class="tc-vol">'+fmtVolShort(c.vol)+'</span><span>'+c.ops+' ops</span><span>~'+fmtVolShort(Math.round(avgMonth))+'/m</span></div></div>';
return '<div class="top-client-card" data-id="'+c.id+'" data-nome="'+esc(c.nome)+'"><span class="tc-rank">#'+c._rank+'</span><div class="tc-name" title="'+esc(c.nome)+'">'+esc(c.nome)+'</div><div class="tc-stats"><span class="tc-vol">'+fmtVolShort(c.vol)+'</span><span>'+c.ops+' ops</span><span>~'+fmtVolShort(Math.round(avgMonth))+'/m</span></div></div>';
}).join('') + '</div>';
}
var html = '';
if (high.length) html += '<div class="top-clients-tier"><div class="tier-header"><span class="tier-dot tier-high"></span><span class="tier-label">Alto Volume</span><span class="tier-count">'+high.length+' clientes</span></div>'+buildCards(high)+'</div>';
if (mid.length) html += '<div class="top-clients-tier"><div class="tier-header"><span class="tier-dot tier-mid"></span><span class="tier-label">Medio Volume</span><span class="tier-count">'+mid.length+' clientes</span></div>'+buildCards(mid)+'</div>';
if (low.length) html += '<div class="top-clients-tier"><div class="tier-header"><span class="tier-dot tier-low"></span><span class="tier-label">Menor Volume</span><span class="tier-count">'+low.length+' clientes</span></div>'+buildCards(low)+'</div>';
if (high.length) html += '<div class="top-clients-tier"><div class="tier-header"><span class="tier-dot tier-high"></span><span class="tier-label">Alto Volume</span><span class="tier-count">'+high.length+'</span></div>'+buildCards(high)+'</div>';
if (mid.length) html += '<div class="top-clients-tier"><div class="tier-header"><span class="tier-dot tier-mid"></span><span class="tier-label">Medio Volume</span><span class="tier-count">'+mid.length+'</span></div>'+buildCards(mid)+'</div>';
if (low.length) html += '<div class="top-clients-tier"><div class="tier-header"><span class="tier-dot tier-low"></span><span class="tier-label">Menor Volume</span><span class="tier-count">'+low.length+'</span></div>'+buildCards(low)+'</div>';
el.innerHTML = html;
el.addEventListener('click', function(e) {
var card = e.target.closest('.top-client-card');
if (card) selectClient(card.dataset.id, card.dataset.nome);
});
}
// Server-side search
document.getElementById('searchInput').addEventListener('input', function(e) {
clearTimeout(_searchTimer);
var val = e.target.value.trim().toLowerCase();
var val = e.target.value.trim();
_searchTimer = setTimeout(function() {
var dd = document.getElementById('searchDropdown');
if (val.length < 1) { dd.classList.remove('open'); return; }
var matches = clientList.filter(function(c){return c.nome.toLowerCase().indexOf(val)!==-1;}).slice(0,15);
if (!matches.length) { dd.innerHTML = '<div class="client-dropdown-item" style="color:var(--text-muted)">Nenhum resultado</div>'; dd.classList.add('open'); return; }
dd.innerHTML = matches.map(function(c){return '<div class="client-dropdown-item" data-id="'+c.id+'" data-nome="'+c.nome.replace(/"/g,'&quot;')+'">'+c.nome+'<span class="item-id">#'+c.id+'</span></div>';}).join('');
dd.classList.add('open');
}, 200);
if (val.length < 2) { dd.classList.remove('open'); return; }
fetch('/admin/api/clientes/search?q=' + encodeURIComponent(val)).then(function(r){return r.json();}).then(function(matches) {
if (!matches.length) { dd.innerHTML = '<div class="client-dropdown-item" style="color:var(--text-muted)">Nenhum resultado</div>'; dd.classList.add('open'); return; }
dd.innerHTML = matches.map(function(c){return '<div class="client-dropdown-item" data-id="'+c.id+'" data-nome="'+esc(c.nome)+'">'+esc(c.nome)+'<span class="item-id">#'+c.id+'</span></div>';}).join('');
dd.classList.add('open');
}).catch(function(){ dd.classList.remove('open'); });
}, 300);
});
document.getElementById('searchDropdown').addEventListener('click', function(e) {
var item = e.target.closest('.client-dropdown-item'); if (!item || !item.dataset.id) return;

View File

@@ -955,8 +955,8 @@ async function fetchRevenueAnalytics(dataInicio, dataFim, granularity = 'dia') {
}
}
// List clients with at least 1 transaction + volume stats for top clients
async function fetchClientList() {
// Top 20 clients by total USD volume
async function fetchTopClients() {
const conn = await pool.getConnection();
try {
const [rows] = await conn.execute(`
@@ -984,6 +984,7 @@ async function fetchClientList() {
) t2 ON t2.id_conta = c.id_conta
WHERE COALESCE(t1.cnt, 0) + COALESCE(t2.cnt, 0) > 0
ORDER BY total_vol_usd DESC
LIMIT 20
`);
return rows.map(r => ({
id: r.id_conta,
@@ -998,6 +999,24 @@ async function fetchClientList() {
}
}
// Search clients by name (server-side, max 15 results)
async function fetchClientSearch(query) {
const conn = await pool.getConnection();
try {
const [rows] = await conn.execute(`
SELECT c.id_conta, c.nome FROM conta c
WHERE c.id_conta IN (
SELECT DISTINCT id_conta FROM br_transaction_to_usa
UNION SELECT DISTINCT id_conta FROM pagamento_br
) AND c.nome LIKE CONCAT('%', ?, '%')
ORDER BY c.nome LIMIT 15
`, [query]);
return rows.map(r => ({ id: r.id_conta, nome: r.nome }));
} finally {
conn.release();
}
}
// Client lifetime profile (no date filter)
async function fetchClientProfile(clienteId) {
const conn = await pool.getConnection();
@@ -1525,7 +1544,8 @@ module.exports = {
fetchBIData,
fetchRevenueAnalytics,
fetchBIStrategic,
fetchClientList,
fetchTopClients,
fetchClientSearch,
fetchClientProfile,
fetchClientData
};