feat: wallet balances grouped by user with split layout
- Left sidebar: user list with ID, username, status icon, wallet count - Right panel: selected user's balances + crypto wallet table - Fix inverted status logic (0=Active, 1=Deleted, 2=Blocked) - Admin bot: block/unblock toggle based on current user status - Seed data: set active users to status=0 instead of status=1 - Toggle-status route: 0↔2 instead of 1↔0
This commit is contained in:
@@ -336,6 +336,79 @@ pre { font-size: 0.8rem; white-space: pre-wrap; word-break: break-all; max-width
|
||||
|
||||
.form-row input { flex: 1; }
|
||||
|
||||
.wallet-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.wallet-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.wallet-sidebar {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wallet-sidebar h3 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.wallet-user-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.wallet-user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.wallet-user-item:hover {
|
||||
background: #f0f4ff;
|
||||
}
|
||||
|
||||
.wallet-user-item.selected {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.wallet-user-id {
|
||||
font-weight: 600;
|
||||
min-width: 28px;
|
||||
}
|
||||
|
||||
.wallet-user-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wallet-user-meta {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wallet-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.topnav { flex-direction: column; align-items: flex-start; }
|
||||
.logout-btn { margin-left: 0; }
|
||||
|
||||
@@ -59,10 +59,10 @@ router.post('/seed-demo', async (req, res) => {
|
||||
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', 1, 150.00, 25.00),
|
||||
('1002', 'bob', 'Russia', 'Moscow', 'Center', 1, 85.50, 10.00),
|
||||
('1003', 'charlie', 'Russia', 'Saint Petersburg', 'North', 1, 320.75, 50.00),
|
||||
('1004', 'diana', 'Germany', 'Berlin', 'Mitte', 1, 45.00, 5.00),
|
||||
('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;
|
||||
|
||||
@@ -24,7 +24,7 @@ router.get('/:id', async (req, res) => {
|
||||
router.post('/:id/toggle-status', async (req, res) => {
|
||||
const user = await db.getAsync('SELECT * FROM users WHERE id = ?', [req.params.id]);
|
||||
if (!user) return res.status(404).send('User not found');
|
||||
const newStatus = user.status === 1 ? 0 : 1;
|
||||
const newStatus = user.status === 0 ? 2 : 0;
|
||||
await db.runAsync('UPDATE users SET status = ? WHERE id = ?', [newStatus, user.id]);
|
||||
res.redirect('/users');
|
||||
});
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import { Router } from 'express';
|
||||
import db from '../../config/database.js';
|
||||
import { renderWalletList } from '../views/wallets.js';
|
||||
import { renderWalletLayout } from '../views/wallets.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const wallets = await db.allAsync(
|
||||
'SELECT * FROM crypto_wallets ORDER BY id DESC LIMIT 200'
|
||||
const users = await db.allAsync(
|
||||
`SELECT u.id, u.telegram_id, u.username, u.status, u.total_balance, u.bonus_balance,
|
||||
COUNT(w.id) AS wallet_count
|
||||
FROM users u
|
||||
LEFT JOIN crypto_wallets w ON w.user_id = u.id AND w.wallet_type NOT LIKE '%#_%' ESCAPE '#'
|
||||
GROUP BY u.id
|
||||
ORDER BY u.id DESC`
|
||||
);
|
||||
res.send(renderWalletList(wallets));
|
||||
|
||||
const selectedId = req.query.user ? parseInt(req.query.user, 10) : (users.length > 0 ? users[0].id : null);
|
||||
|
||||
let wallets = [];
|
||||
let selectedUser = null;
|
||||
if (selectedId) {
|
||||
selectedUser = await db.getAsync('SELECT * FROM users WHERE id = ?', [selectedId]);
|
||||
wallets = await db.allAsync(
|
||||
`SELECT * FROM crypto_wallets WHERE user_id = ? AND wallet_type NOT LIKE '%#_%' ESCAPE '#' ORDER BY wallet_type`,
|
||||
[selectedId]
|
||||
);
|
||||
}
|
||||
|
||||
res.send(renderWalletLayout(users, selectedUser, wallets));
|
||||
});
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
@@ -8,12 +8,12 @@ export function renderUserList(users, message) {
|
||||
<td>${u.username || '-'}</td>
|
||||
<td>${u.country || '-'}</td>
|
||||
<td>${u.city || '-'}</td>
|
||||
<td><span class="badge badge-${u.status === 1 ? 'active' : 'banned'}">${u.status === 1 ? 'Active' : 'Banned'}</span></td>
|
||||
<td><span class="badge badge-${u.status === 0 ? 'active' : 'banned'}">${u.status === 0 ? 'Active' : u.status === 2 ? 'Blocked' : 'Deleted'}</span></td>
|
||||
<td>$${(u.total_balance || 0).toFixed(2)}</td>
|
||||
<td>
|
||||
<a href="/users/${u.id}" class="btn-sm">View</a>
|
||||
<form method="POST" action="/users/${u.id}/toggle-status" style="display:inline">
|
||||
<button class="btn-sm btn-${u.status === 1 ? 'danger' : 'success'}">${u.status === 1 ? 'Ban' : 'Unban'}</button>
|
||||
<button class="btn-sm btn-${u.status === 0 ? 'danger' : 'success'}">${u.status === 0 ? 'Ban' : 'Unban'}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
@@ -37,7 +37,7 @@ export function renderUserDetail(user, purchases) {
|
||||
<p><strong>Telegram ID:</strong> ${user.telegram_id}</p>
|
||||
<p><strong>Country:</strong> ${user.country || '-'}</p>
|
||||
<p><strong>City:</strong> ${user.city || '-'}</p>
|
||||
<p><strong>Status:</strong> ${user.status === 1 ? 'Active' : 'Banned'}</p>
|
||||
<p><strong>Status:</strong> ${user.status === 0 ? 'Active' : user.status === 2 ? 'Blocked' : 'Deleted'}</p>
|
||||
<p><strong>Balance:</strong> $${(user.total_balance || 0).toFixed(2)}</p>
|
||||
<p><strong>Bonus:</strong> $${(user.bonus_balance || 0).toFixed(2)}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,56 @@
|
||||
import { layout, table } from './layout.js';
|
||||
import { layout } from './layout.js';
|
||||
import { escapeHtml } from './escape.js';
|
||||
|
||||
export function renderWalletList(wallets) {
|
||||
const headers = ['ID', 'User ID', 'Type', 'Address', 'Balance', 'Created'];
|
||||
const rows = wallets.map(w => `<tr>
|
||||
<td>${w.id}</td>
|
||||
<td><a href="/users/${w.user_id}">${w.user_id}</a></td>
|
||||
<td>${w.wallet_type}</td>
|
||||
<td><code>${(w.address || '').slice(0, 16)}...</code></td>
|
||||
<td>${(w.balance || 0).toFixed(8)}</td>
|
||||
<td>${w.created_at || '-'}</td>
|
||||
</tr>`).join('');
|
||||
export function renderWalletLayout(users, selectedUser, wallets) {
|
||||
const userListHtml = users.map(u => {
|
||||
const isActive = selectedUser && u.id === selectedUser.id;
|
||||
const statusBadge = u.status === 0 ? 'active' : u.status === 2 ? 'banned' : 'banned';
|
||||
const statusText = u.status === 0 ? '✅' : '❌';
|
||||
return `<a href="/wallets?user=${u.id}" class="wallet-user-item ${isActive ? 'selected' : ''}">
|
||||
<span class="wallet-user-id">#${u.id}</span>
|
||||
<span class="wallet-user-name">${escapeHtml(u.username || u.telegram_id)}</span>
|
||||
<span class="wallet-user-meta">${statusText} ${u.wallet_count}w</span>
|
||||
</a>`;
|
||||
}).join('');
|
||||
|
||||
const walletRows = wallets.length > 0
|
||||
? wallets.map(w => `<tr>
|
||||
<td><strong>${escapeHtml(w.wallet_type)}</strong></td>
|
||||
<td><code title="${escapeHtml(w.address)}">${escapeHtml((w.address || '').slice(0, 20))}${w.address && w.address.length > 20 ? '...' : ''}</code></td>
|
||||
<td>${(w.balance || 0).toFixed(8)}</td>
|
||||
<td>${w.created_at || '-'}</td>
|
||||
</tr>`).join('')
|
||||
: '<tr><td colspan="4" class="muted">No wallets yet</td></tr>';
|
||||
|
||||
const balanceCard = selectedUser ? `
|
||||
<div class="detail-card">
|
||||
<h3>Balances</h3>
|
||||
<p><strong>Main:</strong> $${(selectedUser.total_balance || 0).toFixed(2)}</p>
|
||||
<p><strong>Bonus:</strong> $${(selectedUser.bonus_balance || 0).toFixed(2)}</p>
|
||||
<p><strong>Available:</strong> $${((selectedUser.total_balance || 0) + (selectedUser.bonus_balance || 0)).toFixed(2)}</p>
|
||||
<p><strong>Status:</strong> <span class="badge badge-${selectedUser.status === 0 ? 'active' : 'banned'}">${selectedUser.status === 0 ? 'Active' : selectedUser.status === 2 ? 'Blocked' : 'Deleted'}</span></p>
|
||||
</div>` : '';
|
||||
|
||||
const content = `
|
||||
<div class="wallet-layout">
|
||||
<aside class="wallet-sidebar">
|
||||
<h3>Users</h3>
|
||||
<div class="wallet-user-list">${userListHtml || '<p class="muted">No users</p>'}</div>
|
||||
</aside>
|
||||
<section class="wallet-main">
|
||||
${selectedUser ? `
|
||||
<h2>${escapeHtml(selectedUser.username || 'User #' + selectedUser.id)}
|
||||
<span class="muted" style="font-weight:normal;font-size:0.85rem;"> — ${selectedUser.telegram_id}</span>
|
||||
</h2>
|
||||
${balanceCard}
|
||||
<h3>Crypto Wallets (${wallets.length})</h3>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Address</th><th>Balance</th><th>Created</th></tr></thead>
|
||||
<tbody>${walletRows}</tbody>
|
||||
</table>
|
||||
` : '<p class="muted">Select a user to view wallets</p>'}
|
||||
</section>
|
||||
</div>`;
|
||||
|
||||
const content = table(headers, wallets, () => '')
|
||||
.replace('<tbody></tbody>', `<tbody>${rows}</tbody>`);
|
||||
return layout('Wallets', content, 'wallets');
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,8 @@ export default class AdminUserHandler {
|
||||
const message = `
|
||||
👤 User Profile:
|
||||
|
||||
Status: ${user.status === 0 ? '✅ Active' : user.status === 2 ? '🚫 Blocked' : '❌ Deleted'}
|
||||
|
||||
ID: ${telegramId}
|
||||
📍 Location: ${detailedUser.country || 'Not set'}, ${detailedUser.city || 'Not set'}, ${detailedUser.district || 'Not set'}
|
||||
|
||||
@@ -266,7 +268,7 @@ export default class AdminUserHandler {
|
||||
{text: '📍 Edit Location', callback_data: `edit_user_location_${telegramId}`}
|
||||
],
|
||||
[
|
||||
{text: '🚫 Block User', callback_data: `block_user_${telegramId}`},
|
||||
{text: user.status === 2 ? '✅ Unblock User' : '🚫 Block User', callback_data: `block_user_${telegramId}`},
|
||||
{text: '❌ Delete User', callback_data: `delete_user_${telegramId}`}
|
||||
],
|
||||
[{text: '« Back to User List', callback_data: `list_users_0`}]
|
||||
@@ -364,17 +366,27 @@ export default class AdminUserHandler {
|
||||
const chatId = callbackQuery.message.chat.id;
|
||||
|
||||
try {
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
if (!user) {
|
||||
await bot.sendMessage(chatId, 'User not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const isBlocked = user.status === 2;
|
||||
const actionText = isBlocked ? 'unblock' : 'block';
|
||||
const confirmText = isBlocked ? '✅ Confirm Unblock' : '✅ Confirm Block';
|
||||
|
||||
const keyboard = {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{text: '✅ Confirm Block', callback_data: `confirm_block_user_${telegramId}`},
|
||||
{text: confirmText, callback_data: `confirm_block_user_${telegramId}`},
|
||||
{text: '❌ Cancel', callback_data: `view_user_${telegramId}`}
|
||||
]
|
||||
]
|
||||
};
|
||||
|
||||
await bot.editMessageText(
|
||||
`⚠️ Are you sure you want to block user ${telegramId}?`,
|
||||
`⚠️ Are you sure you want to ${actionText} user ${telegramId}?`,
|
||||
{
|
||||
chat_id: chatId,
|
||||
message_id: callbackQuery.message.message_id,
|
||||
@@ -397,7 +409,15 @@ export default class AdminUserHandler {
|
||||
const chatId = callbackQuery.message.chat.id;
|
||||
|
||||
try {
|
||||
await UserService.updateUserStatus(telegramId, 2);
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
if (!user) {
|
||||
await bot.sendMessage(chatId, 'User not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const isBlocked = user.status === 2;
|
||||
const newStatus = isBlocked ? 0 : 2;
|
||||
await UserService.updateUserStatus(telegramId, newStatus);
|
||||
|
||||
const keyboard = {
|
||||
inline_keyboard: [
|
||||
@@ -406,22 +426,25 @@ export default class AdminUserHandler {
|
||||
};
|
||||
|
||||
try {
|
||||
await bot.sendMessage(telegramId, '⚠️Your account has been blocked by administrator');
|
||||
await bot.sendMessage(telegramId, isBlocked
|
||||
? '✅ Your account has been unblocked by administrator'
|
||||
: '⚠️ Your account has been blocked by administrator');
|
||||
} catch (e) {
|
||||
// ignore if we can't notify user
|
||||
}
|
||||
|
||||
await bot.editMessageText(
|
||||
`✅ User ${telegramId} has been successfully blocked.`,
|
||||
{
|
||||
chat_id: chatId,
|
||||
message_id: callbackQuery.message.message_id,
|
||||
reply_markup: keyboard
|
||||
}
|
||||
);
|
||||
const resultText = isBlocked
|
||||
? `✅ User ${telegramId} has been successfully unblocked.`
|
||||
: `✅ User ${telegramId} has been successfully blocked.`;
|
||||
|
||||
await bot.editMessageText(resultText, {
|
||||
chat_id: chatId,
|
||||
message_id: callbackQuery.message.message_id,
|
||||
reply_markup: keyboard
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Error in handleConfirmBlock');
|
||||
await bot.sendMessage(chatId, 'Error blocking user. Please try again.');
|
||||
await bot.sendMessage(chatId, 'Error updating user status. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user