Files
bi-agents/src/admin-providers.js
root 8641100a18 feat: per-user panel permissions system
Replace hardcoded role-based access with granular per-panel permissions.
Each user can now be assigned any combination of 6 panels (Corporate, BI
Executive, Clientes, Providers, Usuarios, Meu Dashboard) regardless of
their role. Existing users are auto-migrated with defaults based on role.

- Add src/panels.js with panel registry and default permissions
- Add permissions column to SQLite + migration for existing users
- Add requirePermission() middleware, replace requireRole on all routes
- Dynamic nav in buildHeader based on user permissions
- Permissions checkbox UI in admin panel with role presets
- Anti-lockout: users cannot remove 'usuarios' from themselves

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:27:36 -05:00

1075 lines
39 KiB
JavaScript

/**
* Admin Providers Dashboard - Provider Performance Analysis
* Admin-only: provider comparison, success rates, volume analysis, failed transaction breakdown
*/
const { buildHeader, buildFooter, buildHead, getChartJsScript } = require('./ui-template');
function buildAdminProvidersHTML(user) {
const role = user.role || 'admin';
const pageScripts = getChartJsScript();
const now = new Date();
const today = now.toISOString().slice(0, 10);
const thirtyDaysAgo = new Date(now.getTime() - 30 * 86400000).toISOString().slice(0, 10);
const pageCSS = `
/* Smooth scroll for anchor navigation */
html { scroll-behavior: smooth; scroll-padding-top: 20px; }
/* === TRADING CONSOLE: Light Mode (professional clean) === */
body.trading-console {
--tc-accent: #1E8E3E;
--tc-accent-bg: rgba(30,142,62,0.08);
--tc-accent-border: rgba(30,142,62,0.15);
--tc-glass: rgba(255,255,255,0.85);
--tc-grid: rgba(0,0,0,0.06);
background: var(--bg);
color: var(--text);
}
/* === TRADING CONSOLE: Dark Mode (Bloomberg terminal) === */
[data-theme="dark"] body.trading-console {
--bg: #0D1117;
--card: #131A24;
--text: #E2E8F0;
--text-secondary: #94A3B8;
--text-muted: #64748B;
--border: rgba(0,255,136,0.1);
--green: #00FF88;
--green-bg: rgba(0,255,136,0.08);
--blue: #58A6FF;
--blue-bg: rgba(88,166,255,0.08);
--orange: #F0883E;
--orange-bg: rgba(240,136,62,0.08);
--red: #FF4444;
--red-bg: rgba(255,68,68,0.08);
--purple: #BC8CFF;
--purple-bg: rgba(188,140,255,0.08);
--admin-accent: #00FF88;
--admin-bg: rgba(0,255,136,0.05);
--tc-accent: #00FF88;
--tc-accent-bg: rgba(0,255,136,0.08);
--tc-accent-border: rgba(0,255,136,0.15);
--tc-glass: rgba(15,25,35,0.92);
--tc-grid: rgba(0,255,136,0.06);
background: #0A0F18 !important;
color: var(--text);
color-scheme: dark;
}
/* Console Cards - Light */
body.trading-console .hero-card,
body.trading-console .chart-card,
body.trading-console .metric-card,
body.trading-console .filter-bar {
background: var(--card);
border: 1px solid var(--border);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
/* Console Cards - Dark */
[data-theme="dark"] body.trading-console .hero-card,
[data-theme="dark"] body.trading-console .chart-card,
[data-theme="dark"] body.trading-console .metric-card,
[data-theme="dark"] body.trading-console .filter-bar {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(0,255,136,0.1);
box-shadow: 0 2px 12px rgba(0,0,0,0.3), inset 0 1px 0 rgba(0,255,136,0.05);
}
/* Console Values - Light: clean professional */
body.trading-console .hero-value {
font-variant-numeric: tabular-nums;
}
/* Console Values - Dark: monospace terminal */
[data-theme="dark"] body.trading-console .hero-value {
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
text-shadow: 0 0 8px rgba(226,232,240,0.15);
}
/* Console Section Titles - Light */
body.trading-console .section-title {
color: var(--text-secondary);
letter-spacing: 1px;
}
body.trading-console .section-title .icon {
background: var(--tc-accent-bg) !important;
color: var(--tc-accent) !important;
}
/* Console Section Titles - Dark */
[data-theme="dark"] body.trading-console .section-title {
color: rgba(0,255,136,0.7);
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
letter-spacing: 2px;
}
[data-theme="dark"] body.trading-console .section-title .icon {
background: rgba(0,255,136,0.08) !important;
color: #00FF88 !important;
}
/* Console Tables - Light */
body.trading-console .data-table th {
background: var(--bg);
color: var(--text-muted);
}
/* Console Tables - Dark */
[data-theme="dark"] body.trading-console .data-table th {
background: rgba(0,255,136,0.03);
color: rgba(0,255,136,0.6);
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
}
[data-theme="dark"] body.trading-console .data-table td {
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
border-bottom-color: rgba(0,255,136,0.06);
}
[data-theme="dark"] body.trading-console .data-table tr:hover td {
background: rgba(0,255,136,0.05);
}
/* Console Buttons - Dark only overrides */
[data-theme="dark"] body.trading-console .preset-btn {
background: rgba(255,255,255,0.03);
color: var(--text-secondary);
border-color: rgba(0,255,136,0.1);
}
[data-theme="dark"] body.trading-console .preset-btn:hover {
border-color: #00FF88; color: #00FF88;
}
[data-theme="dark"] body.trading-console .preset-btn.active {
background: rgba(0,255,136,0.15);
color: #00FF88; border-color: rgba(0,255,136,0.3);
}
[data-theme="dark"] body.trading-console .date-inputs input[type="date"] {
background: rgba(255,255,255,0.03);
color: var(--text); border-color: rgba(0,255,136,0.1);
}
/* Console Loading - Dark */
[data-theme="dark"] body.trading-console .loading-overlay { background: rgba(10,15,24,0.85); }
/* Console Footer - Dark */
[data-theme="dark"] body.trading-console .app-footer {
background: #0A0F18; border-top-color: rgba(0,255,136,0.1); color: var(--text-muted);
}
/* Console Scrollbars - Dark */
[data-theme="dark"] body.trading-console ::-webkit-scrollbar { width: 6px; height: 6px; }
[data-theme="dark"] body.trading-console ::-webkit-scrollbar-track { background: rgba(0,0,0,0.2); }
[data-theme="dark"] body.trading-console ::-webkit-scrollbar-thumb { background: rgba(0,255,136,0.2); border-radius: 3px; }
[data-theme="dark"] body.trading-console ::-webkit-scrollbar-thumb:hover { background: rgba(0,255,136,0.35); }
/* 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;
background: var(--bg); font-size: 13px; font-weight: 600; cursor: pointer;
color: var(--text-secondary); transition: all 0.15s; font-family: inherit;
}
.preset-btn:hover { border-color: var(--admin-accent); color: var(--admin-accent); }
.preset-btn.active {
background: var(--admin-accent); color: white; border-color: var(--admin-accent);
}
.filter-divider { width: 1px; height: 32px; background: var(--border); }
.date-inputs { display: flex; align-items: center; gap: 8px; }
.date-inputs label { font-size: 12px; font-weight: 600; color: var(--text-muted); }
.date-inputs input[type="date"] {
padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px;
font-size: 13px; font-family: inherit; background: var(--bg); color: var(--text);
}
.period-info {
margin-left: auto; font-size: 12px; color: var(--text-muted);
font-weight: 500; background: var(--bg); padding: 6px 12px; border-radius: 6px;
}
/* Export Button */
.btn-export {
padding: 8px 18px; border: 1px solid var(--border); border-radius: 8px;
background: var(--bg); font-size: 13px; font-weight: 600; cursor: pointer;
color: var(--text-secondary); transition: all 0.15s; font-family: inherit;
display: inline-flex; align-items: center; gap: 6px;
}
.btn-export:hover { border-color: var(--admin-accent); color: var(--admin-accent); }
[data-theme="dark"] body.trading-console .btn-export {
background: rgba(255,255,255,0.03);
color: var(--text-secondary);
border-color: rgba(0,255,136,0.1);
}
[data-theme="dark"] body.trading-console .btn-export:hover {
border-color: #00FF88; color: #00FF88;
}
/* Hero KPI Cards */
.hero-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 28px;
}
.hero-card {
background: var(--card); border-radius: 16px; padding: 20px 18px;
border: 1px solid var(--border); box-shadow: 0 2px 8px rgba(0,0,0,0.04);
position: relative; overflow: hidden;
min-width: 0;
}
.hero-card::before {
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px;
}
.hero-card.providers-count::before { background: linear-gradient(90deg, var(--purple), #AB47BC); }
.hero-card.success-rate::before { background: linear-gradient(90deg, var(--green), #4CAF50); }
.hero-card.total-volume::before { background: linear-gradient(90deg, var(--blue), #42A5F5); }
.hero-card.settlement::before { background: linear-gradient(90deg, var(--orange), #FFA726); }
.hero-label {
font-size: 11px; font-weight: 700; color: var(--text-muted);
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;
}
.hero-value {
font-size: clamp(14px, 1.6vw, 26px); font-weight: 800; color: var(--text); margin-bottom: 4px;
font-variant-numeric: tabular-nums;
word-break: break-word; overflow-wrap: break-word;
min-width: 0; line-height: 1.2;
}
.hero-sub { font-size: 12px; color: var(--text-muted); margin-top: 6px; }
/* Section Headers */
.section-title {
font-size: 14px; font-weight: 700; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 16px;
display: flex; align-items: center; gap: 8px;
}
.section-title .icon {
width: 28px; height: 28px; border-radius: 8px; display: flex;
align-items: center; justify-content: center; font-size: 14px;
}
/* Charts Grid */
.charts-row {
display: grid; grid-template-columns: 1fr 2fr; gap: 20px; margin-bottom: 28px;
}
.charts-row.equal { grid-template-columns: 1fr 1fr; }
.charts-row.triple { grid-template-columns: 1fr 1fr 1fr; }
.chart-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);
}
.chart-card h3 {
font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 16px;
display: flex; align-items: center; gap: 8px;
}
.chart-card h3 .badge {
font-size: 10px; padding: 3px 8px; border-radius: 10px;
font-weight: 700; background: var(--bg); color: var(--text-muted);
}
.chart-wrap { position: relative; height: 280px; }
.chart-wrap.short { height: 220px; }
/* Data Tables */
.data-table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
.data-table th {
text-align: left; padding: 10px 12px; font-weight: 700; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted);
border-bottom: 2px solid var(--border);
cursor: pointer; user-select: none; white-space: nowrap;
}
.data-table th:hover { color: var(--tc-accent); }
.data-table th .sort-arrow { font-size: 9px; margin-left: 4px; opacity: 0.5; }
.data-table th.sorted .sort-arrow { opacity: 1; }
.data-table td {
padding: 10px 12px; border-bottom: 1px solid var(--border);
color: var(--text); font-variant-numeric: tabular-nums;
}
.data-table tr:last-child td { border-bottom: none; }
.data-table tr:hover td { background: var(--bg); }
/* Status badges */
.status-badge {
display: inline-block; font-size: 11px; font-weight: 700; padding: 3px 10px;
border-radius: 12px;
}
.status-badge.good { background: var(--green-bg); color: var(--green); }
.status-badge.warning { background: var(--orange-bg); color: var(--orange); }
.status-badge.bad { background: var(--red-bg); color: var(--red); }
/* Loading */
.loading-overlay {
position: absolute; inset: 0; background: rgba(255,255,255,0.8);
display: flex; align-items: center; justify-content: center;
border-radius: 16px; z-index: 10; font-size: 13px; color: var(--text-muted);
}
/* Responsive - Small Desktop */
@media (max-width: 1200px) {
.hero-grid { grid-template-columns: repeat(2, 1fr); }
.hero-value { font-size: clamp(15px, 2.4vw, 22px); }
.charts-row.triple { grid-template-columns: 1fr 1fr; }
.charts-row.triple > :last-child { grid-column: span 2; }
}
/* Responsive - Tablet */
@media (max-width: 900px) {
.charts-row { grid-template-columns: 1fr; }
.charts-row.equal { grid-template-columns: 1fr; }
.charts-row.triple { grid-template-columns: 1fr; }
.charts-row.triple > :last-child { grid-column: span 1; }
.filter-divider { display: none; }
.filter-bar { gap: 10px; }
}
/* Responsive - Mobile */
@media (max-width: 768px) {
/* Filter bar - stack vertically */
.filter-bar {
padding: 14px 16px; gap: 10px;
flex-direction: column; align-items: stretch;
}
.filter-bar-label { text-align: center; }
.filter-presets { flex-wrap: wrap; justify-content: center; }
.preset-btn { padding: 10px 14px; min-height: 44px; font-size: 13px; flex: 1; min-width: 70px; text-align: center; }
.date-inputs {
flex-wrap: wrap; justify-content: center; gap: 6px;
}
.date-inputs input[type="date"] { flex: 1; min-width: 130px; min-height: 44px; }
.period-info { margin-left: 0; width: 100%; text-align: center; }
/* Hero KPIs */
.hero-grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
.hero-card { padding: 16px 18px; border-radius: 12px; }
.hero-value { font-size: 22px; }
.hero-label { font-size: 10px; }
.hero-sub { font-size: 11px; }
/* Charts - single column */
.charts-row, .charts-row.equal, .charts-row.triple { grid-template-columns: 1fr; }
.chart-card { padding: 18px; border-radius: 12px; }
.chart-wrap { height: 250px; }
/* Tables - horizontal scroll */
.chart-card:has(.data-table) { padding: 18px 0; }
.chart-card:has(.data-table) > h3 { padding: 0 18px; }
.data-table { font-size: 12px; }
.data-table th { padding: 10px 10px; font-size: 10px; white-space: nowrap; }
.data-table td { padding: 10px 10px; white-space: nowrap; }
[style*="overflow-x:auto"], [style*="overflow:auto"] {
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
/* Section titles */
.section-title { font-size: 13px; margin-bottom: 12px; }
}
/* Responsive - Small Mobile */
@media (max-width: 480px) {
/* Filter compact */
.filter-bar { padding: 12px; }
.preset-btn { padding: 8px 10px; font-size: 12px; min-height: 40px; }
.date-inputs input[type="date"] { font-size: 12px; padding: 8px 10px; min-height: 40px; }
/* Hero - single column */
.hero-grid { grid-template-columns: 1fr; gap: 10px; }
.hero-card { padding: 14px 16px; }
.hero-value { font-size: 20px; }
/* Charts compact */
.chart-card { padding: 14px; }
.chart-wrap { height: 200px; }
.chart-wrap.short { height: 160px; }
.chart-card h3 { font-size: 13px; }
/* Tables even more compact */
.data-table { font-size: 11px; }
.data-table th { padding: 8px 6px; font-size: 9px; }
.data-table td { padding: 8px 6px; }
/* Section titles */
.section-title { font-size: 12px; letter-spacing: 0.5px; }
.section-title .icon { width: 24px; height: 24px; font-size: 12px; }
}
/* Dark Mode overrides */
[data-theme="dark"] .loading-overlay { background: rgba(13,17,23,0.85); }
[data-theme="dark"] .preset-btn { background: var(--card); color: var(--text-secondary); border-color: var(--border); }
[data-theme="dark"] .preset-btn:hover { border-color: var(--admin-accent); color: var(--green); }
[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); }
`;
return `<!DOCTYPE html>
<html lang="pt-BR">
<head>
${buildHead('Provider Performance', pageCSS, pageScripts)}
</head>
<body class="trading-console">
${buildHeader({ role: role, userName: user.nome, activePage: 'providers', permissions: user.permissions || [] })}
<div class="app-container">
<!-- Filter Bar -->
<div class="filter-bar" id="filterBar">
<span class="filter-bar-label">Periodo:</span>
<div class="filter-presets">
<button class="preset-btn" data-preset="7d">7 Dias</button>
<button class="preset-btn active" data-preset="30d">30 Dias</button>
<button class="preset-btn" data-preset="90d">90 Dias</button>
<button class="preset-btn" data-preset="mtd">Este Mes</button>
<button class="preset-btn" data-preset="ytd">Este Ano</button>
</div>
<div class="filter-divider"></div>
<div class="date-inputs">
<label>De:</label>
<input type="date" id="dateStart" value="${thirtyDaysAgo}">
<label>Ate:</label>
<input type="date" id="dateEnd" value="${today}">
</div>
<button class="btn-export" id="btnExport" title="Exportar para Excel">
&#x1F4E5; Export Excel
</button>
<span class="period-info" id="periodInfo">Carregando...</span>
</div>
<!-- Hero KPI Cards -->
<div class="hero-grid" id="heroGrid">
<div class="hero-card providers-count">
<div class="hero-label">Total Providers</div>
<div class="hero-value" id="kpiProviders">--</div>
<div class="hero-sub">provedores ativos</div>
</div>
<div class="hero-card success-rate">
<div class="hero-label">Success Rate</div>
<div class="hero-value" id="kpiSuccessRate">--</div>
<div class="hero-sub">taxa geral de sucesso</div>
</div>
<div class="hero-card total-volume">
<div class="hero-label">Volume Total</div>
<div class="hero-value" id="kpiVolume">--</div>
<div class="hero-sub">USD processado no periodo</div>
</div>
<div class="hero-card settlement">
<div class="hero-label">Avg Settlement</div>
<div class="hero-value" id="kpiSettlement">--</div>
<div class="hero-sub">tempo medio de liquidacao</div>
</div>
</div>
<!-- Section: Provider Comparison Table -->
<div class="section-title" id="sectionTable">
<span class="icon">&#x1F3E6;</span>
Provider Comparison
</div>
<div class="chart-card" style="margin-bottom:28px;">
<h3>Performance por Provider <span class="badge" id="providerCount">--</span></h3>
<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;">
<table class="data-table" id="providerTable">
<thead>
<tr>
<th data-sort="provider">Provider <span class="sort-arrow">&#x25B2;</span></th>
<th data-sort="flow">Flow <span class="sort-arrow">&#x25B2;</span></th>
<th data-sort="total_tx">Tx Total <span class="sort-arrow">&#x25B2;</span></th>
<th data-sort="success_rate">Success Rate <span class="sort-arrow">&#x25B2;</span></th>
<th data-sort="vol_usd">Volume USD <span class="sort-arrow">&#x25B2;</span></th>
<th data-sort="avg_ticket">Avg Ticket <span class="sort-arrow">&#x25B2;</span></th>
<th data-sort="avg_spread_pct">Spread % <span class="sort-arrow">&#x25B2;</span></th>
<th data-sort="avg_settlement_hours">Settlement (h) <span class="sort-arrow">&#x25B2;</span></th>
</tr>
</thead>
<tbody id="providerTableBody">
<tr><td colspan="8" style="text-align:center;color:var(--text-muted);">Carregando...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Section: Failed Transactions -->
<div class="section-title" id="sectionFailed">
<span class="icon">&#x26A0;</span>
Failed Transactions Breakdown
</div>
<div class="charts-row equal" style="margin-bottom:28px;">
<div class="chart-card">
<h3>By Provider <span class="badge" id="failedTotal">--</span></h3>
<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;">
<table class="data-table" id="failedByProviderTable">
<thead>
<tr><th>Provider</th><th>Failed Tx</th><th>Volume Lost</th><th>Failure Rate</th></tr>
</thead>
<tbody id="failedByProviderBody">
<tr><td colspan="4" style="text-align:center;color:var(--text-muted);">Carregando...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="chart-card">
<h3>By Status</h3>
<div style="overflow-x:auto;-webkit-overflow-scrolling:touch;">
<table class="data-table" id="failedByStatusTable">
<thead>
<tr><th>Status</th><th>Count</th><th>Volume</th></tr>
</thead>
<tbody id="failedByStatusBody">
<tr><td colspan="3" style="text-align:center;color:var(--text-muted);">Carregando...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Section: Charts -->
<div class="section-title" id="sectionCharts">
<span class="icon">&#x1F4CA;</span>
Provider Analytics
</div>
<div class="charts-row equal">
<div class="chart-card">
<h3>Volume por Provider</h3>
<div class="chart-wrap"><canvas id="chartVolume"></canvas></div>
</div>
<div class="chart-card">
<h3>Success Rate por Provider</h3>
<div class="chart-wrap"><canvas id="chartSuccess"></canvas></div>
</div>
</div>
<div class="chart-card" style="margin-bottom:28px;">
<h3>Volume Trend por Provider <span class="badge">diario</span></h3>
<div class="chart-wrap"><canvas id="chartTrend"></canvas></div>
</div>
</div>
${buildFooter()}
<script>
// Global error handler
window.onerror = function(msg, url, line, col, err) {
var d = document.createElement('div');
d.style.cssText = 'position:fixed;top:0;left:0;right:0;padding:12px 16px;background:#FF4444;color:white;font-size:13px;z-index:99999;font-family:monospace;word-break:break-all;';
d.textContent = 'JS ERROR: ' + msg + ' (line ' + line + ')';
document.body.appendChild(d);
};
<\/script>
<script>
// === Formatters ===
var fmtUSD = function(v) { return '$' + Number(v||0).toLocaleString('en-US', {minimumFractionDigits:0, maximumFractionDigits:0}); };
var fmtNum = function(v) { return Number(v||0).toLocaleString('pt-BR'); };
var fmtPct = function(v) { return Number(v||0).toFixed(1) + '%'; };
var fmtHrs = function(v) { return Number(v||0).toFixed(1) + 'h'; };
// === Date & Filter Logic ===
var currentStart = '${thirtyDaysAgo}';
var currentEnd = '${today}';
function setPreset(preset) {
var now = new Date();
var today = now.toISOString().slice(0, 10);
var start;
switch(preset) {
case '7d':
start = new Date(now.getTime() - 7 * 86400000).toISOString().slice(0, 10); break;
case '30d':
start = new Date(now.getTime() - 30 * 86400000).toISOString().slice(0, 10); break;
case '90d':
start = new Date(now.getTime() - 90 * 86400000).toISOString().slice(0, 10); break;
case 'mtd':
start = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10); break;
case 'ytd':
start = new Date(now.getFullYear(), 0, 1).toISOString().slice(0, 10); break;
default:
start = new Date(now.getTime() - 30 * 86400000).toISOString().slice(0, 10);
}
document.getElementById('dateStart').value = start;
document.getElementById('dateEnd').value = today;
document.querySelectorAll('.filter-presets .preset-btn').forEach(function(b) { b.classList.remove('active'); });
var activeBtn = document.querySelector('[data-preset="'+preset+'"]');
if (activeBtn) activeBtn.classList.add('active');
currentStart = start; currentEnd = today;
loadAll();
}
function loadAll() {
loadProviders();
loadFailed();
loadTrend();
}
document.querySelectorAll('.filter-presets .preset-btn').forEach(function(btn) {
btn.addEventListener('click', function() { setPreset(btn.dataset.preset); });
});
document.getElementById('dateStart').addEventListener('change', function() {
currentStart = document.getElementById('dateStart').value;
document.querySelectorAll('.filter-presets .preset-btn').forEach(function(b) { b.classList.remove('active'); });
loadAll();
});
document.getElementById('dateEnd').addEventListener('change', function() {
currentEnd = document.getElementById('dateEnd').value;
document.querySelectorAll('.filter-presets .preset-btn').forEach(function(b) { b.classList.remove('active'); });
loadAll();
});
// === Theme-aware Chart.js ===
function getChartTheme() {
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
return {
text: isDark ? '#E2E8F0' : '#1A1D23',
grid: isDark ? 'rgba(0,255,136,0.08)' : 'rgba(0,0,0,0.06)',
green: isDark ? '#00FF88' : '#1E8E3E',
blue: isDark ? '#58A6FF' : '#1A73E8',
orange: isDark ? '#F0883E' : '#E8710A',
red: isDark ? '#FF4444' : '#D93025',
purple: isDark ? '#BC8CFF' : '#7B1FA2',
bg: isDark ? '#131A24' : '#FFFFFF',
tooltipBg: isDark ? '#1A2332' : '#FFFFFF',
tooltipTitle: isDark ? '#E2E8F0' : '#1A1D23',
tooltipBody: isDark ? '#94A3B8' : '#5F6368',
tooltipBorder: isDark ? 'rgba(0,255,136,0.15)' : 'rgba(0,0,0,0.1)',
palette: isDark
? ['#00FF88','#58A6FF','#BC8CFF','#F0883E','#FF4444','#F9A825','#00BFA5','#FF6B9D','#7C8CFF','#A1887F']
: ['#1E8E3E','#1A73E8','#7B1FA2','#E8710A','#D93025','#F57C00','#00897B','#D81B60','#3F51B5','#6D4C41']
};
}
function applyChartDefaults(t) {
if (typeof Chart === 'undefined') return;
Chart.defaults.color = t.text;
Chart.defaults.borderColor = t.grid;
Chart.defaults.plugins.legend.labels.color = t.text;
Chart.defaults.plugins.tooltip.backgroundColor = t.tooltipBg;
Chart.defaults.plugins.tooltip.titleColor = t.tooltipTitle;
Chart.defaults.plugins.tooltip.bodyColor = t.tooltipBody;
Chart.defaults.plugins.tooltip.borderColor = t.tooltipBorder;
Chart.defaults.plugins.tooltip.borderWidth = 1;
}
// === Chart instances ===
var chartVolume, chartSuccess, chartTrend;
// === Cached data for theme re-render ===
var _providerData = null;
var _trendData = null;
// Sort state
var _sortCol = 'vol_usd';
var _sortAsc = false;
function destroyCharts() {
[chartVolume, chartSuccess, chartTrend].forEach(function(c) { if (c) c.destroy(); });
chartVolume = null; chartSuccess = null; chartTrend = null;
}
// === Sortable Table Logic ===
function sortProviderData(data, col, asc) {
return data.slice().sort(function(a, b) {
var va = a[col], vb = b[col];
if (typeof va === 'string') {
va = va.toLowerCase(); vb = (vb || '').toLowerCase();
return asc ? va.localeCompare(vb) : vb.localeCompare(va);
}
va = Number(va) || 0; vb = Number(vb) || 0;
return asc ? va - vb : vb - va;
});
}
function renderProviderTable(providers) {
var sorted = sortProviderData(providers, _sortCol, _sortAsc);
var tbody = document.getElementById('providerTableBody');
if (!sorted.length) {
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;color:var(--text-muted);">Nenhum dado encontrado</td></tr>';
return;
}
tbody.innerHTML = sorted.map(function(p) {
var rateClass = p.success_rate >= 95 ? 'good' : p.success_rate >= 80 ? 'warning' : 'bad';
return '<tr>'
+ '<td style="font-weight:700;">' + (p.provider || 'N/A') + '</td>'
+ '<td>' + (p.flow || '--') + '</td>'
+ '<td>' + fmtNum(p.total_tx) + '</td>'
+ '<td><span class="status-badge ' + rateClass + '">' + fmtPct(p.success_rate) + '</span></td>'
+ '<td>' + fmtUSD(p.vol_usd) + '</td>'
+ '<td>' + fmtUSD(p.avg_ticket) + '</td>'
+ '<td>' + (Number(p.avg_spread_pct)||0).toFixed(2) + '%</td>'
+ '<td>' + fmtHrs(p.avg_settlement_hours) + '</td>'
+ '</tr>';
}).join('');
// Update sort arrows
document.querySelectorAll('#providerTable th').forEach(function(th) {
var col = th.getAttribute('data-sort');
th.classList.toggle('sorted', col === _sortCol);
var arrow = th.querySelector('.sort-arrow');
if (arrow && col === _sortCol) {
arrow.innerHTML = _sortAsc ? '&#x25B2;' : '&#x25BC;';
} else if (arrow) {
arrow.innerHTML = '&#x25B2;';
}
});
}
// Attach sort handlers
document.querySelectorAll('#providerTable th[data-sort]').forEach(function(th) {
th.addEventListener('click', function() {
var col = th.getAttribute('data-sort');
if (_sortCol === col) {
_sortAsc = !_sortAsc;
} else {
_sortCol = col;
_sortAsc = col === 'provider' || col === 'flow';
}
if (_providerData && _providerData.providers) {
renderProviderTable(_providerData.providers);
}
});
});
// === Render Charts ===
function renderProviderCharts(providers) {
if (typeof Chart === 'undefined') return;
var t = getChartTheme();
applyChartDefaults(t);
try { destroyCharts(); } catch(e) {}
// Aggregate by provider name for charts
var provMap = {};
providers.forEach(function(p) {
var name = p.provider || 'N/A';
if (!provMap[name]) {
provMap[name] = { vol: 0, successTx: 0, totalTx: 0 };
}
provMap[name].vol += Number(p.vol_usd) || 0;
provMap[name].successTx += Number(p.success_tx) || 0;
provMap[name].totalTx += Number(p.total_tx) || 0;
});
var names = Object.keys(provMap).sort(function(a,b) { return provMap[b].vol - provMap[a].vol; });
var volumes = names.map(function(n) { return provMap[n].vol; });
var rates = names.map(function(n) {
return provMap[n].totalTx > 0 ? (provMap[n].successTx / provMap[n].totalTx * 100) : 0;
});
var bgColors = names.map(function(_, i) { return t.palette[i % t.palette.length]; });
var bgColorsAlpha = names.map(function(_, i) {
var c = t.palette[i % t.palette.length];
return c + '33';
});
// Volume Comparison - horizontal bar
try {
chartVolume = new Chart(document.getElementById('chartVolume'), {
type: 'bar',
data: {
labels: names,
datasets: [{
label: 'Volume USD',
data: volumes,
backgroundColor: bgColorsAlpha,
borderColor: bgColors,
borderWidth: 1,
borderRadius: 4
}]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(ctx) { return 'Volume: ' + fmtUSD(ctx.raw); }
}
}
},
scales: {
x: { grid: { color: t.grid }, ticks: { callback: function(v) { return fmtUSD(v); }, font: { size: 10 } } },
y: { grid: { display: false }, ticks: { font: { size: 11, weight: 600 } } }
}
}
});
} catch(e) { console.warn('Chart volume:', e.message); }
// Success Rate - vertical bar
try {
var barColors = rates.map(function(r) {
return r >= 95 ? t.green : r >= 80 ? t.orange : t.red;
});
var barColorsAlpha = rates.map(function(r) {
var base = r >= 95 ? t.green : r >= 80 ? t.orange : t.red;
return base + '44';
});
chartSuccess = new Chart(document.getElementById('chartSuccess'), {
type: 'bar',
data: {
labels: names,
datasets: [{
label: 'Success Rate %',
data: rates,
backgroundColor: barColorsAlpha,
borderColor: barColors,
borderWidth: 1,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(ctx) { return 'Success: ' + ctx.raw.toFixed(1) + '%'; }
}
}
},
scales: {
y: {
grid: { color: t.grid },
min: 0, max: 100,
ticks: { callback: function(v) { return v + '%'; }, font: { size: 10 } }
},
x: { grid: { display: false }, ticks: { font: { size: 11, weight: 600 }, maxRotation: 45 } }
}
}
});
} catch(e) { console.warn('Chart success:', e.message); }
}
function renderTrendChart(trendData) {
if (typeof Chart === 'undefined') return;
if (chartTrend) { chartTrend.destroy(); chartTrend = null; }
var t = getChartTheme();
applyChartDefaults(t);
if (!trendData || !trendData.length) return;
// Collect all unique days and providers
var daySet = {};
var provSet = {};
trendData.forEach(function(r) {
daySet[r.dia] = true;
provSet[r.provider || 'N/A'] = true;
});
var days = Object.keys(daySet).sort();
var provNames = Object.keys(provSet);
// Build data per provider
var lookup = {};
trendData.forEach(function(r) {
var key = (r.provider || 'N/A') + '|' + r.dia;
lookup[key] = Number(r.vol_usd) || 0;
});
var labels = days.map(function(d) { var p = d.split('-'); return p[2] + '/' + p[1]; });
var datasets = provNames.map(function(prov, i) {
var color = t.palette[i % t.palette.length];
return {
label: prov,
data: days.map(function(day) { return lookup[prov + '|' + day] || 0; }),
borderColor: color,
backgroundColor: color + '15',
borderWidth: 2,
pointRadius: 1,
pointHoverRadius: 5,
fill: false,
tension: 0.3
};
});
try {
chartTrend = new Chart(document.getElementById('chartTrend'), {
type: 'line',
data: { labels: labels, datasets: datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
position: 'top',
labels: { font: { size: 11, weight: 600 }, padding: 12, usePointStyle: true, pointStyle: 'circle' }
},
tooltip: {
callbacks: {
label: function(ctx) { return ctx.dataset.label + ': ' + fmtUSD(ctx.raw); }
}
}
},
scales: {
y: { grid: { color: t.grid }, ticks: { callback: function(v) { return fmtUSD(v); }, font: { size: 10 } } },
x: { grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 45, maxTicksLimit: 20 } }
}
}
});
} catch(e) { console.warn('Chart trend:', e.message); }
}
// === API Loaders ===
async function loadProviders() {
document.getElementById('periodInfo').textContent = 'Carregando providers...';
try {
var resp = await fetch('/admin/api/providers?start=' + currentStart + '&end=' + currentEnd);
if (!resp.ok) { document.getElementById('periodInfo').textContent = 'Erro HTTP ' + resp.status; return; }
var d = await resp.json();
if (d.error) { document.getElementById('periodInfo').textContent = 'Erro: ' + d.error; return; }
_providerData = d;
// Hero KPIs
var s = d.summary || {};
document.getElementById('kpiProviders').textContent = fmtNum(s.total_providers || 0);
document.getElementById('kpiSuccessRate').textContent = fmtPct(s.overall_success_rate);
document.getElementById('kpiVolume').textContent = fmtUSD(s.total_volume);
document.getElementById('kpiSettlement').textContent = fmtHrs(s.avg_settlement_hours);
// Success rate color
var srEl = document.getElementById('kpiSuccessRate');
var sr = Number(s.overall_success_rate) || 0;
srEl.style.color = sr >= 95 ? 'var(--green)' : sr >= 80 ? 'var(--orange)' : 'var(--red)';
// Badge
document.getElementById('providerCount').textContent = (d.providers || []).length + ' providers';
// Table
renderProviderTable(d.providers || []);
// Charts
renderProviderCharts(d.providers || []);
// Period info
var periodDays = Math.round((new Date(currentEnd) - new Date(currentStart)) / 86400000) + 1;
document.getElementById('periodInfo').textContent = periodDays + ' dias | ' + fmtNum((d.providers||[]).length) + ' providers';
} catch(e) {
console.error('loadProviders error:', e);
document.getElementById('periodInfo').textContent = 'Erro: ' + e.message;
}
}
async function loadFailed() {
try {
var resp = await fetch('/admin/api/providers/failed?start=' + currentStart + '&end=' + currentEnd);
if (!resp.ok) return;
var d = await resp.json();
if (d.error) return;
// Total failed badge
document.getElementById('failedTotal').textContent = fmtNum(d.total_failed || 0) + ' falhas | ' + fmtUSD(d.total_vol_failed);
// By Provider
var provBody = document.getElementById('failedByProviderBody');
if (d.byProvider && d.byProvider.length) {
provBody.innerHTML = d.byProvider.map(function(p) {
var rate = Number(p.failure_rate || p.fail_rate || 0);
var rateClass = rate <= 5 ? 'good' : rate <= 20 ? 'warning' : 'bad';
return '<tr>'
+ '<td style="font-weight:700;">' + (p.provider || 'N/A') + '</td>'
+ '<td>' + fmtNum(p.failed_tx || p.qtd || 0) + '</td>'
+ '<td>' + fmtUSD(p.vol_failed || p.vol_usd || 0) + '</td>'
+ '<td><span class="status-badge ' + rateClass + '">' + rate.toFixed(1) + '%</span></td>'
+ '</tr>';
}).join('');
} else {
provBody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-muted);">Nenhuma falha no periodo</td></tr>';
}
// By Status
var statusBody = document.getElementById('failedByStatusBody');
if (d.byStatus && d.byStatus.length) {
statusBody.innerHTML = d.byStatus.map(function(s) {
return '<tr>'
+ '<td style="font-weight:700;">' + (s.status || 'N/A') + '</td>'
+ '<td>' + fmtNum(s.qtd || s.count || 0) + '</td>'
+ '<td>' + fmtUSD(s.vol_usd || s.volume || 0) + '</td>'
+ '</tr>';
}).join('');
} else {
statusBody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:var(--text-muted);">Nenhuma falha no periodo</td></tr>';
}
} catch(e) {
console.error('loadFailed error:', e);
}
}
async function loadTrend() {
try {
var resp = await fetch('/admin/api/providers/trend?start=' + currentStart + '&end=' + currentEnd);
if (!resp.ok) return;
var d = await resp.json();
if (d.error) return;
_trendData = d;
renderTrendChart(d);
} catch(e) {
console.error('loadTrend error:', e);
}
}
// === Export to Excel (CSV) ===
document.getElementById('btnExport').addEventListener('click', function() {
if (!_providerData || !_providerData.providers || !_providerData.providers.length) {
alert('Nenhum dado para exportar.');
return;
}
var rows = [['Provider','Flow','Total Tx','Success Tx','Success Rate %','Volume USD','Avg Ticket','Spread %','Settlement Hours']];
_providerData.providers.forEach(function(p) {
rows.push([
p.provider || '', p.flow || '', p.total_tx || 0, p.success_tx || 0,
(Number(p.success_rate)||0).toFixed(1), (Number(p.vol_usd)||0).toFixed(2),
(Number(p.avg_ticket)||0).toFixed(2), (Number(p.avg_spread_pct)||0).toFixed(2),
(Number(p.avg_settlement_hours)||0).toFixed(1)
]);
});
var csv = rows.map(function(r) {
return r.map(function(v) {
var s = String(v);
return s.indexOf(',') >= 0 || s.indexOf('"') >= 0 ? '"' + s.replace(/"/g, '""') + '"' : s;
}).join(',');
}).join('\\n');
var blob = new Blob(['\\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'providers_' + currentStart + '_' + currentEnd + '.csv';
a.click();
URL.revokeObjectURL(url);
});
// === Theme Change Handler ===
window.addEventListener('themechange', function() {
if (_providerData && _providerData.providers) {
renderProviderCharts(_providerData.providers);
}
if (_trendData) {
renderTrendChart(_trendData);
}
});
// === Init ===
function _startProviders() {
try {
if (typeof Chart !== 'undefined') {
var t = getChartTheme();
applyChartDefaults(t);
}
loadAll();
} catch(e) {
console.error('Init error:', e);
document.getElementById('periodInfo').textContent = 'Init erro: ' + e.message;
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _startProviders);
} else {
_startProviders();
}
<\/script>
</body>
</html>`;
}
module.exports = { buildAdminProvidersHTML };