From bc48334269a1652e0b3bf6e7e2bd2fb51a891344 Mon Sep 17 00:00:00 2001 From: NW Date: Mon, 6 Jul 2026 19:13:05 +0100 Subject: [PATCH] feat(admin): draggable dashboard panels + business KPI redesign - Dashboard: 16 business-focused panels (KPIs, charts, tables, feeds) - New data queries: AOV, conversion rate, revenue by category, top spenders, purchase funnel, 30-day trend, geography, wallet totals - ApexCharts: 30-day revenue line, category bar, funnel bar, geo bar, conversion mini-donut, revenue sparkline - SortableJS: draggable panels within rows, layout saved to localStorage - Theme persistence: body classes (nav-dark, header-fixed, etc.) saved to localStorage and restored on load, MutationObserver syncs all toggles - Panel data-panel-id attributes for layout persistence - All panels use existing SmartAdmin panel-icon/card styling --- src/admin/routes/dashboard.js | 104 ++++- src/admin/views/dashboard.ejs | 525 +++++++++++++++------- src/admin/views/layout.ejs | 4 + src/admin/views/partials/app-scripts.ejs | 47 +- src/admin/views/partials/app-settings.ejs | 2 +- 5 files changed, 491 insertions(+), 191 deletions(-) diff --git a/src/admin/routes/dashboard.js b/src/admin/routes/dashboard.js index 4fc3e10..2a3aaff 100644 --- a/src/admin/routes/dashboard.js +++ b/src/admin/routes/dashboard.js @@ -4,9 +4,9 @@ import config from '../../config/config.js'; const router = Router(); -function last7Days() { +function lastNDays(n) { const days = []; - for (let i = 6; i >= 0; i--) { + for (let i = n - 1; i >= 0; i--) { const d = new Date(); d.setDate(d.getDate() - i); days.push(d.toISOString().split('T')[0]); @@ -21,29 +21,78 @@ function fillDays(days, rows, key, valueKey) { } router.get('/', async (req, res) => { - const [[{ totalUsers }], [{ totalProducts }], [{ totalPurchases }], [{ totalRevenue }], [{ totalSubcategories }]] = await Promise.all([ + const [ + [{ totalUsers }], + [{ totalProducts }], + [{ totalPurchases }], + [{ totalRevenue }], + [{ totalSubcategories }], + [{ aov }], + [{ completedPurchases }], + [{ pendingPurchases }], + [{ cancelledPurchases }], + [{ usersWithPurchases }], + topSpenders, + revenueByCategory, + topCountries, + walletSummary, + lastPayment, + ] = await Promise.all([ db.allAsync('SELECT COUNT(*) as totalUsers FROM users'), db.allAsync('SELECT COUNT(*) as totalProducts FROM products'), db.allAsync('SELECT COUNT(*) as totalPurchases FROM purchases'), db.allAsync('SELECT COALESCE(SUM(total_price), 0) as totalRevenue FROM purchases WHERE status = ?', ['completed']), db.allAsync('SELECT COUNT(*) as totalSubcategories FROM subcategories'), + db.allAsync('SELECT COALESCE(AVG(total_price), 0) as aov FROM purchases WHERE status = ?', ['completed']), + db.allAsync('SELECT COUNT(*) as completedPurchases FROM purchases WHERE status = ?', ['completed']), + db.allAsync('SELECT COUNT(*) as pendingPurchases FROM purchases WHERE status = ?', ['pending']), + db.allAsync('SELECT COUNT(*) as cancelledPurchases FROM purchases WHERE status = ?', ['cancelled']), + db.allAsync('SELECT COUNT(DISTINCT user_id) as usersWithPurchases FROM purchases WHERE status = ?', ['completed']), + db.allAsync( + `SELECT u.username, COALESCE(SUM(pu.total_price), 0) as spent + FROM purchases pu JOIN users u ON pu.user_id = u.id + WHERE pu.status = 'completed' AND pu.purchase_date >= date('now', '-30 days') + GROUP BY u.id ORDER BY spent DESC LIMIT 5` + ), + db.allAsync( + `SELECT c.name, COALESCE(SUM(pu.total_price), 0) as revenue + FROM purchases pu + JOIN products p ON pu.product_id = p.id + JOIN categories c ON p.category_id = c.id + WHERE pu.status = 'completed' AND pu.purchase_date >= date('now', '-30 days') + GROUP BY c.id ORDER BY revenue DESC` + ), + db.allAsync( + `SELECT country, COUNT(*) as count + FROM locations WHERE country IS NOT NULL + GROUP BY country ORDER BY count DESC LIMIT 5` + ), + db.allAsync( + `SELECT UPPER(wallet_type) as wallet_type, COUNT(*) as count, COALESCE(SUM(balance), 0) as total + FROM crypto_wallets WHERE wallet_type NOT LIKE '%#_%' ESCAPE '#' + GROUP BY wallet_type ORDER BY wallet_type` + ), + db.getAsync('SELECT * FROM commission_payments ORDER BY created_at DESC LIMIT 1'), ]); - const days = last7Days(); + const conversionRate = totalUsers > 0 ? ((usersWithPurchases / totalUsers) * 100).toFixed(1) : 0; - const revenueRows = await db.allAsync( + const days7 = lastNDays(7); + const days30 = lastNDays(30); + + const revenueRows7 = await db.allAsync( `SELECT date(purchase_date) as day, COALESCE(SUM(total_price), 0) as revenue FROM purchases WHERE status = 'completed' AND purchase_date >= date('now', '-7 days') GROUP BY day ORDER BY day` ); - const revenueData = fillDays(days, revenueRows, 'day', 'revenue'); + const revenueData7 = fillDays(days7, revenueRows7, 'day', 'revenue'); - const userRows = await db.allAsync( - `SELECT date(created_at) as day, COUNT(*) as count - FROM users WHERE created_at >= date('now', '-7 days') + const revenueRows30 = await db.allAsync( + `SELECT date(purchase_date) as day, COALESCE(SUM(total_price), 0) as revenue + FROM purchases WHERE status = 'completed' AND purchase_date >= date('now', '-30 days') GROUP BY day ORDER BY day` ); - const usersData = fillDays(days, userRows, 'day', 'count'); + const revenueData30 = fillDays(days30, revenueRows30, 'day', 'revenue'); const topProducts = await db.allAsync( `SELECT p.name, SUM(pu.quantity) as qty, COALESCE(SUM(pu.total_price), 0) as revenue @@ -65,18 +114,9 @@ router.get('/', async (req, res) => { ); } - const walletSummary = await db.allAsync( - `SELECT UPPER(wallet_type) as wallet_type, COUNT(*) as count, COALESCE(SUM(balance), 0) as total - FROM crypto_wallets WHERE wallet_type NOT LIKE '%#_%' ESCAPE '#' - GROUP BY wallet_type ORDER BY wallet_type` - ); - const commissionRate = config.COMMISSION_PERCENT / 100; const totalWalletBalance = walletSummary.reduce((sum, w) => sum + (w.total || 0), 0); const currentCommission = totalWalletBalance * commissionRate; - const lastPayment = await db.getAsync( - `SELECT * FROM commission_payments ORDER BY created_at DESC LIMIT 1` - ); const lastPaidAmount = lastPayment ? lastPayment.commission_amount_usd : 0; const commissionDue = Math.max(0, currentCommission - lastPaidAmount); @@ -95,12 +135,32 @@ router.get('/', async (req, res) => { res.render('dashboard', { title: 'Dashboard', - stats: { totalUsers, totalProducts, totalPurchases, totalRevenue, totalSubcategories }, + stats: { + totalUsers, + totalProducts, + totalPurchases, + totalRevenue, + totalSubcategories, + aov, + conversionRate, + completedPurchases, + pendingPurchases, + cancelledPurchases, + }, message, - chartData: { days, revenueData, usersData, topProducts }, + chartData: { + days7, + days30, + revenueData7, + revenueData30, + topProducts, + topSpenders, + revenueByCategory, + topCountries, + }, activities: recentActivity, wallets: walletSummary, - commission: commissionData + commission: commissionData, }); }); diff --git a/src/admin/views/dashboard.ejs b/src/admin/views/dashboard.ejs index cfbf204..66ea7f0 100644 --- a/src/admin/views/dashboard.ejs +++ b/src/admin/views/dashboard.ejs @@ -5,86 +5,201 @@ <% } %> - +
-
-
-
-

Total Users

-
-
-
-
<%= stats.totalUsers %>
-
-
-
-
-
-
-
-

Total Products

-
-
-
-
<%= stats.totalProducts %>
-
-
-
-
-
-
-
-

Subcategories

-
-
-
-
<%= stats.totalSubcategories %>
-
-
-
-
-
-
-
-

Total Purchases

-
-
-
-
<%= stats.totalPurchases %>
-
-
-
-
-
-
+
+

Revenue

-
$<%= Number(stats.totalRevenue || 0).toFixed(2) %>
+
$<%= Number(stats.totalRevenue || 0).toFixed(2) %>
+
Total completed
+
+
+
+
+
+
+
+
+

Users

+
+
+
+
<%= stats.totalUsers %>
+
Registered users
+
+
+
+
+
+
+
+

Purchases

+
+
+
+
<%= stats.totalPurchases %>
+
All time
+
+
+
+
+
+
+
+

AOV

+
+
+
+
$<%= Number(stats.aov || 0).toFixed(2) %>
+
Avg order value
+
+
+
+
+
+
+
+

Conversion

+
+
+
+
<%= stats.conversionRate %>%
+
Users → buyers
+
+
+
+
+
+
+
+
+

Commission

+
+
+
+
$<%= commission.due.toFixed(2) %>
+
Due @ <%= commission.rate %>%
- +
-
-
+
+
-

Revenue (7 Days)

+

Revenue by Day (30 Days)

-
+
-
-
+
+ + +
+
+
+
+

Revenue by Category (30 Days)

+
+
+
+
+
+
+
+
+
+
+
+

Purchase Funnel

+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+

Top Products (30 Days)

+
+
+
+ <% if (chartData.topProducts.length === 0) { %> +
No purchases yet
+ <% } else { %> + + + + + + <% chartData.topProducts.forEach(function(p, i) { %> + + + + + + + <% }); %> + +
#ProductQtyRevenue
<%= i + 1 %><%= p.name %><%= p.qty %>$<%= Number(p.revenue).toFixed(2) %>
+ <% } %> +
+
+
+
+
+
+
+

Top Spenders (30 Days)

+
+
+
+ <% if (chartData.topSpenders.length === 0) { %> +
No purchases yet
+ <% } else { %> + + + + + + <% chartData.topSpenders.forEach(function(s, i) { %> + + + + + + <% }); %> + +
#UserSpent
<%= i + 1 %><%= s.username || 'Unknown' %>$<%= Number(s.spent).toFixed(2) %>
+ <% } %> +
+
+
+
+
+ + +
+
+

Recent Activity

@@ -124,86 +239,8 @@
-
- - -
-
-
-

New Users (7 Days)

-
-
-
-
-
-
-
-
-
-
-
-

Top Products (30 Days)

-
-
-
-
-
-
-
-
-
- - -
-
-
-
-

Quick Actions

-
- -
-
-
-
-
-

Commission Due

-
-
-
- <% if (commission.enabled) { %> -
-
- Commission @ <%= commission.rate %>% - $<%= commission.due.toFixed(2) %> due -
-
-
-
-
-
- Total Balances: $<%= commission.totalBalance.toFixed(2) %> - Last Paid: $<%= commission.lastPaidAmount.toFixed(2) %> -
- Record Payment - <% } else { %> -
Commission disabled
- <% } %> -
-
-
-
-
-
+

Wallet Summary

@@ -233,13 +270,83 @@
+ +
+
+
+
+

Top Countries

+
+
+
+ <% if (chartData.topCountries.length === 0) { %> +
No location data
+ <% } else { %> +
+ <% } %> +
+
+
+
+
+
+
+

Commission Detail

+
+
+
+ <% if (commission.enabled) { %> +
+
+ Commission @ <%= commission.rate %>% + $<%= commission.due.toFixed(2) %> due +
+
+
+
+
+
+ Total Balances: $<%= commission.totalBalance.toFixed(2) %> + Last Paid: $<%= commission.lastPaidAmount.toFixed(2) %> +
+ Record Payment + <% } else { %> +
Commission disabled
+ <% } %> +
+
+
+
+
+
+
+

Quick Actions

+
+ +
+
+
+ diff --git a/src/admin/views/layout.ejs b/src/admin/views/layout.ejs index dbe1ff5..c2d5a92 100644 --- a/src/admin/views/layout.ejs +++ b/src/admin/views/layout.ejs @@ -75,6 +75,10 @@ justify-content: center; gap: 0.4rem; } + /* Draggable panel cursor */ + .panel-hdr { cursor: grab; } + .panel-hdr:active { cursor: grabbing; } + .sortable-ghost { opacity: 0.4; } diff --git a/src/admin/views/partials/app-scripts.ejs b/src/admin/views/partials/app-scripts.ejs index 6b7daa8..ac2e8b5 100644 --- a/src/admin/views/partials/app-scripts.ejs +++ b/src/admin/views/partials/app-scripts.ejs @@ -30,4 +30,49 @@ - \ No newline at end of file + + + + \ No newline at end of file diff --git a/src/admin/views/partials/app-settings.ejs b/src/admin/views/partials/app-settings.ejs index ed29c49..95c2aca 100644 --- a/src/admin/views/partials/app-settings.ejs +++ b/src/admin/views/partials/app-settings.ejs @@ -136,7 +136,7 @@
- +