From ef80c4185dc012ca7859d2613519317b75932e4c Mon Sep 17 00:00:00 2001 From: root Date: Mon, 16 Feb 2026 15:47:36 -0500 Subject: [PATCH] feat: top 20 clientes por volume com cards clicaveis agrupados por faixa - fetchClientList agora retorna volume USD, ops, meses ativos e ultima op - Top 20 clientes exibidos como cards clicaveis na tela inicial - Agrupados em 3 faixas: Alto Volume, Medio Volume, Menor Volume - Cards mostram volume total, ops e media mensal - Click no card carrega o dashboard 360 do cliente Co-Authored-By: Claude Opus 4.6 --- src/admin-cliente.js | 78 +++++++++++++++++++++++++++++++++++++++----- src/queries.js | 40 +++++++++++++++++++---- 2 files changed, 102 insertions(+), 16 deletions(-) diff --git a/src/admin-cliente.js b/src/admin-cliente.js index 52a59ff..593cebb 100644 --- a/src/admin-cliente.js +++ b/src/admin-cliente.js @@ -130,10 +130,36 @@ function buildAdminClienteHTML(user) { .client-selected-badge .badge-clear:hover { border-color: var(--red); color: var(--red); } /* Empty State */ - .empty-state { text-align: center; padding: 80px 20px; color: var(--text-muted); } - .empty-state .empty-icon { font-size: 64px; margin-bottom: 16px; opacity: 0.3; } - .empty-state h2 { font-size: 20px; font-weight: 700; margin-bottom: 8px; color: var(--text-secondary); } - .empty-state p { font-size: 14px; } + .empty-state { text-align: center; padding: 40px 20px 20px; color: var(--text-muted); } + .empty-state .empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.3; } + .empty-state h2 { font-size: 20px; font-weight: 700; margin-bottom: 6px; color: var(--text-secondary); } + .empty-state p { font-size: 14px; margin-bottom: 0; } + + /* Top Clients Grid */ + .top-clients-section { max-width: 1200px; margin: 0 auto; padding: 0 16px; } + .top-clients-tier { margin-bottom: 20px; } + .tier-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; padding: 0 4px; } + .tier-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } + .tier-dot.tier-high { background: var(--green, #1E8E3E); } + .tier-dot.tier-mid { background: var(--blue, #58A6FF); } + .tier-dot.tier-low { background: var(--orange, #F0883E); } + .tier-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); } + .tier-count { font-size: 11px; color: var(--text-muted); margin-left: auto; } + .top-clients-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; } + .top-client-card { + padding: 14px 16px; border-radius: 10px; cursor: pointer; transition: all 0.15s; + background: var(--card); border: 1px solid var(--border); position: relative; overflow: hidden; + } + .top-client-card:hover { border-color: var(--tc-accent); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); } + [data-theme="dark"] .top-client-card { background: rgba(255,255,255,0.02); } + [data-theme="dark"] .top-client-card:hover { background: rgba(0,255,136,0.04); border-color: #00FF88; box-shadow: 0 4px 16px rgba(0,255,136,0.08); } + .top-client-card .tc-rank { position: absolute; top: 8px; right: 10px; font-size: 10px; font-weight: 800; color: var(--text-muted); opacity: 0.5; } + .top-client-card .tc-name { font-size: 13px; font-weight: 700; color: var(--text); margin-bottom: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 24px; } + .top-client-card .tc-stats { display: flex; gap: 12px; font-size: 11px; color: var(--text-secondary); } + .top-client-card .tc-stats span { display: flex; align-items: center; gap: 3px; } + .top-client-card .tc-vol { font-weight: 700; color: var(--tc-accent); } + [data-theme="dark"] .top-client-card .tc-name { font-family: 'SF Mono','Fira Code','Consolas',monospace; } + @media (max-width: 480px) { .top-clients-grid { grid-template-columns: 1fr 1fr; gap: 8px; } .top-client-card { padding: 10px 12px; } } /* === Profile Card === */ .profile-card { border-radius: 16px; padding: 24px; margin-bottom: 24px; display: flex; align-items: center; gap: 24px; flex-wrap: wrap; } @@ -329,11 +355,14 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'cliente' })} - -
-
🔍
-

Selecione um cliente

-

Use a busca acima para encontrar um cliente e visualizar sua analise 360 completa.

+ +
+
+
🔍
+

Selecione um cliente

+

Busque acima ou clique em um dos top clientes abaixo

+
+
@@ -725,14 +754,45 @@ function renderNetting(data) { // === Client Search === 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 loadClients() { fetch('/admin/api/clientes').then(function(r){return r.json();}).then(function(data) { clientList = data; + renderTopClients(data.slice(0, 20)); 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); } }).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; + top.forEach(function(c) { + var ratio = c.vol / maxVol; + if (ratio >= 0.3) high.push(c); + else if (ratio >= 0.05) mid.push(c); + else low.push(c); + }); + function buildCards(list) { + return '
' + list.map(function(c) { + var avgMonth = c.months > 0 ? c.vol / c.months : c.vol; + return '
#'+c._rank+'
'+c.nome+'
'+fmtVolShort(c.vol)+''+c.ops+' ops~'+fmtVolShort(Math.round(avgMonth))+'/m
'; + }).join('') + '
'; + } + var html = ''; + if (high.length) html += '
Alto Volume'+high.length+' clientes
'+buildCards(high)+'
'; + if (mid.length) html += '
Medio Volume'+mid.length+' clientes
'+buildCards(mid)+'
'; + if (low.length) html += '
Menor Volume'+low.length+' clientes
'+buildCards(low)+'
'; + el.innerHTML = html; +} document.getElementById('searchInput').addEventListener('input', function(e) { clearTimeout(_searchTimer); var val = e.target.value.trim().toLowerCase(); diff --git a/src/queries.js b/src/queries.js index f58b095..c8b9155 100644 --- a/src/queries.js +++ b/src/queries.js @@ -955,18 +955,44 @@ async function fetchRevenueAnalytics(dataInicio, dataFim, granularity = 'dia') { } } -// List clients with at least 1 transaction +// List clients with at least 1 transaction + volume stats for top clients async function fetchClientList() { 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 - ) ORDER BY c.nome + SELECT c.id_conta, c.nome, + COALESCE(t1.vol_usd, 0) + COALESCE(t2.vol_usd, 0) AS total_vol_usd, + COALESCE(t1.cnt, 0) + COALESCE(t2.cnt, 0) AS total_ops, + GREATEST(COALESCE(t1.months_active, 0), COALESCE(t2.months_active, 0)) AS months_active, + COALESCE(t1.last_op, t2.last_op) AS last_op + FROM conta c + LEFT JOIN ( + SELECT id_conta, + SUM(amount_usd) AS vol_usd, + COUNT(*) AS cnt, + COUNT(DISTINCT DATE_FORMAT(created_at, '%Y-%m')) AS months_active, + MAX(created_at) AS last_op + FROM br_transaction_to_usa GROUP BY id_conta + ) t1 ON t1.id_conta = c.id_conta + LEFT JOIN ( + SELECT id_conta, + SUM(valor / cotacao) AS vol_usd, + COUNT(*) AS cnt, + COUNT(DISTINCT DATE_FORMAT(created_at, '%Y-%m')) AS months_active, + MAX(created_at) AS last_op + FROM pagamento_br GROUP BY id_conta + ) t2 ON t2.id_conta = c.id_conta + WHERE COALESCE(t1.cnt, 0) + COALESCE(t2.cnt, 0) > 0 + ORDER BY total_vol_usd DESC `); - return rows.map(r => ({ id: r.id_conta, nome: r.nome })); + return rows.map(r => ({ + id: r.id_conta, + nome: r.nome, + vol: Math.round(r.total_vol_usd || 0), + ops: r.total_ops || 0, + months: r.months_active || 0, + lastOp: r.last_op ? r.last_op.toISOString().slice(0, 10) : null + })); } finally { conn.release(); }