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:
NW
2026-06-23 12:41:18 +01:00
parent 7b247075a0
commit b6f21222e7
7 changed files with 194 additions and 41 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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