Add low balance users view with threshold filters
Shows users below 1M/2M/3M/4M/5M tokens with filter buttons. Sorted lowest first so admins can quickly find and top up users running low. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4c4f7d5408
commit
e94cf8f503
5 changed files with 128 additions and 1 deletions
|
|
@ -648,6 +648,45 @@ tr:hover td {
|
|||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Filter Bar */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
color: var(--text-muted);
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
border-color: rgba(255, 196, 7, 0.3);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: rgba(255, 196, 7, 0.1);
|
||||
border-color: var(--accent-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
Find User
|
||||
</div>
|
||||
<div class="nav-item" data-view="low">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg>
|
||||
Low Balance
|
||||
</div>
|
||||
<div class="nav-item" data-view="bulk">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="5" rx="2"/><line x1="2" x2="22" y1="10" y2="10"/></svg>
|
||||
Bulk Operations
|
||||
|
|
@ -69,7 +73,7 @@
|
|||
<h3>Bulk Operations</h3>
|
||||
<p>Add tokens to all users at once or reset everyone to a specific balance amount.</p>
|
||||
</div>
|
||||
<div class="home-card" data-nav="all">
|
||||
<div class="home-card" data-nav="low">
|
||||
<div class="card-icon red">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/></svg>
|
||||
</div>
|
||||
|
|
@ -160,6 +164,38 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Low Balance View -->
|
||||
<div id="view-low" class="view" style="display:none">
|
||||
<div class="page-header">
|
||||
<h2>Low Balance Users</h2>
|
||||
<p>Users below a token threshold — sorted lowest first</p>
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<span class="filter-label">Show users under:</span>
|
||||
<button class="filter-btn active" data-threshold="1000000" onclick="app.loadLow(1000000)">1M</button>
|
||||
<button class="filter-btn" data-threshold="2000000" onclick="app.loadLow(2000000)">2M</button>
|
||||
<button class="filter-btn" data-threshold="3000000" onclick="app.loadLow(3000000)">3M</button>
|
||||
<button class="filter-btn" data-threshold="4000000" onclick="app.loadLow(4000000)">4M</button>
|
||||
<button class="filter-btn" data-threshold="5000000" onclick="app.loadLow(5000000)">5M</button>
|
||||
</div>
|
||||
<div class="table-container" style="margin-top:20px">
|
||||
<div class="table-header">
|
||||
<h3 id="low-count">Users</h3>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Name</th>
|
||||
<th>Balance</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="low-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal -->
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ const app = {
|
|||
|
||||
if (view === 'home') this.loadHome();
|
||||
if (view === 'all') this.loadAll();
|
||||
if (view === 'low') this.loadLow(1000000);
|
||||
if (view === 'search') document.getElementById('search-input').focus();
|
||||
},
|
||||
|
||||
|
|
@ -132,6 +133,21 @@ const app = {
|
|||
this.renderPagination(data);
|
||||
},
|
||||
|
||||
async loadLow(threshold) {
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', parseInt(btn.dataset.threshold) === threshold);
|
||||
});
|
||||
const data = await this.api(`/balances/low?threshold=${threshold}`);
|
||||
if (!data) return;
|
||||
const tbody = document.getElementById('low-tbody');
|
||||
document.getElementById('low-count').textContent = `${data.users.length} User${data.users.length !== 1 ? 's' : ''} under ${(threshold / 1000000)}M`;
|
||||
if (data.users.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-muted);padding:32px">No users below this threshold</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = data.users.map(u => this.userRow(u)).join('');
|
||||
}
|
||||
},
|
||||
|
||||
userRow(u) {
|
||||
const credits = u.tokenCredits || 0;
|
||||
const balanceClass = credits > 0 ? 'balance-positive' : 'balance-zero';
|
||||
|
|
|
|||
|
|
@ -22,6 +22,16 @@ router.get('/balances', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
router.get('/balances/low', async (req, res) => {
|
||||
try {
|
||||
const { threshold = 1000000 } = req.query;
|
||||
const users = await balanceService.getLowBalances(req.db, parseInt(threshold));
|
||||
res.json({ users });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/balances/search', async (req, res) => {
|
||||
try {
|
||||
const { q } = req.query;
|
||||
|
|
|
|||
|
|
@ -82,6 +82,31 @@ async function getAllBalances(db, page, limit) {
|
|||
};
|
||||
}
|
||||
|
||||
async function getLowBalances(db, threshold) {
|
||||
return db.collection('balances').aggregate([
|
||||
{ $match: { tokenCredits: { $lt: threshold } } },
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'user',
|
||||
foreignField: '_id',
|
||||
as: 'userInfo',
|
||||
},
|
||||
},
|
||||
{ $unwind: { path: '$userInfo', preserveNullAndEmptyArrays: true } },
|
||||
{
|
||||
$project: {
|
||||
userId: '$user',
|
||||
tokenCredits: 1,
|
||||
email: '$userInfo.email',
|
||||
name: '$userInfo.name',
|
||||
username: '$userInfo.username',
|
||||
},
|
||||
},
|
||||
{ $sort: { tokenCredits: 1 } },
|
||||
]).toArray();
|
||||
}
|
||||
|
||||
async function searchUsers(db, query) {
|
||||
const regex = new RegExp(query, 'i');
|
||||
|
||||
|
|
@ -181,6 +206,7 @@ async function setAll(db, amount) {
|
|||
module.exports = {
|
||||
getStats,
|
||||
getAllBalances,
|
||||
getLowBalances,
|
||||
searchUsers,
|
||||
getUserBalance,
|
||||
setBalance,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue