{% extends "base.twig" %}
{% block title %}{{ t('ui.admin.economy.page_title', {}, locale, ['common']) }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{{ t('ui.admin.economy.heading', {}, locale, ['common']) }}</h1>
<div class="d-flex align-items-center gap-2">
<label class="me-1 mb-0 text-muted small" for="period-select">{{ t('ui.admin.economy.period', {}, locale, ['common']) }}</label>
<select id="period-select" class="form-select form-select-sm" style="width:auto">
<option value="7d">{{ t('ui.admin.economy.period_7d', {}, locale, ['common']) }}</option>
<option value="30d" selected>{{ t('ui.admin.economy.period_30d', {}, locale, ['common']) }}</option>
<option value="90d">{{ t('ui.admin.economy.period_90d', {}, locale, ['common']) }}</option>
<option value="all">{{ t('ui.admin.economy.period_all', {}, locale, ['common']) }}</option>
</select>
<a href="/admin" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>{{ t('ui.admin.economy.dashboard', {}, locale, ['common']) }}
</a>
</div>
</div>
<div id="economy-alert"></div>
<div id="loading-indicator" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{{ t('ui.common.loading', {}, locale, ['common']) }}</span>
</div>
<p class="mt-2 text-muted">{{ t('ui.admin.economy.loading_statistics', {}, locale, ['common']) }}</p>
</div>
<div id="economy-content" style="display:none">
<div class="row mb-4" id="summary-cards"></div>
<div class="row mb-4">
<div class="col-lg-6 mb-4">
<div class="card shadow-sm h-100">
<div class="card-header"><strong>{{ t('ui.admin.economy.period_snapshot', {}, locale, ['common']) }}</strong></div>
<div class="card-body">
<div class="row gy-3">
<div class="col-sm-6">
<div class="text-muted small text-uppercase">{{ t('ui.admin.economy.transactions', {}, locale, ['common']) }}</div>
<div class="h4 mb-0" id="snapshot-transactions">0</div>
</div>
<div class="col-sm-6">
<div class="text-muted small text-uppercase">{{ t('ui.admin.economy.active_users', {}, locale, ['common']) }}</div>
<div class="h4 mb-0" id="snapshot-users">0</div>
</div>
<div class="col-sm-6">
<div class="text-muted small text-uppercase">{{ t('ui.admin.economy.credits_earned', {}, locale, ['common']) }}</div>
<div class="h4 mb-0 text-success" id="snapshot-earned">0</div>
</div>
<div class="col-sm-6">
<div class="text-muted small text-uppercase">{{ t('ui.admin.economy.credits_spent', {}, locale, ['common']) }}</div>
<div class="h4 mb-0 text-danger" id="snapshot-spent">0</div>
</div>
<div class="col-12">
<div class="text-muted small text-uppercase">{{ t('ui.admin.economy.net_flow', {}, locale, ['common']) }}</div>
<div class="h4 mb-0" id="snapshot-net">0</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card shadow-sm h-100">
<div class="card-header"><strong>{{ t('ui.admin.economy.current_distribution', {}, locale, ['common']) }}</strong></div>
<div class="card-body">
<div class="row gy-3">
<div class="col-sm-6">
<div class="text-muted small text-uppercase">{{ t('ui.admin.economy.funded_users', {}, locale, ['common']) }}</div>
<div class="h4 mb-0" id="distribution-funded">0</div>
</div>
<div class="col-sm-6">
<div class="text-muted small text-uppercase">{{ t('ui.admin.economy.average_balance', {}, locale, ['common']) }}</div>
<div class="h4 mb-0" id="distribution-average">0</div>
</div>
<div class="col-sm-6">
<div class="text-muted small text-uppercase">{{ t('ui.admin.economy.median_balance', {}, locale, ['common']) }}</div>
<div class="h4 mb-0" id="distribution-median">0</div>
</div>
<div class="col-sm-6">
<div class="text-muted small text-uppercase">{{ t('ui.admin.economy.largest_balance', {}, locale, ['common']) }}</div>
<div class="h4 mb-0" id="distribution-largest">0</div>
</div>
<div class="col-12">
<div class="text-muted small text-uppercase">{{ t('ui.admin.economy.richest_user', {}, locale, ['common']) }}</div>
<div class="h5 mb-0" id="distribution-richest-user">{{ t('ui.common.not_applicable', {}, locale, ['common']) }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-xl-4 mb-4">
<div class="card shadow-sm h-100">
<div class="card-header"><strong>{{ t('ui.admin.economy.transaction_types', {}, locale, ['common']) }}</strong></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th>{{ t('ui.admin.economy.type', {}, locale, ['common']) }}</th>
<th class="text-end">{{ t('ui.admin.economy.count', {}, locale, ['common']) }}</th>
<th class="text-end">{{ t('ui.admin.economy.net', {}, locale, ['common']) }}</th>
</tr>
</thead>
<tbody id="type-rows"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-xl-4 mb-4">
<div class="card shadow-sm h-100">
<div class="card-header"><strong>{{ t('ui.admin.economy.top_earners', {}, locale, ['common']) }}</strong></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th>{{ t('ui.admin.economy.user', {}, locale, ['common']) }}</th>
<th class="text-end">{{ t('ui.admin.economy.earned', {}, locale, ['common']) }}</th>
<th class="text-end">{{ t('ui.admin.economy.tx_short', {}, locale, ['common']) }}</th>
</tr>
</thead>
<tbody id="earner-rows"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-xl-4 mb-4">
<div class="card shadow-sm h-100">
<div class="card-header"><strong>{{ t('ui.admin.economy.top_spenders', {}, locale, ['common']) }}</strong></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th>{{ t('ui.admin.economy.user', {}, locale, ['common']) }}</th>
<th class="text-end">{{ t('ui.admin.economy.spent', {}, locale, ['common']) }}</th>
<th class="text-end">{{ t('ui.admin.economy.tx_short', {}, locale, ['common']) }}</th>
</tr>
</thead>
<tbody id="spender-rows"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col-lg-6 mb-4">
<div class="card shadow-sm h-100">
<div class="card-header"><strong>{{ t('ui.admin.economy.richest_accounts', {}, locale, ['common']) }}</strong></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th>{{ t('ui.admin.economy.user', {}, locale, ['common']) }}</th>
<th class="text-end">{{ t('ui.admin.economy.balance', {}, locale, ['common']) }}</th>
</tr>
</thead>
<tbody id="richest-rows"></tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-6 mb-4">
<div class="card shadow-sm h-100">
<div class="card-header"><strong>{{ t('ui.admin.economy.recent_transactions', {}, locale, ['common']) }}</strong></div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th>{{ t('ui.admin.economy.user', {}, locale, ['common']) }}</th>
<th>{{ t('ui.common.description', {}, locale, ['common']) }}</th>
<th class="text-end">{{ t('ui.admin.economy.amount', {}, locale, ['common']) }}</th>
</tr>
</thead>
<tbody id="recent-rows"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
(function() {
function uiT(key, fallback, params = {}) {
if (window.t) {
return window.t(key, params, fallback);
}
return fallback;
}
const periodSelect = document.getElementById('period-select');
const loadingIndicator = document.getElementById('loading-indicator');
const economyContent = document.getElementById('economy-content');
const economyAlert = document.getElementById('economy-alert');
let creditsSymbol = '$';
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function formatNumber(value) {
return new Intl.NumberFormat().format(Number(value || 0));
}
function formatAmount(value) {
return `${creditsSymbol}${formatNumber(value)}`;
}
function formatSignedAmount(value) {
const numericValue = Number(value || 0);
const prefix = numericValue > 0 ? '+' : '';
return `${prefix}${creditsSymbol}${formatNumber(numericValue)}`;
}
function formatUser(user) {
if (!user) {
return uiT('ui.common.not_applicable', 'n/a');
}
const username = user.username ? escapeHtml(user.username) : uiT('ui.common.unknown', 'unknown');
const profileUrl = user.username ? `/profile/${encodeURIComponent(user.username)}` : null;
const usernameHtml = profileUrl
? `<a href="${profileUrl}" class="text-decoration-none">${username}</a>`
: username;
const realName = user.real_name ? ` <span class="text-muted">(${escapeHtml(user.real_name)})</span>` : '';
return `${usernameHtml}${realName}`;
}
function setAlert(message, type) {
economyAlert.innerHTML = `<div class="alert alert-${type}">${escapeHtml(message)}</div>`;
}
function clearAlert() {
economyAlert.innerHTML = '';
}
function renderSummaryCards(data) {
const summary = data.summary || {};
const cards = [
{ label: uiT('ui.admin.economy.credits_in_circulation', 'Credits In Circulation'), value: formatAmount(summary.total_credits), accent: 'success', icon: 'fa-coins' },
{ label: uiT('ui.admin.economy.funded_wallets', 'Funded Wallets'), value: formatNumber(summary.funded_users), accent: 'primary', icon: 'fa-wallet' },
{ label: uiT('ui.admin.economy.average_balance', 'Average Balance'), value: formatAmount(Math.round(summary.avg_balance || 0)), accent: 'info', icon: 'fa-chart-line' },
{ label: uiT('ui.admin.economy.median_balance', 'Median Balance'), value: formatAmount(Math.round(summary.median_balance || 0)), accent: 'warning', icon: 'fa-scale-balanced' }
];
document.getElementById('summary-cards').innerHTML = cards.map(card => `
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-start border-4 border-${card.accent} shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="text-muted small text-uppercase mb-1">${escapeHtml(card.label)}</div>
<div class="h4 mb-0">${escapeHtml(card.value)}</div>
</div>
<i class="fas ${card.icon} fa-2x text-muted opacity-50"></i>
</div>
</div>
</div>
</div>
`).join('');
}
function renderRows(tbodyId, rows, renderFn, emptyMessage, colspan) {
const tbody = document.getElementById(tbodyId);
if (!tbody) {
return;
}
if (!rows || rows.length === 0) {
tbody.innerHTML = `<tr><td colspan="${colspan}" class="text-center text-muted py-3">${escapeHtml(emptyMessage)}</td></tr>`;
return;
}
tbody.innerHTML = rows.map(renderFn).join('');
}
function renderData(data) {
creditsSymbol = data.credits_symbol || '$';
clearAlert();
if (!data.credits_enabled) {
setAlert(uiT('ui.admin.economy.credits_disabled_notice', 'Credits are currently disabled. Historical balances and ledger data are still shown.'), 'warning');
}
renderSummaryCards(data);
const summary = data.summary || {};
const periodSummary = data.period_summary || {};
const richestUser = data.richest_user || null;
document.getElementById('snapshot-transactions').textContent = formatNumber(periodSummary.transaction_count);
document.getElementById('snapshot-users').textContent = formatNumber(periodSummary.active_users);
document.getElementById('snapshot-earned').textContent = formatAmount(periodSummary.credits_earned);
document.getElementById('snapshot-spent').textContent = formatAmount(periodSummary.credits_spent);
document.getElementById('snapshot-net').textContent = formatSignedAmount(periodSummary.net_flow);
document.getElementById('snapshot-net').className = `h4 mb-0 ${periodSummary.net_flow > 0 ? 'text-success' : (periodSummary.net_flow < 0 ? 'text-danger' : 'text-muted')}`;
document.getElementById('distribution-funded').textContent = `${formatNumber(summary.funded_users)} / ${formatNumber(summary.total_users)}`;
document.getElementById('distribution-average').textContent = formatAmount(Math.round(summary.avg_balance || 0));
document.getElementById('distribution-median').textContent = formatAmount(Math.round(summary.median_balance || 0));
document.getElementById('distribution-largest').textContent = formatAmount(summary.max_balance);
document.getElementById('distribution-richest-user').innerHTML = richestUser
? `${formatUser(richestUser)} <span class="text-success ms-1">${escapeHtml(formatAmount(richestUser.credit_balance))}</span>`
: uiT('ui.common.not_applicable', 'n/a');
renderRows(
'type-rows',
data.transaction_types,
row => `
<tr>
<td>
<div class="fw-semibold">${escapeHtml(row.transaction_type)}</div>
<div class="small text-muted">${formatNumber(row.unique_users)} ${uiT('ui.admin.economy.users', 'users')}</div>
</td>
<td class="text-end">${formatNumber(row.transaction_count)}</td>
<td class="text-end ${row.net_amount > 0 ? 'text-success' : (row.net_amount < 0 ? 'text-danger' : 'text-muted')}">${escapeHtml(formatSignedAmount(row.net_amount))}</td>
</tr>
`,
uiT('ui.admin.economy.no_transactions_period', 'No transactions for this period'),
3
);
renderRows(
'earner-rows',
data.top_earners,
row => `
<tr>
<td>${formatUser(row)}</td>
<td class="text-end text-success">${escapeHtml(formatAmount(row.total_earned))}</td>
<td class="text-end">${formatNumber(row.transaction_count)}</td>
</tr>
`,
uiT('ui.admin.economy.no_earning_activity_period', 'No earning activity for this period'),
3
);
renderRows(
'spender-rows',
data.top_spenders,
row => `
<tr>
<td>${formatUser(row)}</td>
<td class="text-end text-danger">${escapeHtml(formatAmount(row.total_spent))}</td>
<td class="text-end">${formatNumber(row.transaction_count)}</td>
</tr>
`,
uiT('ui.admin.economy.no_spending_activity_period', 'No spending activity for this period'),
3
);
renderRows(
'richest-rows',
data.richest_users,
row => `
<tr>
<td>${formatUser(row)}</td>
<td class="text-end fw-semibold">${escapeHtml(formatAmount(row.credit_balance))}</td>
</tr>
`,
uiT('ui.admin.economy.no_funded_users_yet', 'No funded users yet'),
2
);
renderRows(
'recent-rows',
data.recent_transactions,
row => `
<tr>
<td>
<div>${formatUser(row)}</div>
<div class="small text-muted">${escapeHtml(row.transaction_type)}</div>
</td>
<td>
<div>${escapeHtml(row.description || uiT('ui.admin.economy.no_description', 'No description'))}</div>
<div class="small text-muted">${escapeHtml(new Date(row.created_at).toLocaleString())}</div>
</td>
<td class="text-end ${row.amount > 0 ? 'text-success' : 'text-danger'}">
<div>${escapeHtml(formatSignedAmount(row.amount))}</div>
<div class="small text-muted">${uiT('ui.admin.economy.bal_short', 'Bal')} ${escapeHtml(formatAmount(row.balance_after))}</div>
</td>
</tr>
`,
uiT('ui.admin.economy.no_recent_transactions_period', 'No recent transactions in this period'),
3
);
}
function loadEconomy() {
loadingIndicator.style.display = '';
economyContent.style.display = 'none';
fetch(`/admin/api/economy?period=${encodeURIComponent(periodSelect.value)}`)
.then(response => {
if (!response.ok) {
throw new Error(uiT('ui.admin.economy.load_failed', 'Failed to load economy statistics'));
}
return response.json();
})
.then(data => {
renderData(data);
loadingIndicator.style.display = 'none';
economyContent.style.display = '';
})
.catch(error => {
loadingIndicator.style.display = 'none';
economyContent.style.display = 'none';
setAlert(error.message || uiT('ui.admin.economy.load_failed', 'Failed to load economy statistics'), 'danger');
});
}
periodSelect.addEventListener('change', loadEconomy);
loadI18nNamespaces(['common', 'errors']).then(function() { loadEconomy(); });
})();
</script>
{% endblock %}
|