feat: add skeleton shimmer loading for BI Executive dashboard

Replace static '--' placeholders with animated skeleton screens so the
page feels alive while 4 async data sources load. Theme-aware via CSS
vars (works in dark Bloomberg mode + light mode).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-17 09:34:10 -05:00
parent 42803bd946
commit d30e3865ec

View File

@@ -796,6 +796,25 @@ function buildAdminBIHTML(user) {
[data-theme="dark"] .gran-btn:hover { border-color: #F9A825; color: #F9A825; }
[data-theme="dark"] .date-inputs input[type="date"] { background: var(--card); color: var(--text); border-color: var(--border); }
[data-theme="dark"] .data-table tr:hover td { background: rgba(255,255,255,0.03); }
/* === Skeleton Loading Shimmer === */
@keyframes shimmer {
0% { background-position: -400px 0; }
100% { background-position: 400px 0; }
}
.skel {
background: linear-gradient(90deg, var(--card-bg,var(--card)) 25%, var(--border) 50%, var(--card-bg,var(--card)) 75%);
background-size: 800px 100%;
animation: shimmer 1.5s infinite ease-in-out;
border-radius: 6px;
display: inline-block;
}
.skel-value { height: 32px; width: 55%; }
.skel-badge { height: 18px; width: 70px; }
.skel-text { height: 14px; width: 80%; margin: 4px 0; }
.skel-chart { height: 100%; width: 100%; min-height: 180px; border-radius: 12px; }
.skel-row { height: 32px; margin: 6px 0; width: 100%; }
.skel-gauge { height: 40px; width: 90px; }
`;
return `<!DOCTYPE html>
@@ -854,30 +873,30 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
<div class="hero-grid" id="heroGrid">
<div class="hero-card spread">
<div class="hero-label">Receita USD (Spread)</div>
<div class="hero-value" id="kpiSpreadRevenue">--</div>
<span class="hero-badge neutral" id="kpiSpreadBadge">--</span>
<div class="hero-value" id="kpiSpreadRevenue"><div class="skel skel-value"></div></div>
<span class="hero-badge neutral" id="kpiSpreadBadge"><div class="skel skel-badge"></div></span>
<div class="hero-sub" id="kpiSpreadSub">vs periodo anterior</div>
</div>
<div class="hero-card volume">
<div class="hero-label">Volume Processado</div>
<div class="hero-value" id="kpiVolume">--</div>
<span class="hero-badge neutral" id="kpiVolumeBadge">--</span>
<div class="hero-value" id="kpiVolume"><div class="skel skel-value"></div></div>
<span class="hero-badge neutral" id="kpiVolumeBadge"><div class="skel skel-badge"></div></span>
<div class="hero-sub" id="kpiVolumeSub">USD total movimentado</div>
</div>
<div class="hero-card transactions">
<div class="hero-label">Transacoes</div>
<div class="hero-value" id="kpiTransactions">--</div>
<span class="hero-badge neutral" id="kpiTransBadge">--</span>
<div class="hero-value" id="kpiTransactions"><div class="skel skel-value"></div></div>
<span class="hero-badge neutral" id="kpiTransBadge"><div class="skel skel-badge"></div></span>
<div class="hero-sub" id="kpiTransSub">operacoes no periodo</div>
</div>
<div class="hero-card clients">
<div class="hero-label">Clientes Ativos</div>
<div class="hero-value" id="kpiClients">--</div>
<div class="hero-value" id="kpiClients"><div class="skel skel-value"></div></div>
<div class="hero-sub">clientes unicos no periodo</div>
</div>
<div class="hero-card ticket">
<div class="hero-label">Ticket Medio</div>
<div class="hero-value" id="kpiTicket">--</div>
<div class="hero-value" id="kpiTicket"><div class="skel skel-value"></div></div>
<div class="hero-sub">USD por operacao</div>
</div>
</div>
@@ -890,11 +909,11 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
<div class="charts-row">
<div class="chart-card">
<h3>Revenue por Corredor</h3>
<div class="chart-wrap short"><canvas id="chartDonut"></canvas></div>
<div class="chart-wrap short"><div class="skel skel-chart" id="skelDonut"></div><canvas id="chartDonut" style="display:none"></canvas></div>
</div>
<div class="chart-card">
<h3>Spread Medio + Volume Diario <span class="badge" id="spreadBadge">--</span></h3>
<div class="chart-wrap"><canvas id="chartSpreadTrend"></canvas></div>
<div class="chart-wrap"><div class="skel skel-chart" id="skelSpreadTrend"></div><canvas id="chartSpreadTrend" style="display:none"></canvas></div>
</div>
</div>
@@ -909,14 +928,14 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
<button id="btnResetClients" style="display:none;margin-left:auto;" class="preset-btn"
onclick="_resetClientFilter()">Mostrar Todos</button>
</h3>
<div class="chart-wrap"><canvas id="chartTopClients"></canvas></div>
<div class="chart-wrap"><div class="skel skel-chart" id="skelTopClients"></div><canvas id="chartTopClients" style="display:none"></canvas></div>
</div>
<div class="chart-card" style="display:flex;flex-direction:column;gap:20px;">
<div class="metric-card" style="box-shadow:none;border:1px solid var(--border);padding:16px;">
<h3 style="margin-bottom:8px;">Taxa de Retencao</h3>
<div class="gauge-value green" id="retentionValue">--%</div>
<div class="gauge-value green" id="retentionValue"><div class="skel skel-gauge"></div></div>
<div class="gauge-bar"><div class="gauge-bar-fill green" id="retentionBar" style="width:0%"></div></div>
<div class="gauge-label" id="retentionLabel">-- de -- clientes retidos</div>
<div class="gauge-label" id="retentionLabel"><div class="skel skel-text"></div></div>
<div class="retention-breakdown" id="retentionBreakdown" style="display:flex;justify-content:center;gap:16px;margin-top:10px;">
<span class="ret-stat" style="font-size:13px;font-weight:700;color:var(--green);" id="retNovos">+0 novos</span>
<span class="ret-stat" style="font-size:13px;font-weight:700;color:var(--red);" id="retPerdidos">-0 perdidos</span>
@@ -935,7 +954,7 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
</div>
<table class="data-table" id="riskTable">
<thead><tr><th>Cliente</th><th>Ultima Op</th><th>Dias</th><th>Volume USD</th></tr></thead>
<tbody id="riskTableBody"><tr><td colspan="4" style="text-align:center;color:var(--text-muted);">Carregando...</td></tr></tbody>
<tbody id="riskTableBody"><tr class="skel-tr"><td colspan="4"><div class="skel skel-row"></div></td></tr><tr class="skel-tr"><td colspan="4"><div class="skel skel-row"></div></td></tr><tr class="skel-tr"><td colspan="4"><div class="skel skel-row"></div></td></tr></tbody>
</table>
</div>
</div>
@@ -949,14 +968,14 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
<div class="charts-row equal">
<div class="chart-card">
<h3>Volume por Corredor</h3>
<div class="chart-wrap"><canvas id="chartVolFlow"></canvas></div>
<div class="chart-wrap"><div class="skel skel-chart" id="skelVolFlow"></div><canvas id="chartVolFlow" style="display:none"></canvas></div>
</div>
<div class="chart-card">
<h3>Ranking Agentes</h3>
<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;">
<table class="data-table">
<thead><tr><th>#</th><th>Agente</th><th>Volume</th><th>Ops</th><th>Spread R$</th><th>Clientes</th></tr></thead>
<tbody id="agentTableBody"><tr><td colspan="6" style="text-align:center;color:var(--text-muted);">Carregando...</td></tr></tbody>
<tbody id="agentTableBody"><tr class="skel-tr"><td colspan="6"><div class="skel skel-row"></div></td></tr><tr class="skel-tr"><td colspan="6"><div class="skel skel-row"></div></td></tr><tr class="skel-tr"><td colspan="6"><div class="skel skel-row"></div></td></tr></tbody>
</table>
</div>
</div>
@@ -972,7 +991,7 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
<h3 style="margin:0;">Forecast: Historical + Predicted Volume</h3>
<span class="period-info" id="forecastInfo">Loading forecast...</span>
</div>
<div style="height:300px;"><canvas id="forecastChart"></canvas></div>
<div style="height:300px;"><div class="skel skel-chart" id="skelForecast"></div><canvas id="forecastChart" style="display:none"></canvas></div>
</div>
<!-- Section: Netting & Balanco -->
@@ -990,26 +1009,26 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
<button class="netting-gran-btn" data-netting-gran="M">M</button>
</div>
</div>
<div class="chart-wrap"><canvas id="chartNetting"></canvas></div>
<div class="chart-wrap"><div class="skel skel-chart" id="skelNetting"></div><canvas id="chartNetting" style="display:none"></canvas></div>
</div>
<div class="chart-card" style="display:flex;flex-direction:column;gap:16px;">
<h3 style="font-size:14px;font-weight:700;color:var(--text);margin:0;">Resumo Netting</h3>
<div id="nettingContent">
<div class="netting-row">
<span class="netting-label">Saida (BRL&#x2192;USD)</span>
<span class="netting-value red" id="nettingSaida">--</span>
<span class="netting-value red" id="nettingSaida"><div class="skel skel-badge"></div></span>
</div>
<div class="netting-row">
<span class="netting-label">Entrada (USD&#x2192;BRL)</span>
<span class="netting-value green" id="nettingEntrada">--</span>
<span class="netting-value green" id="nettingEntrada"><div class="skel skel-badge"></div></span>
</div>
<div class="netting-row" style="border-top:2px solid var(--border);padding-top:12px;">
<span class="netting-label" style="font-weight:700;">Posicao Liquida</span>
<span class="netting-value" id="nettingPosicao">--</span>
<span class="netting-value" id="nettingPosicao"><div class="skel skel-badge"></div></span>
</div>
<div style="margin-top:16px;">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px;">Eficiencia Netting</div>
<div class="gauge-value blue" id="nettingEficiencia" style="font-size:36px;margin:4px 0;">--%</div>
<div class="gauge-value blue" id="nettingEficiencia" style="font-size:36px;margin:4px 0;"><div class="skel skel-gauge"></div></div>
<div class="gauge-bar"><div class="gauge-bar-fill blue" id="nettingBar" style="width:0%"></div></div>
</div>
</div>
@@ -1035,22 +1054,22 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
<div class="hero-grid hero-grid-4" id="revenueKpis">
<div class="hero-card" style="--top-color:linear-gradient(90deg,#F9A825,#FFD54F);">
<div class="hero-label">Receita Total (P&L)</div>
<div class="hero-value" id="revTotal">--</div>
<div class="hero-sub" id="revTotalQtd">-- operacoes</div>
<div class="hero-value" id="revTotal"><div class="skel skel-value"></div></div>
<div class="hero-sub" id="revTotalQtd"><div class="skel skel-text"></div></div>
</div>
<div class="hero-card" style="--top-color:linear-gradient(90deg,var(--blue),#42A5F5);">
<div class="hero-label">BR &#x2192; US</div>
<div class="hero-value" id="revBrUs">--</div>
<div class="hero-value" id="revBrUs"><div class="skel skel-value"></div></div>
<div class="hero-sub">Checkout + CambioTransfer</div>
</div>
<div class="hero-card" style="--top-color:linear-gradient(90deg,var(--green),#4CAF50);">
<div class="hero-label">US &#x2192; BR</div>
<div class="hero-value" id="revUsBr">--</div>
<div class="hero-value" id="revUsBr"><div class="skel skel-value"></div></div>
<div class="hero-sub">Spread + Fees</div>
</div>
<div class="hero-card" style="--top-color:linear-gradient(90deg,var(--purple),#AB47BC);">
<div class="hero-label">Receita / Operacao</div>
<div class="hero-value" id="revTicket">--</div>
<div class="hero-value" id="revTicket"><div class="skel skel-value"></div></div>
<div class="hero-sub">ticket medio de receita</div>
</div>
</div>
@@ -1058,11 +1077,11 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
<div class="charts-row equal">
<div class="chart-card">
<h3>Receita por Produto ao Longo do Tempo</h3>
<div class="chart-wrap"><canvas id="chartRevTimeline"></canvas></div>
<div class="chart-wrap"><div class="skel skel-chart" id="skelRevTimeline"></div><canvas id="chartRevTimeline" style="display:none"></canvas></div>
</div>
<div class="chart-card">
<h3>Composicao de Receita por Produto</h3>
<div class="chart-wrap short"><canvas id="chartRevDonut"></canvas></div>
<div class="chart-wrap short"><div class="skel skel-chart" id="skelRevDonut"></div><canvas id="chartRevDonut" style="display:none"></canvas></div>
</div>
</div>
@@ -1071,7 +1090,7 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
<div style="overflow-x:auto;">
<table class="data-table" id="revTable">
<thead><tr><th>Periodo</th><th>Produto</th><th>Receita</th><th>Operacoes</th><th>Receita/Op</th></tr></thead>
<tbody id="revTableBody"><tr><td colspan="5" style="text-align:center;color:var(--text-muted);">Carregando...</td></tr></tbody>
<tbody id="revTableBody"><tr class="skel-tr"><td colspan="5"><div class="skel skel-row"></div></td></tr><tr class="skel-tr"><td colspan="5"><div class="skel skel-row"></div></td></tr><tr class="skel-tr"><td colspan="5"><div class="skel skel-row"></div></td></tr></tbody>
</table>
</div>
</div>
@@ -1084,39 +1103,39 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
<div class="waterfall-grid" id="waterfallGrid" style="margin-bottom:28px;">
<div class="waterfall-item wf-new">
<div class="wf-label">Novos</div>
<div class="wf-count" id="wfNewCount">--</div>
<div class="wf-revenue" id="wfNewRev" style="color:var(--blue);">--</div>
<div class="wf-count" id="wfNewCount"><div class="skel skel-badge"></div></div>
<div class="wf-revenue" id="wfNewRev" style="color:var(--blue);"><div class="skel skel-text"></div></div>
</div>
<div class="waterfall-item wf-expansion">
<div class="wf-label">Expansao</div>
<div class="wf-count" id="wfExpCount">--</div>
<div class="wf-revenue" id="wfExpRev" style="color:var(--green);">--</div>
<div class="wf-count" id="wfExpCount"><div class="skel skel-badge"></div></div>
<div class="wf-revenue" id="wfExpRev" style="color:var(--green);"><div class="skel skel-text"></div></div>
</div>
<div class="waterfall-item wf-stable">
<div class="wf-label">Estavel</div>
<div class="wf-count" id="wfStaCount">--</div>
<div class="wf-revenue" id="wfStaRev" style="color:var(--text-secondary);">--</div>
<div class="wf-count" id="wfStaCount"><div class="skel skel-badge"></div></div>
<div class="wf-revenue" id="wfStaRev" style="color:var(--text-secondary);"><div class="skel skel-text"></div></div>
</div>
<div class="waterfall-item wf-contraction">
<div class="wf-label">Contracao</div>
<div class="wf-count" id="wfConCount">--</div>
<div class="wf-revenue" id="wfConRev" style="color:var(--orange);">--</div>
<div class="wf-count" id="wfConCount"><div class="skel skel-badge"></div></div>
<div class="wf-revenue" id="wfConRev" style="color:var(--orange);"><div class="skel skel-text"></div></div>
</div>
<div class="waterfall-item wf-churned">
<div class="wf-label">Churned</div>
<div class="wf-count" id="wfChurnCount">--</div>
<div class="wf-revenue" id="wfChurnRev" style="color:var(--red);">--</div>
<div class="wf-count" id="wfChurnCount"><div class="skel skel-badge"></div></div>
<div class="wf-revenue" id="wfChurnRev" style="color:var(--red);"><div class="skel skel-text"></div></div>
</div>
</div>
<div class="charts-row equal" style="margin-bottom:28px;">
<div class="chart-card">
<h3>Waterfall de Receita</h3>
<div class="chart-wrap"><canvas id="chartWaterfall"></canvas></div>
<div class="chart-wrap"><div class="skel skel-chart" id="skelWaterfall"></div><canvas id="chartWaterfall" style="display:none"></canvas></div>
</div>
<div class="chart-card">
<h3>Distribuicao por Segmento</h3>
<div class="chart-wrap"><canvas id="chartExpDonut"></canvas></div>
<div class="chart-wrap"><div class="skel skel-chart" id="skelExpDonut"></div><canvas id="chartExpDonut" style="display:none"></canvas></div>
</div>
</div>
@@ -1128,12 +1147,12 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
<div class="segment-grid">
<div class="segment-card">
<h3>Cross-sell <span class="badge">CambioPay vs Checkout</span></h3>
<div class="chart-wrap short"><canvas id="chartCrossSell"></canvas></div>
<div class="chart-wrap short"><div class="skel skel-chart" id="skelCrossSell"></div><canvas id="chartCrossSell" style="display:none"></canvas></div>
<div class="segment-bars" id="crossSellBars" style="margin-top:16px;"></div>
</div>
<div class="segment-card">
<h3>Maturidade de Clientes</h3>
<div class="chart-wrap short"><canvas id="chartMaturity"></canvas></div>
<div class="chart-wrap short"><div class="skel skel-chart" id="skelMaturity"></div><canvas id="chartMaturity" style="display:none"></canvas></div>
<div class="segment-bars" id="maturityBars" style="margin-top:16px;"></div>
</div>
</div>
@@ -1148,7 +1167,7 @@ ${buildHeader({ role: role, userName: user.nome, activePage: 'bi' })}
<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;">
<table class="cohort-table" id="cohortTable">
<thead id="cohortHead"><tr><th>Cohort</th><th>Clientes</th></tr></thead>
<tbody id="cohortBody"><tr><td colspan="14" style="text-align:center;color:var(--text-muted);">Carregando...</td></tr></tbody>
<tbody id="cohortBody"><tr class="skel-tr"><td colspan="14"><div class="skel skel-row"></div></td></tr><tr class="skel-tr"><td colspan="14"><div class="skel skel-row"></div></td></tr><tr class="skel-tr"><td colspan="14"><div class="skel skel-row"></div></td></tr></tbody>
</table>
</div>
</div>
@@ -1211,6 +1230,14 @@ const fmtPct = (curr, prev) => {
return { text: (pct > 0 ? '+' : '') + pct + '%', cls: pct >= 0 ? 'up' : 'down' };
};
// === Skeleton helpers ===
function showChart(skelId, canvasId) {
var s = document.getElementById(skelId);
if (s) s.remove();
var c = document.getElementById(canvasId);
if (c) c.style.display = '';
}
// === Date & Filter Logic ===
let currentStart = '${thirtyDaysAgo}';
let currentEnd = '${today}';
@@ -1325,6 +1352,11 @@ function _renderBICharts(d) {
if (typeof Chart === 'undefined') return;
var _ct = getChartTheme();
try { destroyCharts(); } catch(e) {}
showChart('skelDonut', 'chartDonut');
showChart('skelSpreadTrend', 'chartSpreadTrend');
showChart('skelTopClients', 'chartTopClients');
showChart('skelVolFlow', 'chartVolFlow');
showChart('skelNetting', 'chartNetting');
// Donut: Revenue por Corredor
try {
@@ -1759,6 +1791,8 @@ function _renderRevCharts(d) {
if (typeof Chart === 'undefined' || !d.timeline || !d.totals) return;
var _ct = getChartTheme();
try { destroyRevCharts(); } catch(e) {}
showChart('skelRevTimeline', 'chartRevTimeline');
showChart('skelRevDonut', 'chartRevDonut');
var periods = [...new Set(d.timeline.map(function(r){return r.periodo_label;}))].sort();
var products = [...new Set(d.timeline.map(function(r){return r.produto;}))].sort();
@@ -1944,6 +1978,8 @@ function renderStrategic(d) {
// --- Expansion / Contraction ---
function renderExpansion(exp, t) {
showChart('skelWaterfall', 'chartWaterfall');
showChart('skelExpDonut', 'chartExpDonut');
document.getElementById('wfNewCount').textContent = exp.new_clients.count;
document.getElementById('wfNewRev').textContent = '+' + fmtUSD(exp.new_clients.revenue);
document.getElementById('wfExpCount').textContent = exp.expansion.count;
@@ -1991,6 +2027,7 @@ function renderExpansion(exp, t) {
// --- Cross-sell ---
function renderCrossSell(cs, t) {
showChart('skelCrossSell', 'chartCrossSell');
if (typeof Chart === 'undefined') return;
try { if (chartCrossSell) chartCrossSell.destroy(); } catch(e){}
@@ -2024,6 +2061,7 @@ function renderCrossSell(cs, t) {
// --- Client Maturity ---
function renderMaturity(mat, t) {
showChart('skelMaturity', 'chartMaturity');
if (typeof Chart === 'undefined') return;
try { if (chartMaturity) chartMaturity.destroy(); } catch(e){}
@@ -2120,6 +2158,7 @@ async function loadForecast() {
var resp = await fetch('/admin/api/bi/forecast?metric=volume&days=30');
var data = await resp.json();
if (!data.historical || data.historical.length === 0) {
showChart('skelForecast', 'forecastChart');
document.getElementById('forecastInfo').textContent = 'No data';
return;
}
@@ -2134,6 +2173,7 @@ async function loadForecast() {
// Extend historical with nulls for prediction period
var histFull = histValues.concat(new Array(predLabels.length).fill(null));
showChart('skelForecast', 'forecastChart');
if (_forecastChart) _forecastChart.destroy();
var ctx = document.getElementById('forecastChart');
if (!ctx) return;
@@ -2164,6 +2204,7 @@ async function loadForecast() {
document.getElementById('forecastInfo').textContent = data.predicted.length + '-day forecast';
} catch (e) {
console.error('Forecast error:', e);
showChart('skelForecast', 'forecastChart');
document.getElementById('forecastInfo').textContent = 'Forecast error';
}
}