feat: dashboard cliente 360 — visao completa por cliente com KPIs, timeline, fluxos e transacoes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-16 13:16:26 -05:00
parent 95958e9a96
commit 4595be0b07
4 changed files with 1626 additions and 7 deletions

View File

@@ -11,12 +11,13 @@ const express = require('express');
const session = require('express-session');
const path = require('path');
const { authenticate, requireAuth, requireRole, createAgente, createUser } = require('./src/auth');
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics } = require('./src/queries');
const { fetchTransacoes, fetchAllTransacoes, serialize, fetchDailyStats, fetchKPIs, fetchTrend30Days, fetchTopAgentes, fetchTrendByPeriod, fetchKPIsByPeriod, fetchBIData, fetchRevenueAnalytics, fetchClientList, fetchClientProfile, fetchClientData } = require('./src/queries');
const { buildHTML } = require('./src/dashboard');
const { buildAdminHTML } = require('./src/admin-panel');
const { buildAdminHomeHTML } = require('./src/admin-home');
const { buildAdminDashboardHTML } = require('./src/admin-dashboard');
const { buildAdminBIHTML } = require('./src/admin-bi');
const { buildAdminClienteHTML } = require('./src/admin-cliente');
const bcrypt = require('bcrypt');
const db = require('./src/db-local');
const cache = require('./src/cache');
@@ -369,6 +370,54 @@ app.get('/admin/api/bi/revenue', requireRole('admin'), async (req, res) => {
}
});
// --- Admin Cliente Dashboard (admin only) ---
app.get('/admin/cliente', requireRole('admin'), (req, res) => {
try {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate');
res.set('Pragma', 'no-cache');
const html = buildAdminClienteHTML(req.session.user);
res.send(html);
} catch (err) {
console.error('Admin Cliente error:', err);
res.status(500).send('Erro ao carregar pagina de cliente: ' + err.message);
}
});
app.get('/admin/api/clientes', requireRole('admin'), async (req, res) => {
try {
const data = await cache.getOrFetch('client-list', fetchClientList, 15 * 60 * 1000);
res.json(data);
} catch (err) {
console.error('Client list API error:', err);
res.status(500).json({ error: err.message });
}
});
app.get('/admin/api/cliente/:id/profile', requireRole('admin'), async (req, res) => {
try {
const clienteId = parseInt(req.params.id);
if (!clienteId) return res.status(400).json({ error: 'Invalid client ID' });
const data = await fetchClientProfile(clienteId);
res.json(data);
} catch (err) {
console.error('Client profile API error:', err);
res.status(500).json({ error: err.message });
}
});
app.get('/admin/api/cliente/:id/data', requireRole('admin'), async (req, res) => {
try {
const clienteId = parseInt(req.params.id);
const { start, end } = req.query;
if (!clienteId || !start || !end) return res.status(400).json({ error: 'client ID, start and end required' });
const data = await fetchClientData(clienteId, start, end);
res.json(data);
} catch (err) {
console.error('Client data API error:', err);
res.status(500).json({ error: err.message });
}
});
// Create user (admin only)
app.post('/admin/agentes', requireRole('admin'), async (req, res) => {
const { nome, email, agente_id, senha, role } = req.body;

1276
src/admin-cliente.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -653,9 +653,11 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
) curr ON prev.id_conta = curr.id_conta
`, [prevStartStr, prevEndStr, prevStartStr, prevEndStr, dataInicio, dataFim, dataInicio, dataFim]);
// 11. Clients at risk (last transaction > 30 days ago, had meaningful volume)
// 11. Clients at risk (inactive clients with meaningful volume)
const [clientsAtRisk] = await conn.execute(`
SELECT nome, MAX(last_op) as last_op, SUM(vol) as total_usd, SUM(qtd) as total_qtd FROM (
SELECT nome, MAX(last_op) as last_op, SUM(vol) as total_usd, SUM(qtd) as total_qtd,
DATEDIFF(CURDATE(), MAX(last_op)) as days_inactive
FROM (
SELECT c.nome, MAX(t.created_at) as last_op, SUM(t.amount_usd) as vol, COUNT(*) as qtd
FROM br_transaction_to_usa t
INNER JOIN conta c ON c.id_conta = t.id_conta
@@ -667,8 +669,8 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
GROUP BY c.nome
) combined
GROUP BY nome
HAVING MAX(last_op) < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
ORDER BY total_usd DESC LIMIT 10
HAVING MAX(last_op) < CURDATE()
ORDER BY total_usd DESC LIMIT 20
`);
// 12. Agent ranking with spread revenue
@@ -747,7 +749,8 @@ async function fetchBIData(dataInicio, dataFim, getAgenteName = null) {
retention: { prev_clients: retPrev, retained: retCurr, rate: retPrev > 0 ? Math.round(retCurr / retPrev * 100) : 0 },
clientsAtRisk: clientsAtRisk.map(r => ({
nome: r.nome, vol_usd: Number(r.total_usd), qtd: Number(r.total_qtd),
last_op: r.last_op instanceof Date ? r.last_op.toISOString().slice(0, 10) : String(r.last_op).slice(0, 16)
last_op: r.last_op instanceof Date ? r.last_op.toISOString().slice(0, 10) : String(r.last_op).slice(0, 16),
days_inactive: Number(r.days_inactive) || 0
})),
agentRanking: agents,
netting: {
@@ -952,6 +955,292 @@ async function fetchRevenueAnalytics(dataInicio, dataFim, granularity = 'dia') {
}
}
// List clients with at least 1 transaction
async function fetchClientList() {
const conn = await pool.getConnection();
try {
const [rows] = await conn.execute(`
SELECT c.id_conta, c.nome FROM conta c
WHERE c.id_conta IN (
SELECT DISTINCT id_conta FROM br_transaction_to_usa
UNION SELECT DISTINCT id_conta FROM pagamento_br
) ORDER BY c.nome
`);
return rows.map(r => ({ id: r.id_conta, nome: r.nome }));
} finally {
conn.release();
}
}
// Client lifetime profile (no date filter)
async function fetchClientProfile(clienteId) {
const conn = await pool.getConnection();
try {
// Name
const [conta] = await conn.execute('SELECT nome FROM conta WHERE id_conta = ?', [clienteId]);
const nome = conta[0]?.nome || 'Cliente #' + clienteId;
// BRL->USD lifetime
const [brl] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
ROUND(COALESCE(SUM(amount_brl),0),2) as vol_brl,
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd),0),2) as spread_revenue,
MIN(created_at) as first_op, MAX(created_at) as last_op
FROM br_transaction_to_usa WHERE id_conta = ?
`, [clienteId]);
// USD->BRL lifetime
const [usd] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
ROUND(COALESCE(SUM(valor_sol),0),2) as vol_brl,
ROUND(COALESCE(SUM((ptax - cotacao) * valor),0),2) as spread_revenue,
MIN(created_at) as first_op, MAX(created_at) as last_op
FROM pagamento_br WHERE id_conta = ?
AND cotacao IS NOT NULL AND cotacao > 0
AND (pgto IS NULL OR pgto != 'balance')
`, [clienteId]);
const brlData = brl[0] || {};
const usdData = usd[0] || {};
const dates = [brlData.first_op, usdData.first_op].filter(Boolean);
const lastDates = [brlData.last_op, usdData.last_op].filter(Boolean);
const firstOp = dates.length > 0 ? new Date(Math.min(...dates.map(d => new Date(d).getTime()))) : null;
const lastOp = lastDates.length > 0 ? new Date(Math.max(...lastDates.map(d => new Date(d).getTime()))) : null;
const daysInactive = lastOp ? Math.round((Date.now() - lastOp.getTime()) / 86400000) : null;
const brlQtd = Number(brlData.qtd) || 0;
const usdQtd = Number(usdData.qtd) || 0;
return {
id: clienteId,
nome,
first_op: firstOp ? firstOp.toISOString().slice(0, 10) : null,
last_op: lastOp ? lastOp.toISOString().slice(0, 10) : null,
days_inactive: daysInactive,
total_ops: brlQtd + usdQtd,
total_vol_usd: (Number(brlData.vol_usd) || 0) + (Number(usdData.vol_usd) || 0),
total_vol_brl: (Number(brlData.vol_brl) || 0) + (Number(usdData.vol_brl) || 0),
total_spread_revenue: (Number(brlData.spread_revenue) || 0) + (Number(usdData.spread_revenue) || 0),
brlUsd: { qtd: brlQtd, vol_usd: Number(brlData.vol_usd) || 0 },
usdBrl: { qtd: usdQtd, vol_usd: Number(usdData.vol_usd) || 0 }
};
} finally {
conn.release();
}
}
// Client data for a period — full analytics
async function fetchClientData(clienteId, dataInicio, dataFim) {
const conn = await pool.getConnection();
try {
// Previous period for comparison
const start = new Date(dataInicio);
const end = new Date(dataFim);
const periodDays = Math.round((end - start) / 86400000) + 1;
const prevEnd = new Date(start); prevEnd.setDate(prevEnd.getDate() - 1);
const prevStart = new Date(prevEnd); prevStart.setDate(prevStart.getDate() - periodDays + 1);
const prevStartStr = prevStart.toISOString().slice(0, 10);
const prevEndStr = prevEnd.toISOString().slice(0, 10);
// KPIs BRL->USD
const [kpiBrl] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
ROUND(COALESCE(SUM(amount_brl),0),2) as vol_brl,
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd),0),2) as spread_revenue,
ROUND(COALESCE(AVG((exchange_rate - ptax) / exchange_rate * 100),0),2) as avg_spread_pct
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
`, [clienteId, dataInicio, dataFim]);
// KPIs USD->BRL
const [kpiUsd] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
ROUND(COALESCE(SUM(valor_sol),0),2) as vol_brl,
ROUND(COALESCE(SUM((ptax - cotacao) * valor),0),2) as spread_revenue,
ROUND(COALESCE(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END),0),2) as avg_spread_pct
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
`, [clienteId, dataInicio, dataFim]);
// Previous period
const [prevBrl] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(amount_usd),0),2) as vol_usd,
ROUND(COALESCE(SUM((exchange_rate - ptax) * amount_usd),0),2) as spread_revenue
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
`, [clienteId, prevStartStr, prevEndStr]);
const [prevUsd] = await conn.execute(`
SELECT COUNT(*) as qtd, ROUND(COALESCE(SUM(valor),0),2) as vol_usd,
ROUND(COALESCE(SUM((ptax - cotacao) * valor),0),2) as spread_revenue
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
`, [clienteId, prevStartStr, prevEndStr]);
// Trend BRL->USD
const [trendBrl] = await conn.execute(`
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
ROUND(SUM(amount_usd),2) as vol_usd,
ROUND(AVG((exchange_rate - ptax) / exchange_rate * 100),2) as avg_spread
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
GROUP BY DATE(created_at) ORDER BY dia
`, [clienteId, dataInicio, dataFim]);
// Trend USD->BRL
const [trendUsd] = await conn.execute(`
SELECT DATE(created_at) as dia, COUNT(*) as qtd,
ROUND(SUM(valor),2) as vol_usd,
ROUND(AVG(CASE WHEN cotacao > 0 THEN (ptax - cotacao) / ptax * 100 ELSE 0 END),2) as avg_spread
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
GROUP BY DATE(created_at) ORDER BY dia
`, [clienteId, dataInicio, dataFim]);
// Individual transactions BRL->USD
const [txBrl] = await conn.execute(`
SELECT t.created_at as date, 'BRL→USD' as flow,
t.amount_usd as usd, t.amount_brl as brl,
ROUND(t.exchange_rate,4) as rate, ROUND(t.ptax,4) as ptax,
ROUND((t.exchange_rate - t.ptax) / t.exchange_rate * 100, 2) as spread_pct,
t.iof, t.status,
COALESCE(pm.provider, '') as provider
FROM br_transaction_to_usa t
LEFT JOIN br_payment_methods pm ON t.payment_method_id = pm.id
WHERE t.id_conta = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
ORDER BY t.created_at DESC
`, [clienteId, dataInicio, dataFim]);
// Individual transactions USD->BRL
const [txUsd] = await conn.execute(`
SELECT p.created_at as date, 'USD→BRL' as flow,
p.valor as usd, ROUND(p.valor * p.cotacao, 2) as brl,
ROUND(p.cotacao,4) as rate, ROUND(p.ptax,4) as ptax,
CASE WHEN p.cotacao > 0 THEN ROUND((p.ptax - p.cotacao) / p.ptax * 100, 2) ELSE 0 END as spread_pct,
0 as iof, COALESCE(p.pgto, '') as status,
COALESCE(p.tipo_envio, '') as provider
FROM pagamento_br p
WHERE p.id_conta = ? AND DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
AND p.cotacao IS NOT NULL AND p.cotacao > 0 AND (p.pgto IS NULL OR p.pgto != 'balance')
ORDER BY p.created_at DESC
`, [clienteId, dataInicio, dataFim]);
// Day of week
const [dowBrl] = await conn.execute(`
SELECT DAYOFWEEK(created_at) as dow, COUNT(*) as qtd, ROUND(SUM(amount_usd),2) as vol_usd
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
GROUP BY DAYOFWEEK(created_at)
`, [clienteId, dataInicio, dataFim]);
const [dowUsd] = await conn.execute(`
SELECT DAYOFWEEK(created_at) as dow, COUNT(*) as qtd, ROUND(SUM(valor),2) as vol_usd
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
GROUP BY DAYOFWEEK(created_at)
`, [clienteId, dataInicio, dataFim]);
// Providers
const [provBrl] = await conn.execute(`
SELECT COALESCE(pm.provider, 'N/A') as name, COUNT(*) as qtd, ROUND(SUM(t.amount_usd),2) as vol_usd
FROM br_transaction_to_usa t
LEFT JOIN br_payment_methods pm ON t.payment_method_id = pm.id
WHERE t.id_conta = ? AND DATE(t.created_at) >= ? AND DATE(t.created_at) <= ?
GROUP BY pm.provider
`, [clienteId, dataInicio, dataFim]);
const [provUsd] = await conn.execute(`
SELECT COALESCE(p.tipo_envio, 'N/A') as name, COUNT(*) as qtd, ROUND(SUM(p.valor),2) as vol_usd
FROM pagamento_br p
WHERE p.id_conta = ? AND DATE(p.created_at) >= ? AND DATE(p.created_at) <= ?
AND p.cotacao IS NOT NULL AND p.cotacao > 0 AND (p.pgto IS NULL OR p.pgto != 'balance')
GROUP BY p.tipo_envio
`, [clienteId, dataInicio, dataFim]);
// Monthly average
const [monthlyBrl] = await conn.execute(`
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes, ROUND(AVG(amount_usd),2) as avg_usd, COUNT(*) as qtd
FROM br_transaction_to_usa WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY mes
`, [clienteId, dataInicio, dataFim]);
const [monthlyUsd] = await conn.execute(`
SELECT DATE_FORMAT(created_at, '%Y-%m') as mes, ROUND(AVG(valor),2) as avg_usd, COUNT(*) as qtd
FROM pagamento_br WHERE id_conta = ? AND DATE(created_at) >= ? AND DATE(created_at) <= ?
AND cotacao IS NOT NULL AND cotacao > 0 AND (pgto IS NULL OR pgto != 'balance')
GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY mes
`, [clienteId, dataInicio, dataFim]);
// Format helpers
const fmtD = (d) => d instanceof Date ? d.toISOString().slice(0, 10) : String(d).slice(0, 10);
const fmtDT = (d) => { try { const dt = d instanceof Date ? d : new Date(d); return dt.toISOString().slice(0, 16).replace('T', ' '); } catch(e) { return String(d); } };
const fmtTrend = (rows) => rows.map(r => ({
dia: fmtD(r.dia), qtd: Number(r.qtd), vol_usd: Number(r.vol_usd), avg_spread: Number(r.avg_spread) || 0
}));
const b = kpiBrl[0] || {};
const u = kpiUsd[0] || {};
const bQtd = Number(b.qtd) || 0;
const uQtd = Number(u.qtd) || 0;
const totalQtd = bQtd + uQtd;
const totalVolUsd = (Number(b.vol_usd) || 0) + (Number(u.vol_usd) || 0);
// Merge day of week
const dowMap = {};
for (let i = 1; i <= 7; i++) dowMap[i] = { qtd: 0, vol_usd: 0 };
dowBrl.forEach(r => { dowMap[r.dow].qtd += Number(r.qtd); dowMap[r.dow].vol_usd += Number(r.vol_usd); });
dowUsd.forEach(r => { dowMap[r.dow].qtd += Number(r.qtd); dowMap[r.dow].vol_usd += Number(r.vol_usd); });
// Merge providers
const provMap = {};
[...provBrl, ...provUsd].forEach(r => {
const n = r.name || 'N/A';
if (!provMap[n]) provMap[n] = { name: n, qtd: 0, vol_usd: 0 };
provMap[n].qtd += Number(r.qtd);
provMap[n].vol_usd += Number(r.vol_usd);
});
// Merge monthly avg
const monthMap = {};
[...monthlyBrl, ...monthlyUsd].forEach(r => {
if (!monthMap[r.mes]) monthMap[r.mes] = { mes: r.mes, total_usd: 0, total_qtd: 0 };
monthMap[r.mes].total_usd += Number(r.avg_usd) * Number(r.qtd);
monthMap[r.mes].total_qtd += Number(r.qtd);
});
const transactions = [
...txBrl.map(r => ({
date: fmtDT(r.date), flow: r.flow, usd: Number(r.usd), brl: Number(r.brl),
rate: Number(r.rate), ptax: Number(r.ptax), spread_pct: Number(r.spread_pct),
iof: Number(r.iof), status: r.status, provider: r.provider
})),
...txUsd.map(r => ({
date: fmtDT(r.date), flow: r.flow, usd: Number(r.usd), brl: Number(r.brl),
rate: Number(r.rate), ptax: Number(r.ptax), spread_pct: Number(r.spread_pct),
iof: Number(r.iof), status: r.status, provider: r.provider
}))
].sort((a, b) => b.date.localeCompare(a.date));
return {
kpis: {
brlUsd: { qtd: bQtd, vol_usd: Number(b.vol_usd)||0, vol_brl: Number(b.vol_brl)||0, spread_revenue: Number(b.spread_revenue)||0, avg_spread_pct: Number(b.avg_spread_pct)||0, ticket_medio: bQtd > 0 ? Math.round((Number(b.vol_usd)||0) / bQtd) : 0 },
usdBrl: { qtd: uQtd, vol_usd: Number(u.vol_usd)||0, vol_brl: Number(u.vol_brl)||0, spread_revenue: Number(u.spread_revenue)||0, avg_spread_pct: Number(u.avg_spread_pct)||0, ticket_medio: uQtd > 0 ? Math.round((Number(u.vol_usd)||0) / uQtd) : 0 },
total: {
qtd: totalQtd, vol_usd: totalVolUsd, vol_brl: (Number(b.vol_brl)||0) + (Number(u.vol_brl)||0),
spread_revenue: (Number(b.spread_revenue)||0) + (Number(u.spread_revenue)||0),
avg_spread_pct: totalQtd > 0 ? ((Number(b.avg_spread_pct)||0) * bQtd + (Number(u.avg_spread_pct)||0) * uQtd) / totalQtd : 0,
ticket_medio: totalQtd > 0 ? Math.round(totalVolUsd / totalQtd) : 0
}
},
comparison: {
prev_qtd: (Number(prevBrl[0]?.qtd)||0) + (Number(prevUsd[0]?.qtd)||0),
prev_vol_usd: (Number(prevBrl[0]?.vol_usd)||0) + (Number(prevUsd[0]?.vol_usd)||0),
prev_spread: (Number(prevBrl[0]?.spread_revenue)||0) + (Number(prevUsd[0]?.spread_revenue)||0)
},
trend: { brlUsd: fmtTrend(trendBrl), usdBrl: fmtTrend(trendUsd) },
transactions,
dayOfWeek: dowMap,
providers: Object.values(provMap).sort((a, b) => b.vol_usd - a.vol_usd),
monthlyAvg: Object.values(monthMap).map(m => ({ mes: m.mes, avg_usd: m.total_qtd > 0 ? Math.round(m.total_usd / m.total_qtd) : 0, qtd: m.total_qtd })).sort((a, b) => a.mes.localeCompare(b.mes))
};
} finally {
conn.release();
}
}
module.exports = {
fetchTransacoes,
fetchAllTransacoes,
@@ -963,5 +1252,8 @@ module.exports = {
fetchTrendByPeriod,
fetchKPIsByPeriod,
fetchBIData,
fetchRevenueAnalytics
fetchRevenueAnalytics,
fetchClientList,
fetchClientProfile,
fetchClientData
};

View File

@@ -366,6 +366,7 @@ function buildHeader(options = {}) {
<nav class="header-nav">
<a href="/corporate" class="${activePage === 'dashboard' ? 'active' : ''}">Corporate</a>
<a href="/admin/bi" class="${activePage === 'bi' ? 'active' : ''}">BI Executive</a>
<a href="/admin/cliente" class="${activePage === 'cliente' ? 'active' : ''}">Clientes</a>
<a href="/admin" class="${activePage === 'users' ? 'active' : ''}">Usuarios</a>
</nav>
`;
@@ -459,6 +460,7 @@ const themeScript = `
localStorage.setItem('bi-theme', next);
var icon = document.getElementById('themeIcon');
if (icon) icon.textContent = next === 'dark' ? '\\u2600' : '\\u263E';
window.dispatchEvent(new CustomEvent('themechange', {detail:{theme:next}}));
}
document.addEventListener('DOMContentLoaded', function() {
var icon = document.getElementById('themeIcon');