feat: trading terminal live rates + fix spread negativo + fix USD→BRL
- Adiciona widget de cotações ao vivo (USD/BRL e EUR/BRL) com design estilo terminal de trading (dark theme, tipografia mono, glow effects) - Proxy server-side /api/cotacao com cache 3s e token AwesomeAPI - Auto-refresh a cada 3 segundos apenas quando a página está aberta - Corrige cálculo de spread negativo: remove Math.abs() em USD→BRL e Math.max(0,...) no spread líquido - Corrige seção USD→BRL que não aparecia (filtro status !== 'finalizado') - Corrige valor_reais no fluxo USD→BRL: agora calcula valor * cotação - Adiciona classe CSS spread-negative para destacar spreads negativos - Bandeiras de fluxo (BR/US/EU) nos botões de compra e venda Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,110 @@
|
||||
/**
|
||||
* Admin Dashboard - KPIs, Tendências e Ranking
|
||||
* Lazy loading para performance
|
||||
* Admin Dashboard Corporate - KPIs, Tendências e Detalhes
|
||||
* Filtros por período: Este Mês, Mês Anterior, Últimos 2 Meses, ou período customizado
|
||||
*/
|
||||
const { buildHeader, buildFooter, buildHead } = require('./ui-template');
|
||||
|
||||
function buildAdminDashboardHTML(admin) {
|
||||
const pageScripts = `<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>`;
|
||||
function buildAdminDashboardHTML(user) {
|
||||
// Support both admin and corporate roles
|
||||
const role = user.role || 'corporate';
|
||||
const pageScripts = '<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"><\/script>';
|
||||
|
||||
// Calculate default dates (current month)
|
||||
const now = new Date();
|
||||
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
|
||||
const pageCSS = `
|
||||
/* Filter Bar */
|
||||
.filter-bar {
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-bar-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.filter-presets {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.preset-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background: white;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.preset-btn:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
.preset-btn.active {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
.filter-divider {
|
||||
width: 1px;
|
||||
height: 32px;
|
||||
background: var(--border);
|
||||
margin: 0 8px;
|
||||
}
|
||||
.date-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.date-inputs label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.date-inputs input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-apply {
|
||||
padding: 8px 20px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-apply:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
.period-info {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg);
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
@@ -41,7 +138,7 @@ function buildAdminDashboardHTML(admin) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.kpi-value {
|
||||
font-size: 36px;
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
@@ -50,18 +147,14 @@ function buildAdminDashboardHTML(admin) {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.kpi-badge {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
.kpi-detail {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.kpi-badge.up { background: var(--green-bg); color: var(--green); }
|
||||
.kpi-badge.down { background: var(--red-bg); color: var(--red); }
|
||||
.kpi-badge.neutral { background: var(--blue-bg); color: var(--blue); }
|
||||
.kpi-detail span { font-weight: 600; color: var(--text); }
|
||||
|
||||
/* Chart Cards */
|
||||
.charts-row {
|
||||
@@ -78,6 +171,9 @@ function buildAdminDashboardHTML(admin) {
|
||||
min-height: 380px;
|
||||
position: relative;
|
||||
}
|
||||
.chart-card.full-width {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.chart-card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
@@ -89,6 +185,41 @@ function buildAdminDashboardHTML(admin) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Details Table */
|
||||
.details-card {
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
.details-card h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text);
|
||||
}
|
||||
.details-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.details-table th {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
padding: 12px 8px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
}
|
||||
.details-table td {
|
||||
padding: 12px 8px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid #F3F4F6;
|
||||
}
|
||||
.details-table tr:hover { background: #FAFBFC; }
|
||||
.details-table .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Ranking Card */
|
||||
.ranking-card {
|
||||
background: var(--card);
|
||||
@@ -97,7 +228,6 @@ function buildAdminDashboardHTML(admin) {
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
min-height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
.ranking-header {
|
||||
display: flex;
|
||||
@@ -110,15 +240,6 @@ function buildAdminDashboardHTML(admin) {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.ranking-header select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ranking-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -138,11 +259,7 @@ function buildAdminDashboardHTML(admin) {
|
||||
border-bottom: 1px solid #F3F4F6;
|
||||
}
|
||||
.ranking-table tr:last-child td { border-bottom: none; }
|
||||
.rank-num {
|
||||
width: 40px;
|
||||
font-weight: 800;
|
||||
color: var(--primary);
|
||||
}
|
||||
.rank-num { width: 40px; font-weight: 800; color: var(--primary); }
|
||||
.rank-1 { color: #FFD700; }
|
||||
.rank-2 { color: #C0C0C0; }
|
||||
.rank-3 { color: #CD7F32; }
|
||||
@@ -163,9 +280,7 @@ function buildAdminDashboardHTML(admin) {
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.loading-text {
|
||||
margin-left: 12px;
|
||||
font-size: 13px;
|
||||
@@ -175,72 +290,117 @@ function buildAdminDashboardHTML(admin) {
|
||||
/* Responsive */
|
||||
@media (max-width: 1200px) {
|
||||
.kpi-row { grid-template-columns: repeat(2, 1fr); }
|
||||
.charts-row { grid-template-columns: 1fr; }
|
||||
.chart-card.full-width { grid-column: span 1; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.kpi-row { grid-template-columns: 1fr; }
|
||||
.charts-row { grid-template-columns: 1fr; }
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
.filter-presets { flex-wrap: wrap; justify-content: center; }
|
||||
.preset-btn { flex: 1; min-width: 90px; text-align: center; }
|
||||
.filter-divider { display: none; }
|
||||
.date-inputs {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.date-inputs input { width: 100%; }
|
||||
.btn-apply { width: 100%; }
|
||||
.period-info { margin-left: 0; text-align: center; }
|
||||
|
||||
.kpi-card { padding: 16px; min-height: 120px; }
|
||||
.kpi-value { font-size: 24px; }
|
||||
.kpi-label { font-size: 11px; }
|
||||
.kpi-sub { font-size: 12px; }
|
||||
.kpi-detail { font-size: 11px; }
|
||||
|
||||
.chart-card { padding: 16px; min-height: 300px; }
|
||||
.chart-card h3 { font-size: 13px; margin-bottom: 12px; }
|
||||
.chart-wrap { height: 240px; }
|
||||
|
||||
.details-card, .ranking-card { padding: 16px; }
|
||||
.details-table th, .details-table td { padding: 8px 6px; font-size: 11px; }
|
||||
.ranking-table th, .ranking-table td { padding: 10px 6px; font-size: 12px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.filter-bar { padding: 12px; }
|
||||
.preset-btn { font-size: 11px; padding: 6px 10px; }
|
||||
.date-inputs input { font-size: 12px; padding: 8px 10px; }
|
||||
.kpi-value { font-size: 20px; }
|
||||
.chart-wrap { height: 200px; }
|
||||
.details-table th, .details-table td { padding: 6px 4px; font-size: 10px; }
|
||||
}
|
||||
`;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
${buildHead('Dashboard', pageCSS, pageScripts)}
|
||||
${buildHead('Dashboard Corporate', pageCSS, pageScripts)}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
${buildHeader({ role: 'admin', userName: admin.nome, activePage: 'dashboard' })}
|
||||
${buildHeader({ role: role, userName: user.nome, activePage: 'dashboard' })}
|
||||
|
||||
<div class="app-container">
|
||||
<div class="filter-bar">
|
||||
<span class="filter-bar-label">Periodo:</span>
|
||||
<div class="filter-presets">
|
||||
<button class="preset-btn active" data-preset="thisMonth">Este Mes</button>
|
||||
<button class="preset-btn" data-preset="lastMonth">Mes Anterior</button>
|
||||
<button class="preset-btn" data-preset="last2Months">Ultimos 2 Meses</button>
|
||||
</div>
|
||||
<div class="filter-divider"></div>
|
||||
<div class="date-inputs">
|
||||
<label>De:</label>
|
||||
<input type="date" id="dateStart" value="${firstDayOfMonth}">
|
||||
<label>Ate:</label>
|
||||
<input type="date" id="dateEnd" value="${today}">
|
||||
<button class="btn-apply" onclick="applyCustomDates()">Aplicar</button>
|
||||
</div>
|
||||
<div class="period-info" id="periodInfo">Carregando...</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
|
||||
<!-- KPIs Row -->
|
||||
<div class="kpi-row" id="kpiRow">
|
||||
<div class="kpi-card total">
|
||||
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando...</span></div>
|
||||
</div>
|
||||
<div class="kpi-card brl-usd">
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="kpi-card usd-brl">
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="kpi-card usd-usd">
|
||||
<div class="loading"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="kpi-card total"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando...</span></div></div>
|
||||
<div class="kpi-card brl-usd"><div class="loading"><div class="spinner"></div></div></div>
|
||||
<div class="kpi-card usd-brl"><div class="loading"><div class="spinner"></div></div></div>
|
||||
<div class="kpi-card usd-usd"><div class="loading"><div class="spinner"></div></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Row -->
|
||||
<div class="charts-row">
|
||||
<div class="chart-card" id="chartConsolidado">
|
||||
<h3>Tendencia 30 dias - Total Consolidado</h3>
|
||||
<div class="chart-wrap">
|
||||
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div>
|
||||
</div>
|
||||
<div class="chart-card" id="chartVolume">
|
||||
<h3>Volume Diario (USD)</h3>
|
||||
<div class="chart-wrap"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div></div>
|
||||
</div>
|
||||
<div class="chart-card" id="chartFluxos">
|
||||
<h3>Tendencia 30 dias - Por Fluxo</h3>
|
||||
<div class="chart-wrap">
|
||||
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div>
|
||||
</div>
|
||||
<div class="chart-card" id="chartOrdens">
|
||||
<h3>Quantidade de Ordens por Dia</h3>
|
||||
<div class="chart-wrap"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ranking -->
|
||||
<div class="charts-row">
|
||||
<div class="chart-card full-width" id="chartFluxos">
|
||||
<h3>Volume por Fluxo (Comparativo)</h3>
|
||||
<div class="chart-wrap"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando grafico...</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="details-card" id="detailsCard">
|
||||
<h3>Resumo Diario do Periodo</h3>
|
||||
<div id="detailsContent"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando detalhes...</span></div></div>
|
||||
</div>
|
||||
|
||||
<div class="ranking-card" id="rankingCard">
|
||||
<div class="ranking-header">
|
||||
<h3>Top 5 Agentes</h3>
|
||||
<select id="rankingPeriodo" onchange="loadRanking()">
|
||||
<option value="30" selected>Ultimo Mes</option>
|
||||
<option value="7">Ultima Semana</option>
|
||||
<option value="90">Ultimos 3 Meses</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="rankingContent">
|
||||
<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando ranking...</span></div>
|
||||
</div>
|
||||
<div class="ranking-header"><h3>Top 5 Agentes no Periodo</h3></div>
|
||||
<div id="rankingContent"><div class="loading"><div class="spinner"></div><span class="loading-text">Carregando ranking...</span></div></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -249,213 +409,175 @@ ${buildFooter()}
|
||||
<script>
|
||||
const formatUSD = (v) => '$' + Number(v).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||||
const formatNum = (v) => Number(v).toLocaleString('pt-BR');
|
||||
const formatDate = (d) => d.split('-').reverse().join('/');
|
||||
|
||||
// Load KPIs
|
||||
async function loadKPIs() {
|
||||
try {
|
||||
const res = await fetch('/admin/api/kpis');
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(json.error);
|
||||
let currentPeriod = { inicio: '${firstDayOfMonth}', fim: '${today}' };
|
||||
let trendData = null;
|
||||
let charts = {};
|
||||
|
||||
const d = json.data;
|
||||
const calcVar = (hoje, media) => media > 0 ? ((hoje - media) / media * 100).toFixed(0) : 0;
|
||||
|
||||
const cards = document.querySelectorAll('#kpiRow .kpi-card');
|
||||
|
||||
// Total
|
||||
const totalVar = calcVar(d.total.hoje_qtd, d.total.media_qtd);
|
||||
cards[0].innerHTML = \`
|
||||
<div class="kpi-badge \${totalVar >= 0 ? 'up' : 'down'}">\${totalVar >= 0 ? '+' : ''}\${totalVar}%</div>
|
||||
<div class="kpi-label">Total Ordens Hoje</div>
|
||||
<div class="kpi-value">\${d.total.hoje_qtd}</div>
|
||||
<div class="kpi-sub">Media 30d: \${d.total.media_qtd} ordens</div>
|
||||
\`;
|
||||
|
||||
// BRL->USD
|
||||
const brlVar = calcVar(d.brlUsd.hoje_qtd, d.brlUsd.media_qtd);
|
||||
cards[1].innerHTML = \`
|
||||
<div class="kpi-badge \${brlVar >= 0 ? 'up' : 'down'}">\${brlVar >= 0 ? '+' : ''}\${brlVar}%</div>
|
||||
<div class="kpi-label">BRL → USD</div>
|
||||
<div class="kpi-value">\${d.brlUsd.hoje_qtd}</div>
|
||||
<div class="kpi-sub">Media 30d: \${d.brlUsd.media_qtd}</div>
|
||||
\`;
|
||||
|
||||
// USD->BRL
|
||||
const usdBrlVar = calcVar(d.usdBrl.hoje_qtd, d.usdBrl.media_qtd);
|
||||
cards[2].innerHTML = \`
|
||||
<div class="kpi-badge \${usdBrlVar >= 0 ? 'up' : 'down'}">\${usdBrlVar >= 0 ? '+' : ''}\${usdBrlVar}%</div>
|
||||
<div class="kpi-label">USD → BRL</div>
|
||||
<div class="kpi-value">\${d.usdBrl.hoje_qtd}</div>
|
||||
<div class="kpi-sub">Media 30d: \${d.usdBrl.media_qtd}</div>
|
||||
\`;
|
||||
|
||||
// USD->USD
|
||||
const usdUsdVar = calcVar(d.usdUsd.hoje_qtd, d.usdUsd.media_qtd);
|
||||
cards[3].innerHTML = \`
|
||||
<div class="kpi-badge \${usdUsdVar >= 0 ? 'up' : 'down'}">\${usdUsdVar >= 0 ? '+' : ''}\${usdUsdVar}%</div>
|
||||
<div class="kpi-label">USD → USD</div>
|
||||
<div class="kpi-value">\${d.usdUsd.hoje_qtd}</div>
|
||||
<div class="kpi-sub">Media 30d: \${d.usdUsd.media_qtd}</div>
|
||||
\`;
|
||||
} catch (err) {
|
||||
console.error('KPIs error:', err);
|
||||
function getPresetDates(preset) {
|
||||
const now = new Date();
|
||||
let inicio, fim;
|
||||
if (preset === 'thisMonth') {
|
||||
inicio = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
fim = now;
|
||||
} else if (preset === 'lastMonth') {
|
||||
inicio = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
fim = new Date(now.getFullYear(), now.getMonth(), 0);
|
||||
} else if (preset === 'last2Months') {
|
||||
inicio = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
fim = now;
|
||||
}
|
||||
return { inicio: inicio.toISOString().slice(0, 10), fim: fim.toISOString().slice(0, 10) };
|
||||
}
|
||||
|
||||
// Load Trend Charts
|
||||
async function loadTrend() {
|
||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
const dates = getPresetDates(btn.dataset.preset);
|
||||
document.getElementById('dateStart').value = dates.inicio;
|
||||
document.getElementById('dateEnd').value = dates.fim;
|
||||
currentPeriod = dates;
|
||||
loadAllData();
|
||||
});
|
||||
});
|
||||
|
||||
function applyCustomDates() {
|
||||
document.querySelectorAll('.preset-btn').forEach(b => b.classList.remove('active'));
|
||||
currentPeriod = { inicio: document.getElementById('dateStart').value, fim: document.getElementById('dateEnd').value };
|
||||
loadAllData();
|
||||
}
|
||||
|
||||
function updatePeriodInfo() {
|
||||
const dias = Math.ceil((new Date(currentPeriod.fim) - new Date(currentPeriod.inicio)) / (1000 * 60 * 60 * 24)) + 1;
|
||||
document.getElementById('periodInfo').textContent = formatDate(currentPeriod.inicio) + ' - ' + formatDate(currentPeriod.fim) + ' (' + dias + ' dias)';
|
||||
}
|
||||
|
||||
async function loadKPIs() {
|
||||
try {
|
||||
const res = await fetch('/admin/api/trend');
|
||||
const res = await fetch('/corporate/api/kpis-period?inicio=' + currentPeriod.inicio + '&fim=' + currentPeriod.fim);
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(json.error);
|
||||
|
||||
const d = json.data;
|
||||
const cards = document.querySelectorAll('#kpiRow .kpi-card');
|
||||
|
||||
cards[0].innerHTML = '<div class="kpi-label">Total no Periodo</div><div class="kpi-value">' + formatNum(d.total.qtd) + '</div><div class="kpi-sub">ordens realizadas</div><div class="kpi-detail">Volume: <span>' + formatUSD(d.total.vol_usd) + '</span> | Ticket Medio: <span>' + formatUSD(d.total.ticket_medio) + '</span></div>';
|
||||
cards[1].innerHTML = '<div class="kpi-label">BRL → USD</div><div class="kpi-value">' + formatNum(d.brlUsd.qtd) + '</div><div class="kpi-sub">' + formatUSD(d.brlUsd.vol_usd) + '</div><div class="kpi-detail">Ticket Medio: <span>' + formatUSD(d.brlUsd.ticket_medio) + '</span></div>';
|
||||
cards[2].innerHTML = '<div class="kpi-label">USD → BRL</div><div class="kpi-value">' + formatNum(d.usdBrl.qtd) + '</div><div class="kpi-sub">' + formatUSD(d.usdBrl.vol_usd) + '</div><div class="kpi-detail">Ticket Medio: <span>' + formatUSD(d.usdBrl.ticket_medio) + '</span></div>';
|
||||
cards[3].innerHTML = '<div class="kpi-label">USD → USD</div><div class="kpi-value">' + formatNum(d.usdUsd.qtd) + '</div><div class="kpi-sub">' + formatUSD(d.usdUsd.vol_usd) + '</div><div class="kpi-detail">Ticket Medio: <span>' + formatUSD(d.usdUsd.ticket_medio) + '</span></div>';
|
||||
} catch (err) { console.error('KPIs error:', err); }
|
||||
}
|
||||
|
||||
async function loadTrend() {
|
||||
try {
|
||||
const res = await fetch('/corporate/api/trend-period?inicio=' + currentPeriod.inicio + '&fim=' + currentPeriod.fim);
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(json.error);
|
||||
trendData = json.data;
|
||||
|
||||
// Build consolidated data
|
||||
const allDates = new Set();
|
||||
d.brlUsd.forEach(r => allDates.add(r.dia));
|
||||
d.usdBrl.forEach(r => allDates.add(r.dia));
|
||||
d.usdUsd.forEach(r => allDates.add(r.dia));
|
||||
trendData.brlUsd.forEach(r => allDates.add(r.dia));
|
||||
trendData.usdBrl.forEach(r => allDates.add(r.dia));
|
||||
trendData.usdUsd.forEach(r => allDates.add(r.dia));
|
||||
const dates = Array.from(allDates).sort();
|
||||
|
||||
const getQtd = (arr, dia) => arr.find(r => r.dia === dia)?.qtd || 0;
|
||||
const getVal = (arr, dia, key) => arr.find(r => r.dia === dia)?.[key] || 0;
|
||||
|
||||
const consolidado = dates.map(dia =>
|
||||
getQtd(d.brlUsd, dia) + getQtd(d.usdBrl, dia) + getQtd(d.usdUsd, dia)
|
||||
);
|
||||
if (charts.volume) charts.volume.destroy();
|
||||
if (charts.ordens) charts.ordens.destroy();
|
||||
if (charts.fluxos) charts.fluxos.destroy();
|
||||
|
||||
// Chart 1: Consolidado
|
||||
document.querySelector('#chartConsolidado .chart-wrap').innerHTML = '<canvas id="canvasConsolidado"></canvas>';
|
||||
new Chart(document.getElementById('canvasConsolidado'), {
|
||||
type: 'line',
|
||||
document.querySelector('#chartVolume .chart-wrap').innerHTML = '<canvas id="canvasVolume"></canvas>';
|
||||
charts.volume = new Chart(document.getElementById('canvasVolume'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: dates.map(d => d.slice(5)),
|
||||
datasets: [{
|
||||
label: 'Total Ordens',
|
||||
data: consolidado,
|
||||
borderColor: '#7600be',
|
||||
backgroundColor: 'rgba(118,0,190,0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 2
|
||||
}]
|
||||
datasets: [
|
||||
{ label: 'BRL→USD', data: dates.map(dia => getVal(trendData.brlUsd, dia, 'vol_usd')), backgroundColor: '#1A73E8', borderRadius: 4 },
|
||||
{ label: 'USD→BRL', data: dates.map(dia => getVal(trendData.usdBrl, dia, 'vol_usd')), backgroundColor: '#1E8E3E', borderRadius: 4 },
|
||||
{ label: 'USD→USD', data: dates.map(dia => getVal(trendData.usdUsd, dia, 'vol_usd')), backgroundColor: '#7B1FA2', borderRadius: 4 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, grid: { color: '#F3F4F6' } },
|
||||
x: { grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } }
|
||||
}
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { usePointStyle: true, font: { size: 11 } } }, tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + formatUSD(ctx.raw) } } },
|
||||
scales: { y: { beginAtZero: true, stacked: true, grid: { color: '#F3F4F6' }, ticks: { callback: v => '$' + (v >= 1e6 ? (v/1e6).toFixed(1)+'M' : v >= 1e3 ? (v/1e3).toFixed(0)+'k' : v) } }, x: { stacked: true, grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 15 } } }
|
||||
}
|
||||
});
|
||||
|
||||
// Chart 2: Por Fluxo
|
||||
const consolidadoQtd = dates.map(dia => getVal(trendData.brlUsd, dia, 'qtd') + getVal(trendData.usdBrl, dia, 'qtd') + getVal(trendData.usdUsd, dia, 'qtd'));
|
||||
document.querySelector('#chartOrdens .chart-wrap').innerHTML = '<canvas id="canvasOrdens"></canvas>';
|
||||
charts.ordens = new Chart(document.getElementById('canvasOrdens'), {
|
||||
type: 'line',
|
||||
data: { labels: dates.map(d => d.slice(5)), datasets: [{ label: 'Total Ordens', data: consolidadoQtd, borderColor: '#7600be', backgroundColor: 'rgba(118,0,190,0.1)', fill: true, tension: 0.3, pointRadius: 3 }] },
|
||||
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: '#F3F4F6' } }, x: { grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 15 } } } }
|
||||
});
|
||||
|
||||
document.querySelector('#chartFluxos .chart-wrap').innerHTML = '<canvas id="canvasFluxos"></canvas>';
|
||||
new Chart(document.getElementById('canvasFluxos'), {
|
||||
charts.fluxos = new Chart(document.getElementById('canvasFluxos'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: dates.map(d => d.slice(5)),
|
||||
datasets: [
|
||||
{
|
||||
label: 'BRL→USD',
|
||||
data: dates.map(dia => getQtd(d.brlUsd, dia)),
|
||||
borderColor: '#1A73E8',
|
||||
backgroundColor: 'transparent',
|
||||
tension: 0.3,
|
||||
pointRadius: 2
|
||||
},
|
||||
{
|
||||
label: 'USD→BRL',
|
||||
data: dates.map(dia => getQtd(d.usdBrl, dia)),
|
||||
borderColor: '#1E8E3E',
|
||||
backgroundColor: 'transparent',
|
||||
tension: 0.3,
|
||||
pointRadius: 2
|
||||
},
|
||||
{
|
||||
label: 'USD→USD',
|
||||
data: dates.map(dia => getQtd(d.usdUsd, dia)),
|
||||
borderColor: '#7B1FA2',
|
||||
backgroundColor: 'transparent',
|
||||
tension: 0.3,
|
||||
pointRadius: 2
|
||||
}
|
||||
{ label: 'BRL→USD', data: dates.map(dia => getVal(trendData.brlUsd, dia, 'vol_usd')), borderColor: '#1A73E8', backgroundColor: 'rgba(26,115,232,0.1)', fill: true, tension: 0.3, pointRadius: 2 },
|
||||
{ label: 'USD→BRL', data: dates.map(dia => getVal(trendData.usdBrl, dia, 'vol_usd')), borderColor: '#1E8E3E', backgroundColor: 'rgba(30,142,62,0.1)', fill: true, tension: 0.3, pointRadius: 2 },
|
||||
{ label: 'USD→USD', data: dates.map(dia => getVal(trendData.usdUsd, dia, 'vol_usd')), borderColor: '#7B1FA2', backgroundColor: 'rgba(123,31,162,0.1)', fill: true, tension: 0.3, pointRadius: 2 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top', labels: { usePointStyle: true, font: { size: 11 } } }
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true, grid: { color: '#F3F4F6' } },
|
||||
x: { grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } }
|
||||
}
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { usePointStyle: true, font: { size: 11 } } }, tooltip: { callbacks: { label: ctx => ctx.dataset.label + ': ' + formatUSD(ctx.raw) } } },
|
||||
scales: { y: { beginAtZero: true, grid: { color: '#F3F4F6' }, ticks: { callback: v => '$' + (v >= 1e6 ? (v/1e6).toFixed(1)+'M' : v >= 1e3 ? (v/1e3).toFixed(0)+'k' : v) } }, x: { grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 20 } } }
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Trend error:', err);
|
||||
}
|
||||
|
||||
renderDetailsTable(dates);
|
||||
} catch (err) { console.error('Trend error:', err); }
|
||||
}
|
||||
|
||||
function renderDetailsTable(dates) {
|
||||
if (!trendData || !dates.length) { document.getElementById('detailsContent').innerHTML = '<p style="color:var(--text-muted);text-align:center;padding:20px;">Sem dados para o periodo.</p>'; return; }
|
||||
const getVal = (arr, dia, key) => arr.find(r => r.dia === dia)?.[key] || 0;
|
||||
const recentDates = dates.slice(-10).reverse();
|
||||
|
||||
let html = '<table class="details-table"><thead><tr><th>Data</th><th class="num">BRL→USD</th><th class="num">USD→BRL</th><th class="num">USD→USD</th><th class="num">Total Qtd</th><th class="num">Volume Total</th></tr></thead><tbody>';
|
||||
recentDates.forEach(dia => {
|
||||
const brlUsdQtd = getVal(trendData.brlUsd, dia, 'qtd');
|
||||
const usdBrlQtd = getVal(trendData.usdBrl, dia, 'qtd');
|
||||
const usdUsdQtd = getVal(trendData.usdUsd, dia, 'qtd');
|
||||
const brlUsdVol = getVal(trendData.brlUsd, dia, 'vol_usd');
|
||||
const usdBrlVol = getVal(trendData.usdBrl, dia, 'vol_usd');
|
||||
const usdUsdVol = getVal(trendData.usdUsd, dia, 'vol_usd');
|
||||
const totalQtd = brlUsdQtd + usdBrlQtd + usdUsdQtd;
|
||||
const totalVol = brlUsdVol + usdBrlVol + usdUsdVol;
|
||||
html += '<tr><td>' + formatDate(dia) + '</td><td class="num">' + brlUsdQtd + '</td><td class="num">' + usdBrlQtd + '</td><td class="num">' + usdUsdQtd + '</td><td class="num"><strong>' + totalQtd + '</strong></td><td class="num"><strong>' + formatUSD(totalVol) + '</strong></td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
if (dates.length > 10) html += '<p style="font-size:12px;color:var(--text-muted);margin-top:12px;text-align:center;">Mostrando os 10 dias mais recentes de ' + dates.length + ' dias no periodo.</p>';
|
||||
document.getElementById('detailsContent').innerHTML = html;
|
||||
}
|
||||
|
||||
// Load Ranking
|
||||
async function loadRanking() {
|
||||
const dias = document.getElementById('rankingPeriodo').value;
|
||||
const dias = Math.ceil((new Date(currentPeriod.fim) - new Date(currentPeriod.inicio)) / (1000 * 60 * 60 * 24)) + 1;
|
||||
const content = document.getElementById('rankingContent');
|
||||
content.innerHTML = '<div class="loading"><div class="spinner"></div><span class="loading-text">Carregando...</span></div>';
|
||||
|
||||
try {
|
||||
const res = await fetch('/admin/api/top-agentes?dias=' + dias);
|
||||
const res = await fetch('/corporate/api/top-agentes?dias=' + dias);
|
||||
const json = await res.json();
|
||||
if (!json.success) throw new Error(json.error);
|
||||
if (json.data.length === 0) { content.innerHTML = '<p style="text-align:center;color:var(--text-muted);padding:40px;">Nenhum dado encontrado.</p>'; return; }
|
||||
|
||||
if (json.data.length === 0) {
|
||||
content.innerHTML = '<p style="text-align:center;color:var(--text-muted);padding:40px;">Nenhum dado encontrado para o periodo.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = \`
|
||||
<table class="ranking-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Agente</th>
|
||||
<th>Qtd Ordens</th>
|
||||
<th>Volume USD</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
\`;
|
||||
|
||||
json.data.forEach(r => {
|
||||
html += \`
|
||||
<tr>
|
||||
<td class="rank-num rank-\${r.rank}">\${r.rank}</td>
|
||||
<td>\${r.agente}</td>
|
||||
<td>\${formatNum(r.qtd)}</td>
|
||||
<td>\${formatUSD(r.vol_usd)}</td>
|
||||
</tr>
|
||||
\`;
|
||||
});
|
||||
|
||||
let html = '<table class="ranking-table"><thead><tr><th>#</th><th>Agente</th><th>Qtd Ordens</th><th>Volume USD</th></tr></thead><tbody>';
|
||||
json.data.forEach(r => { html += '<tr><td class="rank-num rank-' + r.rank + '">' + r.rank + '</td><td>' + r.agente + '</td><td>' + formatNum(r.qtd) + '</td><td>' + formatUSD(r.vol_usd) + '</td></tr>'; });
|
||||
html += '</tbody></table>';
|
||||
content.innerHTML = html;
|
||||
} catch (err) {
|
||||
console.error('Ranking error:', err);
|
||||
content.innerHTML = '<p style="color:var(--red);padding:20px;">Erro ao carregar ranking</p>';
|
||||
}
|
||||
} catch (err) { console.error('Ranking error:', err); content.innerHTML = '<p style="color:var(--red);padding:20px;">Erro ao carregar ranking</p>'; }
|
||||
}
|
||||
|
||||
// Load all sections
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadKPIs();
|
||||
loadTrend();
|
||||
loadRanking();
|
||||
});
|
||||
</script>
|
||||
function loadAllData() { updatePeriodInfo(); loadKPIs(); loadTrend(); loadRanking(); }
|
||||
document.addEventListener('DOMContentLoaded', loadAllData);
|
||||
<\/script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user