feat: add sortable admin user columns
This commit is contained in:
@@ -785,6 +785,7 @@
|
|||||||
<UsersSection
|
<UsersSection
|
||||||
{at}
|
{at}
|
||||||
{fmtDateShort}
|
{fmtDateShort}
|
||||||
|
{fmtMoney}
|
||||||
{panelStatusBadge}
|
{panelStatusBadge}
|
||||||
{resolvedAvatarUrl}
|
{resolvedAvatarUrl}
|
||||||
{userDisplayName}
|
{userDisplayName}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import { Input } from "$components/ui/index.js";
|
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 { Label } from "$components/ui/primitives.js";
|
||||||
import {
|
import {
|
||||||
AdminBadge,
|
AdminBadge,
|
||||||
@@ -15,6 +22,7 @@
|
|||||||
|
|
||||||
export let at = (key) => key;
|
export let at = (key) => key;
|
||||||
export let fmtDateShort = (value) => value;
|
export let fmtDateShort = (value) => value;
|
||||||
|
export let fmtMoney = (value) => value;
|
||||||
export let panelStatusBadge = () => ({});
|
export let panelStatusBadge = () => ({});
|
||||||
export let resolvedAvatarUrl = () => "";
|
export let resolvedAvatarUrl = () => "";
|
||||||
export let userDisplayName = () => "";
|
export let userDisplayName = () => "";
|
||||||
@@ -49,16 +57,31 @@
|
|||||||
{ value: "panel_linked", label: at("filter_panel_linked", {}, "С панелью") },
|
{ value: "panel_linked", label: at("filter_panel_linked", {}, "С панелью") },
|
||||||
];
|
];
|
||||||
|
|
||||||
const USERS_SORT_OPTIONS = [
|
const SORT_COLUMNS = {
|
||||||
{ value: "registered_desc", label: at("sort_registered_desc", {}, "Сначала новые") },
|
user: { asc: "name_asc", desc: "name_desc", defaultDirection: "asc" },
|
||||||
{ value: "registered_asc", label: at("sort_registered_asc", {}, "Сначала старые") },
|
premium: { asc: "premium_ratio_asc", desc: "premium_ratio_desc", defaultDirection: "desc" },
|
||||||
{ value: "name_asc", label: at("sort_name_asc", {}, "Имя ↑") },
|
paymentsTotal: {
|
||||||
{ value: "name_desc", label: at("sort_name_desc", {}, "Имя ↓") },
|
asc: "payments_total_asc",
|
||||||
{ value: "id_asc", label: at("sort_id_asc", {}, "ID ↑") },
|
desc: "payments_total_desc",
|
||||||
{ value: "id_desc", label: at("sort_id_desc", {}, "ID ↓") },
|
defaultDirection: "desc",
|
||||||
{ value: "premium_ratio_asc", label: at("sort_premium_ratio_asc", {}, "Премиум % ↑") },
|
},
|
||||||
{ value: "premium_ratio_desc", label: at("sort_premium_ratio_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 = [
|
const USERS_PANEL_STATUS_OPTIONS = [
|
||||||
{ value: "all", label: at("panel_status_all", {}, "Все статусы") },
|
{ value: "all", label: at("panel_status_all", {}, "Все статусы") },
|
||||||
@@ -94,12 +117,77 @@
|
|||||||
return trafficOfLabel(pt.used_bytes, pt.limit_bytes);
|
return trafficOfLabel(pt.used_bytes, pt.limit_bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
$: userTableHeaders = [
|
function userTableColumns() {
|
||||||
at("user", {}, "Пользователь"),
|
return [
|
||||||
at("premium_traffic_filter_label", {}, "Премиум трафик"),
|
{ key: "user", label: at("user", {}, "Пользователь"), sort: SORT_COLUMNS.user },
|
||||||
at("status", {}, "Статус"),
|
{
|
||||||
at("users_col_registration", {}, "Регистрация"),
|
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(() => {
|
onMount(() => {
|
||||||
usersStore.loadUsers();
|
usersStore.loadUsers();
|
||||||
@@ -171,20 +259,6 @@
|
|||||||
/>
|
/>
|
||||||
</Label.Root>
|
</Label.Root>
|
||||||
|
|
||||||
<Label.Root class="admin-toolbar-field">
|
|
||||||
<span class="admin-toolbar-field-label">{at("sort", {}, "Сортировка")}</span>
|
|
||||||
<AdminSelect
|
|
||||||
value={usersSort}
|
|
||||||
items={USERS_SORT_OPTIONS}
|
|
||||||
class="admin-toolbar-select"
|
|
||||||
ariaLabel={at("sort", {}, "Сортировка")}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
usersStore.updateState({ usersSort: value, usersPage: 0 });
|
|
||||||
usersStore.loadUsers();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Label.Root>
|
|
||||||
|
|
||||||
<div class="admin-toolbar-summary">
|
<div class="admin-toolbar-summary">
|
||||||
<span class="admin-toolbar-field-label">{at("total", {}, "Всего")}</span>
|
<span class="admin-toolbar-field-label">{at("total", {}, "Всего")}</span>
|
||||||
<strong>{usersTotal}</strong>
|
<strong>{usersTotal}</strong>
|
||||||
@@ -192,12 +266,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-table-wrap admin-users-table-wrap">
|
<div class="admin-users-table-wrap">
|
||||||
{#if usersLoading}
|
{#if usersLoading}
|
||||||
<AdminTableSkeleton
|
<AdminTableSkeleton
|
||||||
headers={userTableHeaders}
|
headers={userTableHeaders}
|
||||||
rows={USERS_PAGE_SIZE}
|
rows={USERS_PAGE_SIZE}
|
||||||
widths={["minmax(220px, 42%)", "minmax(140px, 28%)", "108px", "112px"]}
|
widths={["220px", "128px", "112px", "78px", "88px", "96px", "112px", "112px"]}
|
||||||
/>
|
/>
|
||||||
{:else if !users.length}
|
{:else if !users.length}
|
||||||
<AdminEmptyState tone="card"
|
<AdminEmptyState tone="card"
|
||||||
@@ -208,10 +282,35 @@
|
|||||||
<AdminTable class="admin-users-table">
|
<AdminTable class="admin-users-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{at("user", {}, "Пользователь")}</th>
|
{#each userTableColumns() as column (column.key)}
|
||||||
<th>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</th>
|
<th aria-sort={column.sort ? sortState(column.sort) : undefined}>
|
||||||
<th>{at("status", {}, "Статус")}</th>
|
{#if column.sort}
|
||||||
<th>{at("users_col_registration", {}, "Регистрация")}</th>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="admin-sort-header"
|
||||||
|
title={sortTitle(column.sort)}
|
||||||
|
on:click={() => toggleUsersSort(column.sort)}
|
||||||
|
>
|
||||||
|
<span>{column.label}</span>
|
||||||
|
<span
|
||||||
|
class="admin-sort-state"
|
||||||
|
data-state={sortState(column.sort)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{#if sortState(column.sort) === "ascending"}
|
||||||
|
<ArrowUp size={13} />
|
||||||
|
{:else if sortState(column.sort) === "descending"}
|
||||||
|
<ArrowDown size={13} />
|
||||||
|
{:else}
|
||||||
|
<ChevronsUpDown size={13} />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
{column.label}
|
||||||
|
{/if}
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -264,9 +363,41 @@
|
|||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
|
<td
|
||||||
|
class="admin-users-cell-money"
|
||||||
|
data-label={at("users_col_payments_total", {}, "Сумма платежей")}
|
||||||
|
>
|
||||||
|
<AdminBadge variant="success" class="admin-user-money-badge">
|
||||||
|
{rowPaymentsTotal(user)}
|
||||||
|
</AdminBadge>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="admin-users-cell-counter"
|
||||||
|
data-label={at("users_col_payments_count", {}, "Платежи")}
|
||||||
|
>
|
||||||
|
<span class="admin-user-counter">
|
||||||
|
<DollarSign size={12} />
|
||||||
|
<span>{user.payments_count ?? 0}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="admin-users-cell-counter"
|
||||||
|
data-label={at("users_col_invited", {}, "Приглашенные")}
|
||||||
|
>
|
||||||
|
<span class="admin-user-counter">
|
||||||
|
<UsersRound size={13} />
|
||||||
|
<span>{user.invited_users_count ?? 0}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td data-label={at("status", {}, "Статус")}>
|
<td data-label={at("status", {}, "Статус")}>
|
||||||
<AdminBadge variant={badge.variant}>{badge.label}</AdminBadge>
|
<AdminBadge variant={badge.variant}>{badge.label}</AdminBadge>
|
||||||
</td>
|
</td>
|
||||||
|
<td
|
||||||
|
class="admin-users-cell-date admin-cell-mono"
|
||||||
|
data-label={at("users_col_subscription_expires", {}, "Истекает")}
|
||||||
|
>
|
||||||
|
{fmtDateShort(user.subscription_expires_at || user.panel_status_expired_at)}
|
||||||
|
</td>
|
||||||
<td
|
<td
|
||||||
class="admin-users-cell-date admin-cell-mono"
|
class="admin-users-cell-date admin-cell-mono"
|
||||||
data-label={at("users_col_registration", {}, "Регистрация")}
|
data-label={at("users_col_registration", {}, "Регистрация")}
|
||||||
@@ -297,6 +428,57 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
:global(.admin-toolbar-users .admin-toolbar-controls) {
|
||||||
|
grid-template-columns: repeat(3, minmax(130px, 1fr)) minmax(96px, auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-table-wrap :global(.admin-table-wrap) {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-table-wrap :global(.admin-users-table) {
|
||||||
|
min-width: 1080px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sort-header {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: -4px -6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
text-transform: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sort-header:hover,
|
||||||
|
.admin-sort-header:focus-visible {
|
||||||
|
color: var(--admin-text);
|
||||||
|
background: color-mix(in srgb, var(--admin-muted) 10%, transparent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sort-header:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px var(--admin-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sort-state {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--admin-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sort-state[data-state="ascending"],
|
||||||
|
.admin-sort-state[data-state="descending"] {
|
||||||
|
color: color-mix(in srgb, var(--accent) 72%, var(--admin-muted));
|
||||||
|
}
|
||||||
|
|
||||||
.admin-users-cell-user-inner {
|
.admin-users-cell-user-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -353,6 +535,30 @@
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-users-cell-money,
|
||||||
|
.admin-users-cell-counter {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-cell-money :global(.admin-user-money-badge) {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-counter {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--admin-text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-user-counter :global(svg) {
|
||||||
|
color: var(--admin-muted);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-users-cell-date {
|
.admin-users-cell-date {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -363,4 +569,30 @@
|
|||||||
outline: 2px solid var(--admin-ring);
|
outline: 2px solid var(--admin-ring);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.admin-users-table-wrap :global(.admin-users-table thead) {
|
||||||
|
display: table-header-group;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-table-wrap :global(.admin-users-table tbody tr) {
|
||||||
|
display: table-row;
|
||||||
|
padding: 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-table-wrap :global(.admin-users-table tbody tr:last-child td) {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-table-wrap :global(.admin-users-table tbody td) {
|
||||||
|
display: table-cell;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--admin-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-table-wrap :global(.admin-users-table tbody td::before) {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
usersFilter: "all",
|
usersFilter: "all",
|
||||||
usersPanelStatus: "all",
|
usersPanelStatus: "all",
|
||||||
usersPremiumTraffic: "all",
|
usersPremiumTraffic: "all",
|
||||||
usersSort: "registered_desc",
|
usersSort: "",
|
||||||
usersLoading: false,
|
usersLoading: false,
|
||||||
|
|
||||||
openedUser: null,
|
openedUser: null,
|
||||||
@@ -103,7 +103,7 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) {
|
|||||||
if (s.usersPremiumTraffic && s.usersPremiumTraffic !== "all") {
|
if (s.usersPremiumTraffic && s.usersPremiumTraffic !== "all") {
|
||||||
params.set("premium_traffic", s.usersPremiumTraffic);
|
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()}`);
|
const data = await api(`/admin/users?${params.toString()}`);
|
||||||
if (data?.ok) {
|
if (data?.ok) {
|
||||||
state.update((st) => ({
|
state.update((st) => ({
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
export {
|
export {
|
||||||
Activity,
|
Activity,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowDown,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
ArrowUp,
|
||||||
Bitcoin,
|
Bitcoin,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Check,
|
Check,
|
||||||
@@ -19,6 +21,7 @@ export {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
Crown,
|
Crown,
|
||||||
Database,
|
Database,
|
||||||
|
DollarSign,
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Eye,
|
Eye,
|
||||||
|
|||||||
@@ -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) {
|
function withDemoAvatars(users, size = 96) {
|
||||||
return (users || []).map((user) => withDemoAvatar(user, size));
|
return (users || []).map((user) => withDemoAvatar(user, size));
|
||||||
}
|
}
|
||||||
@@ -436,7 +476,7 @@ function withDemoAvatarTickets(tickets, size = 96) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function filterDemoUsers(params) {
|
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();
|
const q = (params.get("q") || params.get("search") || "").trim().toLowerCase();
|
||||||
if (q) {
|
if (q) {
|
||||||
out = out.filter((user) =>
|
out = out.filter((user) =>
|
||||||
@@ -483,6 +523,22 @@ function filterDemoUsers(params) {
|
|||||||
return Number(a.premium_traffic?.percent ?? -1) - Number(b.premium_traffic?.percent ?? -1);
|
return Number(a.premium_traffic?.percent ?? -1) - Number(b.premium_traffic?.percent ?? -1);
|
||||||
if (sort === "premium_ratio_desc")
|
if (sort === "premium_ratio_desc")
|
||||||
return Number(b.premium_traffic?.percent ?? -1) - Number(a.premium_traffic?.percent ?? -1);
|
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);
|
return stringDate(b.registration_date) - stringDate(a.registration_date);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -976,56 +1032,61 @@ export async function mockApi(path, options = {}, context = {}) {
|
|||||||
normalizeLangCode,
|
normalizeLangCode,
|
||||||
});
|
});
|
||||||
if (demoResponse !== undefined) return demoResponse;
|
if (demoResponse !== undefined) return demoResponse;
|
||||||
const adminUsers = withDemoAvatars([
|
const adminUsers = withDemoAvatars(
|
||||||
{
|
[
|
||||||
user_id: 100200300,
|
{
|
||||||
telegram_id: 100200300,
|
user_id: 100200300,
|
||||||
username: "anna_ops",
|
telegram_id: 100200300,
|
||||||
first_name: "Анна",
|
username: "anna_ops",
|
||||||
last_name: "Смирнова",
|
first_name: "Анна",
|
||||||
email: "anna@example.com",
|
last_name: "Смирнова",
|
||||||
telegram_photo_url: "",
|
email: "anna@example.com",
|
||||||
registration_date: "2026-04-24T10:20:00Z",
|
telegram_photo_url: "",
|
||||||
is_banned: false,
|
registration_date: "2026-04-24T10:20:00Z",
|
||||||
premium_traffic: {
|
is_banned: false,
|
||||||
state: "good",
|
premium_traffic: {
|
||||||
unlimited: false,
|
state: "good",
|
||||||
used_bytes: 4 * 1073741824,
|
unlimited: false,
|
||||||
limit_bytes: 25 * 1073741824,
|
used_bytes: 4 * 1073741824,
|
||||||
percent: 16,
|
limit_bytes: 25 * 1073741824,
|
||||||
|
percent: 16,
|
||||||
|
},
|
||||||
|
panel_status: "active",
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
user_id: 100200301,
|
||||||
user_id: 100200301,
|
telegram_id: 87543123,
|
||||||
telegram_id: 87543123,
|
username: "client_pro",
|
||||||
username: "client_pro",
|
first_name: "Максим",
|
||||||
first_name: "Максим",
|
last_name: "Котов",
|
||||||
last_name: "Котов",
|
email: "",
|
||||||
email: "",
|
telegram_photo_url: "",
|
||||||
telegram_photo_url: "",
|
registration_date: "2026-04-26T08:15:00Z",
|
||||||
registration_date: "2026-04-26T08:15:00Z",
|
is_banned: false,
|
||||||
is_banned: false,
|
premium_traffic: {
|
||||||
premium_traffic: {
|
state: "warn",
|
||||||
state: "warn",
|
unlimited: false,
|
||||||
unlimited: false,
|
used_bytes: 22 * 1073741824,
|
||||||
used_bytes: 22 * 1073741824,
|
limit_bytes: 25 * 1073741824,
|
||||||
limit_bytes: 25 * 1073741824,
|
percent: 88,
|
||||||
percent: 88,
|
},
|
||||||
|
panel_status: "active",
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
user_id: 100200302,
|
||||||
user_id: 100200302,
|
telegram_id: 88440011,
|
||||||
telegram_id: 88440011,
|
username: "",
|
||||||
username: "",
|
first_name: "Daria",
|
||||||
first_name: "Daria",
|
last_name: "",
|
||||||
last_name: "",
|
email: "daria@example.com",
|
||||||
email: "daria@example.com",
|
telegram_photo_url: "",
|
||||||
telegram_photo_url: "",
|
registration_date: "2026-04-29T16:45:00Z",
|
||||||
registration_date: "2026-04-29T16:45:00Z",
|
is_banned: true,
|
||||||
is_banned: true,
|
premium_traffic: { state: "none" },
|
||||||
premium_traffic: { state: "none" },
|
panel_status: "bot_only",
|
||||||
},
|
},
|
||||||
]);
|
].map(withDemoAdminUserMetrics)
|
||||||
|
);
|
||||||
const supportTickets = [
|
const supportTickets = [
|
||||||
{
|
{
|
||||||
ticket_id: 42,
|
ticket_id: 42,
|
||||||
|
|||||||
@@ -1024,6 +1024,9 @@
|
|||||||
"admin_sort_id_desc": "ID ↓",
|
"admin_sort_id_desc": "ID ↓",
|
||||||
"admin_sort_premium_ratio_asc": "Premium usage % ↑",
|
"admin_sort_premium_ratio_asc": "Premium usage % ↑",
|
||||||
"admin_sort_premium_ratio_desc": "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_label": "Premium traffic",
|
||||||
"admin_premium_traffic_filter_all": "All",
|
"admin_premium_traffic_filter_all": "All",
|
||||||
"admin_premium_traffic_filter_none": "No tariff limit",
|
"admin_premium_traffic_filter_none": "No tariff limit",
|
||||||
@@ -1043,6 +1046,10 @@
|
|||||||
"admin_sort": "Sort",
|
"admin_sort": "Sort",
|
||||||
"admin_total": "Total",
|
"admin_total": "Total",
|
||||||
"admin_users_empty": "No users found",
|
"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_users_col_registration": "Registered",
|
||||||
"admin_page": "Page",
|
"admin_page": "Page",
|
||||||
"admin_page_short": "Page",
|
"admin_page_short": "Page",
|
||||||
|
|||||||
@@ -1024,6 +1024,9 @@
|
|||||||
"admin_sort_id_desc": "ID ↓",
|
"admin_sort_id_desc": "ID ↓",
|
||||||
"admin_sort_premium_ratio_asc": "Премиум % ↑",
|
"admin_sort_premium_ratio_asc": "Премиум % ↑",
|
||||||
"admin_sort_premium_ratio_desc": "Премиум % ↓",
|
"admin_sort_premium_ratio_desc": "Премиум % ↓",
|
||||||
|
"admin_sort_ascending": "Сортировка по возрастанию",
|
||||||
|
"admin_sort_descending": "Сортировка по убыванию",
|
||||||
|
"admin_sort_off": "Без сортировки",
|
||||||
"admin_premium_traffic_filter_label": "Премиум трафик",
|
"admin_premium_traffic_filter_label": "Премиум трафик",
|
||||||
"admin_premium_traffic_filter_all": "Все",
|
"admin_premium_traffic_filter_all": "Все",
|
||||||
"admin_premium_traffic_filter_none": "Без лимита в тарифе",
|
"admin_premium_traffic_filter_none": "Без лимита в тарифе",
|
||||||
@@ -1043,6 +1046,10 @@
|
|||||||
"admin_sort": "Сортировка",
|
"admin_sort": "Сортировка",
|
||||||
"admin_total": "Всего",
|
"admin_total": "Всего",
|
||||||
"admin_users_empty": "Никого не найдено",
|
"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_users_col_registration": "Регистрация",
|
||||||
"admin_page": "Страница",
|
"admin_page": "Страница",
|
||||||
"admin_page_short": "Стр.",
|
"admin_page_short": "Стр.",
|
||||||
|
|||||||
Reference in New Issue
Block a user