- All balance changes (top-ups, sets, bulk ops, request approvals) are now logged to data/history.json - New "History" tab in admin sidebar - Search by email to see all changes for a user with totals - Partial name search shows matching users with summary stats - Each entry shows action type, amount, source, OMG job #, resulting balance, and timestamp Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
646 lines
25 KiB
JavaScript
646 lines
25 KiB
JavaScript
const app = {
|
|
apiKey: localStorage.getItem('bm_api_key') || '',
|
|
currentView: 'home',
|
|
currentPage: 1,
|
|
allData: null,
|
|
searchTimeout: null,
|
|
|
|
init() {
|
|
if (!this.apiKey) {
|
|
this.promptApiKey();
|
|
} else {
|
|
this.loadHome();
|
|
}
|
|
this.bindNav();
|
|
this.bindSearch();
|
|
this.bindHistorySearch();
|
|
this.bindModalClose();
|
|
this.bindTableActions();
|
|
},
|
|
|
|
promptApiKey() {
|
|
const key = prompt('Enter your API key:');
|
|
if (key) {
|
|
this.apiKey = key;
|
|
localStorage.setItem('bm_api_key', key);
|
|
this.loadHome();
|
|
}
|
|
},
|
|
|
|
async api(path, options = {}) {
|
|
const base = document.baseURI.replace(/\/$/, '');
|
|
const url = `${base}/api${path}`;
|
|
const res = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-api-key': this.apiKey,
|
|
...options.headers,
|
|
},
|
|
});
|
|
if (res.status === 401) {
|
|
localStorage.removeItem('bm_api_key');
|
|
this.apiKey = '';
|
|
this.promptApiKey();
|
|
return null;
|
|
}
|
|
return res.json();
|
|
},
|
|
|
|
bindNav() {
|
|
document.querySelectorAll('.nav-item').forEach(item => {
|
|
item.addEventListener('click', () => this.navigate(item.dataset.view));
|
|
});
|
|
document.querySelectorAll('.home-card[data-nav]').forEach(card => {
|
|
card.addEventListener('click', () => this.navigate(card.dataset.nav));
|
|
});
|
|
},
|
|
|
|
bindSearch() {
|
|
const input = document.getElementById('search-input');
|
|
input.addEventListener('input', () => {
|
|
clearTimeout(this.searchTimeout);
|
|
this.searchTimeout = setTimeout(() => this.doSearch(input.value), 300);
|
|
});
|
|
},
|
|
|
|
bindModalClose() {
|
|
document.getElementById('modal-overlay').addEventListener('click', (e) => {
|
|
if (e.target.id === 'modal-overlay') this.closeModal();
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') this.closeModal();
|
|
});
|
|
},
|
|
|
|
bindTableActions() {
|
|
document.addEventListener('click', (e) => {
|
|
const balanceBtn = e.target.closest('[data-action]');
|
|
if (balanceBtn) {
|
|
const { action, userid, email, credits } = balanceBtn.dataset;
|
|
this.showEditModal(userid, email, parseInt(credits), action);
|
|
return;
|
|
}
|
|
const reqBtn = e.target.closest('[data-req-action]');
|
|
if (reqBtn) {
|
|
const { reqAction, reqid, amount, email } = reqBtn.dataset;
|
|
if (reqAction === 'custom') {
|
|
this.showCustomApproveModal(reqid, email || '');
|
|
} else {
|
|
this.handleRequestAction(reqid, reqAction, amount ? parseInt(amount) : null);
|
|
}
|
|
return;
|
|
}
|
|
const histBtn = e.target.closest('[data-history-email]');
|
|
if (histBtn) {
|
|
const email = histBtn.dataset.historyEmail;
|
|
document.getElementById('history-search').value = email;
|
|
this.loadUserHistory(email);
|
|
}
|
|
});
|
|
},
|
|
|
|
navigate(view) {
|
|
this.currentView = view;
|
|
document.querySelectorAll('.view').forEach(v => v.style.display = 'none');
|
|
document.getElementById(`view-${view}`).style.display = 'block';
|
|
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
document.querySelector(`.nav-item[data-view="${view}"]`).classList.add('active');
|
|
|
|
if (view === 'home') this.loadHome();
|
|
if (view === 'all') this.loadAll();
|
|
if (view === 'low') this.loadLow(1000000);
|
|
if (view === 'requests') this.loadRequests('pending');
|
|
if (view === 'history') document.getElementById('history-search').focus();
|
|
if (view === 'search') document.getElementById('search-input').focus();
|
|
},
|
|
|
|
async loadHome() {
|
|
this.updateRequestBadge();
|
|
const stats = await this.api('/stats');
|
|
if (!stats) return;
|
|
document.getElementById('home-stats').innerHTML = `
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Users</div>
|
|
<div class="stat-value">${stats.totalUsers.toLocaleString()}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">With Balance</div>
|
|
<div class="stat-value green">${stats.usersWithBalance.toLocaleString()}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Zero Balance</div>
|
|
<div class="stat-value red">${stats.zeroBalance.toLocaleString()}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Avg Balance</div>
|
|
<div class="stat-value purple">${stats.avgCredits.toLocaleString()}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Credits</div>
|
|
<div class="stat-value">${stats.totalCredits.toLocaleString()}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Min Balance</div>
|
|
<div class="stat-value red">${stats.minCredits.toLocaleString()}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Max Balance</div>
|
|
<div class="stat-value green">${stats.maxCredits.toLocaleString()}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">No Balance Record</div>
|
|
<div class="stat-value red">${stats.usersWithoutBalance.toLocaleString()}</div>
|
|
</div>
|
|
`;
|
|
},
|
|
|
|
async loadAll(page = 1) {
|
|
this.currentPage = page;
|
|
const data = await this.api(`/balances?page=${page}&limit=50`);
|
|
if (!data) return;
|
|
this.allData = data;
|
|
document.getElementById('all-count').textContent = `${data.total} Users`;
|
|
document.getElementById('all-tbody').innerHTML = data.users.map(u => this.userRow(u)).join('');
|
|
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';
|
|
const name = u.name || u.username || '-';
|
|
const email = u.email || '-';
|
|
const safeEmail = email.replace(/"/g, '"');
|
|
return `
|
|
<tr>
|
|
<td class="email-cell">${this.esc(email)}</td>
|
|
<td class="name-cell">${this.esc(name)}</td>
|
|
<td class="balance-cell ${balanceClass}">${credits.toLocaleString()}</td>
|
|
<td>
|
|
<div class="actions-cell">
|
|
<button class="btn btn-sm btn-green" data-action="add" data-userid="${u.userId}" data-email="${safeEmail}" data-credits="${credits}">Top Up</button>
|
|
<button class="btn btn-sm btn-secondary" data-action="set" data-userid="${u.userId}" data-email="${safeEmail}" data-credits="${credits}">Set</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
},
|
|
|
|
renderPagination(data) {
|
|
const el = document.getElementById('pagination');
|
|
if (data.totalPages <= 1) {
|
|
el.innerHTML = '';
|
|
return;
|
|
}
|
|
el.innerHTML = `
|
|
<button ${data.page <= 1 ? 'disabled' : ''} onclick="app.loadAll(${data.page - 1})">Prev</button>
|
|
<span class="page-info">Page ${data.page} of ${data.totalPages}</span>
|
|
<button ${data.page >= data.totalPages ? 'disabled' : ''} onclick="app.loadAll(${data.page + 1})">Next</button>
|
|
`;
|
|
},
|
|
|
|
async doSearch(query) {
|
|
const container = document.getElementById('search-results-container');
|
|
const tbody = document.getElementById('search-tbody');
|
|
|
|
if (!query || query.length < 2) {
|
|
container.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const data = await this.api(`/balances/search?q=${encodeURIComponent(query)}`);
|
|
if (!data) return;
|
|
|
|
container.style.display = 'block';
|
|
document.getElementById('search-count').textContent = `${data.users.length} Result${data.users.length !== 1 ? 's' : ''}`;
|
|
|
|
if (data.users.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-muted);padding:32px">No users found</td></tr>';
|
|
} else {
|
|
tbody.innerHTML = data.users.map(u => this.userRow(u)).join('');
|
|
}
|
|
},
|
|
|
|
showEditModal(userId, email, currentBalance, mode) {
|
|
const isAdd = mode === 'add';
|
|
const modal = document.getElementById('modal');
|
|
modal.innerHTML = `
|
|
<h3>${isAdd ? 'Top Up Balance' : 'Set Balance'}</h3>
|
|
<div class="modal-subtitle">${email}</div>
|
|
<div class="modal-field">
|
|
<label>Current Balance</label>
|
|
<div class="current-balance">${currentBalance.toLocaleString()}</div>
|
|
</div>
|
|
<div class="modal-field">
|
|
<label>${isAdd ? 'Amount to Add' : 'New Balance'}</label>
|
|
<input type="number" id="modal-amount" placeholder="e.g. 1000000" autofocus>
|
|
<div class="bulk-presets" style="margin-top:10px">
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=100000">100K</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=500000">500K</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=1000000">1M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=2000000">2M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=5000000">5M</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-secondary" onclick="app.closeModal()">Cancel</button>
|
|
<button class="btn btn-primary" onclick="app.submitEdit('${userId}', '${mode}')">
|
|
${isAdd ? 'Add Tokens' : 'Set Balance'}
|
|
</button>
|
|
</div>
|
|
`;
|
|
document.getElementById('modal-overlay').classList.add('active');
|
|
setTimeout(() => document.getElementById('modal-amount').focus(), 100);
|
|
},
|
|
|
|
async submitEdit(userId, mode) {
|
|
const amount = parseInt(document.getElementById('modal-amount').value);
|
|
if (isNaN(amount)) {
|
|
this.toast('Please enter a valid number', 'error');
|
|
return;
|
|
}
|
|
if (mode === 'set' && amount < 0) {
|
|
this.toast('Balance cannot be negative', 'error');
|
|
return;
|
|
}
|
|
|
|
const endpoint = mode === 'add' ? 'add' : 'set';
|
|
const result = await this.api(`/balances/${userId}/${endpoint}`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ amount }),
|
|
});
|
|
|
|
if (result && !result.error) {
|
|
this.toast(`Balance updated to ${result.tokenCredits.toLocaleString()}`, 'success');
|
|
this.closeModal();
|
|
if (this.currentView === 'all') this.loadAll(this.currentPage);
|
|
if (this.currentView === 'search') {
|
|
const q = document.getElementById('search-input').value;
|
|
if (q) this.doSearch(q);
|
|
}
|
|
if (this.currentView === 'home') this.loadHome();
|
|
} else {
|
|
this.toast(result?.error || 'Failed to update', 'error');
|
|
}
|
|
},
|
|
|
|
showBulkModal(mode) {
|
|
const isAdd = mode === 'add';
|
|
const modal = document.getElementById('modal');
|
|
modal.innerHTML = `
|
|
<h3>${isAdd ? 'Add Tokens to All Users' : 'Set All Users Balance'}</h3>
|
|
<div class="modal-subtitle">${isAdd
|
|
? 'This will increment every user\'s balance by the specified amount.'
|
|
: 'This will set every user\'s balance to the exact amount. Existing balances will be overwritten.'
|
|
}</div>
|
|
<div class="modal-field">
|
|
<label>${isAdd ? 'Amount to Add' : 'New Balance for All'}</label>
|
|
<input type="number" id="modal-amount" placeholder="e.g. 1000000" autofocus>
|
|
<div class="bulk-presets" style="margin-top:10px">
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=100000">100K</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=500000">500K</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=1000000">1M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=2000000">2M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=5000000">5M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=10000000">10M</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-secondary" onclick="app.closeModal()">Cancel</button>
|
|
<button class="btn ${isAdd ? 'btn-green' : 'btn-red'}" onclick="app.submitBulk('${mode}')">
|
|
${isAdd ? 'Add to All' : 'Set All Balances'}
|
|
</button>
|
|
</div>
|
|
`;
|
|
document.getElementById('modal-overlay').classList.add('active');
|
|
setTimeout(() => document.getElementById('modal-amount').focus(), 100);
|
|
},
|
|
|
|
async submitBulk(mode) {
|
|
const amount = parseInt(document.getElementById('modal-amount').value);
|
|
if (isNaN(amount)) {
|
|
this.toast('Please enter a valid number', 'error');
|
|
return;
|
|
}
|
|
if (mode === 'set' && amount < 0) {
|
|
this.toast('Balance cannot be negative', 'error');
|
|
return;
|
|
}
|
|
|
|
const confirmed = confirm(`Are you sure you want to ${mode === 'add' ? 'add ' + amount.toLocaleString() + ' tokens to' : 'set'} ALL users${mode === 'set' ? ' to ' + amount.toLocaleString() : ''}?`);
|
|
if (!confirmed) return;
|
|
|
|
const result = await this.api(`/balances/bulk/${mode}`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ amount }),
|
|
});
|
|
|
|
if (result && !result.error) {
|
|
this.toast(`Updated ${result.modifiedCount} users`, 'success');
|
|
this.closeModal();
|
|
} else {
|
|
this.toast(result?.error || 'Bulk operation failed', 'error');
|
|
}
|
|
},
|
|
|
|
exportCSV() {
|
|
if (!this.allData || !this.allData.users.length) return;
|
|
const rows = [['Email', 'Name', 'Balance']];
|
|
for (const u of this.allData.users) {
|
|
rows.push([u.email || '', u.name || u.username || '', u.tokenCredits || 0]);
|
|
}
|
|
const csv = rows.map(r => r.map(c => `"${c}"`).join(',')).join('\n');
|
|
const blob = new Blob([csv], { type: 'text/csv' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `balances-page${this.currentPage}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
},
|
|
|
|
closeModal() {
|
|
document.getElementById('modal-overlay').classList.remove('active');
|
|
},
|
|
|
|
toast(message, type = 'info') {
|
|
const container = document.getElementById('toast-container');
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
container.appendChild(toast);
|
|
setTimeout(() => {
|
|
toast.style.opacity = '0';
|
|
toast.style.transition = 'opacity 0.3s';
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 3000);
|
|
},
|
|
|
|
bindHistorySearch() {
|
|
const input = document.getElementById('history-search');
|
|
let timeout;
|
|
input.addEventListener('input', () => {
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => this.doHistorySearch(input.value), 300);
|
|
});
|
|
},
|
|
|
|
async doHistorySearch(query) {
|
|
const summary = document.getElementById('history-summary');
|
|
const results = document.getElementById('history-results');
|
|
const usersContainer = document.getElementById('history-users');
|
|
|
|
if (!query || query.length < 2) {
|
|
summary.style.display = 'none';
|
|
results.style.display = 'none';
|
|
usersContainer.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// Check if it looks like a full email
|
|
if (query.includes('@')) {
|
|
await this.loadUserHistory(query);
|
|
} else {
|
|
// Search for matching users
|
|
const data = await this.api(`/history/search?q=${encodeURIComponent(query)}`);
|
|
if (!data) return;
|
|
|
|
summary.style.display = 'none';
|
|
results.style.display = 'none';
|
|
|
|
if (data.users.length === 0) {
|
|
usersContainer.style.display = 'block';
|
|
document.getElementById('history-users-count').textContent = 'No matches';
|
|
document.getElementById('history-users-tbody').innerHTML =
|
|
'<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:32px">No history found for this search</td></tr>';
|
|
return;
|
|
}
|
|
|
|
if (data.users.length === 1) {
|
|
await this.loadUserHistory(data.users[0].email);
|
|
return;
|
|
}
|
|
|
|
usersContainer.style.display = 'block';
|
|
document.getElementById('history-users-count').textContent = `${data.users.length} Users`;
|
|
document.getElementById('history-users-tbody').innerHTML = data.users.map(u => {
|
|
const lastDate = u.lastActivity
|
|
? new Date(u.lastActivity).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
|
: '-';
|
|
return `
|
|
<tr>
|
|
<td class="email-cell">${this.esc(u.email)}</td>
|
|
<td>${u.totalRequests}</td>
|
|
<td class="balance-cell balance-positive">${u.totalAdded.toLocaleString()}</td>
|
|
<td style="color:var(--text-muted);font-size:12px">${lastDate}</td>
|
|
<td><button class="btn btn-sm btn-primary" data-history-email="${u.email.replace(/"/g, '"')}">View</button></td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
},
|
|
|
|
async loadUserHistory(email) {
|
|
const data = await this.api(`/history?email=${encodeURIComponent(email)}`);
|
|
if (!data) return;
|
|
|
|
const summary = document.getElementById('history-summary');
|
|
const results = document.getElementById('history-results');
|
|
const usersContainer = document.getElementById('history-users');
|
|
|
|
usersContainer.style.display = 'none';
|
|
summary.style.display = 'block';
|
|
results.style.display = 'block';
|
|
|
|
document.getElementById('history-email').textContent = email;
|
|
document.getElementById('history-total').textContent = data.totalAdded.toLocaleString();
|
|
document.getElementById('history-count').textContent = data.entries.length;
|
|
document.getElementById('history-results-count').textContent = `${data.entries.length} Entries`;
|
|
|
|
const tbody = document.getElementById('history-tbody');
|
|
if (data.entries.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-muted);padding:32px">No history for this user</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = data.entries.map(h => {
|
|
const date = new Date(h.timestamp).toLocaleDateString('en-GB', {
|
|
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
});
|
|
const actionLabels = {
|
|
add: 'Top Up',
|
|
set: 'Set Balance',
|
|
approve: 'Request Approved',
|
|
bulk_add: 'Bulk Add',
|
|
bulk_set: 'Bulk Set',
|
|
};
|
|
const actionLabel = actionLabels[h.action] || h.action;
|
|
const actionClass = h.action === 'approve' ? 'status-approved' : h.action === 'set' || h.action === 'bulk_set' ? 'status-pending' : 'status-approved';
|
|
return `
|
|
<tr>
|
|
<td class="email-cell">${this.esc(h.email)}</td>
|
|
<td><span class="status-badge ${actionClass}">${actionLabel}</span></td>
|
|
<td class="balance-cell balance-positive">${h.amount ? h.amount.toLocaleString() : '-'}</td>
|
|
<td style="color:var(--text-muted)">${h.source || '-'}</td>
|
|
<td style="color:var(--text-muted)">${h.omgJobNumber || '-'}</td>
|
|
<td class="balance-cell">${h.balanceAfter != null ? h.balanceAfter.toLocaleString() : '-'}</td>
|
|
<td style="color:var(--text-muted);font-size:12px">${date}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
},
|
|
|
|
async updateRequestBadge() {
|
|
const data = await this.api('/admin/requests/count');
|
|
if (!data) return;
|
|
const badge = document.getElementById('requests-badge');
|
|
if (data.count > 0) {
|
|
badge.textContent = data.count;
|
|
badge.style.display = 'inline';
|
|
} else {
|
|
badge.style.display = 'none';
|
|
}
|
|
},
|
|
|
|
async loadRequests(filter = 'pending') {
|
|
document.querySelectorAll('[data-req-filter]').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.reqFilter === filter);
|
|
});
|
|
|
|
const param = filter === 'all' ? '' : `?status=${filter}`;
|
|
const data = await this.api(`/admin/requests${param}`);
|
|
if (!data) return;
|
|
|
|
const tbody = document.getElementById('requests-tbody');
|
|
document.getElementById('requests-count').textContent = `${data.requests.length} Request${data.requests.length !== 1 ? 's' : ''}`;
|
|
|
|
if (data.requests.length === 0) {
|
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted);padding:32px">No requests</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = data.requests.map(r => this.requestRow(r)).join('');
|
|
},
|
|
|
|
requestRow(r) {
|
|
const date = new Date(r.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
const statusClass = `status-${r.status}`;
|
|
const statusText = r.status.charAt(0).toUpperCase() + r.status.slice(1);
|
|
const amountText = r.amountApproved ? ` (${(r.amountApproved / 1000000).toFixed(1)}M)` : '';
|
|
|
|
let actions = '';
|
|
if (r.status === 'pending') {
|
|
actions = `
|
|
<div class="actions-cell" style="flex-wrap:wrap">
|
|
<button class="btn btn-sm btn-green" data-req-action="approve" data-reqid="${r.id}" data-amount="5000000">5M</button>
|
|
<button class="btn btn-sm btn-green" data-req-action="approve" data-reqid="${r.id}" data-amount="10000000">10M</button>
|
|
<button class="btn btn-sm btn-green" data-req-action="approve" data-reqid="${r.id}" data-amount="20000000">20M</button>
|
|
<button class="btn btn-sm btn-primary" data-req-action="custom" data-reqid="${r.id}" data-email="${r.email.replace(/"/g, '"')}">Custom</button>
|
|
<button class="btn btn-sm btn-red" data-req-action="reject" data-reqid="${r.id}">Reject</button>
|
|
</div>
|
|
`;
|
|
} else {
|
|
actions = `<span style="color:var(--text-muted);font-size:12px">${statusText}${amountText}</span>`;
|
|
}
|
|
|
|
return `
|
|
<tr>
|
|
<td class="email-cell">${this.esc(r.email)}</td>
|
|
<td>${this.esc(r.omgJobNumber)}</td>
|
|
<td><span class="status-badge ${statusClass}">${statusText}</span></td>
|
|
<td style="color:var(--text-muted);font-size:12px">${date}</td>
|
|
<td>${actions}</td>
|
|
</tr>
|
|
`;
|
|
},
|
|
|
|
showCustomApproveModal(reqId, email) {
|
|
const modal = document.getElementById('modal');
|
|
modal.innerHTML = `
|
|
<h3>Custom Top Up</h3>
|
|
<div class="modal-subtitle">${this.esc(email)}</div>
|
|
<div class="modal-field">
|
|
<label>Amount to Add</label>
|
|
<input type="number" id="modal-amount" placeholder="e.g. 12000000" autofocus>
|
|
<div class="bulk-presets" style="margin-top:10px">
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=1000000">1M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=5000000">5M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=10000000">10M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=12000000">12M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=15000000">15M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=20000000">20M</button>
|
|
<button class="preset-btn" onclick="document.getElementById('modal-amount').value=50000000">50M</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-secondary" onclick="app.closeModal()">Cancel</button>
|
|
<button class="btn btn-primary" onclick="app.submitCustomApprove('${reqId}')">Approve & Top Up</button>
|
|
</div>
|
|
`;
|
|
document.getElementById('modal-overlay').classList.add('active');
|
|
setTimeout(() => document.getElementById('modal-amount').focus(), 100);
|
|
},
|
|
|
|
async submitCustomApprove(reqId) {
|
|
const amount = parseInt(document.getElementById('modal-amount').value);
|
|
if (isNaN(amount) || amount <= 0) {
|
|
this.toast('Please enter a valid amount', 'error');
|
|
return;
|
|
}
|
|
this.closeModal();
|
|
await this.handleRequestAction(reqId, 'approve', amount);
|
|
},
|
|
|
|
async handleRequestAction(reqId, action, amount) {
|
|
if (action === 'custom') return;
|
|
if (action === 'approve') {
|
|
const result = await this.api(`/admin/requests/${reqId}/approve`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ amount }),
|
|
});
|
|
if (result && result.success) {
|
|
this.toast(`Approved and added ${(amount / 1000000).toFixed(1)}M tokens`, 'success');
|
|
this.loadRequests('pending');
|
|
this.updateRequestBadge();
|
|
} else {
|
|
this.toast(result?.error || 'Failed to approve', 'error');
|
|
}
|
|
} else if (action === 'reject') {
|
|
const confirmed = confirm('Reject this request?');
|
|
if (!confirmed) return;
|
|
const result = await this.api(`/admin/requests/${reqId}/reject`, {
|
|
method: 'POST',
|
|
});
|
|
if (result && result.success) {
|
|
this.toast('Request rejected', 'info');
|
|
this.loadRequests('pending');
|
|
this.updateRequestBadge();
|
|
} else {
|
|
this.toast(result?.error || 'Failed to reject', 'error');
|
|
}
|
|
}
|
|
},
|
|
|
|
esc(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
},
|
|
};
|
|
|
|
document.addEventListener('DOMContentLoaded', () => app.init());
|