feat: add sortable admin user columns

This commit is contained in:
3252a8
2026-06-01 15:53:03 +03:00
parent d7840d3a86
commit dcdddeb1c5
7 changed files with 398 additions and 87 deletions
+1
View File
@@ -785,6 +785,7 @@
<UsersSection <UsersSection
{at} {at}
{fmtDateShort} {fmtDateShort}
{fmtMoney}
{panelStatusBadge} {panelStatusBadge}
{resolvedAvatarUrl} {resolvedAvatarUrl}
{userDisplayName} {userDisplayName}
+268 -36
View File
@@ -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>
+2 -2
View File
@@ -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) => ({
+3
View File
@@ -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,
+110 -49
View File
@@ -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,
+7
View File
@@ -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",
+7
View File
@@ -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": "Стр.",