diff --git a/frontend/src/admin/AdminPanel.svelte b/frontend/src/admin/AdminPanel.svelte index ab381fe..bd47e25 100644 --- a/frontend/src/admin/AdminPanel.svelte +++ b/frontend/src/admin/AdminPanel.svelte @@ -785,6 +785,7 @@ import { Input } from "$components/ui/index.js"; + import { + ArrowDown, + ArrowUp, + ChevronsUpDown, + DollarSign, + UsersRound, + } from "$components/ui/icons.js"; import { Label } from "$components/ui/primitives.js"; import { AdminBadge, @@ -15,6 +22,7 @@ export let at = (key) => key; export let fmtDateShort = (value) => value; + export let fmtMoney = (value) => value; export let panelStatusBadge = () => ({}); export let resolvedAvatarUrl = () => ""; export let userDisplayName = () => ""; @@ -49,16 +57,31 @@ { value: "panel_linked", label: at("filter_panel_linked", {}, "С панелью") }, ]; - const USERS_SORT_OPTIONS = [ - { value: "registered_desc", label: at("sort_registered_desc", {}, "Сначала новые") }, - { value: "registered_asc", label: at("sort_registered_asc", {}, "Сначала старые") }, - { value: "name_asc", label: at("sort_name_asc", {}, "Имя ↑") }, - { value: "name_desc", label: at("sort_name_desc", {}, "Имя ↓") }, - { value: "id_asc", label: at("sort_id_asc", {}, "ID ↑") }, - { value: "id_desc", label: at("sort_id_desc", {}, "ID ↓") }, - { value: "premium_ratio_asc", label: at("sort_premium_ratio_asc", {}, "Премиум % ↑") }, - { value: "premium_ratio_desc", label: at("sort_premium_ratio_desc", {}, "Премиум % ↓") }, - ]; + const SORT_COLUMNS = { + user: { asc: "name_asc", desc: "name_desc", defaultDirection: "asc" }, + premium: { asc: "premium_ratio_asc", desc: "premium_ratio_desc", defaultDirection: "desc" }, + paymentsTotal: { + asc: "payments_total_asc", + desc: "payments_total_desc", + defaultDirection: "desc", + }, + paymentsCount: { + asc: "payments_count_asc", + desc: "payments_count_desc", + defaultDirection: "desc", + }, + invited: { + asc: "invited_users_count_asc", + desc: "invited_users_count_desc", + defaultDirection: "desc", + }, + subscriptionExpires: { + asc: "subscription_expires_at_asc", + desc: "subscription_expires_at_desc", + defaultDirection: "asc", + }, + registration: { asc: "registered_asc", desc: "registered_desc", defaultDirection: "desc" }, + }; const USERS_PANEL_STATUS_OPTIONS = [ { value: "all", label: at("panel_status_all", {}, "Все статусы") }, @@ -94,12 +117,77 @@ return trafficOfLabel(pt.used_bytes, pt.limit_bytes); } - $: userTableHeaders = [ - at("user", {}, "Пользователь"), - at("premium_traffic_filter_label", {}, "Премиум трафик"), - at("status", {}, "Статус"), - at("users_col_registration", {}, "Регистрация"), - ]; + function userTableColumns() { + return [ + { key: "user", label: at("user", {}, "Пользователь"), sort: SORT_COLUMNS.user }, + { + key: "premium", + label: at("premium_traffic_filter_label", {}, "Премиум трафик"), + sort: SORT_COLUMNS.premium, + }, + { + key: "paymentsTotal", + label: at("users_col_payments_total", {}, "Сумма платежей"), + sort: SORT_COLUMNS.paymentsTotal, + }, + { + key: "paymentsCount", + label: at("users_col_payments_count", {}, "Платежи"), + sort: SORT_COLUMNS.paymentsCount, + }, + { + key: "invited", + label: at("users_col_invited", {}, "Приглашенные"), + sort: SORT_COLUMNS.invited, + }, + { key: "status", label: at("status", {}, "Статус") }, + { + key: "subscriptionExpires", + label: at("users_col_subscription_expires", {}, "Истекает"), + sort: SORT_COLUMNS.subscriptionExpires, + }, + { + key: "registration", + label: at("users_col_registration", {}, "Регистрация"), + sort: SORT_COLUMNS.registration, + }, + ]; + } + + function sortState(column) { + if (!column) return "none"; + if (usersSort === column.asc) return "ascending"; + if (usersSort === column.desc) return "descending"; + return "none"; + } + + function nextSortValue(column) { + const state = sortState(column); + const defaultValue = column[column.defaultDirection] || column.asc; + if (state === "none") return defaultValue; + if (usersSort === defaultValue) { + return column.defaultDirection === "asc" ? column.desc : column.asc; + } + return ""; + } + + function toggleUsersSort(column) { + usersStore.updateState({ usersSort: nextSortValue(column), usersPage: 0 }); + usersStore.loadUsers(); + } + + function sortTitle(column) { + const state = sortState(column); + if (state === "ascending") return at("sort_ascending", {}, "По возрастанию"); + if (state === "descending") return at("sort_descending", {}, "По убыванию"); + return at("sort_off", {}, "Без сортировки"); + } + + function rowPaymentsTotal(user) { + return fmtMoney(user?.payments_total_amount ?? 0, user?.payments_currency || "RUB"); + } + + $: userTableHeaders = userTableColumns().map((column) => column.label); onMount(() => { usersStore.loadUsers(); @@ -171,20 +259,6 @@ /> - - {at("sort", {}, "Сортировка")} - { - usersStore.updateState({ usersSort: value, usersPage: 0 }); - usersStore.loadUsers(); - }} - /> - -
{at("total", {}, "Всего")} {usersTotal} @@ -192,12 +266,12 @@
-
+
{#if usersLoading} {:else if !users.length} - {at("user", {}, "Пользователь")} - {at("premium_traffic_filter_label", {}, "Премиум трафик")} - {at("status", {}, "Статус")} - {at("users_col_registration", {}, "Регистрация")} + {#each userTableColumns() as column (column.key)} + + {#if column.sort} + + {:else} + {column.label} + {/if} + + {/each} @@ -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": "Стр.",