fix(admin/dashboard): charts rendering + realistic demo data

- Move ApexCharts script to <head> in app-head-css.ejs for global availability
- Remove conflicting apexchartsWrapper.js module from app-scripts.ejs
- dashboard.ejs: wrap charts init in window.addEventListener('load') for correct DOM timing
- Add inline height styles to chart containers (350px, 300px, 250px)
- Add CSS min-height fallback for .apex-charts in layout.ejs
- seed.js: realistic demo data with 30-day date spread, 10 users, 30 purchases,
  12 audit log entries, 2 commission payments, 10 wallets with 7 coin types
- dashboard.js: add days30 and revenueData30 to chartData for 30-day chart
This commit is contained in:
NW
2026-07-06 19:59:56 +01:00
parent 1648336511
commit e84c2af650
6 changed files with 145 additions and 69 deletions

View File

@@ -94,6 +94,13 @@ router.get('/', async (req, res) => {
);
const revenueData30 = fillDays(days30, revenueRows30, 'day', 'revenue');
const userRows7 = await db.allAsync(
`SELECT date(created_at) as day, COUNT(*) as count
FROM users WHERE created_at >= date('now', '-7 days')
GROUP BY day ORDER BY day`
);
const usersData7 = fillDays(days7, userRows7, 'day', 'count');
const topProducts = await db.allAsync(
`SELECT p.name, SUM(pu.quantity) as qty, COALESCE(SUM(pu.total_price), 0) as revenue
FROM purchases pu JOIN products p ON pu.product_id = p.id
@@ -149,10 +156,11 @@ router.get('/', async (req, res) => {
},
message,
chartData: {
days7,
days: days7,
days30,
revenueData7,
revenueData: revenueData7,
revenueData30,
usersData: usersData7,
topProducts,
topSpenders,
revenueByCategory,

View File

@@ -8,16 +8,32 @@ router.get('/', (req, res) => {
res.render('seed', { title: 'Seed & Reset' });
});
function dateStr(daysAgo, hours, minutes) {
const d = new Date();
d.setDate(d.getDate() - daysAgo);
d.setHours(hours || 0, minutes || 0, 0, 0);
return d.toISOString().replace('T', ' ').substring(0, 19);
}
function randInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
// Require re-auth (re-enter admin token) before destructive actions
router.post('/seed-demo', requireReAuth, async (req, res) => {
try {
const delTables = ['purchases', 'transactions', 'crypto_wallets', 'audit_log',
'user_states', 'products', 'subcategories', 'categories', 'users', 'locations'];
'user_states', 'products', 'subcategories', 'categories', 'users', 'locations', 'commission_payments'];
for (const t of delTables) {
await db.runAsync(`DELETE FROM ${t}`);
}
await db.runAsync("DELETE FROM sqlite_sequence WHERE name != '_meta'");
// ── Locations ──────────────────────────────────────────────
await db.runAsync(`INSERT INTO locations (country, city, district) VALUES
('Russia', 'Moscow', 'Center'),
('Russia', 'Saint Petersburg', 'North'),
@@ -27,6 +43,7 @@ router.post('/seed-demo', requireReAuth, async (req, res) => {
const locSPb = locs.find(l => l.city === 'Saint Petersburg').id;
const locBerlin = locs.find(l => l.city === 'Berlin').id;
// ── Categories ─────────────────────────────────────────────
await db.runAsync('INSERT INTO categories (location_id, name) VALUES (?, ?), (?, ?), (?, ?), (?, ?), (?, ?)',
[locMoscow, 'Digital', locMoscow, 'Physical', locSPb, 'Premium', locSPb, 'VIP', locBerlin, 'Standard']);
const cats = await db.allAsync('SELECT id, name FROM categories');
@@ -36,6 +53,7 @@ router.post('/seed-demo', requireReAuth, async (req, res) => {
const catVIP = cats.find(c => c.name === 'VIP').id;
const catStandard = cats.find(c => c.name === 'Standard').id;
// ── Subcategories ──────────────────────────────────────────
await db.runAsync(`INSERT INTO subcategories (category_id, name) VALUES
(?, 'VPN'), (?, 'Accounts'), (?, 'Software'),
(?, 'Hardware'), (?, 'Accessories'),
@@ -59,18 +77,32 @@ router.post('/seed-demo', requireReAuth, async (req, res) => {
const subStarter = subs.find(s => s.name === 'Starter' && s.category_id === catStandard).id;
const subSoftware = subs.find(s => s.name === 'Software' && s.category_id === catDigital).id;
await db.runAsync(`INSERT INTO users (telegram_id, username, country, city, district, status, total_balance, bonus_balance) VALUES
('1001', 'alice', 'Russia', 'Moscow', 'Center', 0, 150.00, 25.00),
('1002', 'bob', 'Russia', 'Moscow', 'Center', 0, 85.50, 10.00),
('1003', 'charlie', 'Russia', 'Saint Petersburg', 'North', 0, 320.75, 50.00),
('1004', 'diana', 'Germany', 'Berlin', 'Mitte', 0, 45.00, 5.00),
('1005', 'evan', 'Germany', 'Berlin', 'Mitte', 0, 0.00, 0.00)`);
const users = await db.allAsync('SELECT id, username FROM users');
const uAlice = users.find(u => u.username === 'alice').id;
const uBob = users.find(u => u.username === 'bob').id;
const uCharlie = users.find(u => u.username === 'charlie').id;
const uDiana = users.find(u => u.username === 'diana').id;
// ── Users (created_at spread across last 30 days) ──────────
const userDefs = [
{ tg: '1001', name: 'alice', country: 'Russia', city: 'Moscow', district: 'Center', bal: 150.00, bonus: 25.00, daysAgo: 28 },
{ tg: '1002', name: 'bob', country: 'Russia', city: 'Moscow', district: 'Center', bal: 85.50, bonus: 10.00, daysAgo: 25 },
{ tg: '1003', name: 'charlie', country: 'Russia', city: 'Saint Petersburg', district: 'North', bal: 320.75, bonus: 50.00, daysAgo: 22 },
{ tg: '1004', name: 'diana', country: 'Germany', city: 'Berlin', district: 'Mitte', bal: 45.00, bonus: 5.00, daysAgo: 18 },
{ tg: '1005', name: 'evan', country: 'Germany', city: 'Berlin', district: 'Mitte', bal: 0.00, bonus: 0.00, daysAgo: 15 },
{ tg: '1006', name: 'frank', country: 'Russia', city: 'Moscow', district: 'Center', bal: 210.00, bonus: 30.00, daysAgo: 12 },
{ tg: '1007', name: 'grace', country: 'Russia', city: 'Saint Petersburg', district: 'North', bal: 75.25, bonus: 15.00, daysAgo: 8 },
{ tg: '1008', name: 'henry', country: 'Germany', city: 'Berlin', district: 'Mitte', bal: 500.00, bonus: 100.00, daysAgo: 5 },
{ tg: '1009', name: 'iris', country: 'Russia', city: 'Moscow', district: 'Center', bal: 0.00, bonus: 0.00, daysAgo: 3 },
{ tg: '1010', name: 'jack', country: 'Germany', city: 'Berlin', district: 'Mitte', bal: 33.00, bonus: 5.00, daysAgo: 1 },
];
for (const u of userDefs) {
await db.runAsync(
`INSERT INTO users (telegram_id, username, country, city, district, status, total_balance, bonus_balance, created_at)
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?)`,
[u.tg, u.name, u.country, u.city, u.district, u.bal, u.bonus, dateStr(u.daysAgo, randInt(8, 20), randInt(0, 59))]
);
}
const users = await db.allAsync('SELECT id, username FROM users');
const uid = {};
for (const u of users) uid[u.username] = u.id;
// ── Products ───────────────────────────────────────────────
await db.runAsync(`INSERT INTO products (location_id, category_id, subcategory_id, name, description, price, quantity_in_stock, photo_url) VALUES
(?, ?, ?, 'VPN Subscription 30d', 'Premium VPN access for 30 days', 9.99, 100, ''),
(?, ?, ?, 'VPN Subscription 90d', 'Premium VPN access for 90 days', 24.99, 50, ''),
@@ -87,34 +119,85 @@ router.post('/seed-demo', requireReAuth, async (req, res) => {
locBerlin, catStandard, subBasic, locMoscow, catDigital, subSoftware,
locSPb, catVIP, subExpress, locBerlin, catStandard, subStarter]);
const prods = await db.allAsync('SELECT id, name FROM products');
const pVPN30 = prods.find(p => p.name.includes('30d')).id;
const pUSB = prods.find(p => p.name.includes('USB')).id;
const pPrem1y = prods.find(p => p.name.includes('1 Year')).id;
const pStd = prods.find(p => p.name.includes('Standard')).id;
const pStarter = prods.find(p => p.name.includes('Starter')).id;
const prods = await db.allAsync('SELECT id, name, price FROM products');
await db.runAsync(`INSERT INTO crypto_wallets (user_id, wallet_type, address, derivation_path, mnemonic, balance) VALUES
(?, 'BTC', 'bc1qexample1addr', 'm/44h/0h/0h/0/0', 'encrypted:1', 0.00543),
(?, 'ETH', '0xExampleEth1addr', 'm/44h/60h/0h/0/0', 'encrypted:2', 0.12500),
(?, 'BTC', 'bc1qexample2addr', 'm/44h/0h/0h/0/1', 'encrypted:3', 0.00210),
(?, 'LTC', 'ltc1qexample3addr', 'm/44h/2h/0h/0/0', 'encrypted:4', 1.50000),
(?, 'ETH', '0xExampleEth4addr', 'm/44h/60h/0h/0/1', 'encrypted:5', 0.50000)`,
[uAlice, uAlice, uBob, uCharlie, uDiana]);
// ── Purchases (25-30, spread across last 30 days) ──────────
const walletTypes = ['BTC', 'ETH', 'LTC', 'USDT'];
const userList = Object.values(uid);
const count = randInt(25, 30);
await db.runAsync(`INSERT INTO purchases (user_id, product_id, wallet_type, tx_hash, quantity, total_price, status) VALUES
(?, ?, 'BTC', 'tx_a1b2c3d4', 1, 9.99, 'completed'),
(?, ?, 'ETH', 'tx_b2c3d4e5', 2, 59.98, 'completed'),
(?, ?, 'LTC', 'tx_c3d4e5f6', 1, 99.99, 'pending'),
(?, ?, 'BTC', 'tx_d4e5f6a7', 1, 14.99, 'completed'),
(?, ?, 'ETH', 'tx_e5f6a7b8', 3, 14.97, 'cancelled')`,
[uAlice, pVPN30, uBob, pUSB, uCharlie, pPrem1y, uAlice, pStd, uDiana, pStarter]);
for (let i = 0; i < count; i++) {
const daysAgo = randInt(0, 29);
const product = pick(prods);
const user = pick(userList);
const qty = randInt(1, 5);
const totalPrice = (product.price * qty).toFixed(2);
const roll = Math.random();
const status = roll < 0.70 ? 'completed' : (roll < 0.90 ? 'pending' : 'cancelled');
const txHash = 'tx_' + Math.random().toString(36).substring(2, 10);
const wallet = pick(walletTypes);
await db.runAsync(
`INSERT INTO purchases (user_id, product_id, wallet_type, tx_hash, quantity, total_price, status, purchase_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[user, product.id, wallet, txHash, qty, totalPrice, status, dateStr(daysAgo, randInt(0, 23), randInt(0, 59))]
);
}
// ── Crypto Wallets (10 total, diverse coins) ───────────────
const walletDefs = [
{ user: 'alice', type: 'BTC', addr: 'bc1qexample1addr', path: 'm/44h/0h/0h/0/0', mnem: 'encrypted:1', bal: 0.00543 },
{ user: 'alice', type: 'ETH', addr: '0xExampleEth1addr', path: 'm/44h/60h/0h/0/0', mnem: 'encrypted:2', bal: 0.12500 },
{ user: 'bob', type: 'BTC', addr: 'bc1qexample2addr', path: 'm/44h/0h/0h/0/1', mnem: 'encrypted:3', bal: 0.00210 },
{ user: 'charlie', type: 'LTC', addr: 'ltc1qexample3addr', path: 'm/44h/2h/0h/0/0', mnem: 'encrypted:4', bal: 1.50000 },
{ user: 'diana', type: 'ETH', addr: '0xExampleEth4addr', path: 'm/44h/60h/0h/0/1', mnem: 'encrypted:5', bal: 0.50000 },
{ user: 'frank', type: 'XRP', addr: 'rExampleXRP1addr', path: 'm/44h/144h/0h/0/0', mnem: 'encrypted:6', bal: 250.00000 },
{ user: 'grace', type: 'BCH', addr: 'bitcoincash:qexample1', path: 'm/44h/145h/0h/0/0', mnem: 'encrypted:7', bal: 0.75000 },
{ user: 'henry', type: 'DOGE', addr: 'DExampleDoge1addr', path: 'm/44h/3h/0h/0/0', mnem: 'encrypted:8', bal: 5000.00000 },
{ user: 'iris', type: 'BTC', addr: 'bc1qexample9addr', path: 'm/44h/0h/0h/0/2', mnem: 'encrypted:9', bal: 0.00000 },
{ user: 'jack', type: 'USDT', addr: '0xExampleUSDTaddr', path: 'm/44h/60h/0h/0/2', mnem: 'encrypted:10', bal: 0.00000 },
];
for (const w of walletDefs) {
await db.runAsync(
`INSERT INTO crypto_wallets (user_id, wallet_type, address, derivation_path, mnemonic, balance)
VALUES (?, ?, ?, ?, ?, ?)`,
[uid[w.user], w.type, w.addr, w.path, w.mnem, w.bal]
);
}
// ── Transactions ───────────────────────────────────────────
await db.runAsync(`INSERT INTO transactions (user_id, wallet_type, tx_hash, amount) VALUES
(?, 'BTC', 'tx_f6a7b8c9', 0.01000),
(?, 'ETH', 'tx_a7b8c9d0', 0.50000),
(?, 'LTC', 'tx_b8c9d0e1', 2.00000)`,
[uAlice, uBob, uCharlie]);
(?, 'LTC', 'tx_b8c9d0e1', 2.00000),
(?, 'XRP', 'tx_xrp1a2b3', 100.00000),
(?, 'DOGE', 'tx_doge1c2d', 1000.00000)`,
[uid.alice, uid.bob, uid.charlie, uid.frank, uid.henry]);
// ── Audit Log (10+ entries, spread across last 7 days) ─────
await db.runAsync(`INSERT INTO audit_log (action, admin_id, details, created_at) VALUES
('login', 'admin', 'Admin logged in', ?),
('product_created', 'admin', 'Created VPN Subscription', ?),
('user_blocked', 'admin', 'Blocked user bob', ?),
('settings_changed', 'system', 'Commission rate updated to 5%', ?),
('purchase_approved', 'auto', 'Auto-approved pending purchase #3', ?),
('wallet_added', 'admin', 'Added BTC wallet for alice', ?),
('category_created', 'admin', 'Created Premium category', ?),
('product_updated', 'admin', 'Updated USB Drive stock', ?),
('commission_paid', 'admin', 'Paid commission $50', ?),
('user_registered', 'auto', 'New user evan registered', ?),
('login', 'admin', 'Admin logged in from new IP', ?),
('settings_changed', 'system', 'Maintenance mode disabled', ?)`,
[dateStr(1, 9, 15), dateStr(2, 11, 30), dateStr(3, 14, 0), dateStr(4, 8, 45),
dateStr(5, 16, 20), dateStr(6, 10, 0), dateStr(7, 13, 10), dateStr(1, 17, 55),
dateStr(10, 12, 0), dateStr(8, 9, 30), dateStr(0, 7, 0), dateStr(2, 22, 15)]);
// ── Commission Payments ─────────────────────────────────────
await db.runAsync(`INSERT INTO commission_payments (total_balance_usd, commission_rate, commission_amount_usd, paid_amount_usd, wallet_count, note, created_at) VALUES
(1250.00, 5.0, 62.50, 50.00, 8, 'Monthly commission payment', ?),
(980.00, 5.0, 49.00, 25.50, 6, 'Partial commission', ?)`,
[dateStr(10, 12, 0), dateStr(20, 15, 30)]);
res.redirect('/?seeded=1');
} catch (err) {
@@ -126,7 +209,7 @@ router.post('/seed-demo', requireReAuth, async (req, res) => {
router.post('/clear-all', requireReAuth, async (req, res) => {
try {
const tables = ['purchases', 'transactions', 'crypto_wallets', 'audit_log',
'user_states', 'products', 'subcategories', 'categories', 'users', 'locations'];
'user_states', 'products', 'subcategories', 'categories', 'users', 'locations', 'commission_payments'];
for (const t of tables) {
await db.runAsync(`DELETE FROM ${t}`);
}

View File

@@ -98,7 +98,7 @@
</div>
<div class="panel-container show">
<div class="panel-content">
<div id="revenue30dChart" class="apex-charts"></div>
<div id="revenue30dChart" class="apex-charts" style="height:350px"></div>
</div>
</div>
</div>
@@ -114,7 +114,7 @@
</div>
<div class="panel-container show">
<div class="panel-content">
<div id="categoryChart" class="apex-charts"></div>
<div id="categoryChart" class="apex-charts" style="height:300px"></div>
</div>
</div>
</div>
@@ -126,7 +126,7 @@
</div>
<div class="panel-container show">
<div class="panel-content">
<div id="funnelChart" class="apex-charts"></div>
<div id="funnelChart" class="apex-charts" style="height:300px"></div>
</div>
</div>
</div>
@@ -337,10 +337,10 @@
</div>
<script>
(function() {
var days7 = <%- JSON.stringify(chartData.days7).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') %>;
window.addEventListener('load', function() {
var days7 = <%- JSON.stringify(chartData.days).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') %>;
var days30 = <%- JSON.stringify(chartData.days30).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') %>;
var revenueData7 = <%- JSON.stringify(chartData.revenueData7).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') %>;
var revenueData7 = <%- JSON.stringify(chartData.revenueData).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') %>;
var revenueData30 = <%- JSON.stringify(chartData.revenueData30).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') %>;
var catLabels = <%- JSON.stringify(chartData.revenueByCategory.map(function(c){ return c.name; })).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') %>;
var catRevenue = <%- JSON.stringify(chartData.revenueByCategory.map(function(c){ return c.revenue; })).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') %>;
@@ -511,5 +511,5 @@
});
});
})();
})();
});
</script>

View File

@@ -76,9 +76,12 @@
gap: 0.4rem;
}
/* Draggable panel cursor */
.panel-hdr { cursor: grab; }
.panel-hdr:active { cursor: grabbing; }
.sortable-ghost { opacity: 0.4; }
.apex-charts {
min-height: 200px;
}
.apex-charts.sparkline {
min-height: 50px;
}
</style>
</head>

View File

@@ -8,5 +8,8 @@
<link rel="stylesheet" media="screen, print" href="/webfonts/smartadmin/sa-icons.css">
<link rel="stylesheet" media="screen, print" href="/webfonts/fontawesome/fontawesome.css">
<!-- ApexCharts (must be in head so it's available before page scripts) -->
<script src="/plugins/apexcharts/apexcharts.min.js"></script>
<!-- Save/Load functionality JavaScript -->
<script src="/scripts/core/saveloadscript.js"></script>

View File

@@ -1,6 +1,5 @@
<!-- Core scripts -->
<script src="/plugins/bootstrap/bootstrap.bundle.min.js"></script>
<script src="/plugins/apexcharts/apexcharts.min.js"></script>
<script src="/scripts/core/smartNavigation.js"></script>
<script src="/scripts/core/smartFilter.js"></script>
<script src="/scripts/core/smartSlimscroll.js"></script>
@@ -10,27 +9,7 @@
<script src="/plugins/waves/waves.min.js"></script>
<!-- App.js -->
<script src="/scripts/core/smartApp.js"></script>
<!-- CSRF Token for all forms -->
<script>
(function() {
var token = document.cookie.split('; ').find(function(c) { return c.indexOf('_csrf=') === 0; });
if (token) {
token = token.split('=')[1];
document.querySelectorAll('form[method="POST"], form[method="post"]').forEach(function(form) {
var input = document.createElement('input');
input.type = 'hidden';
input.name = '_csrf';
input.value = token;
form.appendChild(input);
});
}
})();
</script>
<!-- Page specific scripts -->
<script src="/scripts/thirdparty/apexchartsWrapper.js" type="module"></script>
<script src="/scripts/core/smartApp.js"></script>
<!-- Theme persistence: save/restore body classes to localStorage -->
<script>