feat: improve admin user filters

This commit is contained in:
3252a8
2026-06-01 18:04:08 +03:00
parent 88d99fb578
commit 7d3178bd48
5 changed files with 335 additions and 2 deletions
+309 -1
View File
@@ -5,8 +5,11 @@
ArrowUp,
ChevronsUpDown,
DollarSign,
Sliders,
X,
UsersRound,
} from "$components/ui/icons.js";
import Dialog from "$components/ui/dialog.svelte";
import { Label } from "$components/ui/primitives.js";
import {
AdminBadge,
@@ -44,6 +47,7 @@
} = $usersStore);
const USERS_PAGE_SIZE = 25;
let usersFilterSheetOpen = false;
$: usersHasMore = users.length === USERS_PAGE_SIZE;
const USERS_FILTER_OPTIONS = [
@@ -102,6 +106,29 @@
{ value: "critical", label: at("premium_traffic_filter_critical", {}, "Премиум: исчерпан") },
];
function optionLabel(options, value) {
return options.find((item) => item.value === value)?.label || value;
}
function updateUsersFilterState(patch) {
usersStore.updateState({ ...patch, usersPage: 0 });
usersStore.loadUsers();
}
function resetUsersFilters() {
updateUsersFilterState({
usersFilter: "all",
usersPanelStatus: "all",
usersPremiumTraffic: "all",
});
}
function clearUsersFilter(key) {
if (key === "usersFilter") updateUsersFilterState({ usersFilter: "all" });
if (key === "usersPanelStatus") updateUsersFilterState({ usersPanelStatus: "all" });
if (key === "usersPremiumTraffic") updateUsersFilterState({ usersPremiumTraffic: "all" });
}
/** @param {Record<string, unknown> | null | undefined} pt */
function premiumTrafficBadgeVariant(pt) {
if (!pt || pt.state === "none") return "muted";
@@ -187,6 +214,24 @@
return fmtMoney(user?.payments_total_amount ?? 0, user?.payments_currency || "RUB");
}
$: activeUserFilterChips = [
usersFilter !== "all" && {
key: "usersFilter",
label: at("filter", {}, "Фильтр"),
value: optionLabel(USERS_FILTER_OPTIONS, usersFilter),
},
usersPanelStatus !== "all" && {
key: "usersPanelStatus",
label: at("panel_status", {}, "Статус панели"),
value: optionLabel(USERS_PANEL_STATUS_OPTIONS, usersPanelStatus),
},
usersPremiumTraffic !== "all" && {
key: "usersPremiumTraffic",
label: at("premium_traffic_filter_label", {}, "Премиум трафик"),
value: optionLabel(USERS_PREMIUM_TRAFFIC_OPTIONS, usersPremiumTraffic),
},
].filter(Boolean);
$: activeUsersFilterCount = activeUserFilterChips.length;
$: userTableHeaders = userTableColumns().map((column) => column.label);
onMount(() => {
@@ -194,6 +239,65 @@
});
</script>
{#snippet renderUserFilterControls()}
<Label.Root class="admin-toolbar-field admin-users-filter-field">
<span class="admin-toolbar-field-label">{at("filter", {}, "Фильтр")}</span>
<AdminSelect
value={usersFilter}
items={USERS_FILTER_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("filter", {}, "Фильтр")}
onValueChange={(value) => updateUsersFilterState({ usersFilter: value })}
/>
</Label.Root>
<Label.Root class="admin-toolbar-field admin-users-filter-field">
<span class="admin-toolbar-field-label">{at("panel_status", {}, "Статус панели")}</span>
<AdminSelect
value={usersPanelStatus}
items={USERS_PANEL_STATUS_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("panel_status", {}, "Статус панели")}
onValueChange={(value) => updateUsersFilterState({ usersPanelStatus: value })}
/>
</Label.Root>
<Label.Root class="admin-toolbar-field admin-users-filter-field">
<span class="admin-toolbar-field-label"
>{at("premium_traffic_filter_label", {}, "Премиум трафик")}</span
>
<AdminSelect
value={usersPremiumTraffic}
items={USERS_PREMIUM_TRAFFIC_OPTIONS}
class="admin-toolbar-select"
ariaLabel={at("premium_traffic_filter_label", {}, "Премиум трафик")}
onValueChange={(value) => updateUsersFilterState({ usersPremiumTraffic: value })}
/>
</Label.Root>
{/snippet}
{#snippet renderActiveUserFilterChips()}
{#if activeUsersFilterCount}
<div class="admin-users-filter-chips" aria-label={at("active_filters", {}, "Активные фильтры")}>
{#each activeUserFilterChips as chip (chip.key)}
<span class="admin-users-filter-chip">
<span class="admin-users-filter-chip-text">
<strong>{chip.label}</strong>
<span>{chip.value}</span>
</span>
<button
type="button"
aria-label={at("clear_filter", { label: chip.label }, "Сбросить фильтр")}
on:click={() => clearUsersFilter(chip.key)}
>
<X size={12} />
</button>
</span>
{/each}
</div>
{/if}
{/snippet}
<div class="admin-toolbar admin-toolbar-users">
<div class="admin-toolbar-search">
<Input
@@ -207,11 +311,28 @@
/>
<AdminButton
variant="primary"
class="admin-users-search-button"
onclick={() => {
usersStore.updateState({ usersPage: 0 });
usersStore.loadUsers();
}}>{at("find", {}, "Найти")}</AdminButton
>
<AdminButton
variant={activeUsersFilterCount ? "primary" : "default"}
class="admin-users-filter-toggle"
aria-label={at("users_filters_open", {}, "Открыть фильтры")}
aria-haspopup="dialog"
aria-expanded={usersFilterSheetOpen}
onclick={() => {
usersFilterSheetOpen = true;
}}
>
<Sliders size={15} />
<span class="admin-users-filter-toggle-label">{at("filters", {}, "Фильтры")}</span>
{#if activeUsersFilterCount}
<span class="admin-users-filter-count">{activeUsersFilterCount}</span>
{/if}
</AdminButton>
</div>
<div class="admin-toolbar-controls">
@@ -264,8 +385,45 @@
<strong>{usersTotal}</strong>
</div>
</div>
{@render renderActiveUserFilterChips()}
</div>
<Dialog
open={usersFilterSheetOpen}
class="admin-dialog admin-users-filter-dialog"
title={at("users_filters_title", {}, "Фильтры пользователей")}
description={at("users_filters_description", {}, "Уточните список пользователей")}
closeLabel={at("close_menu", {}, "Закрыть меню")}
onclose={() => {
usersFilterSheetOpen = false;
}}
>
<div class="admin-users-filter-sheet-body">
<div class="admin-users-filter-fields admin-users-filter-fields-sheet">
{@render renderUserFilterControls()}
</div>
{@render renderActiveUserFilterChips()}
<div class="admin-users-filter-sheet-actions">
<AdminButton
variant="ghost"
disabled={activeUsersFilterCount === 0}
onclick={resetUsersFilters}
>
{at("reset", {}, "Сбросить")}
</AdminButton>
<AdminButton
variant="primary"
onclick={() => {
usersFilterSheetOpen = false;
}}
>
{at("done", {}, "Готово")}
</AdminButton>
</div>
</div>
</Dialog>
<div class="admin-users-table-wrap">
{#if usersLoading}
<AdminTableSkeleton
@@ -429,7 +587,111 @@
<style>
:global(.admin-toolbar-users .admin-toolbar-controls) {
grid-template-columns: repeat(3, minmax(130px, 1fr)) minmax(96px, auto);
grid-template-columns: repeat(3, minmax(150px, 1fr)) minmax(82px, auto);
gap: 10px;
}
:global(.admin-users-search-button) {
min-width: 82px;
}
:global(.admin-users-filter-toggle) {
display: none;
position: relative;
align-items: center;
gap: 7px;
min-width: 0;
}
.admin-users-filter-count {
display: inline-grid;
min-width: 18px;
height: 18px;
place-items: center;
padding: 0 5px;
border-radius: 999px;
background: color-mix(in srgb, var(--admin-bg) 74%, transparent);
color: inherit;
font-size: 11px;
font-weight: 750;
line-height: 1;
}
.admin-users-filter-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.admin-users-filter-chip {
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
min-height: 28px;
padding: 3px 5px 3px 10px;
border: 1px solid var(--admin-border);
border-radius: 999px;
background: color-mix(in srgb, var(--admin-muted) 8%, transparent);
color: var(--admin-text);
font-size: 12px;
}
.admin-users-filter-chip-text {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 0;
max-width: 260px;
}
.admin-users-filter-chip strong {
color: var(--admin-muted);
font-size: 11px;
font-weight: 650;
}
.admin-users-filter-chip-text > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-users-filter-chip button {
display: inline-grid;
width: 20px;
height: 20px;
place-items: center;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--admin-muted);
cursor: pointer;
}
.admin-users-filter-chip button:hover,
.admin-users-filter-chip button:focus-visible {
background: color-mix(in srgb, var(--admin-muted) 14%, transparent);
color: var(--admin-text);
outline: none;
}
.admin-users-filter-fields-sheet,
.admin-users-filter-sheet-body {
display: grid;
gap: 12px;
}
.admin-users-filter-sheet-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
padding-top: 2px;
}
:global(.admin-users-filter-dialog) {
width: min(100%, 420px);
}
.admin-users-table-wrap :global(.admin-table-wrap) {
@@ -571,6 +833,52 @@
}
@media (max-width: 720px) {
:global(.admin-toolbar-users .admin-toolbar-search) {
grid-template-columns: minmax(0, 1fr) auto auto;
}
:global(.admin-toolbar-users .admin-toolbar-controls) {
display: none;
}
:global(.admin-users-search-button) {
min-width: 0;
padding-inline: 10px;
}
:global(.admin-users-filter-toggle) {
display: inline-flex;
min-width: 38px;
padding-inline: 10px;
}
.admin-users-filter-toggle-label {
display: none;
}
.admin-users-filter-chips {
gap: 5px;
}
.admin-users-filter-chip-text {
max-width: min(250px, calc(100vw - 96px));
}
:global(.dialog:has(.admin-users-filter-dialog)) {
align-items: end;
padding: max(12px, env(safe-area-inset-top)) 0 0;
}
:global(.admin-users-filter-dialog) {
width: 100%;
max-height: min(82dvh, 620px);
padding: 16px;
border-right: 0;
border-bottom: 0;
border-left: 0;
border-radius: 18px 18px 0 0;
}
.admin-users-table-wrap :global(.admin-users-table thead) {
display: table-header-group;
}
@@ -7,7 +7,10 @@
export let ariaLabel = "";
export let placeholder = "";
export let disabled = false;
export let side = "bottom";
export let align = "start";
export let sideOffset = 6;
export let collisionPadding = 12;
export let onValueChange = () => {};
let className = "";
export { className as class };
@@ -29,7 +32,7 @@
<ChevronDown size={14} class="admin-select-icon" />
</Select.Trigger>
<Select.Portal>
<Select.Content class="admin-select-content" {sideOffset}>
<Select.Content class="admin-select-content" {side} {align} {sideOffset} {collisionPadding}>
<Select.Viewport class="admin-select-viewport">
{#each items as item (item.value)}
<Select.Item value={item.value} label={item.label} class="admin-select-item">
+8
View File
@@ -4045,6 +4045,7 @@
.admin-select-content {
z-index: 90;
min-width: var(--bits-select-anchor-width);
max-width: calc(100vw - 24px);
max-height: var(--bits-select-content-available-height, 320px);
overflow: hidden;
border: 1px solid var(--admin-border);
@@ -4098,6 +4099,13 @@
user-select: none;
}
.admin-select-item span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-select-item[data-highlighted],
.admin-select-item:hover {
background: var(--admin-surface-2);
+7
View File
@@ -1043,6 +1043,13 @@
"admin_users_search_placeholder": "ID, @username, or email",
"admin_find": "Find",
"admin_filter": "Filter",
"admin_filters": "Filters",
"admin_active_filters": "Active filters",
"admin_clear_filter": "Clear {label}",
"admin_done": "Done",
"admin_users_filters_open": "Open filters",
"admin_users_filters_title": "User filters",
"admin_users_filters_description": "Refine the user list without leaving the table.",
"admin_sort": "Sort",
"admin_total": "Total",
"admin_users_empty": "No users found",
+7
View File
@@ -1043,6 +1043,13 @@
"admin_users_search_placeholder": "ID, @username или email",
"admin_find": "Найти",
"admin_filter": "Фильтр",
"admin_filters": "Фильтры",
"admin_active_filters": "Активные фильтры",
"admin_clear_filter": "Сбросить {label}",
"admin_done": "Готово",
"admin_users_filters_open": "Открыть фильтры",
"admin_users_filters_title": "Фильтры пользователей",
"admin_users_filters_description": "Уточните список пользователей, не уходя из таблицы.",
"admin_sort": "Сортировка",
"admin_total": "Всего",
"admin_users_empty": "Никого не найдено",