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>
1075 lines
39 KiB
JavaScript
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">
|
|
📥 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">🏦</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">▲</span></th>
|
|
<th data-sort="flow">Flow <span class="sort-arrow">▲</span></th>
|
|
<th data-sort="total_tx">Tx Total <span class="sort-arrow">▲</span></th>
|
|
<th data-sort="success_rate">Success Rate <span class="sort-arrow">▲</span></th>
|
|
<th data-sort="vol_usd">Volume USD <span class="sort-arrow">▲</span></th>
|
|
<th data-sort="avg_ticket">Avg Ticket <span class="sort-arrow">▲</span></th>
|
|
<th data-sort="avg_spread_pct">Spread % <span class="sort-arrow">▲</span></th>
|
|
<th data-sort="avg_settlement_hours">Settlement (h) <span class="sort-arrow">▲</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">⚠</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">📊</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 ? '▲' : '▼';
|
|
} else if (arrow) {
|
|
arrow.innerHTML = '▲';
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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 };
|