Files
bedolaga-cabinet/src/pages/AdminBulkActions.tsx
c0mrade 0a89838926 style(cabinet-icons): use regular Phosphor weight and switch wheel to dice
- Drop the Duotone variants (their translucent secondary fill looked murky on
  the dark theme) in favour of the clean regular weight across all icons
- WheelIcon: PiSteeringWheel → PiDiceFive (a steering wheel made no sense for
  the fortune-wheel feature)
2026-05-31 19:24:29 +03:00

1126 lines
40 KiB
TypeScript

import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
type RowSelectionState,
} from '@tanstack/react-table';
import { adminUsersApi, type UserListItem, type UserListItemSubscription } from '../api/adminUsers';
import { tariffsApi, type TariffListItem } from '../api/tariffs';
import { promocodesApi, type PromoGroup } from '../api/promocodes';
import { campaignsApi, type CampaignListItem } from '../api/campaigns';
import { partnerApi, type AdminPartnerItem } from '../api/partners';
import {
adminBulkActionsApi,
type BulkActionType,
type BulkActionParams,
} from '../api/adminBulkActions';
import { PiCaretDown } from 'react-icons/pi';
import { usePlatform } from '../platform/hooks/usePlatform';
import { useCurrency } from '../hooks/useCurrency';
import { cn } from '@/lib/utils';
import {
BackIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
RefreshIcon,
SearchIcon,
} from '@/components/icons';
import { ActionModal, type ModalState } from '@/components/admin/bulkActions/ActionModal';
import { DropdownSelect, type DropdownOption } from '@/components/admin/bulkActions/DropdownSelect';
import { FloatingActionBar } from '@/components/admin/bulkActions/FloatingActionBar';
import { isSubscriptionLevelAction } from '@/components/admin/bulkActions/actionTargets';
import {
MultiSelectDropdown,
type MultiSelectOption,
} from '@/components/admin/bulkActions/MultiSelectDropdown';
import { SubscriptionSubRow, StatusBadge } from '@/components/admin/bulkActions/SubscriptionSubRow';
// ============ Types ============
type SubscriptionStatusFilter = '' | 'active' | 'expired' | 'trial' | 'limited' | 'disabled';
// (ActionConfig moved into ./bulkActions/FloatingActionBar.tsx as a private type)
// (ProgressState + ModalState moved into <ActionModal>; ModalState is re-exported.)
// ============ Icons ============
// ChevronExpandIcon keeps a custom `expanded` prop (not the barrel's
// className-only API), so it stays local as a thin wrapper over the panel's
// Phosphor caret, preserving the rotate-on-expand behavior.
const ChevronExpandIcon = ({ expanded }: { expanded: boolean }) => (
<PiCaretDown
className={cn('h-4 w-4 text-white transition-transform duration-200', expanded && 'rotate-180')}
/>
);
// (SUBSCRIPTION_LEVEL_ACTIONS + isSubscriptionLevelAction moved into ./bulkActions/actionTargets.ts)
// (SubscriptionSubRow + StatusBadge moved into ./bulkActions/SubscriptionSubRow.tsx)
// ============ Progress Bar ============
function ProgressBar({ loading }: { loading: boolean }) {
const [progress, setProgress] = useState(0);
const [visible, setVisible] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
useEffect(() => {
if (loading) {
setProgress(0);
setVisible(true);
intervalRef.current = setInterval(() => {
setProgress((prev) => {
if (prev < 30) return prev + 8;
if (prev < 60) return prev + 3;
if (prev < 85) return prev + 1;
if (prev < 95) return prev + 0.3;
return prev;
});
}, 100);
} else {
if (visible) {
setProgress(100);
clearInterval(intervalRef.current);
const timer = setTimeout(() => {
setVisible(false);
setProgress(0);
}, 300);
return () => clearTimeout(timer);
}
}
return () => clearInterval(intervalRef.current);
}, [loading, visible]);
if (!visible) return null;
return (
<div className="absolute left-0 right-0 top-0 z-50 h-0.5 overflow-hidden rounded-full bg-dark-700/50">
<div
className="h-full rounded-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-200 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
);
}
// (DropdownSelect moved into ./bulkActions/DropdownSelect.tsx)
// (MultiSelectDropdown moved into ./bulkActions/MultiSelectDropdown.tsx)
// (ProgressView moved into <ActionModal>)
// (ErrorDetails moved into <ActionModal>)
// (ActionModal moved into ./bulkActions/ActionModal.tsx)
// (FloatingActionBar moved into ./bulkActions/FloatingActionBar.tsx)
// ============ Helpers ============
// ============ Main Page ============
export default function AdminBulkActions() {
const { t } = useTranslation();
const navigate = useNavigate();
const { capabilities } = usePlatform();
const { formatWithCurrency } = useCurrency();
// Data
const [users, setUsers] = useState<UserListItem[]>([]);
const [total, setTotal] = useState(0);
const [tariffs, setTariffs] = useState<TariffListItem[]>([]);
const [promoGroups, setPromoGroups] = useState<PromoGroup[]>([]);
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
const [partners, setPartners] = useState<AdminPartnerItem[]>([]);
const [loading, setLoading] = useState(true);
// Filters
const [searchInput, setSearchInput] = useState('');
const [committedSearch, setCommittedSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<SubscriptionStatusFilter>('');
const [trialOnly, setTrialOnly] = useState(false);
const [tariffFilter, setTariffFilter] = useState<number[]>([]);
const [promoGroupFilter, setPromoGroupFilter] = useState('');
const [campaignFilter, setCampaignFilter] = useState('');
const [partnerFilter, setPartnerFilter] = useState('');
// Pagination
const [offset, setOffset] = useState(0);
const [limit, setLimit] = useState(50);
// Selection
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [subscriptionSelection, setSubscriptionSelection] = useState<Record<number, boolean>>({});
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({});
// Modal
const [modal, setModal] = useState<ModalState>({
open: false,
action: null,
loading: false,
result: null,
progress: null,
});
// Debounce timer ref
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// ---- Multi-tariff detection ----
const isMultiTariff = useMemo(
() => users.some((u) => (u.subscriptions?.length ?? 0) > 1),
[users],
);
// ---- Data loading ----
// React Query owns network state; existing setters stay so downstream
// selection / handler code does not need to change.
const usersQuery = useQuery({
queryKey: [
'admin-bulk-users',
offset,
limit,
committedSearch,
statusFilter,
tariffFilter.join(','),
promoGroupFilter,
campaignFilter,
partnerFilter,
] as const,
queryFn: () => {
const params: Record<string, unknown> = { offset, limit };
if (committedSearch) params.search = committedSearch;
if (statusFilter) params.subscription_status = statusFilter;
if (tariffFilter.length > 0) params.tariff_id = tariffFilter.join(',');
if (promoGroupFilter) params.promo_group_id = Number(promoGroupFilter);
if (campaignFilter) params.campaign_id = Number(campaignFilter);
if (partnerFilter) params.partner_id = Number(partnerFilter);
return adminUsersApi.getUsers(params as Parameters<typeof adminUsersApi.getUsers>[0]);
},
});
useEffect(() => {
if (!usersQuery.data) return;
setUsers(usersQuery.data.users);
setTotal(usersQuery.data.total);
// Auto-expand all users who have subscriptions
const autoExpand: Record<number, boolean> = {};
for (const u of usersQuery.data.users) {
if ((u.subscriptions?.length ?? 0) > 0) {
autoExpand[u.id] = true;
}
}
setExpandedRows(autoExpand);
}, [usersQuery.data]);
useEffect(() => {
setLoading(usersQuery.isFetching);
}, [usersQuery.isFetching]);
const loadUsers = useCallback(
async () => {
await usersQuery.refetch();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[usersQuery.refetch],
);
// Static lookup tables — long staleTime since these change rarely.
const tariffsLookupQuery = useQuery({
queryKey: ['admin-bulk-tariffs-lookup'] as const,
queryFn: () => tariffsApi.getTariffs(true),
staleTime: 5 * 60 * 1000,
});
const promoGroupsLookupQuery = useQuery({
queryKey: ['admin-bulk-promo-groups-lookup'] as const,
queryFn: () => promocodesApi.getPromoGroups({ limit: 200 }),
staleTime: 5 * 60 * 1000,
});
const campaignsLookupQuery = useQuery({
queryKey: ['admin-bulk-campaigns-lookup'] as const,
queryFn: () => campaignsApi.getCampaigns(true, 0, 100),
staleTime: 5 * 60 * 1000,
});
const partnersLookupQuery = useQuery({
queryKey: ['admin-bulk-partners-lookup'] as const,
queryFn: () => partnerApi.getPartners({ limit: 100 }),
staleTime: 5 * 60 * 1000,
});
useEffect(() => {
if (tariffsLookupQuery.data) setTariffs(tariffsLookupQuery.data.tariffs);
}, [tariffsLookupQuery.data]);
useEffect(() => {
if (promoGroupsLookupQuery.data) setPromoGroups(promoGroupsLookupQuery.data.items);
}, [promoGroupsLookupQuery.data]);
useEffect(() => {
if (campaignsLookupQuery.data) setCampaigns(campaignsLookupQuery.data.campaigns);
}, [campaignsLookupQuery.data]);
useEffect(() => {
if (partnersLookupQuery.data) setPartners(partnersLookupQuery.data.items);
}, [partnersLookupQuery.data]);
// ---- Handlers ----
const clearAllSelections = useCallback(() => {
setRowSelection({});
setSubscriptionSelection({});
setExpandedRows({});
}, []);
const handleSearchChange = (value: string) => {
setSearchInput(value);
clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => {
setOffset(0);
setCommittedSearch(value);
}, 400);
};
// Cleanup debounce on unmount
useEffect(() => {
return () => clearTimeout(searchTimerRef.current);
}, []);
const handleSearchSubmit = (e: React.SyntheticEvent) => {
e.preventDefault();
clearTimeout(searchTimerRef.current);
setOffset(0);
setCommittedSearch(searchInput);
};
const handleStatusFilterChange = (v: string) => {
setStatusFilter(v as SubscriptionStatusFilter);
setOffset(0);
};
const handleTariffFilterChange = (ids: number[]) => {
setTariffFilter(ids);
setOffset(0);
};
const handlePromoGroupFilterChange = (v: string) => {
setPromoGroupFilter(v);
setOffset(0);
};
const handleCampaignFilterChange = (v: string) => {
setCampaignFilter(v);
setOffset(0);
};
const handlePartnerFilterChange = (v: string) => {
setPartnerFilter(v);
setOffset(0);
};
const handleRefresh = () => {
loadUsers();
};
const selectedUserIds = useMemo(() => {
return Object.keys(rowSelection)
.filter((k) => rowSelection[k])
.map(Number)
.filter((id) => id > 0);
}, [rowSelection]);
const selectedSubscriptionIds = useMemo(() => {
return Object.keys(subscriptionSelection)
.filter((k) => subscriptionSelection[Number(k)])
.map(Number);
}, [subscriptionSelection]);
const toggleExpandRow = useCallback((userId: number) => {
setExpandedRows((prev) => ({
...prev,
[userId]: !prev[userId],
}));
}, []);
const toggleSubscriptionSelection = useCallback((subscriptionId: number) => {
setSubscriptionSelection((prev) => ({
...prev,
[subscriptionId]: !prev[subscriptionId],
}));
}, []);
const getFilteredSubs = useCallback(
(subs: UserListItemSubscription[]): UserListItemSubscription[] => {
let result = subs;
if (tariffFilter.length > 0) {
result = result.filter((s) => s.tariff_id !== null && tariffFilter.includes(s.tariff_id));
}
if (trialOnly) {
result = result.filter((s) => s.is_trial);
}
return result;
},
[tariffFilter, trialOnly],
);
// Selection-related derivations live here so the columns useMemo (below)
// can list them as deps without forward-reference warnings.
// When multiple tariffs are selected, filter users client-side
// (server only supports single tariff_id filter)
const filteredUsers = useMemo(() => {
let result = users;
if (trialOnly) {
result = result.filter(
(u) => u.subscription_is_trial || (u.subscriptions ?? []).some((s) => s.status === 'trial'),
);
}
return result;
}, [users, trialOnly]);
const allVisibleSubscriptionIds = useMemo(() => {
const ids: number[] = [];
for (const user of filteredUsers) {
const subs = user.subscriptions ?? [];
const filtered = getFilteredSubs(subs);
for (const sub of filtered) {
ids.push(sub.id);
}
}
return ids;
}, [filteredUsers, getFilteredSubs]);
const toggleAllSubscriptions = useCallback(() => {
const allSelected =
allVisibleSubscriptionIds.length > 0 &&
allVisibleSubscriptionIds.every((id) => subscriptionSelection[id]);
if (allSelected) {
const next: Record<number, boolean> = {};
for (const key of Object.keys(subscriptionSelection)) {
const id = Number(key);
if (!allVisibleSubscriptionIds.includes(id)) {
next[id] = subscriptionSelection[id];
}
}
setSubscriptionSelection(next);
} else {
const next = { ...subscriptionSelection };
for (const id of allVisibleSubscriptionIds) {
next[id] = true;
}
setSubscriptionSelection(next);
}
// Auto-expand rows that have filtered subs
const expanded: Record<number, boolean> = { ...expandedRows };
for (const user of users) {
const subs = user.subscriptions ?? [];
const filtered = getFilteredSubs(subs);
if (filtered.length > 1) {
expanded[user.id] = true;
}
}
setExpandedRows(expanded);
}, [allVisibleSubscriptionIds, subscriptionSelection, expandedRows, users, getFilteredSubs]);
const handleOpenAction = (type: BulkActionType) => {
setModal({ open: true, action: type, loading: false, result: null, progress: null });
};
const handleExecuteAction = async (params: BulkActionParams) => {
if (!modal.action) return;
const isSubAction = isMultiTariff && isSubscriptionLevelAction(modal.action);
const targetIds = isSubAction ? selectedSubscriptionIds : selectedUserIds;
if (targetIds.length === 0) return;
const totalCount = targetIds.length;
setModal((prev) => ({
...prev,
loading: true,
progress: {
current: 0,
total: totalCount,
successCount: 0,
errorCount: 0,
log: [],
},
}));
const requestPayload = isSubAction
? { action: modal.action, subscription_ids: targetIds, params }
: { action: modal.action, user_ids: targetIds, params };
try {
await adminBulkActionsApi.executeWithStream(requestPayload, (event) => {
if (event.type === 'progress') {
setModal((prev) => ({
...prev,
progress: prev.progress
? {
current: event.current,
total: event.total,
successCount: prev.progress.successCount + (event.success ? 1 : 0),
errorCount: prev.progress.errorCount + (event.success ? 0 : 1),
log: [...prev.progress.log, event],
}
: prev.progress,
}));
} else if (event.type === 'complete') {
setModal((prev) => {
// Build errors from accumulated progress log
const errors = (prev.progress?.log ?? [])
.filter((e) => !e.success)
.map((e) => ({
user_id: e.user_id,
username: e.username,
error: e.message || e.error || '',
}));
return {
...prev,
loading: false,
progress: null,
result: {
success: event.error_count === 0,
total: event.total,
success_count: event.success_count,
error_count: event.error_count,
skipped_count: event.skipped_count || 0,
errors,
},
};
});
loadUsers();
}
});
// If stream ended without a complete event, finalize from progress
setModal((prev) => {
if (prev.loading && prev.progress) {
return {
...prev,
loading: false,
result: {
success: prev.progress.errorCount === 0,
total: prev.progress.total,
success_count: prev.progress.successCount,
error_count: prev.progress.errorCount,
skipped_count: 0,
errors: prev.progress.log
.filter((e) => !e.success)
.map((e) => ({
user_id: e.user_id,
username: e.username,
error: e.error || '',
})),
},
progress: null,
};
}
return prev;
});
loadUsers();
} catch {
setModal((prev) => ({
...prev,
loading: false,
progress: null,
result: {
success: false,
total: totalCount,
success_count: prev.progress?.successCount ?? 0,
error_count: totalCount - (prev.progress?.successCount ?? 0),
skipped_count: 0,
errors: [],
},
}));
}
};
const handleCloseModal = () => {
if (modal.loading) return;
if (modal.result) {
clearAllSelections();
}
setModal({ open: false, action: null, loading: false, result: null, progress: null });
};
// ---- TanStack Table ----
const columns = useMemo<ColumnDef<UserListItem>[]>(
() => [
{
id: 'select',
size: 56,
header: ({ table }) => {
const allSubsSelected =
allVisibleSubscriptionIds.length > 0 &&
allVisibleSubscriptionIds.every((id) => subscriptionSelection[id]);
const someSubsSelected =
!allSubsSelected && allVisibleSubscriptionIds.some((id) => subscriptionSelection[id]);
return (
<div className="flex items-center justify-center gap-1.5">
{/* Select all users */}
<button
onClick={table.getToggleAllRowsSelectedHandler()}
className={cn(
'flex h-5 w-5 items-center justify-center rounded-md border-2 transition-all duration-150',
table.getIsAllRowsSelected()
? 'border-accent-500 bg-accent-500 shadow-[0_0_8px_rgba(var(--color-accent-500),0.4)]'
: table.getIsSomeRowsSelected()
? 'border-accent-500 bg-accent-500/30'
: 'border-dark-500 bg-dark-700/60 hover:border-accent-500/50 hover:bg-dark-600/60',
)}
aria-label={t('admin.bulkActions.selectAll')}
title={t('admin.bulkActions.selectAll')}
>
{table.getIsAllRowsSelected() && <CheckIcon className="h-3 w-3" />}
{table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && (
<div className="h-0.5 w-2 rounded-full bg-white" />
)}
</button>
{/* Select all subscriptions */}
{isMultiTariff && allVisibleSubscriptionIds.length > 0 && (
<button
onClick={toggleAllSubscriptions}
className={cn(
'flex h-5 w-5 items-center justify-center rounded-md border-2 transition-all duration-150',
allSubsSelected
? 'border-success-500 bg-success-500 shadow-[0_0_8px_rgba(34,197,94,0.4)]'
: someSubsSelected
? 'border-success-500 bg-success-500/30'
: 'border-dark-500 bg-dark-700/60 hover:border-success-500/50 hover:bg-dark-600/60',
)}
aria-label={t('admin.bulkActions.selectAllSubs')}
title={t('admin.bulkActions.selectAllSubs')}
>
{allSubsSelected && <CheckIcon className="h-3 w-3" />}
{someSubsSelected && <div className="h-0.5 w-2 rounded-full bg-white" />}
</button>
)}
</div>
);
},
cell: ({ row }) => {
const userName =
row.original.full_name || row.original.username || String(row.original.telegram_id);
return (
<div className="flex items-center justify-center">
<button
onClick={row.getToggleSelectedHandler()}
className={cn(
'flex h-5 w-5 items-center justify-center rounded-md border-2 transition-all duration-150',
row.getIsSelected()
? 'border-accent-500 bg-accent-500 shadow-[0_0_8px_rgba(var(--color-accent-500),0.4)]'
: 'border-dark-500 bg-dark-700/60 hover:border-accent-500/50 hover:bg-dark-600/60',
)}
aria-label={
row.getIsSelected()
? t('admin.bulkActions.deselectUser', { name: userName })
: t('admin.bulkActions.selectUser', { name: userName })
}
>
{row.getIsSelected() && <CheckIcon className="h-3 w-3" />}
</button>
</div>
);
},
enableSorting: false,
},
{
id: 'user',
accessorFn: (row) => row.full_name,
header: t('admin.bulkActions.columns.user'),
size: 200,
cell: ({ row }) => {
const user = row.original;
const filteredSubs = getFilteredSubs(user.subscriptions ?? []);
const subCount = filteredSubs.length;
const canExpand = isMultiTariff && subCount > 0;
const isExpanded = expandedRows[user.id] ?? false;
return (
<div className="flex items-center gap-2.5">
{canExpand ? (
<button
onClick={(e) => {
e.stopPropagation();
toggleExpandRow(user.id);
}}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 transition-transform"
aria-label={
isExpanded
? t('admin.bulkActions.collapseSubscriptions')
: t('admin.bulkActions.expandSubscriptions')
}
aria-expanded={isExpanded}
>
<ChevronExpandIcon expanded={isExpanded} />
</button>
) : (
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-accent-500 to-accent-700 text-[10px] font-medium text-white">
{user.first_name?.[0] || user.username?.[0] || '?'}
</div>
)}
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate text-xs font-medium text-dark-100">
{user.full_name}
</span>
{canExpand && (
<span className="shrink-0 rounded-md bg-dark-700/60 px-1.5 py-0.5 text-[9px] font-medium tabular-nums text-dark-400">
{subCount}
</span>
)}
</div>
<div className="truncate text-[10px] leading-tight text-dark-500">
{user.username ? `@${user.username}` : `ID: ${user.telegram_id}`}
</div>
</div>
</div>
);
},
},
{
id: 'subscription_status',
accessorFn: (row) => row.subscription_status,
header: t('admin.bulkActions.columns.status'),
size: 100,
cell: ({ row }) => {
const user = row.original;
if (!user.has_subscription) {
return <span className="text-xs text-dark-500">-</span>;
}
return <StatusBadge status={user.subscription_status} />;
},
},
{
id: 'tariff',
accessorFn: (row) => row.tariff_name,
header: t('admin.bulkActions.columns.tariff'),
size: 160,
cell: ({ row }) => {
const user = row.original;
const subs = user.subscriptions ?? [];
const tariffNames = subs
.map((s) => s.tariff_name)
.filter((name): name is string => !!name);
const uniqueNames = [...new Set(tariffNames)];
if (uniqueNames.length === 0) {
if (!user.tariff_name) {
return <span className="text-xs text-dark-500"></span>;
}
return <span className="text-xs text-dark-200">{user.tariff_name}</span>;
}
return (
<div className="flex flex-wrap gap-1">
{uniqueNames.map((name) => (
<span
key={name}
className="inline-flex rounded-md border border-dark-600/40 bg-dark-700/40 px-1.5 py-0.5 text-[10px] font-medium text-dark-200"
>
{name}
</span>
))}
</div>
);
},
},
{
id: 'balance',
accessorKey: 'balance_rubles',
header: t('admin.bulkActions.columns.balance'),
size: 100,
cell: ({ getValue }) => (
<span className="text-xs font-medium text-dark-200">
{formatWithCurrency(getValue() as number)}
</span>
),
},
{
id: 'promo_group',
accessorFn: (row) => row.promo_group_name,
header: t('admin.bulkActions.columns.promoGroup'),
size: 120,
cell: ({ getValue }) => {
const name = getValue() as string | null;
return name ? (
<span className="inline-flex rounded-lg border border-accent-500/20 bg-accent-500/5 px-2 py-0.5 text-[10px] font-medium text-accent-400">
{name}
</span>
) : (
<span className="text-xs text-dark-500">-</span>
);
},
},
{
id: 'total_spent',
accessorKey: 'total_spent_kopeks',
header: t('admin.bulkActions.columns.spent'),
size: 100,
cell: ({ getValue }) => {
const kopeks = getValue() as number;
return (
<span className="text-xs text-dark-300">
{kopeks > 0 ? formatWithCurrency(kopeks / 100) : '-'}
</span>
);
},
},
],
[
t,
formatWithCurrency,
expandedRows,
toggleExpandRow,
getFilteredSubs,
allVisibleSubscriptionIds,
isMultiTariff,
subscriptionSelection,
toggleAllSubscriptions,
],
);
const table = useReactTable({
data: filteredUsers,
columns,
state: { rowSelection },
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
getRowId: (row) => String(row.id),
});
const totalPages = Math.ceil(total / limit);
const currentPage = Math.floor(offset / limit) + 1;
// ---- Status filter options ----
const statusOptions: DropdownOption[] = [
{ value: '', label: t('admin.bulkActions.filters.allStatuses') },
{ value: 'active', label: t('admin.bulkActions.statuses.active') },
{ value: 'expired', label: t('admin.bulkActions.statuses.expired') },
{ value: 'limited', label: t('admin.bulkActions.statuses.limited') },
{ value: 'disabled', label: t('admin.bulkActions.statuses.disabled') },
];
const tariffMultiOptions: MultiSelectOption[] = tariffs.map((tt) => ({
value: tt.id,
label: tt.name,
}));
const promoGroupOptions: DropdownOption[] = [
{ value: '', label: t('admin.bulkActions.filters.allGroups') },
...promoGroups.map((pg) => ({ value: String(pg.id), label: pg.name })),
];
const campaignOptions: DropdownOption[] = [
{ value: '', label: t('admin.bulkActions.filters.allCampaigns') },
...campaigns.map((c) => ({ value: String(c.id), label: c.name })),
];
const partnerOptions: DropdownOption[] = [
{ value: '', label: t('admin.bulkActions.filters.allPartners') },
...partners.map((p) => ({
value: String(p.user_id),
label: p.username ? `@${p.username}` : p.first_name || String(p.telegram_id),
})),
];
return (
<div className="relative animate-fade-in">
<ProgressBar loading={loading} />
{/* Header */}
<div className="mb-5 flex items-center justify-between">
<div className="flex items-center gap-3">
{!capabilities.hasBackButton && (
<button
onClick={() => navigate('/admin')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
aria-label={t('common.back')}
>
<BackIcon />
</button>
)}
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-xl font-bold text-dark-100">{t('admin.bulkActions.title')}</h1>
<span className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-0.5 text-xs font-medium tabular-nums text-dark-400">
{total.toLocaleString()}
</span>
</div>
<p className="text-sm text-dark-400">{t('admin.bulkActions.subtitle')}</p>
</div>
</div>
<button
onClick={handleRefresh}
disabled={loading}
className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
aria-label={t('common.refresh')}
>
<RefreshIcon className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Filters */}
<div className="mb-4 flex flex-col gap-3">
{/* Search */}
<form onSubmit={handleSearchSubmit}>
<div className="relative">
<input
type="text"
value={searchInput}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder={t('admin.bulkActions.filters.search')}
className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2.5 pl-10 pr-4 text-sm text-dark-100 outline-none transition-colors placeholder:text-dark-500 focus:border-accent-500/40 focus:shadow-[0_0_0_3px_rgba(var(--color-accent-500),0.08)]"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
<SearchIcon />
</div>
</div>
</form>
{/* Filter dropdowns + trial toggle */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<DropdownSelect
value={statusFilter}
options={statusOptions}
onChange={handleStatusFilterChange}
/>
<MultiSelectDropdown
options={tariffMultiOptions}
selected={tariffFilter}
onChange={handleTariffFilterChange}
placeholder={t('admin.bulkActions.filters.allTariffs')}
/>
<DropdownSelect
value={promoGroupFilter}
options={promoGroupOptions}
onChange={handlePromoGroupFilterChange}
/>
</div>
{/* Campaign & Partner filters */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<DropdownSelect
value={campaignFilter}
options={campaignOptions}
onChange={handleCampaignFilterChange}
/>
<DropdownSelect
value={partnerFilter}
options={partnerOptions}
onChange={handlePartnerFilterChange}
/>
</div>
{/* Trial filter checkbox */}
<label className="inline-flex cursor-pointer items-center gap-2">
<button
onClick={() => {
setTrialOnly((prev) => !prev);
setOffset(0);
}}
className={cn(
'flex h-5 w-5 items-center justify-center rounded-md border-2 transition-all duration-150',
trialOnly
? 'border-warning-500 bg-warning-500 shadow-[0_0_8px_rgba(245,158,11,0.4)]'
: 'border-dark-500 bg-dark-700/60 hover:border-warning-500/50 hover:bg-dark-600/60',
)}
aria-pressed={trialOnly}
>
{trialOnly && <CheckIcon className="h-3 w-3" />}
</button>
<span
className={cn('text-sm', trialOnly ? 'font-medium text-warning-400' : 'text-dark-400')}
>
{t('admin.bulkActions.filters.trialOnly')}
</span>
</label>
</div>
{/* Table */}
{loading && users.length === 0 ? (
<div className="flex justify-center py-16">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : users.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16">
<div className="mb-3 flex h-14 w-14 items-center justify-center rounded-2xl border border-dark-700/50 bg-dark-800/40 text-dark-500">
<SearchIcon />
</div>
<p className="text-sm font-medium text-dark-300">{t('admin.bulkActions.noResults')}</p>
</div>
) : (
<div
className={cn(
'transition-opacity duration-200',
loading && users.length > 0 && 'opacity-60',
)}
>
<div className="overflow-x-auto rounded-xl border border-dark-700">
<table className="w-full text-left text-sm">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b border-dark-700 bg-dark-800/80">
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="whitespace-nowrap px-3 py-2.5 text-xs font-medium text-dark-400"
style={{ width: header.getSize() }}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => {
const user = row.original;
const filteredSubs = getFilteredSubs(user.subscriptions ?? []);
const canExpand = isMultiTariff && filteredSubs.length > 0;
const isExpanded = expandedRows[user.id] ?? false;
return (
<React.Fragment key={row.id}>
{/* User row */}
<tr
className={cn(
'border-b transition-colors',
canExpand && isExpanded ? 'border-dark-700/30' : 'border-dark-700/50',
row.getIsSelected() ? 'bg-accent-500/5' : 'hover:bg-dark-800/50',
)}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
className="px-3 py-2.5"
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
{/* Subscription sub-rows (only when expanded and multi-sub) */}
{canExpand &&
isExpanded &&
filteredSubs.map((sub) => (
<SubscriptionSubRow
key={`sub-${sub.id}`}
subscription={sub}
isSelected={subscriptionSelection[sub.id] ?? false}
onToggleSelect={() => toggleSubscriptionSelection(sub.id)}
isMultiTariff={isMultiTariff}
/>
))}
{/* Single subscription — auto-select with user (no separate sub-row) */}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Pagination + per-page selector */}
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400">
{offset + 1}&ndash;{Math.min(offset + limit, total)} / {total}
</span>
<div className="flex items-center gap-1.5">
<span className="text-xs text-dark-500">{t('admin.bulkActions.perPage')}</span>
<select
value={limit}
onChange={(e) => {
setLimit(Number(e.target.value));
setOffset(0);
}}
className="rounded-lg border border-dark-700 bg-dark-800 px-2 py-1.5 text-xs text-dark-200 outline-none transition-colors focus:border-accent-500/40"
>
{[25, 50, 100, 200].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
</div>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<button
onClick={() => {
setOffset(Math.max(0, offset - limit));
}}
disabled={offset === 0}
className="rounded-lg border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700 disabled:opacity-50"
aria-label={t('common.back')}
>
<ChevronLeftIcon />
</button>
<span className="px-3 py-2 text-sm text-dark-300">
{currentPage} / {totalPages}
</span>
<button
onClick={() => {
setOffset(offset + limit);
}}
disabled={offset + limit >= total}
className="rounded-lg border border-dark-700 bg-dark-800 p-2 transition-colors hover:bg-dark-700 disabled:opacity-50"
aria-label={t('common.next')}
>
<ChevronRightIcon />
</button>
</div>
)}
</div>
{/* Bottom spacer to prevent floating bar from covering pagination */}
{(selectedUserIds.length > 0 || selectedSubscriptionIds.length > 0) && (
<div className="h-24" />
)}
{/* Floating action bar — portal to body for correct fixed positioning */}
{createPortal(
<FloatingActionBar
selectedUserCount={selectedUserIds.length}
selectedSubscriptionCount={selectedSubscriptionIds.length}
isMultiTariff={isMultiTariff}
totalVisibleSubscriptionCount={allVisibleSubscriptionIds.length}
onAction={handleOpenAction}
onToggleAllSubscriptions={toggleAllSubscriptions}
/>,
document.body,
)}
{/* Action modal */}
<ActionModal
modal={modal}
selectedCount={
modal.action && isMultiTariff && isSubscriptionLevelAction(modal.action)
? selectedSubscriptionIds.length
: selectedUserIds.length
}
tariffs={tariffs}
promoGroups={promoGroups}
users={users}
selectedSubscriptionIds={selectedSubscriptionIds}
onClose={handleCloseModal}
onExecute={handleExecuteAction}
/>
</div>
);
}