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:
NW
2026-07-06 19:13:05 +01:00
parent 2a34b40411
commit bc48334269
5 changed files with 491 additions and 191 deletions

View File

@@ -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,
});
});

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>