import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; 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 { adminBulkActionsApi, type BulkActionType, type BulkActionParams, type BulkActionResult, type BulkProgressEvent, } from '../api/adminBulkActions'; import { usePlatform } from '../platform/hooks/usePlatform'; import { useCurrency } from '../hooks/useCurrency'; import { cn } from '@/lib/utils'; // ============ Types ============ type SubscriptionStatusFilter = '' | 'active' | 'expired' | 'trial' | 'limited' | 'disabled'; interface ActionConfig { type: BulkActionType; labelKey: string; icon: React.ReactNode; colorClass: string; } interface ProgressState { current: number; total: number; successCount: number; errorCount: number; log: BulkProgressEvent[]; } interface ModalState { open: boolean; action: BulkActionType | null; loading: boolean; result: BulkActionResult | null; progress: ProgressState | null; } // ============ Icons ============ const BackIcon = () => ( ); const SearchIcon = () => ( ); const ChevronLeftIcon = () => ( ); const ChevronRightIcon = () => ( ); const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( ); const CheckIcon = () => ( ); const XCloseIcon = () => ( ); const ChevronDownIcon = () => ( ); const ChevronExpandIcon = ({ expanded }: { expanded: boolean }) => ( ); // ============ Action target helpers ============ const SUBSCRIPTION_LEVEL_ACTIONS: Set = new Set([ 'extend_subscription', 'add_days', 'cancel_subscription', 'activate_subscription', 'change_tariff', 'add_traffic', ]); function isSubscriptionLevelAction(action: BulkActionType): boolean { return SUBSCRIPTION_LEVEL_ACTIONS.has(action); } // ============ Subscription sub-row ============ interface SubscriptionSubRowProps { subscription: UserListItemSubscription; isSelected: boolean; onToggleSelect: () => void; isMultiTariff: boolean; } function SubscriptionSubRow({ subscription, isSelected, onToggleSelect, isMultiTariff, }: SubscriptionSubRowProps) { const { t } = useTranslation(); const days = subscription.days_remaining; const daysColor = days === 0 ? 'text-error-400' : days <= 7 ? 'text-amber-400' : 'text-success-400'; const trafficPercent = subscription.traffic_limit_gb > 0 ? Math.min(100, (subscription.traffic_used_gb / subscription.traffic_limit_gb) * 100) : 0; const trafficBarColor = trafficPercent >= 90 ? 'bg-error-400' : trafficPercent >= 70 ? 'bg-amber-400' : 'bg-accent-400'; return ( {/* Checkbox cell */} {isMultiTariff && (
)} {/* Subscription info — spans remaining columns */}
{/* Tariff name */}
{subscription.tariff_name || '—'}
{/* Status */} {/* Days remaining */} {days} {t('admin.bulkActions.daysUnit')} {/* Traffic progress */}
{subscription.traffic_used_gb.toFixed(1)} {t('admin.bulkActions.trafficOf')}{' '} {subscription.traffic_limit_gb} {t('admin.bulkActions.trafficGbUnit')}
); } // ============ Status badge ============ function StatusBadge({ status }: { status: string | null }) { const { t } = useTranslation(); const config: Record = { active: { class: 'border-success-500/30 bg-success-500/15 text-success-400', labelKey: 'admin.bulkActions.statuses.active', }, expired: { class: 'border-error-500/30 bg-error-500/15 text-error-400', labelKey: 'admin.bulkActions.statuses.expired', }, trial: { class: 'border-amber-500/30 bg-amber-500/15 text-amber-400', labelKey: 'admin.bulkActions.statuses.trial', }, limited: { class: 'border-amber-500/30 bg-amber-500/15 text-amber-400', labelKey: 'admin.bulkActions.statuses.limited', }, disabled: { class: 'border-dark-500/30 bg-dark-500/15 text-dark-400', labelKey: 'admin.bulkActions.statuses.disabled', }, }; const c = config[status || ''] || config.disabled; return ( {t(c.labelKey, status || '')} ); } // ============ 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 (
); } // ============ Dropdown Select ============ interface DropdownOption { value: string; label: string; } function DropdownSelect({ value, options, onChange, className, }: { value: string; options: DropdownOption[]; onChange: (v: string) => void; className?: string; }) { return (
); } // ============ Progress View ============ function ProgressView({ progress }: { progress: ProgressState }) { const { t } = useTranslation(); const logEndRef = useRef(null); const percentage = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0; useEffect(() => { logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [progress.log.length]); return (
{/* Progress bar */}
{t('admin.bulkActions.progress.processed', { current: progress.current, total: progress.total, })} {percentage}%
{/* Counters */}
{progress.successCount} {t('admin.bulkActions.progress.succeeded')}
{progress.errorCount} {t('admin.bulkActions.progress.failed')}
{/* Live log */} {progress.log.length > 0 && (
{progress.log.slice(-10).map((entry, idx) => (
{entry.success ? ( ) : ( )} {entry.username ? `@${entry.username}` : `#${entry.user_id}`} {entry.success ? entry.message || t('admin.bulkActions.progress.ok') : entry.message || entry.error || t('admin.bulkActions.progress.errorGeneric')}
))}
)}
); } // ============ Error Details ============ function ErrorDetails({ result }: { result: BulkActionResult }) { const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); if (result.error_count === 0 || result.errors.length === 0) return null; return (
{expanded && (
{result.errors.map((err, idx) => (
{err.username ? `@${err.username}` : `#${err.user_id}`} {err.error}
))}
)}
); } // ============ Action Modal ============ interface ActionModalProps { modal: ModalState; selectedCount: number; tariffs: TariffListItem[]; promoGroups: PromoGroup[]; onClose: () => void; onExecute: (params: BulkActionParams) => void; } function ActionModal({ modal, selectedCount, tariffs, promoGroups, onClose, onExecute, }: ActionModalProps) { const { t } = useTranslation(); const [days, setDays] = useState(30); const [tariffId, setTariffId] = useState(tariffs[0]?.id ?? 0); const [trafficGb, setTrafficGb] = useState(10); const [balanceRub, setBalanceRub] = useState(100); const [promoGroupId, setPromoGroupId] = useState(promoGroups[0]?.id ?? null); const [grantTariffId, setGrantTariffId] = useState(tariffs[0]?.id ?? 0); const [grantDays, setGrantDays] = useState(30); useEffect(() => { if (tariffs.length > 0 && tariffId === 0) { setTariffId(tariffs[0].id); } if (tariffs.length > 0 && grantTariffId === 0) { setGrantTariffId(tariffs[0].id); } }, [tariffs, tariffId, grantTariffId]); useEffect(() => { if (promoGroups.length > 0 && promoGroupId === null) { setPromoGroupId(promoGroups[0].id); } }, [promoGroups, promoGroupId]); // Escape key handler — only when not loading useEffect(() => { if (!modal.open) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && !modal.loading) { onClose(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [modal.open, modal.loading, onClose]); if (!modal.open || !modal.action) return null; const actionLabelKeys: Record = { extend_subscription: 'admin.bulkActions.actions.extend', add_days: 'admin.bulkActions.actions.extend', cancel_subscription: 'admin.bulkActions.actions.cancel', activate_subscription: 'admin.bulkActions.actions.activate', change_tariff: 'admin.bulkActions.actions.changeTariff', add_traffic: 'admin.bulkActions.actions.addTraffic', add_balance: 'admin.bulkActions.actions.addBalance', assign_promo_group: 'admin.bulkActions.actions.assignPromoGroup', grant_subscription: 'admin.bulkActions.actions.grantSubscription', }; const handleSubmit = () => { const params: BulkActionParams = {}; switch (modal.action) { case 'extend_subscription': params.days = days; break; case 'change_tariff': params.tariff_id = tariffId; break; case 'add_traffic': params.traffic_gb = trafficGb; break; case 'add_balance': params.amount_kopeks = Math.round(balanceRub * 100); break; case 'assign_promo_group': params.promo_group_id = promoGroupId; break; case 'grant_subscription': params.tariff_id = grantTariffId; params.days = grantDays; break; } onExecute(params); }; const handleBackdropClick = () => { if (!modal.loading) { onClose(); } }; const renderInputs = () => { switch (modal.action) { case 'extend_subscription': return (
setDays(Number(e.target.value))} className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" />
); case 'change_tariff': return (
({ value: String(tt.id), label: tt.name }))} onChange={(v) => setTariffId(Number(v))} />
); case 'add_traffic': return (
setTrafficGb(Number(e.target.value))} className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" />
); case 'add_balance': return (
setBalanceRub(Number(e.target.value))} className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" />
); case 'assign_promo_group': return (
({ value: String(pg.id), label: pg.name })), ]} onChange={(v) => setPromoGroupId(v === 'null' ? null : Number(v))} />
); case 'grant_subscription': return (
({ value: String(tt.id), label: tt.name }))} onChange={(v) => setGrantTariffId(Number(v))} />
setGrantDays(Number(e.target.value))} className="w-full rounded-xl border border-dark-700 bg-dark-800 px-3 py-2.5 text-sm text-dark-100 outline-none transition-colors focus:border-accent-500/40" />
{/* Warning about users with existing subscriptions */}

