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
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,86 +5,201 @@
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- Row 1: Stat Cards -->
|
||||
<!-- Row 1: Key Financial Metrics (KPI cards) -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-xl col-md-4 col-sm-6">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#users"></use></svg> Total Users</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content text-center">
|
||||
<div class="fw-bold fs-2"><%= stats.totalUsers %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl col-md-4 col-sm-6">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#box"></use></svg> Total Products</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content text-center">
|
||||
<div class="fw-bold fs-2"><%= stats.totalProducts %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl col-md-4 col-sm-6">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#layers"></use></svg> Subcategories</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content text-center">
|
||||
<div class="fw-bold fs-2"><%= stats.totalSubcategories %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl col-md-4 col-sm-6">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#shopping-cart"></use></svg> Total Purchases</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content text-center">
|
||||
<div class="fw-bold fs-2"><%= stats.totalPurchases %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl col-md-4 col-sm-6">
|
||||
<div class="panel panel-icon">
|
||||
<div class="col-xl-2 col-md-4 col-sm-6">
|
||||
<div class="panel panel-icon" data-panel-id="kpi-revenue">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#dollar-sign"></use></svg> Revenue</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content text-center">
|
||||
<div class="fw-bold fs-2">$<%= Number(stats.totalRevenue || 0).toFixed(2) %></div>
|
||||
<div class="fw-bold fs-2 text-primary">$<%= Number(stats.totalRevenue || 0).toFixed(2) %></div>
|
||||
<div class="small text-muted">Total completed</div>
|
||||
<div id="kpiRevenueSpark" class="apex-charts" style="height:50px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-2 col-md-4 col-sm-6">
|
||||
<div class="panel panel-icon" data-panel-id="kpi-users">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#users"></use></svg> Users</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content text-center">
|
||||
<div class="fw-bold fs-2 text-success"><%= stats.totalUsers %></div>
|
||||
<div class="small text-muted">Registered users</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-2 col-md-4 col-sm-6">
|
||||
<div class="panel panel-icon" data-panel-id="kpi-purchases">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#shopping-cart"></use></svg> Purchases</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content text-center">
|
||||
<div class="fw-bold fs-2 text-info"><%= stats.totalPurchases %></div>
|
||||
<div class="small text-muted">All time</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-2 col-md-4 col-sm-6">
|
||||
<div class="panel panel-icon" data-panel-id="kpi-aov">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#trending-up"></use></svg> AOV</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content text-center">
|
||||
<div class="fw-bold fs-2 text-warning">$<%= Number(stats.aov || 0).toFixed(2) %></div>
|
||||
<div class="small text-muted">Avg order value</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-2 col-md-4 col-sm-6">
|
||||
<div class="panel panel-icon" data-panel-id="kpi-conversion">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#percent"></use></svg> Conversion</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content text-center">
|
||||
<div class="fw-bold fs-2 text-danger"><%= stats.conversionRate %>%</div>
|
||||
<div class="small text-muted">Users → buyers</div>
|
||||
<div id="kpiConversionDonut" class="apex-charts" style="height:60px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-2 col-md-4 col-sm-6">
|
||||
<div class="panel panel-icon" data-panel-id="kpi-commission">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#file-text"></use></svg> Commission</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content text-center">
|
||||
<div class="fw-bold fs-2 text-dark">$<%= commission.due.toFixed(2) %></div>
|
||||
<div class="small text-muted">Due @ <%= commission.rate %>%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Revenue Chart + Activity -->
|
||||
<!-- Row 2: Revenue Chart (full width, 30 days) -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-xl-8">
|
||||
<div class="panel panel-icon">
|
||||
<div class="col-12">
|
||||
<div class="panel panel-icon" data-panel-id="chart-revenue-30d">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#trending-up"></use></svg> Revenue (7 Days)</h2>
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#trending-up"></use></svg> Revenue by Day (30 Days)</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<div id="revenueChart" class="apex-charts"></div>
|
||||
<div id="revenue30dChart" class="apex-charts"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<div class="panel panel-icon">
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Revenue by Category + Purchase Funnel -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-xl-7">
|
||||
<div class="panel panel-icon" data-panel-id="chart-category">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#pie-chart"></use></svg> Revenue by Category (30 Days)</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<div id="categoryChart" class="apex-charts"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-5">
|
||||
<div class="panel panel-icon" data-panel-id="chart-funnel">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#filter"></use></svg> Purchase Funnel</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<div id="funnelChart" class="apex-charts"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 4: Top Products + Top Spenders -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-xl-6">
|
||||
<div class="panel panel-icon" data-panel-id="table-top-products">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#award"></use></svg> Top Products (30 Days)</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<% if (chartData.topProducts.length === 0) { %>
|
||||
<div class="text-center text-muted py-4">No purchases yet</div>
|
||||
<% } else { %>
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<thead>
|
||||
<tr><th>#</th><th>Product</th><th class="text-end">Qty</th><th class="text-end">Revenue</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% chartData.topProducts.forEach(function(p, i) { %>
|
||||
<tr>
|
||||
<td class="text-muted"><%= i + 1 %></td>
|
||||
<td><%= p.name %></td>
|
||||
<td class="text-end"><%= p.qty %></td>
|
||||
<td class="text-end">$<%= Number(p.revenue).toFixed(2) %></td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-6">
|
||||
<div class="panel panel-icon" data-panel-id="table-top-spenders">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#user-check"></use></svg> Top Spenders (30 Days)</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<% if (chartData.topSpenders.length === 0) { %>
|
||||
<div class="text-center text-muted py-4">No purchases yet</div>
|
||||
<% } else { %>
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<thead>
|
||||
<tr><th>#</th><th>User</th><th class="text-end">Spent</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% chartData.topSpenders.forEach(function(s, i) { %>
|
||||
<tr>
|
||||
<td class="text-muted"><%= i + 1 %></td>
|
||||
<td><%= s.username || 'Unknown' %></td>
|
||||
<td class="text-end fw-bold">$<%= Number(s.spent).toFixed(2) %></td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 5: Recent Activity + Wallet Summary -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-xl-6">
|
||||
<div class="panel panel-icon" data-panel-id="feed-activity">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#activity"></use></svg> Recent Activity</h2>
|
||||
</div>
|
||||
@@ -124,86 +239,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Users + Top Products -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-xl-6">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#user-plus"></use></svg> New Users (7 Days)</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<div id="usersChart" class="apex-charts"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-6">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#award"></use></svg> Top Products (30 Days)</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<div id="topProductsChart" class="apex-charts"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 4: Quick Actions + Commission + Wallets -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-xl-4">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#zap"></use></svg> Quick Actions</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="/products" class="btn btn-primary"><svg class="sa-icon me-2"><use href="/icons/sprite.svg#plus"></use></svg> Add Product</a>
|
||||
<a href="/users" class="btn btn-success"><svg class="sa-icon me-2"><use href="/icons/sprite.svg#user-plus"></use></svg> Add User</a>
|
||||
<a href="/catalog" class="btn btn-info text-white"><svg class="sa-icon me-2"><use href="/icons/sprite.svg#list"></use></svg> View Catalog</a>
|
||||
<a href="/settings" class="btn btn-dark"><svg class="sa-icon me-2"><use href="/icons/sprite.svg#settings"></use></svg> Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#percent"></use></svg> Commission Due</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<% if (commission.enabled) { %>
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span>Commission @ <%= commission.rate %>%</span>
|
||||
<span>$<%= commission.due.toFixed(2) %> due</span>
|
||||
</div>
|
||||
<div class="progress progress-sm">
|
||||
<div class="progress-bar bg-warning" role="progressbar" aria-valuenow="<%= Math.min(100, commission.totalBalance > 0 ? Math.round((commission.lastPaidAmount / Math.max(commission.currentCommission, 0.01)) * 100) : 0) %>" aria-valuemin="0" aria-valuemax="100" style="width: <%= Math.min(100, commission.totalBalance > 0 ? Math.round((commission.lastPaidAmount / Math.max(commission.currentCommission, 0.01)) * 100) : 0) %>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted mb-2">
|
||||
<span>Total Balances: $<%= commission.totalBalance.toFixed(2) %></span>
|
||||
<span>Last Paid: $<%= commission.lastPaidAmount.toFixed(2) %></span>
|
||||
</div>
|
||||
<a href="/wallets" class="btn btn-outline-warning btn-sm w-100"><svg class="sa-icon me-2"><use href="/icons/sprite.svg#file-text"></use></svg> Record Payment</a>
|
||||
<% } else { %>
|
||||
<div class="text-center text-muted py-3">Commission disabled</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<div class="panel panel-icon">
|
||||
<div class="panel panel-icon" data-panel-id="table-wallets">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#credit-card"></use></svg> Wallet Summary</h2>
|
||||
</div>
|
||||
@@ -233,13 +270,83 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 6: Geography + Commission Detail + Quick Actions -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-xl-4">
|
||||
<div class="panel panel-icon" data-panel-id="chart-geo">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#map-pin"></use></svg> Top Countries</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<% if (chartData.topCountries.length === 0) { %>
|
||||
<div class="text-center text-muted py-4">No location data</div>
|
||||
<% } else { %>
|
||||
<div id="geoChart" class="apex-charts"></div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<div class="panel panel-icon" data-panel-id="panel-commission-detail">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#percent"></use></svg> Commission Detail</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<% if (commission.enabled) { %>
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between small mb-1">
|
||||
<span>Commission @ <%= commission.rate %>%</span>
|
||||
<span>$<%= commission.due.toFixed(2) %> due</span>
|
||||
</div>
|
||||
<div class="progress progress-sm">
|
||||
<div class="progress-bar bg-warning" role="progressbar" aria-valuenow="<%= Math.min(100, commission.totalBalance > 0 ? Math.round((commission.lastPaidAmount / Math.max(commission.currentCommission, 0.01)) * 100) : 0) %>" aria-valuemin="0" aria-valuemax="100" style="width: <%= Math.min(100, commission.totalBalance > 0 ? Math.round((commission.lastPaidAmount / Math.max(commission.currentCommission, 0.01)) * 100) : 0) %>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between small text-muted mb-2">
|
||||
<span>Total Balances: $<%= commission.totalBalance.toFixed(2) %></span>
|
||||
<span>Last Paid: $<%= commission.lastPaidAmount.toFixed(2) %></span>
|
||||
</div>
|
||||
<a href="/wallets" class="btn btn-outline-warning btn-sm w-100"><svg class="sa-icon me-2"><use href="/icons/sprite.svg#file-text"></use></svg> Record Payment</a>
|
||||
<% } else { %>
|
||||
<div class="text-center text-muted py-3">Commission disabled</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<div class="panel panel-icon" data-panel-id="panel-quick-actions">
|
||||
<div class="panel-hdr">
|
||||
<h2><svg class="sa-icon me-2"><use href="/icons/sprite.svg#zap"></use></svg> Quick Actions</h2>
|
||||
</div>
|
||||
<div class="panel-container show">
|
||||
<div class="panel-content">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a href="/products" class="btn btn-primary"><svg class="sa-icon me-2"><use href="/icons/sprite.svg#plus"></use></svg> Add Product</a>
|
||||
<a href="/users" class="btn btn-success"><svg class="sa-icon me-2"><use href="/icons/sprite.svg#user-plus"></use></svg> Add User</a>
|
||||
<a href="/catalog" class="btn btn-info text-white"><svg class="sa-icon me-2"><use href="/icons/sprite.svg#list"></use></svg> View Catalog</a>
|
||||
<a href="/settings" class="btn btn-dark"><svg class="sa-icon me-2"><use href="/icons/sprite.svg#settings"></use></svg> Settings</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var days = <%- JSON.stringify(chartData.days).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') %>;
|
||||
var revenueData = <%- JSON.stringify(chartData.revenueData).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') %>;
|
||||
var usersData = <%- JSON.stringify(chartData.usersData).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') %>;
|
||||
var topLabels = <%- JSON.stringify(chartData.topProducts.map(function(p){ return p.name; })).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') %>;
|
||||
var topQty = <%- JSON.stringify(chartData.topProducts.map(function(p){ return p.qty; })).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') %>;
|
||||
var days7 = <%- JSON.stringify(chartData.days7).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 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') %>;
|
||||
var geoLabels = <%- JSON.stringify(chartData.topCountries.map(function(g){ return g.country; })).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') %>;
|
||||
var geoCounts = <%- JSON.stringify(chartData.topCountries.map(function(g){ return g.count; })).replace(/</g, '\\u003c').replace(/>/g, '\\u003e') %>;
|
||||
var conversionRate = <%- JSON.stringify(stats.conversionRate) %>;
|
||||
|
||||
function renderChart(id, options) {
|
||||
var el = document.querySelector(id);
|
||||
@@ -250,43 +357,127 @@
|
||||
el._apexChart = chart;
|
||||
}
|
||||
|
||||
renderChart('#revenueChart', {
|
||||
chart: { type: 'line', height: 300, toolbar: { show: false }, animations: { enabled: true } },
|
||||
series: [{ name: 'Revenue', data: revenueData }],
|
||||
xaxis: { categories: days, labels: { rotate: -45, style: { fontSize: '11px' } } },
|
||||
// KPI Revenue Sparkline (7 days)
|
||||
renderChart('#kpiRevenueSpark', {
|
||||
chart: { type: 'line', height: 50, sparkline: { enabled: true } },
|
||||
series: [{ name: 'Revenue', data: revenueData7 }],
|
||||
colors: ['#0d6efd'],
|
||||
stroke: { curve: 'smooth', width: 2 },
|
||||
tooltip: { fixed: { enabled: false }, y: { formatter: function(v) { return '$' + v.toFixed(2); } } }
|
||||
});
|
||||
|
||||
// KPI Conversion Mini Donut
|
||||
renderChart('#kpiConversionDonut', {
|
||||
chart: { type: 'donut', height: 60, sparkline: { enabled: true } },
|
||||
series: [parseFloat(conversionRate), Math.max(0, 100 - parseFloat(conversionRate))],
|
||||
labels: ['Converted', 'Not'],
|
||||
colors: ['#ef4444', '#e5e7eb'],
|
||||
plotOptions: { pie: { donut: { size: '70%' }, expandOnClick: false } },
|
||||
dataLabels: { enabled: false },
|
||||
tooltip: { enabled: false },
|
||||
stroke: { width: 0 }
|
||||
});
|
||||
|
||||
// Revenue 30 Days Line Chart
|
||||
renderChart('#revenue30dChart', {
|
||||
chart: { type: 'line', height: 350, toolbar: { show: false }, animations: { enabled: true } },
|
||||
series: [{ name: 'Revenue', data: revenueData30 }],
|
||||
xaxis: { categories: days30, labels: { rotate: -45, style: { fontSize: '10px' } }, tickAmount: 15 },
|
||||
yaxis: { labels: { formatter: function(v) { return '$' + v.toFixed(0); } } },
|
||||
colors: ['#0d6efd'],
|
||||
stroke: { curve: 'smooth', width: 2 },
|
||||
markers: { size: 4 },
|
||||
markers: { size: 2 },
|
||||
grid: { borderColor: '#f0f0f0' },
|
||||
tooltip: { y: { formatter: function(v) { return '$' + v.toFixed(2); } } },
|
||||
noData: { text: 'No data', align: 'center', verticalAlign: 'middle', style: { fontSize: '14px', color: '#999' } }
|
||||
});
|
||||
|
||||
renderChart('#usersChart', {
|
||||
chart: { type: 'area', height: 300, toolbar: { show: false }, animations: { enabled: true } },
|
||||
series: [{ name: 'New Users', data: usersData }],
|
||||
xaxis: { categories: days, labels: { rotate: -45, style: { fontSize: '11px' } } },
|
||||
yaxis: { labels: { formatter: function(v) { return v.toFixed(0); } } },
|
||||
// Revenue by Category Bar Chart
|
||||
renderChart('#categoryChart', {
|
||||
chart: { type: 'bar', height: 300, toolbar: { show: false }, animations: { enabled: true } },
|
||||
series: [{ name: 'Revenue', data: catRevenue }],
|
||||
xaxis: { categories: catLabels, labels: { style: { fontSize: '11px' } } },
|
||||
yaxis: { labels: { formatter: function(v) { return '$' + v.toFixed(0); } } },
|
||||
colors: ['#10b981'],
|
||||
fill: { type: 'gradient', gradient: { shadeIntensity: 1, opacityFrom: 0.4, opacityTo: 0.05 } },
|
||||
stroke: { curve: 'smooth', width: 2 },
|
||||
markers: { size: 4 },
|
||||
plotOptions: { bar: { borderRadius: 4, horizontal: false, columnWidth: '60%' } },
|
||||
grid: { borderColor: '#f0f0f0' },
|
||||
tooltip: { y: { formatter: function(v) { return v + ' users'; } } },
|
||||
tooltip: { y: { formatter: function(v) { return '$' + v.toFixed(2); } } },
|
||||
noData: { text: 'No data', align: 'center', verticalAlign: 'middle', style: { fontSize: '14px', color: '#999' } }
|
||||
});
|
||||
|
||||
renderChart('#topProductsChart', {
|
||||
chart: { type: 'donut', height: 300, toolbar: { show: false }, animations: { enabled: true } },
|
||||
series: topQty,
|
||||
labels: topLabels,
|
||||
colors: ['#0d6efd', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'],
|
||||
legend: { position: 'bottom', fontSize: '12px' },
|
||||
plotOptions: { pie: { donut: { size: '60%' } } },
|
||||
tooltip: { y: { formatter: function(v) { return v + ' sold'; } } },
|
||||
noData: { text: 'No data', align: 'center', verticalAlign: 'middle', style: { fontSize: '14px', color: '#999' } },
|
||||
responsive: [{ breakpoint: 480, options: { chart: { height: 250 }, legend: { position: 'bottom' } } }]
|
||||
// Purchase Funnel (horizontal bar)
|
||||
renderChart('#funnelChart', {
|
||||
chart: { type: 'bar', height: 300, toolbar: { show: false }, animations: { enabled: true } },
|
||||
series: [{
|
||||
name: 'Count',
|
||||
data: [<%= stats.completedPurchases %>, <%= stats.pendingPurchases %>, <%= stats.cancelledPurchases %>]
|
||||
}],
|
||||
xaxis: { categories: ['Completed', 'Pending', 'Cancelled'], labels: { style: { fontSize: '12px' } } },
|
||||
colors: ['#10b981', '#f59e0b', '#ef4444'],
|
||||
plotOptions: { bar: { borderRadius: 4, horizontal: true, barHeight: '50%' } },
|
||||
grid: { borderColor: '#f0f0f0' },
|
||||
dataLabels: { enabled: true, style: { fontSize: '12px' } },
|
||||
tooltip: { y: { formatter: function(v) { return v + ' purchases'; } } },
|
||||
noData: { text: 'No data', align: 'center', verticalAlign: 'middle', style: { fontSize: '14px', color: '#999' } }
|
||||
});
|
||||
|
||||
// Geography Bar Chart
|
||||
renderChart('#geoChart', {
|
||||
chart: { type: 'bar', height: 250, toolbar: { show: false }, animations: { enabled: true } },
|
||||
series: [{ name: 'Locations', data: geoCounts }],
|
||||
xaxis: { categories: geoLabels, labels: { style: { fontSize: '11px' } } },
|
||||
colors: ['#8b5cf6'],
|
||||
plotOptions: { bar: { borderRadius: 4, horizontal: false, columnWidth: '60%' } },
|
||||
grid: { borderColor: '#f0f0f0' },
|
||||
tooltip: { y: { formatter: function(v) { return v + ' locations'; } } },
|
||||
noData: { text: 'No data', align: 'center', verticalAlign: 'middle', style: { fontSize: '14px', color: '#999' } }
|
||||
});
|
||||
|
||||
// ===== SortableJS: Draggable Panels with localStorage =====
|
||||
(function() {
|
||||
var rows = document.querySelectorAll('.main-content > .row');
|
||||
rows.forEach(function(row, rowIndex) {
|
||||
new Sortable(row, {
|
||||
group: 'dashboard-row-' + rowIndex,
|
||||
handle: '.panel-hdr',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
onEnd: function() {
|
||||
var layout = {};
|
||||
rows.forEach(function(r, ri) {
|
||||
layout['row' + ri] = Array.from(r.children).map(function(col) {
|
||||
var panel = col.querySelector('.panel');
|
||||
return panel ? panel.dataset.panelId : null;
|
||||
}).filter(Boolean);
|
||||
});
|
||||
localStorage.setItem('dashboardLayout', JSON.stringify(layout));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Restore saved layout
|
||||
var saved = localStorage.getItem('dashboardLayout');
|
||||
if (saved) {
|
||||
try {
|
||||
var layout = JSON.parse(saved);
|
||||
rows.forEach(function(row, rowIndex) {
|
||||
var order = layout['row' + rowIndex];
|
||||
if (!order || !order.length) return;
|
||||
var children = Array.from(row.children);
|
||||
var panelMap = {};
|
||||
children.forEach(function(col) {
|
||||
var panel = col.querySelector('.panel');
|
||||
if (panel && panel.dataset.panelId) {
|
||||
panelMap[panel.dataset.panelId] = col;
|
||||
}
|
||||
});
|
||||
order.forEach(function(panelId) {
|
||||
var col = panelMap[panelId];
|
||||
if (col) row.appendChild(col);
|
||||
});
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -30,4 +30,49 @@
|
||||
</script>
|
||||
|
||||
<!-- Page specific scripts -->
|
||||
<script src="/scripts/thirdparty/apexchartsWrapper.js" type="module"></script>
|
||||
<script src="/scripts/thirdparty/apexchartsWrapper.js" type="module"></script>
|
||||
|
||||
<!-- Theme persistence: save/restore body classes to localStorage -->
|
||||
<script>
|
||||
(function() {
|
||||
var STORAGE_KEY = 'adminTheme';
|
||||
|
||||
// Restore saved theme on load
|
||||
var saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
document.body.className = saved;
|
||||
}
|
||||
|
||||
// After SmartAdmin initializes, sync checkbox states with body classes
|
||||
function syncSettingsCheckboxes() {
|
||||
var bodyClass = document.body.className;
|
||||
var checkboxes = document.querySelectorAll('.js-drawer-settings input[data-class]');
|
||||
checkboxes.forEach(function(cb) {
|
||||
var cls = cb.getAttribute('data-class');
|
||||
if (cls) {
|
||||
cb.checked = bodyClass.indexOf(cls) !== -1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Persist theme whenever any toggle changes body classes
|
||||
var observer = new MutationObserver(function() {
|
||||
localStorage.setItem(STORAGE_KEY, document.body.className);
|
||||
});
|
||||
observer.observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
// Also hook the theme-style radio buttons
|
||||
document.addEventListener('change', function(e) {
|
||||
var target = e.target;
|
||||
if (target && target.getAttribute('data-action') === 'theme-style') {
|
||||
// Small delay to let SmartAdmin apply the theme
|
||||
setTimeout(function() {
|
||||
localStorage.setItem(STORAGE_KEY, document.body.className);
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
// Sync checkboxes after a short delay (SmartAdmin init)
|
||||
setTimeout(syncSettingsCheckboxes, 300);
|
||||
})();
|
||||
</script>
|
||||
@@ -136,7 +136,7 @@
|
||||
|
||||
<div class="d-flex" style="gap:10px">
|
||||
<button type="button" onclick="appDOM.resetStyle();" class="btn reset-button btn-outline-danger flex-grow-1"> <i class="sa sa-reload align-base me-1"></i> Factory Reset</button>
|
||||
<button type="button" onclick="resetPanelState();" class="btn btn-outline-secondary flex-grow-1"><i class="sa sa-reload align-base me-1"></i> Reset Panel</button>
|
||||
<button type="button" onclick="localStorage.removeItem('dashboardLayout'); localStorage.removeItem('adminTheme'); location.reload();" class="btn btn-outline-secondary flex-grow-1"><i class="sa sa-reload align-base me-1"></i> Reset Panel</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user