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 ; 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 }) => ( ); // (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>(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 (
); } // (DropdownSelect moved into ./bulkActions/DropdownSelect.tsx) // (MultiSelectDropdown moved into ./bulkActions/MultiSelectDropdown.tsx) // (ProgressView moved into ) // (ErrorDetails moved into ) // (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([]); const [total, setTotal] = useState(0); const [tariffs, setTariffs] = useState([]); const [promoGroups, setPromoGroups] = useState([]); const [campaigns, setCampaigns] = useState([]); const [partners, setPartners] = useState([]); const [loading, setLoading] = useState(true); // Filters const [searchInput, setSearchInput] = useState(''); const [committedSearch, setCommittedSearch] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [trialOnly, setTrialOnly] = useState(false); const [tariffFilter, setTariffFilter] = useState([]); 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({}); const [subscriptionSelection, setSubscriptionSelection] = useState>({}); const [expandedRows, setExpandedRows] = useState>({}); // Modal const [modal, setModal] = useState({ open: false, action: null, loading: false, result: null, progress: null, }); // Debounce timer ref const searchTimerRef = useRef>(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 = { 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[0]); }, }); useEffect(() => { if (!usersQuery.data) return; setUsers(usersQuery.data.users); setTotal(usersQuery.data.total); // Auto-expand all users who have subscriptions const autoExpand: Record = {}; 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 = {}; 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 = { ...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[]>( () => [ { 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 (
{/* Select all users */} {/* Select all subscriptions */} {isMultiTariff && allVisibleSubscriptionIds.length > 0 && ( )}
); }, cell: ({ row }) => { const userName = row.original.full_name || row.original.username || String(row.original.telegram_id); return (
); }, 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 (
{canExpand ? ( ) : (
{user.first_name?.[0] || user.username?.[0] || '?'}
)}
{user.full_name} {canExpand && ( {subCount} )}
{user.username ? `@${user.username}` : `ID: ${user.telegram_id}`}
); }, }, { 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 -; } return ; }, }, { 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 ; } return {user.tariff_name}; } return (
{uniqueNames.map((name) => ( {name} ))}
); }, }, { id: 'balance', accessorKey: 'balance_rubles', header: t('admin.bulkActions.columns.balance'), size: 100, cell: ({ getValue }) => ( {formatWithCurrency(getValue() as number)} ), }, { 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 ? ( {name} ) : ( - ); }, }, { id: 'total_spent', accessorKey: 'total_spent_kopeks', header: t('admin.bulkActions.columns.spent'), size: 100, cell: ({ getValue }) => { const kopeks = getValue() as number; return ( {kopeks > 0 ? formatWithCurrency(kopeks / 100) : '-'} ); }, }, ], [ 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 (
{/* Header */}
{!capabilities.hasBackButton && ( )}

{t('admin.bulkActions.title')}

{total.toLocaleString()}

{t('admin.bulkActions.subtitle')}

{/* Filters */}
{/* Search */}
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)]" />
{/* Filter dropdowns + trial toggle */}
{/* Campaign & Partner filters */}
{/* Trial filter checkbox */}
{/* Table */} {loading && users.length === 0 ? (
) : users.length === 0 ? (

{t('admin.bulkActions.noResults')}

) : (
0 && 'opacity-60', )} >
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( ))} ))} {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 ( {/* User row */} {row.getVisibleCells().map((cell) => ( ))} {/* Subscription sub-rows (only when expanded and multi-sub) */} {canExpand && isExpanded && filteredSubs.map((sub) => ( toggleSubscriptionSelection(sub.id)} isMultiTariff={isMultiTariff} /> ))} {/* Single subscription — auto-select with user (no separate sub-row) */} ); })}
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
)} {/* Pagination + per-page selector */}
{offset + 1}–{Math.min(offset + limit, total)} / {total}
{t('admin.bulkActions.perPage')}
{totalPages > 1 && (
{currentPage} / {totalPages}
)}
{/* Bottom spacer to prevent floating bar from covering pagination */} {(selectedUserIds.length > 0 || selectedSubscriptionIds.length > 0) && (
)} {/* Floating action bar — portal to body for correct fixed positioning */} {createPortal( , document.body, )} {/* Action modal */}
); }