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:
20
server.js
20
server.js
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,'&').replace(/"/g,'"').replace(/</g,'<'); }
|
||||
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,'"')+'">'+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,'"')+'">'+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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user