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:
DJP 2026-03-17 23:11:02 -04:00
parent 4c4f7d5408
commit e94cf8f503
5 changed files with 128 additions and 1 deletions

View file

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

View file

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

View file

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

View file

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

View file

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