| |
| |
| |
|
|
| let allWalletUsers = []; |
| let filteredWalletUsers = []; |
| let walletTransactions = []; |
| let activeWalletUser = null; |
|
|
| async function loadUserWallets() { |
| const cardsGrid = document.getElementById('wallet-cards-grid'); |
| if (!cardsGrid) return; |
|
|
| cardsGrid.innerHTML = '<div class="loading-state"><i class="fa-solid fa-spinner fa-spin"></i><span>Loading user wallets...</span></div>'; |
|
|
| try { |
| const walletRes = await fetch('/api/admin/wallets'); |
| allWalletUsers = await walletRes.json(); |
|
|
| try { |
| const txRes = await fetch('/api/admin/transactions'); |
| walletTransactions = txRes.ok ? await txRes.json() : []; |
| } catch (e) { |
| walletTransactions = []; |
| } |
|
|
| updateWalletOverview(allWalletUsers, walletTransactions); |
| applyWalletFilters(); |
| } catch (err) { |
| cardsGrid.innerHTML = '<div class="error-state"><i class="fa-solid fa-circle-exclamation"></i><span>Error loading user wallets. Please refresh.</span></div>'; |
| } |
| } |
|
|
| function applyWalletFilters() { |
| const searchInput = document.getElementById('wallet-search'); |
| const balanceFilter = document.getElementById('wallet-balance-filter'); |
| const sortSelect = document.getElementById('wallet-sort'); |
| const searchTerm = searchInput ? searchInput.value.trim().toLowerCase() : ''; |
| const filterType = balanceFilter ? balanceFilter.value : 'all'; |
| const sortType = sortSelect ? sortSelect.value : 'balance-desc'; |
|
|
| filteredWalletUsers = [...allWalletUsers].filter(user => { |
| const balance = parseFloat(user.balance) || 0; |
| const searchable = `${user.id || ''} ${user.name || ''} ${user.phone || ''}`.toLowerCase(); |
| if (searchTerm && !searchable.includes(searchTerm)) return false; |
| if (filterType === 'low' && balance >= 100) return false; |
| if (filterType === 'medium' && (balance < 100 || balance > 1000)) return false; |
| if (filterType === 'high' && balance < 1000) return false; |
| return true; |
| }); |
|
|
| filteredWalletUsers.sort((a, b) => { |
| const balanceA = parseFloat(a.balance) || 0; |
| const balanceB = parseFloat(b.balance) || 0; |
| const recentA = Date.parse(a.last_tx_date) || 0; |
| const recentB = Date.parse(b.last_tx_date) || 0; |
| if (sortType === 'balance-asc') return balanceA - balanceB; |
| if (sortType === 'name-asc') return (a.name || '').localeCompare(b.name || ''); |
| if (sortType === 'recent') return recentB - recentA; |
| return balanceB - balanceA; |
| }); |
|
|
| renderWalletUsers(filteredWalletUsers); |
| } |
|
|
| function renderWalletUsers(wallets) { |
| const cardsGrid = document.getElementById('wallet-cards-grid'); |
| const resultCount = document.getElementById('wallet-result-count'); |
| if (!cardsGrid) return; |
|
|
| if (resultCount) resultCount.innerText = `${wallets.length} users`; |
|
|
| if (wallets.length === 0) { |
| cardsGrid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-users-slash"></i><span>No matching users found.</span></div>'; |
| return; |
| } |
|
|
| const balanceLevelClass = (b) => { |
| const n = parseFloat(b) || 0; |
| if (n < 100) return 'balance-low'; |
| if (n < 1000) return 'balance-medium'; |
| return 'balance-high'; |
| }; |
|
|
| cardsGrid.innerHTML = wallets.map(w => { |
| const safeId = escapeHtml(w.id || ''); |
| const safeName = escapeHtml(w.name || 'Unknown'); |
| const safePhone = escapeHtml(w.phone || '-'); |
| const safeDate = escapeHtml(w.last_tx_date || 'N/A'); |
| const shortId = escapeHtml((w.id || '').slice(0, 10)); |
| const bal = formatInr(w.balance); |
| const balClass = balanceLevelClass(w.balance); |
| const initial = (w.name || 'U').charAt(0).toUpperCase(); |
|
|
| return ` |
| <div class="wallet-user-card" id="wallet-card-${safeId}"> |
| <div class="wallet-card-header"> |
| <div class="wallet-user-avatar">${initial}</div> |
| <div class="wallet-user-info"> |
| <div class="wallet-user-name">${safeName}</div> |
| <div class="wallet-user-phone"><i class="fa-solid fa-mobile-screen"></i> ${safePhone}</div> |
| </div> |
| <div class="wallet-balance-badge ${balClass}">${bal}</div> |
| </div> |
| <div class="wallet-card-meta"> |
| <span><i class="fa-solid fa-fingerprint"></i> ${shortId}...</span> |
| <span><i class="fa-solid fa-clock-rotate-left"></i> ${safeDate}</span> |
| </div> |
| <div class="wallet-card-actions"> |
| <button class="wallet-action-btn" id="wallet-toggle-btn-${safeId}" |
| onclick="toggleWalletUserDetails('${safeId}', '${safeName}')"> |
| <i class="fa-solid fa-chevron-down"></i> View Details |
| </button> |
| <button class="wallet-action-btn primary" |
| onclick="showWalletAdjustModal('${safeId}', '${safeName}', ${parseFloat(w.balance) || 0})"> |
| <i class="fa-solid fa-wallet"></i> Manage |
| </button> |
| </div> |
| <div class="wallet-expanded-area" id="wallet-expanded-${safeId}"></div> |
| </div>`; |
| }).join(''); |
| } |
|
|
| async function toggleWalletUserDetails(userId, userName) { |
| const card = document.getElementById(`wallet-card-${userId}`); |
| const expandedArea = document.getElementById(`wallet-expanded-${userId}`); |
| const toggleBtn = document.getElementById(`wallet-toggle-btn-${userId}`); |
| if (!card || !expandedArea) return; |
|
|
| |
| if (card.classList.contains('expanded')) { |
| card.classList.remove('expanded'); |
| expandedArea.innerHTML = ''; |
| if (toggleBtn) toggleBtn.innerHTML = '<i class="fa-solid fa-chevron-down"></i> View Details'; |
| return; |
| } |
|
|
| |
| document.querySelectorAll('.wallet-user-card.expanded').forEach(c => { |
| c.classList.remove('expanded'); |
| const ea = c.querySelector('.wallet-expanded-area'); |
| if (ea) ea.innerHTML = ''; |
| const tb = c.querySelector('[id^="wallet-toggle-btn-"]'); |
| if (tb) tb.innerHTML = '<i class="fa-solid fa-chevron-down"></i> View Details'; |
| }); |
|
|
| |
| card.classList.add('expanded'); |
| if (toggleBtn) toggleBtn.innerHTML = '<i class="fa-solid fa-chevron-up"></i> Collapse'; |
| expandedArea.innerHTML = `<div class="wallet-detail-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading history for ${escapeHtml(userName)}...</div>`; |
|
|
| try { |
| const res = await fetch(`/api/admin/user/${encodeURIComponent(userId)}/details`); |
| const data = await res.json(); |
|
|
| if (!res.ok) { |
| expandedArea.innerHTML = `<div class="error-state">${escapeHtml(data.error || 'Failed to load details')}</div>`; |
| return; |
| } |
|
|
| const u = data.user || {}; |
| const topups = data.topup_history || []; |
| const prints = data.print_history || []; |
| const downloads = data.download_history || []; |
| const ledger = data.wallet_ledger || []; |
|
|
| const statusPill = (s) => { |
| const cls = (s || '').toLowerCase(); |
| return `<span class="status-pill status-${cls}">${escapeHtml((s || 'N/A').toUpperCase())}</span>`; |
| }; |
|
|
| |
| const topupsHtml = topups.length === 0 |
| ? '<div class="wh-empty"><i class="fa-solid fa-inbox"></i> No topup records found.</div>' |
| : `<table class="wh-table"> |
| <thead> |
| <tr> |
| <th>Date & Time</th> |
| <th>Amount</th> |
| <th>UTR Number</th> |
| <th>Status</th> |
| <th>Remark</th> |
| </tr> |
| </thead> |
| <tbody> |
| ${topups.map(tx => ` |
| <tr> |
| <td style="white-space:nowrap;">${escapeHtml((tx.date || '').split('.')[0])}</td> |
| <td class="wh-amount-credit">+${formatInr(tx.amount)}</td> |
| <td class="wh-utr">${escapeHtml(tx.utr || '-')}</td> |
| <td>${statusPill(tx.status)}</td> |
| <td style="color:var(--text-muted); font-size:0.82rem;">${escapeHtml(tx.reason || '-')}</td> |
| </tr>`).join('')} |
| </tbody> |
| </table>`; |
|
|
| |
| const printsHtml = prints.length === 0 |
| ? '<div class="wh-empty"><i class="fa-solid fa-inbox"></i> No print records found.</div>' |
| : `<table class="wh-table"> |
| <thead> |
| <tr> |
| <th>Date & Time</th> |
| <th>Aadhaar No.</th> |
| <th>Name</th> |
| <th>DOB</th> |
| <th>Status</th> |
| <th>Reason / Remark</th> |
| </tr> |
| </thead> |
| <tbody> |
| ${prints.map(p => { |
| const rawAadhaar = p.aadhaar || ''; |
| const maskedAadhaar = rawAadhaar.length >= 8 |
| ? 'XXXX XXXX ' + rawAadhaar.slice(-4) |
| : (rawAadhaar || '-'); |
| const ts = (p.timestamp || '').replace('T', ' ').split('.')[0]; |
| return ` |
| <tr> |
| <td style="white-space:nowrap;">${escapeHtml(ts || '-')}</td> |
| <td class="wh-utr">${escapeHtml(maskedAadhaar)}</td> |
| <td style="font-weight:600;">${escapeHtml(p.name || '-')}</td> |
| <td>${escapeHtml(p.dob || '-')}</td> |
| <td>${statusPill(p.status)}</td> |
| <td style="max-width:220px; color:var(--text-muted); font-size:0.82rem;">${escapeHtml(p.reason || '-')}</td> |
| </tr>`; |
| }).join('')} |
| </tbody> |
| </table>`; |
|
|
| |
| const downloadsHtml = downloads.length === 0 |
| ? '<div class="wh-empty"><i class="fa-solid fa-inbox"></i> No download records found.</div>' |
| : `<table class="wh-table"> |
| <thead> |
| <tr> |
| <th>Date & Time</th> |
| <th>Aadhaar No.</th> |
| <th>EID</th> |
| <th>Type</th> |
| <th>IP Address</th> |
| </tr> |
| </thead> |
| <tbody> |
| ${downloads.map(d => { |
| const ts = (d.downloaded_at || '').replace('T', ' ').split('.')[0]; |
| const typeLabel = d.is_masked |
| ? '<span class="status-pill status-masked">Masked</span>' |
| : '<span class="status-pill status-normal">Normal</span>'; |
| return ` |
| <tr> |
| <td style="white-space:nowrap;">${escapeHtml(ts || '-')}</td> |
| <td class="wh-utr">${escapeHtml(d.aadhaar_number)}</td> |
| <td>${escapeHtml(d.eid || '-')}</td> |
| <td>${typeLabel}</td> |
| <td style="font-size:0.82rem; color:var(--text-muted);">${escapeHtml(d.ip_address || '-')}</td> |
| </tr>`; |
| }).join('')} |
| </tbody> |
| </table>`; |
|
|
| |
| |
| let allActivities = []; |
| |
| |
| ledger.forEach(l => { |
| allActivities.push({ |
| type: 'wallet', |
| subType: l.type, |
| date: l.date, |
| description: l.description || 'Wallet adjustment', |
| amount: l.amount, |
| balance_after: l.balance_after, |
| aadhaar: '-', |
| status: l.type |
| }); |
| }); |
| |
| |
| prints.forEach(p => { |
| const rawAadhaar = p.aadhaar || ''; |
| const maskedAadhaar = rawAadhaar.length >= 8 |
| ? 'XXXX XXXX ' + rawAadhaar.slice(-4) |
| : (rawAadhaar || '-'); |
| allActivities.push({ |
| type: 'print', |
| subType: 'generate', |
| date: (p.timestamp || '').replace('T', ' ').split('.')[0], |
| description: `Generated Aadhaar for ${p.name || 'User'}`, |
| amount: null, |
| balance_after: null, |
| aadhaar: maskedAadhaar, |
| status: 'completed' |
| }); |
| }); |
| |
| |
| downloads.forEach(d => { |
| allActivities.push({ |
| type: 'download', |
| subType: d.is_masked ? 'masked' : 'normal', |
| date: (d.downloaded_at || '').replace('T', ' ').split('.')[0], |
| description: `Downloaded Aadhaar ${d.is_masked ? '(Masked)' : ''}`, |
| amount: null, |
| balance_after: null, |
| aadhaar: d.aadhaar_number, |
| status: d.is_masked ? 'masked' : 'normal' |
| }); |
| }); |
| |
| |
| allActivities.sort((a, b) => new Date(b.date) - new Date(a.date)); |
| |
| const activityHtml = allActivities.length === 0 |
| ? '<div class="wh-empty"><i class="fa-solid fa-inbox"></i> No activity records found.</div>' |
| : `<table class="wh-table"> |
| <thead> |
| <tr> |
| <th>Date & Time</th> |
| <th>Activity</th> |
| <th>Aadhaar No.</th> |
| <th>Details</th> |
| <th>Amount/Balance</th> |
| </tr> |
| </thead> |
| <tbody> |
| ${allActivities.map(act => { |
| let typeLabel, typeIcon, typeClass; |
| if (act.type === 'wallet') { |
| typeLabel = act.subType === 'credit' ? 'Wallet Credit' : 'Wallet Debit'; |
| typeIcon = 'fa-wallet'; |
| typeClass = act.subType; |
| } else if (act.type === 'print') { |
| typeLabel = 'Generate Aadhaar'; |
| typeIcon = 'fa-id-card'; |
| typeClass = 'success'; |
| } else if (act.type === 'download') { |
| typeLabel = act.subType === 'masked' ? 'Download (Masked)' : 'Download'; |
| typeIcon = 'fa-download'; |
| typeClass = act.subType === 'masked' ? 'masked' : 'normal'; |
| } |
| |
| const amountDisplay = act.type === 'wallet' |
| ? `<span class="${act.subType === 'credit' ? 'wh-amount-credit' : 'wh-amount-debit'}">${act.subType === 'credit' ? '+' : '-'}${formatInr(act.amount)}</span><br><small>Bal: ${formatInr(act.balance_after)}</small>` |
| : '-'; |
| |
| return ` |
| <tr> |
| <td style="white-space:nowrap;">${escapeHtml(act.date || '-')}</td> |
| <td><span class="status-pill status-${typeClass}"><i class="fa-solid ${typeIcon}"></i> ${typeLabel}</span></td> |
| <td class="wh-utr">${escapeHtml(act.aadhaar)}</td> |
| <td style="font-size:0.85rem;">${escapeHtml(act.description)}</td> |
| <td>${amountDisplay}</td> |
| </tr>`; |
| }).join('')} |
| </tbody> |
| </table>`; |
|
|
| expandedArea.innerHTML = ` |
| <div class="wallet-detail-panel"> |
| <!-- User Profile Strip --> |
| <div class="wdp-profile"> |
| <div class="wdp-profile-grid"> |
| <div class="wdp-item"> |
| <span class="wdp-label"><i class="fa-solid fa-user"></i> Name</span> |
| <span class="wdp-value">${escapeHtml(u.name || '-')}</span> |
| </div> |
| <div class="wdp-item"> |
| <span class="wdp-label"><i class="fa-solid fa-mobile-screen"></i> Phone</span> |
| <span class="wdp-value">${escapeHtml(u.phone || '-')}</span> |
| </div> |
| <div class="wdp-item"> |
| <span class="wdp-label"><i class="fa-solid fa-envelope"></i> Email</span> |
| <span class="wdp-value">${escapeHtml(u.email || '-')}</span> |
| </div> |
| <div class="wdp-item"> |
| <span class="wdp-label"><i class="fa-solid fa-calendar-plus"></i> Joined</span> |
| <span class="wdp-value">${escapeHtml((u.created_at || '').split(' ')[0])}</span> |
| </div> |
| <div class="wdp-item"> |
| <span class="wdp-label"><i class="fa-solid fa-shield-halved"></i> Role</span> |
| <span class="wdp-value">${escapeHtml(u.role || 'user')}</span> |
| </div> |
| <div class="wdp-item"> |
| <span class="wdp-label"><i class="fa-solid fa-wallet"></i> Balance</span> |
| <span class="wdp-value wdp-balance">${formatInr(u.balance)}</span> |
| </div> |
| </div> |
| </div> |
| |
| <!-- History Tabs --> |
| <div class="wdp-tabs"> |
| <button class="wdp-tab active" onclick="switchWalletTab(this, 'wdp-topups-${userId}')"> |
| <i class="fa-solid fa-arrow-up-to-dotted-line"></i> |
| Topups |
| <span class="wdp-tab-badge">${topups.length}</span> |
| </button> |
| <button class="wdp-tab" onclick="switchWalletTab(this, 'wdp-prints-${userId}')"> |
| <i class="fa-solid fa-file-invoice"></i> |
| Prints |
| <span class="wdp-tab-badge">${prints.length}</span> |
| </button> |
| <button class="wdp-tab" onclick="switchWalletTab(this, 'wdp-downloads-${userId}')"> |
| <i class="fa-solid fa-download"></i> |
| Downloads |
| <span class="wdp-tab-badge">${downloads.length}</span> |
| </button> |
| <button class="wdp-tab" onclick="switchWalletTab(this, 'wdp-ledger-${userId}')"> |
| <i class="fa-solid fa-list-ul"></i> |
| All Activity |
| <span class="wdp-tab-badge">${allActivities.length}</span> |
| </button> |
| </div> |
| |
| <div class="wdp-content active" id="wdp-topups-${userId}">${topupsHtml}</div> |
| <div class="wdp-content" id="wdp-prints-${userId}" style="display:none;">${printsHtml}</div> |
| <div class="wdp-content" id="wdp-downloads-${userId}" style="display:none;">${downloadsHtml}</div> |
| <div class="wdp-content" id="wdp-ledger-${userId}" style="display:none;">${activityHtml}</div> |
| </div>`; |
|
|
| card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); |
|
|
| } catch (e) { |
| expandedArea.innerHTML = '<div class="error-state"><i class="fa-solid fa-triangle-exclamation"></i> Network error loading details.</div>'; |
| } |
| } |
|
|
| function switchWalletTab(btn, targetId) { |
| const panel = btn.closest('.wallet-detail-panel'); |
| panel.querySelectorAll('.wdp-tab').forEach(t => t.classList.remove('active')); |
| panel.querySelectorAll('.wdp-content').forEach(c => c.style.display = 'none'); |
| btn.classList.add('active'); |
| const target = document.getElementById(targetId); |
| if (target) target.style.display = 'block'; |
| } |
|
|
| function updateWalletOverview(users, transactions) { |
| const totalUsersEl = document.getElementById('wallet-total-users'); |
| const totalBalanceEl = document.getElementById('wallet-total-balance'); |
| const recentActivityEl = document.getElementById('wallet-recent-activity'); |
| const lowBalanceEl = document.getElementById('wallet-low-balance'); |
|
|
| const totalUsers = users.length; |
| const totalBalance = users.reduce((sum, user) => sum + (parseFloat(user.balance) || 0), 0); |
| const lowBalanceCount = users.filter(user => (parseFloat(user.balance) || 0) < 100).length; |
| const dayAgo = Date.now() - (24 * 60 * 60 * 1000); |
| const recentCount = transactions.filter(tx => new Date(tx.date).getTime() >= dayAgo).length; |
|
|
| if (totalUsersEl) totalUsersEl.innerText = String(totalUsers); |
| if (totalBalanceEl) totalBalanceEl.innerText = formatInr(totalBalance); |
| if (recentActivityEl) recentActivityEl.innerText = String(recentCount); |
| if (lowBalanceEl) lowBalanceEl.innerText = String(lowBalanceCount); |
| } |
|
|
| function formatInr(value) { |
| const amount = Number(value) || 0; |
| return `₹${amount.toLocaleString('en-IN', { maximumFractionDigits: 2 })}`; |
| } |
|
|
| function escapeHtml(value) { |
| return String(value) |
| .replaceAll('&', '&') |
| .replaceAll('<', '<') |
| .replaceAll('>', '>') |
| .replaceAll('"', '"') |
| .replaceAll("'", '''); |
| } |
|
|
| function showWalletAdjustModal(userId, userName, currentBalance) { |
| if (typeof openWalletManageModal === 'function') { |
| openWalletManageModal(userId, userName, currentBalance); |
| } |
| } |
|
|
| function openWalletManageModal(userId, userName, currentBalance) { |
| activeWalletUser = userId; |
| const modal = document.getElementById('wallet-manage-modal'); |
| const userEl = document.getElementById('wallet-manage-user'); |
| const balanceEl = document.getElementById('wallet-manage-current-balance'); |
| const amountInput = document.getElementById('wallet-manage-amount'); |
| const reasonInput = document.getElementById('wallet-manage-reason'); |
| const feedback = document.getElementById('wallet-manage-feedback'); |
|
|
| if (!modal) return; |
|
|
| if (userEl) userEl.innerText = `Managing: ${userName}`; |
| if (balanceEl) balanceEl.innerText = `Current Balance: ${formatInr(currentBalance)}`; |
| if (amountInput) amountInput.value = ''; |
| if (reasonInput) reasonInput.value = ''; |
| if (feedback) feedback.innerText = ''; |
|
|
| modal.style.display = 'flex'; |
| } |
|
|
| function closeWalletManageModal() { |
| activeWalletUser = null; |
| const modal = document.getElementById('wallet-manage-modal'); |
| if (modal) modal.style.display = 'none'; |
| } |
|
|
| async function submitWalletManage() { |
| if (!activeWalletUser) return; |
|
|
| const action = document.getElementById('wallet-manage-action').value; |
| const amount = parseFloat(document.getElementById('wallet-manage-amount').value); |
| const reason = document.getElementById('wallet-manage-reason').value.trim(); |
| const feedback = document.getElementById('wallet-manage-feedback'); |
|
|
| if (!amount || amount <= 0) { |
| if (feedback) feedback.innerText = 'Please enter a valid amount greater than 0.'; |
| return; |
| } |
| if (!reason) { |
| if (feedback) feedback.innerText = 'Please provide a reason for this adjustment.'; |
| return; |
| } |
|
|
| if (feedback) feedback.innerText = 'Processing request...'; |
|
|
| try { |
| const res = await fetch(`/api/admin/user/${encodeURIComponent(activeWalletUser)}/wallet`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ action, amount, reason }) |
| }); |
| const data = await res.json(); |
|
|
| if (res.ok) { |
| closeWalletManageModal(); |
| if (typeof showToast === 'function') { |
| showToast('success', 'Wallet Updated', `Successfully ${action}ed ${formatInr(amount)}`); |
| } |
| loadUserWallets(); |
| } else { |
| if (feedback) feedback.innerText = data.error || 'Failed to update wallet.'; |
| } |
| } catch (e) { |
| if (feedback) feedback.innerText = 'Network error occurred.'; |
| } |
| } |
|
|
| |
| document.addEventListener('DOMContentLoaded', () => { |
| const walletSearch = document.getElementById('wallet-search'); |
| const walletBalanceFilter = document.getElementById('wallet-balance-filter'); |
| const walletSort = document.getElementById('wallet-sort'); |
|
|
| if (walletSearch) walletSearch.addEventListener('input', applyWalletFilters); |
| if (walletBalanceFilter) walletBalanceFilter.addEventListener('change', applyWalletFilters); |
| if (walletSort) walletSort.addEventListener('change', applyWalletFilters); |
| }); |