fix: alinha filtros com corpo e remove alertas
- Header e filtros alinhados com container (1600px) - Adiciona filters-inner wrapper - Remove secao de Alertas Inteligentes (CSS, HTML e JS) - Responsivo atualizado Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
371
src/dashboard.js
371
src/dashboard.js
@@ -87,9 +87,17 @@ function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, as
|
||||
|
||||
.filters {
|
||||
background: var(--card); border-bottom: 1px solid var(--border);
|
||||
padding: 14px 40px; display: flex; gap: 24px; align-items: center; flex-wrap: wrap;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
|
||||
}
|
||||
.filters-inner {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 14px 40px;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-group { display: flex; align-items: center; gap: 8px; }
|
||||
.filter-group label {
|
||||
font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||||
@@ -233,149 +241,6 @@ function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, as
|
||||
.netting-kpi .value.neutral { color: var(--blue); }
|
||||
.netting-kpi .sub { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
/* Alerts Section */
|
||||
.alerts-section {
|
||||
background: var(--card);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
margin-bottom: 28px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.alerts-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: #FAFBFC;
|
||||
}
|
||||
.alerts-header h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.alerts-header .alert-count {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
min-width: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
.alerts-header .alert-count.zero {
|
||||
background: var(--green);
|
||||
}
|
||||
.alerts-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.alerts-toggle:hover {
|
||||
background: var(--bg);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
.alerts-body {
|
||||
padding: 20px 22px;
|
||||
transition: max-height 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.alerts-body.collapsed {
|
||||
max-height: 0 !important;
|
||||
padding: 0 22px;
|
||||
opacity: 0;
|
||||
}
|
||||
.alerts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.alert-card {
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.alert-card.critical {
|
||||
background: #FEE2E2;
|
||||
border: 1px solid #FECACA;
|
||||
}
|
||||
.alert-card.warning {
|
||||
background: #FEF3C7;
|
||||
border: 1px solid #FDE68A;
|
||||
}
|
||||
.alert-card.info {
|
||||
background: var(--blue-bg);
|
||||
border: 1px solid #BFDBFE;
|
||||
}
|
||||
.alert-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.alert-card.critical .alert-icon {
|
||||
background: #DC2626;
|
||||
color: white;
|
||||
}
|
||||
.alert-card.warning .alert-icon {
|
||||
background: #F59E0B;
|
||||
color: white;
|
||||
}
|
||||
.alert-card.info .alert-icon {
|
||||
background: var(--blue);
|
||||
color: white;
|
||||
}
|
||||
.alert-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.alert-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.alert-card.critical .alert-title { color: #DC2626; }
|
||||
.alert-card.warning .alert-title { color: #B45309; }
|
||||
.alert-card.info .alert-title { color: var(--blue); }
|
||||
.alert-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.no-alerts {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.no-alerts .check-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
/* Additional Charts Grid */
|
||||
.charts-grid-3 {
|
||||
display: grid;
|
||||
@@ -430,7 +295,7 @@ function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, as
|
||||
.chart-card.full-width { grid-column: span 1; }
|
||||
.chart-card { min-height: 320px; }
|
||||
.container { padding: 20px; }
|
||||
.filters { padding: 12px 20px; flex-wrap: wrap; }
|
||||
.filters-inner { padding: 12px 20px; }
|
||||
.header-inner { padding: 16px 20px; flex-direction: column; gap: 12px; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
@@ -478,49 +343,37 @@ ${isEmulating ? `
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<div class="filter-group"><label>De:</label><input type="date" id="filterStart"></div>
|
||||
<div class="filter-group"><label>Ate:</label><input type="date" id="filterEnd"></div>
|
||||
<div class="filter-group">
|
||||
<label>Granulacao:</label>
|
||||
<select id="filterGran">
|
||||
<option value="dia">Dia</option>
|
||||
<option value="mes" selected>Mes</option>
|
||||
<option value="ano">Ano</option>
|
||||
</select>
|
||||
<div class="filters-inner">
|
||||
<div class="filter-group"><label>De:</label><input type="date" id="filterStart"></div>
|
||||
<div class="filter-group"><label>Ate:</label><input type="date" id="filterEnd"></div>
|
||||
<div class="filter-group">
|
||||
<label>Granulacao:</label>
|
||||
<select id="filterGran">
|
||||
<option value="dia">Dia</option>
|
||||
<option value="mes" selected>Mes</option>
|
||||
<option value="ano">Ano</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Fluxo:</label>
|
||||
<select id="filterFluxo">
|
||||
<option value="">Todos</option>
|
||||
<option value="BRL \\u2192 USD">BRL → USD</option>
|
||||
<option value="USD \\u2192 BRL">USD → BRL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Cliente:</label>
|
||||
<select id="filterCliente"><option value="">Todos</option></select>
|
||||
</div>
|
||||
<button class="btn-apply" onclick="applyFilters()">Aplicar</button>
|
||||
<button class="btn-export" onclick="exportCSV()">⬇ Exportar CSV</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Fluxo:</label>
|
||||
<select id="filterFluxo">
|
||||
<option value="">Todos</option>
|
||||
<option value="BRL \\u2192 USD">BRL → USD</option>
|
||||
<option value="USD \\u2192 BRL">USD → BRL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Cliente:</label>
|
||||
<select id="filterCliente"><option value="">Todos</option></select>
|
||||
</div>
|
||||
<button class="btn-apply" onclick="applyFilters()">Aplicar</button>
|
||||
<button class="btn-export" onclick="exportCSV()">⬇ Exportar CSV</button>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="kpi-grid" id="kpiGrid"></div>
|
||||
|
||||
<!-- Alerts Section -->
|
||||
<div class="alerts-section" id="alertsSection">
|
||||
<div class="alerts-header">
|
||||
<h3>Alertas Inteligentes <span class="alert-count" id="alertCount">0</span></h3>
|
||||
<button class="alerts-toggle" id="alertsToggle" onclick="toggleAlerts()">
|
||||
<span id="alertsToggleIcon">▼</span>
|
||||
<span id="alertsToggleText">Recolher</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="alerts-body" id="alertsBody">
|
||||
<div class="alerts-grid" id="alertsGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Volume Charts Section -->
|
||||
<div class="section-header">
|
||||
<h2>Volume e Taxas</h2>
|
||||
@@ -705,7 +558,6 @@ function applyFilters() {
|
||||
renderKPIs();
|
||||
try { renderCharts(gran); } catch(e) { console.error('Chart error:', e); }
|
||||
renderTable();
|
||||
renderAlerts();
|
||||
renderPortfolioAnalysis();
|
||||
try { renderPortfolioCharts(gran); } catch(e) { console.error('Portfolio chart error:', e); }
|
||||
try { renderNettingAnalysis(gran); } catch(e) { console.error('Netting chart error:', e); }
|
||||
@@ -1174,159 +1026,6 @@ function renderPortfolioAnalysis() {
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ALERTS SECTION
|
||||
// ============================================
|
||||
|
||||
let alertsCollapsed = false;
|
||||
|
||||
function toggleAlerts() {
|
||||
alertsCollapsed = !alertsCollapsed;
|
||||
const body = document.getElementById('alertsBody');
|
||||
const icon = document.getElementById('alertsToggleIcon');
|
||||
const text = document.getElementById('alertsToggleText');
|
||||
|
||||
if (alertsCollapsed) {
|
||||
body.classList.add('collapsed');
|
||||
icon.innerHTML = '▶';
|
||||
text.textContent = 'Expandir';
|
||||
} else {
|
||||
body.classList.remove('collapsed');
|
||||
icon.innerHTML = '▼';
|
||||
text.textContent = 'Recolher';
|
||||
}
|
||||
}
|
||||
|
||||
function detectAlerts() {
|
||||
const alerts = [];
|
||||
const today = new Date();
|
||||
const endDate = document.getElementById('filterEnd').value;
|
||||
const referenceDate = endDate ? new Date(endDate) : today;
|
||||
|
||||
// Calculate client metrics for alerts (using all data)
|
||||
const clientData = {};
|
||||
RAW_DATA.forEach(r => {
|
||||
if (!clientData[r.cliente]) {
|
||||
clientData[r.cliente] = {
|
||||
cliente: r.cliente,
|
||||
operations: [],
|
||||
volumeTotal: 0
|
||||
};
|
||||
}
|
||||
clientData[r.cliente].operations.push(r);
|
||||
clientData[r.cliente].volumeTotal += r.valor_dolar;
|
||||
});
|
||||
|
||||
// Calculate last operation date for each client
|
||||
Object.values(clientData).forEach(c => {
|
||||
const dates = c.operations
|
||||
.map(op => op.data_operacao ? new Date(op.data_operacao.replace(' ', 'T')) : null)
|
||||
.filter(d => d && !isNaN(d.getTime()))
|
||||
.sort((a, b) => b - a);
|
||||
c.lastDate = dates[0];
|
||||
c.daysSinceLast = c.lastDate ? Math.floor((referenceDate - c.lastDate) / (1000 * 60 * 60 * 24)) : 999;
|
||||
c.operationCount = c.operations.length;
|
||||
});
|
||||
|
||||
/// 1. Cliente Esfriando: Clients who haven't operated in >30 days but had >2 operations
|
||||
const clientesEsfriando = Object.values(clientData).filter(c =>
|
||||
c.daysSinceLast > 30 && c.operationCount > 2
|
||||
);
|
||||
clientesEsfriando.forEach(c => {
|
||||
alerts.push({
|
||||
type: 'warning',
|
||||
icon: '❄',
|
||||
title: 'Cliente Esfriando',
|
||||
desc: c.cliente + ' - ' + c.daysSinceLast + ' dias sem operar (' + c.operationCount + ' operacoes historicas)'
|
||||
});
|
||||
});
|
||||
|
||||
/// 2. Queda de Volume: If total volume dropped >20% vs previous period
|
||||
const start = document.getElementById('filterStart').value;
|
||||
const end = document.getElementById('filterEnd').value;
|
||||
const prev = calcPreviousPeriod(start, end);
|
||||
|
||||
if (prev.start && prev.end) {
|
||||
const currVolume = filtered.reduce((sum, r) => sum + r.valor_dolar, 0);
|
||||
const prevData = RAW_DATA.filter(r => {
|
||||
const dateOnly = r.data_operacao ? r.data_operacao.slice(0, 10) : null;
|
||||
return dateOnly && dateOnly >= prev.start && dateOnly <= prev.end;
|
||||
});
|
||||
const prevVolume = prevData.reduce((sum, r) => sum + r.valor_dolar, 0);
|
||||
|
||||
if (prevVolume > 0) {
|
||||
const dropPct = ((prevVolume - currVolume) / prevVolume) * 100;
|
||||
if (dropPct > 20) {
|
||||
alerts.push({
|
||||
type: 'critical',
|
||||
icon: '↓',
|
||||
title: 'Queda de Volume',
|
||||
desc: 'Volume caiu ' + fmtNum(dropPct, 1) + '% vs periodo anterior (' + fmtUSD(prevVolume) + ' -> ' + fmtUSD(currVolume) + ')'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. VIP Inativo: Top 5 clients by LTV who haven't operated in >15 days
|
||||
const top5ByLTV = Object.values(clientData)
|
||||
.sort((a, b) => b.volumeTotal - a.volumeTotal)
|
||||
.slice(0, 5);
|
||||
|
||||
top5ByLTV.filter(c => c.daysSinceLast > 15).forEach(c => {
|
||||
alerts.push({
|
||||
type: 'critical',
|
||||
icon: '★',
|
||||
title: 'VIP Inativo',
|
||||
desc: c.cliente + ' (LTV: ' + fmtUSD(c.volumeTotal) + ') - ' + c.daysSinceLast + ' dias sem operar'
|
||||
});
|
||||
});
|
||||
|
||||
// 4. Spread Baixo: Operations with spread % below (average - 1 std deviation)
|
||||
if (filtered.length > 0) {
|
||||
const spreads = filtered.map(r => r.spread_pct);
|
||||
const avgSpread = spreads.reduce((a, b) => a + b, 0) / spreads.length;
|
||||
const variance = spreads.reduce((sum, s) => sum + Math.pow(s - avgSpread, 2), 0) / spreads.length;
|
||||
const stdDev = Math.sqrt(variance);
|
||||
const threshold = avgSpread - stdDev;
|
||||
|
||||
const lowSpreadOps = filtered.filter(r => r.spread_pct < threshold);
|
||||
if (lowSpreadOps.length > 0) {
|
||||
const clientsLowSpread = [...new Set(lowSpreadOps.map(r => r.cliente))];
|
||||
alerts.push({
|
||||
type: 'info',
|
||||
icon: '▼',
|
||||
title: 'Spread Baixo Detectado',
|
||||
desc: lowSpreadOps.length + ' operacoes abaixo de ' + fmtPct(threshold) + ' (media: ' + fmtPct(avgSpread) + '). Clientes: ' + clientsLowSpread.slice(0, 3).join(', ') + (clientsLowSpread.length > 3 ? '...' : '')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return alerts;
|
||||
}
|
||||
|
||||
function renderAlerts() {
|
||||
const alerts = detectAlerts();
|
||||
const grid = document.getElementById('alertsGrid');
|
||||
const countBadge = document.getElementById('alertCount');
|
||||
|
||||
countBadge.textContent = alerts.length;
|
||||
countBadge.className = 'alert-count' + (alerts.length === 0 ? ' zero' : '');
|
||||
|
||||
if (alerts.length === 0) {
|
||||
grid.innerHTML = '<div class="no-alerts"><div class="check-icon">✓</div>Nenhum alerta no momento</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = alerts.map(a => \`
|
||||
<div class="alert-card \${a.type}">
|
||||
<div class="alert-icon">\${a.icon}</div>
|
||||
<div class="alert-content">
|
||||
<div class="alert-title">\${a.title}</div>
|
||||
<div class="alert-desc">\${a.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
\`).join('');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PORTFOLIO ANALYSIS CHARTS
|
||||
// ============================================
|
||||
|
||||
Reference in New Issue
Block a user