@@ -264,9 +363,41 @@
>
{/if}
+ |
+
+ {rowPaymentsTotal(user)}
+
+ |
+
+
+
+ {user.payments_count ?? 0}
+
+ |
+
+
+
+ {user.invited_users_count ?? 0}
+
+ |
{badge.label}
|
+
+ {fmtDateShort(user.subscription_expires_at || user.panel_status_expired_at)}
+ |
|
diff --git a/frontend/src/lib/admin/stores/usersStore.js b/frontend/src/lib/admin/stores/usersStore.js
index 082081b..8c8b1e6 100644
--- a/frontend/src/lib/admin/stores/usersStore.js
+++ b/frontend/src/lib/admin/stores/usersStore.js
@@ -13,7 +13,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
usersFilter: "all",
usersPanelStatus: "all",
usersPremiumTraffic: "all",
- usersSort: "registered_desc",
+ usersSort: "",
usersLoading: false,
openedUser: null,
@@ -103,7 +103,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
if (s.usersPremiumTraffic && s.usersPremiumTraffic !== "all") {
params.set("premium_traffic", s.usersPremiumTraffic);
}
- if (s.usersSort && s.usersSort !== "registered_desc") params.set("sort", s.usersSort);
+ if (s.usersSort) params.set("sort", s.usersSort);
const data = await api(`/admin/users?${params.toString()}`);
if (data?.ok) {
state.update((st) => ({
diff --git a/frontend/src/lib/components/ui/icons.js b/frontend/src/lib/components/ui/icons.js
index 3162fe9..605da1a 100644
--- a/frontend/src/lib/components/ui/icons.js
+++ b/frontend/src/lib/components/ui/icons.js
@@ -1,7 +1,9 @@
export {
Activity,
ArrowLeft,
+ ArrowDown,
ArrowRight,
+ ArrowUp,
Bitcoin,
CalendarDays,
Check,
@@ -19,6 +21,7 @@ export {
CreditCard,
Crown,
Database,
+ DollarSign,
Download,
ExternalLink,
Eye,
diff --git a/frontend/src/lib/webapp/mockApi.js b/frontend/src/lib/webapp/mockApi.js
index 8f0c816..a2a279f 100644
--- a/frontend/src/lib/webapp/mockApi.js
+++ b/frontend/src/lib/webapp/mockApi.js
@@ -401,6 +401,46 @@ function userName(user) {
);
}
+function demoUserSeed(user) {
+ return Math.abs(Number(user?.user_id || user?.telegram_id || 0)) || 1;
+}
+
+function demoFutureIso(user, offsetDays = 30) {
+ const seed = demoUserSeed(user);
+ const base = Date.parse(user?.registration_date || "") || Date.UTC(2026, 0, 1);
+ return new Date(base + (offsetDays + (seed % 180)) * 86400000).toISOString();
+}
+
+function withDemoAdminUserMetrics(user) {
+ const seed = demoUserSeed(user);
+ const paymentsCount =
+ user.payments_count ?? (user.panel_status === "bot_only" ? 0 : Math.max(1, seed % 9));
+ const paymentsTotal = user.payments_total_amount ?? paymentsCount * (290 + (seed % 11) * 75);
+ const invitedCount = user.invited_users_count ?? (seed % 5 === 0 ? seed % 8 : seed % 3);
+ const subscriptionExpiresAt =
+ user.subscription_expires_at ??
+ user.panel_status_expired_at ??
+ (user.panel_status === "active" ? demoFutureIso(user, 45) : null);
+
+ return {
+ ...user,
+ payments_total_amount: paymentsTotal,
+ payments_count: paymentsCount,
+ payments_currency: user.payments_currency || "RUB",
+ invited_users_count: invitedCount,
+ subscription_expires_at: subscriptionExpiresAt,
+ };
+}
+
+function compareNullableDate(a, b, direction = "asc") {
+ const at = stringDate(a);
+ const bt = stringDate(b);
+ if (!at && !bt) return 0;
+ if (!at) return 1;
+ if (!bt) return -1;
+ return direction === "desc" ? bt - at : at - bt;
+}
+
function withDemoAvatars(users, size = 96) {
return (users || []).map((user) => withDemoAvatar(user, size));
}
@@ -436,7 +476,7 @@ function withDemoAvatarTickets(tickets, size = 96) {
}
function filterDemoUsers(params) {
- let out = [...(DEMO_DATASET.adminUsers || [])];
+ let out = (DEMO_DATASET.adminUsers || []).map(withDemoAdminUserMetrics);
const q = (params.get("q") || params.get("search") || "").trim().toLowerCase();
if (q) {
out = out.filter((user) =>
@@ -483,6 +523,22 @@ function filterDemoUsers(params) {
return Number(a.premium_traffic?.percent ?? -1) - Number(b.premium_traffic?.percent ?? -1);
if (sort === "premium_ratio_desc")
return Number(b.premium_traffic?.percent ?? -1) - Number(a.premium_traffic?.percent ?? -1);
+ if (sort === "payments_total_asc")
+ return Number(a.payments_total_amount || 0) - Number(b.payments_total_amount || 0);
+ if (sort === "payments_total_desc")
+ return Number(b.payments_total_amount || 0) - Number(a.payments_total_amount || 0);
+ if (sort === "payments_count_asc")
+ return Number(a.payments_count || 0) - Number(b.payments_count || 0);
+ if (sort === "payments_count_desc")
+ return Number(b.payments_count || 0) - Number(a.payments_count || 0);
+ if (sort === "invited_users_count_asc")
+ return Number(a.invited_users_count || 0) - Number(b.invited_users_count || 0);
+ if (sort === "invited_users_count_desc")
+ return Number(b.invited_users_count || 0) - Number(a.invited_users_count || 0);
+ if (sort === "subscription_expires_at_asc")
+ return compareNullableDate(a.subscription_expires_at, b.subscription_expires_at, "asc");
+ if (sort === "subscription_expires_at_desc")
+ return compareNullableDate(a.subscription_expires_at, b.subscription_expires_at, "desc");
return stringDate(b.registration_date) - stringDate(a.registration_date);
});
@@ -976,56 +1032,61 @@ export async function mockApi(path, options = {}, context = {}) {
normalizeLangCode,
});
if (demoResponse !== undefined) return demoResponse;
- const adminUsers = withDemoAvatars([
- {
- user_id: 100200300,
- telegram_id: 100200300,
- username: "anna_ops",
- first_name: "Анна",
- last_name: "Смирнова",
- email: "anna@example.com",
- telegram_photo_url: "",
- registration_date: "2026-04-24T10:20:00Z",
- is_banned: false,
- premium_traffic: {
- state: "good",
- unlimited: false,
- used_bytes: 4 * 1073741824,
- limit_bytes: 25 * 1073741824,
- percent: 16,
+ const adminUsers = withDemoAvatars(
+ [
+ {
+ user_id: 100200300,
+ telegram_id: 100200300,
+ username: "anna_ops",
+ first_name: "Анна",
+ last_name: "Смирнова",
+ email: "anna@example.com",
+ telegram_photo_url: "",
+ registration_date: "2026-04-24T10:20:00Z",
+ is_banned: false,
+ premium_traffic: {
+ state: "good",
+ unlimited: false,
+ used_bytes: 4 * 1073741824,
+ limit_bytes: 25 * 1073741824,
+ percent: 16,
+ },
+ panel_status: "active",
},
- },
- {
- user_id: 100200301,
- telegram_id: 87543123,
- username: "client_pro",
- first_name: "Максим",
- last_name: "Котов",
- email: "",
- telegram_photo_url: "",
- registration_date: "2026-04-26T08:15:00Z",
- is_banned: false,
- premium_traffic: {
- state: "warn",
- unlimited: false,
- used_bytes: 22 * 1073741824,
- limit_bytes: 25 * 1073741824,
- percent: 88,
+ {
+ user_id: 100200301,
+ telegram_id: 87543123,
+ username: "client_pro",
+ first_name: "Максим",
+ last_name: "Котов",
+ email: "",
+ telegram_photo_url: "",
+ registration_date: "2026-04-26T08:15:00Z",
+ is_banned: false,
+ premium_traffic: {
+ state: "warn",
+ unlimited: false,
+ used_bytes: 22 * 1073741824,
+ limit_bytes: 25 * 1073741824,
+ percent: 88,
+ },
+ panel_status: "active",
},
- },
- {
- user_id: 100200302,
- telegram_id: 88440011,
- username: "",
- first_name: "Daria",
- last_name: "",
- email: "daria@example.com",
- telegram_photo_url: "",
- registration_date: "2026-04-29T16:45:00Z",
- is_banned: true,
- premium_traffic: { state: "none" },
- },
- ]);
+ {
+ user_id: 100200302,
+ telegram_id: 88440011,
+ username: "",
+ first_name: "Daria",
+ last_name: "",
+ email: "daria@example.com",
+ telegram_photo_url: "",
+ registration_date: "2026-04-29T16:45:00Z",
+ is_banned: true,
+ premium_traffic: { state: "none" },
+ panel_status: "bot_only",
+ },
+ ].map(withDemoAdminUserMetrics)
+ );
const supportTickets = [
{
ticket_id: 42,
diff --git a/locales/en.json b/locales/en.json
index b37f5f3..3c1d8eb 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -1024,6 +1024,9 @@
"admin_sort_id_desc": "ID ↓",
"admin_sort_premium_ratio_asc": "Premium usage % ↑",
"admin_sort_premium_ratio_desc": "Premium usage % ↓",
+ "admin_sort_ascending": "Sorted ascending",
+ "admin_sort_descending": "Sorted descending",
+ "admin_sort_off": "Not sorted",
"admin_premium_traffic_filter_label": "Premium traffic",
"admin_premium_traffic_filter_all": "All",
"admin_premium_traffic_filter_none": "No tariff limit",
@@ -1043,6 +1046,10 @@
"admin_sort": "Sort",
"admin_total": "Total",
"admin_users_empty": "No users found",
+ "admin_users_col_payments_total": "Paid total",
+ "admin_users_col_payments_count": "Payments",
+ "admin_users_col_invited": "Invited",
+ "admin_users_col_subscription_expires": "Expires",
"admin_users_col_registration": "Registered",
"admin_page": "Page",
"admin_page_short": "Page",
diff --git a/locales/ru.json b/locales/ru.json
index cdc1f9e..a32bd98 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -1024,6 +1024,9 @@
"admin_sort_id_desc": "ID ↓",
"admin_sort_premium_ratio_asc": "Премиум % ↑",
"admin_sort_premium_ratio_desc": "Премиум % ↓",
+ "admin_sort_ascending": "Сортировка по возрастанию",
+ "admin_sort_descending": "Сортировка по убыванию",
+ "admin_sort_off": "Без сортировки",
"admin_premium_traffic_filter_label": "Премиум трафик",
"admin_premium_traffic_filter_all": "Все",
"admin_premium_traffic_filter_none": "Без лимита в тарифе",
@@ -1043,6 +1046,10 @@
"admin_sort": "Сортировка",
"admin_total": "Всего",
"admin_users_empty": "Никого не найдено",
+ "admin_users_col_payments_total": "Сумма платежей",
+ "admin_users_col_payments_count": "Платежи",
+ "admin_users_col_invited": "Приглашенные",
+ "admin_users_col_subscription_expires": "Истекает",
"admin_users_col_registration": "Регистрация",
"admin_page": "Страница",
"admin_page_short": "Стр.",
From 8366a7357557e09ee7e67be51ccf6cdbf85080a0 Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Mon, 1 Jun 2026 15:58:28 +0300
Subject: [PATCH 4/4] style: format admin users api
---
backend/bot/app/web/admin_api_impl/users.py | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py
index 98703ff..f578cbd 100644
--- a/backend/bot/app/web/admin_api_impl/users.py
+++ b/backend/bot/app/web/admin_api_impl/users.py
@@ -128,9 +128,7 @@ async def _load_admin_users_list_payload_uncached(
active_subs = await _bulk_active_subscriptions_for_users(
session, [u.user_id for u in users]
)
- payment_summaries = await _bulk_user_payment_summaries(
- session, [u.user_id for u in users]
- )
+ payment_summaries = await _bulk_user_payment_summaries(session, [u.user_id for u in users])
referral_counts = await _bulk_user_referral_counts(session, [u.user_id for u in users])
serialized = []