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:
325
src/dashboard.js
325
src/dashboard.js
@@ -87,9 +87,17 @@ function buildHTML(data, agente, isAgentDashboard = true, diasPeriodo = null, as
|
|||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
background: var(--card); border-bottom: 1px solid var(--border);
|
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);
|
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 { display: flex; align-items: center; gap: 8px; }
|
||||||
.filter-group label {
|
.filter-group label {
|
||||||
font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
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 .value.neutral { color: var(--blue); }
|
||||||
.netting-kpi .sub { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
.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 */
|
/* Additional Charts Grid */
|
||||||
.charts-grid-3 {
|
.charts-grid-3 {
|
||||||
display: grid;
|
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.full-width { grid-column: span 1; }
|
||||||
.chart-card { min-height: 320px; }
|
.chart-card { min-height: 320px; }
|
||||||
.container { padding: 20px; }
|
.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; }
|
.header-inner { padding: 16px 20px; flex-direction: column; gap: 12px; }
|
||||||
}
|
}
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
@@ -478,6 +343,7 @@ ${isEmulating ? `
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
|
<div class="filters-inner">
|
||||||
<div class="filter-group"><label>De:</label><input type="date" id="filterStart"></div>
|
<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>Ate:</label><input type="date" id="filterEnd"></div>
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
@@ -502,25 +368,12 @@ ${isEmulating ? `
|
|||||||
</div>
|
</div>
|
||||||
<button class="btn-apply" onclick="applyFilters()">Aplicar</button>
|
<button class="btn-apply" onclick="applyFilters()">Aplicar</button>
|
||||||
<button class="btn-export" onclick="exportCSV()">⬇ Exportar CSV</button>
|
<button class="btn-export" onclick="exportCSV()">⬇ Exportar CSV</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="kpi-grid" id="kpiGrid"></div>
|
<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 -->
|
<!-- Volume Charts Section -->
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Volume e Taxas</h2>
|
<h2>Volume e Taxas</h2>
|
||||||
@@ -705,7 +558,6 @@ function applyFilters() {
|
|||||||
renderKPIs();
|
renderKPIs();
|
||||||
try { renderCharts(gran); } catch(e) { console.error('Chart error:', e); }
|
try { renderCharts(gran); } catch(e) { console.error('Chart error:', e); }
|
||||||
renderTable();
|
renderTable();
|
||||||
renderAlerts();
|
|
||||||
renderPortfolioAnalysis();
|
renderPortfolioAnalysis();
|
||||||
try { renderPortfolioCharts(gran); } catch(e) { console.error('Portfolio chart error:', e); }
|
try { renderPortfolioCharts(gran); } catch(e) { console.error('Portfolio chart error:', e); }
|
||||||
try { renderNettingAnalysis(gran); } catch(e) { console.error('Netting 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
|
// PORTFOLIO ANALYSIS CHARTS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
Reference in New Issue
Block a user