{t('admin.bulkActions.grantSubscription.warning')}

); default: return null; } }; return (
{/* Backdrop — no close on click during loading */}
{/* Modal */}
{/* Header */}

{t(actionLabelKeys[modal.action])}

{!modal.loading && ( )}
{/* Progress view during execution */} {modal.loading && modal.progress ? (

{t('admin.bulkActions.progress.doNotClose')}

) : modal.result ? ( /* Result view */
{/* Summary line */}
{t('admin.bulkActions.complete')}
{/* Colored summary */}
{t('admin.bulkActions.progress.summarySuccess', { count: modal.result.success_count, })} {modal.result.error_count > 0 && ( <> {t('admin.bulkActions.progress.summaryErrors', { count: modal.result.error_count, })} )}
{modal.result.success_count}
{t('admin.bulkActions.successCount', { count: modal.result.success_count })}
{modal.result.error_count > 0 && (
{modal.result.error_count}
{t('admin.bulkActions.errorCount', { count: modal.result.error_count })}
)}
{/* Collapsible error details */}
) : ( <> {/* Affected count */}

{t('admin.bulkActions.selectedCount', { count: selectedCount })}

{/* Action-specific inputs */}
{renderInputs()}
{/* Buttons */}
)}
); } // ============ Floating Action Bar ============ interface FloatingActionBarProps { selectedUserCount: number; selectedSubscriptionCount: number; isMultiTariff: boolean; onAction: (type: BulkActionType) => void; } function FloatingActionBar({ selectedUserCount, selectedSubscriptionCount, isMultiTariff, onAction, }: FloatingActionBarProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const menuRef = useRef(null); useEffect(() => { const handler = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { setOpen(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, []); const hasAnySelection = selectedUserCount > 0 || selectedSubscriptionCount > 0; if (!hasAnySelection) return null; const actions: ActionConfig[] = [ { type: 'extend_subscription', labelKey: 'admin.bulkActions.actions.extend', icon: , colorClass: 'text-success-400 hover:bg-success-500/10', }, { type: 'activate_subscription', labelKey: 'admin.bulkActions.actions.activate', icon: , colorClass: 'text-success-400 hover:bg-success-500/10', }, { type: 'cancel_subscription', labelKey: 'admin.bulkActions.actions.cancel', icon: , colorClass: 'text-error-400 hover:bg-error-500/10', }, { type: 'change_tariff', labelKey: 'admin.bulkActions.actions.changeTariff', icon: , colorClass: 'text-accent-400 hover:bg-accent-500/10', }, { type: 'add_traffic', labelKey: 'admin.bulkActions.actions.addTraffic', icon: , colorClass: 'text-accent-400 hover:bg-accent-500/10', }, { type: 'add_balance', labelKey: 'admin.bulkActions.actions.addBalance', icon: , colorClass: 'text-warning-400 hover:bg-warning-500/10', }, { type: 'grant_subscription', labelKey: 'admin.bulkActions.actions.grantSubscription', icon: , colorClass: 'text-success-400 hover:bg-success-500/10', }, { type: 'assign_promo_group', labelKey: 'admin.bulkActions.actions.assignPromoGroup', icon: , colorClass: 'text-accent-300 hover:bg-accent-300/10', }, ]; return (
{/* Selection counts */}
{selectedUserCount > 0 && (
{selectedUserCount}
{t('admin.bulkActions.usersSelected', { count: selectedUserCount })}
)} {isMultiTariff && selectedSubscriptionCount > 0 && (
{selectedUserCount > 0 &&
}
{selectedSubscriptionCount}
{t('admin.bulkActions.subscriptionsSelected', { count: selectedSubscriptionCount, })}
)}
{/* Actions dropdown */}
{open && (
{/* Subscription-level actions */} {isMultiTariff && (
{t('admin.bulkActions.subscriptionTarget')}
)} {actions .filter((a) => isSubscriptionLevelAction(a.type)) .map((a) => { const count = isMultiTariff ? selectedSubscriptionCount : selectedUserCount; const disabled = count === 0; return ( ); })} {/* User-level actions */} {isMultiTariff && (
{t('admin.bulkActions.userTarget')}
)} {actions .filter((a) => !isSubscriptionLevelAction(a.type)) .map((a) => { const disabled = selectedUserCount === 0; return ( ); })}
)}
); } // ============ 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 [loading, setLoading] = useState(true); // Filters const [searchInput, setSearchInput] = useState(''); const [committedSearch, setCommittedSearch] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [tariffFilter, setTariffFilter] = useState(''); const [promoGroupFilter, setPromoGroupFilter] = useState(''); // Pagination const [offset, setOffset] = useState(0); const limit = 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 ---- const loadUsers = useCallback(async () => { try { setLoading(true); const params: Record = { offset, limit, }; if (committedSearch) params.search = committedSearch; if (statusFilter) params.subscription_status = statusFilter; if (tariffFilter) params.tariff_id = Number(tariffFilter); if (promoGroupFilter) params.promo_group_id = Number(promoGroupFilter); const data = await adminUsersApi.getUsers( params as Parameters[0], ); setUsers(data.users); setTotal(data.total); } catch { // keep stale data } finally { setLoading(false); } }, [offset, committedSearch, statusFilter, tariffFilter, promoGroupFilter]); useEffect(() => { loadUsers(); }, [loadUsers]); // Load tariffs and promo groups once useEffect(() => { const load = async () => { try { const [tariffData, pgData] = await Promise.all([ tariffsApi.getTariffs(true), promocodesApi.getPromoGroups({ limit: 200 }), ]); setTariffs(tariffData.tariffs); setPromoGroups(pgData.items); } catch { // silently fail } }; load(); }, []); // ---- Handlers ---- const clearAllSelections = useCallback(() => { setRowSelection({}); setSubscriptionSelection({}); setExpandedRows({}); }, []); const handleSearchChange = (value: string) => { setSearchInput(value); clearTimeout(searchTimerRef.current); searchTimerRef.current = setTimeout(() => { setOffset(0); setCommittedSearch(value); clearAllSelections(); }, 400); }; // Cleanup debounce on unmount useEffect(() => { return () => clearTimeout(searchTimerRef.current); }, []); const handleSearchSubmit = (e: React.FormEvent) => { e.preventDefault(); clearTimeout(searchTimerRef.current); setOffset(0); setCommittedSearch(searchInput); clearAllSelections(); }; const handleStatusFilterChange = (v: string) => { setStatusFilter(v as SubscriptionStatusFilter); setOffset(0); clearAllSelections(); }; const handleTariffFilterChange = (v: string) => { setTariffFilter(v); setOffset(0); clearAllSelections(); }; const handlePromoGroupFilterChange = (v: string) => { setPromoGroupFilter(v); setOffset(0); clearAllSelections(); }; const handleRefresh = () => { clearAllSelections(); 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 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: 40, header: ({ table }) => (
), 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 subCount = user.subscriptions?.length ?? 0; const canExpand = subCount > 1; 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: 120, cell: ({ row }) => { const user = row.original; if (!user.tariff_name) { return ; } return {user.tariff_name}; }, }, { id: 'balance', accessorKey: 'balance_rubles', header: t('admin.bulkActions.columns.balance'), size: 100, cell: ({ getValue }) => ( {formatWithCurrency(getValue() as number)} ), }, { id: 'days_remaining', header: t('admin.bulkActions.columns.daysRemaining'), size: 80, cell: ({ row }) => { const user = row.original; if (!user.subscription_end_date) { return -; } const end = new Date(user.subscription_end_date); const now = new Date(); const days = Math.max( 0, Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)), ); return ( {days} ); }, }, { 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], ); const table = useReactTable({ data: users, 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: 'trial', label: t('admin.bulkActions.statuses.trial') }, { value: 'limited', label: t('admin.bulkActions.statuses.limited') }, { value: 'disabled', label: t('admin.bulkActions.statuses.disabled') }, ]; const tariffOptions: DropdownOption[] = [ { value: '', label: t('admin.bulkActions.filters.allTariffs') }, ...tariffs.map((tt) => ({ value: String(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 })), ]; 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 */}
{/* 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 subs = user.subscriptions ?? []; const canExpand = subs.length > 1; const isExpanded = expandedRows[user.id] ?? false; return ( {/* User row */} {row.getVisibleCells().map((cell) => ( ))} {/* Subscription sub-rows (only when expanded and multi-sub) */} {canExpand && isExpanded && subs.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 */} {totalPages > 1 && (
{offset + 1}–{Math.min(offset + limit, total)} / {total}
{currentPage} / {totalPages}
)} {/* Floating action bar */} {/* Action modal */} {/* Bottom spacer when action bar is visible */} {(selectedUserIds.length > 0 || selectedSubscriptionIds.length > 0) && (
)}
); }