= {
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',
set_devices: 'admin.bulkActions.actions.setDevices',
delete_subscription: 'admin.bulkActions.actions.deleteSubscription',
delete_user: 'admin.bulkActions.actions.deleteUser',
};
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;
case 'set_devices':
params.device_limit = deviceLimit;
break;
case 'delete_subscription':
params.force_delete_active_paid = forceDeleteActivePaid;
break;
case 'delete_user':
params.delete_from_panel = deleteFromPanel;
break;
}
onExecute(params);
};
const handleBackdropClick = () => {
if (!modal.loading) {
onClose();
}
};
const renderInputs = () => {
switch (modal.action) {
case 'extend_subscription':
return (
{t('admin.bulkActions.params.days')}
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 (
{t('admin.bulkActions.params.tariff')}
({ value: String(tt.id), label: tt.name }))}
onChange={(v) => setTariffId(Number(v))}
/>
);
case 'add_traffic':
return (
{t('admin.bulkActions.params.trafficGb')}
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 (
{t('admin.bulkActions.params.balanceRub')}
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 (
{t('admin.bulkActions.params.promoGroup')}
({ value: String(pg.id), label: pg.name })),
]}
onChange={(v) => setPromoGroupId(v === 'null' ? null : Number(v))}
/>
);
case 'set_devices':
return (
{t('admin.bulkActions.params.deviceLimit')}
setDeviceLimit(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 'grant_subscription':
return (
{t('admin.bulkActions.params.tariff')}
({ value: String(tt.id), label: tt.name }))}
onChange={(v) => setGrantTariffId(Number(v))}
/>
{t('admin.bulkActions.params.days')}
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')}
);
case 'delete_subscription': {
const totalSelected = selectedSubscriptionIds.length;
return (
{t('admin.bulkActions.deleteSubscription.warning')}
{t('admin.bulkActions.deleteSubscription.hint')}
{activePaidCount > 0 && (
<>
{t('admin.bulkActions.deleteSubscription.activePaidWarning', {
count: activePaidCount,
total: totalSelected,
})}
setForceDeleteActivePaid((prev) => !prev)}
className={cn(
'flex h-6 w-6 items-center justify-center rounded-md border-2 transition-all duration-150',
forceDeleteActivePaid
? 'border-error-500 bg-error-500 shadow-[0_0_8px_rgba(239,68,68,0.4)]'
: 'border-dark-500 bg-dark-700/60 hover:border-error-500/50 hover:bg-dark-600/60',
)}
aria-pressed={forceDeleteActivePaid}
>
{forceDeleteActivePaid && }
{t('admin.bulkActions.deleteSubscription.forceDeleteConfirm')}
>
)}
);
}
case 'delete_user':
return (
{t('admin.bulkActions.deleteUser.warning')}
{t('admin.bulkActions.deleteUser.hint')}
setDeleteFromPanel((prev) => !prev)}
className={cn(
'flex h-6 w-6 items-center justify-center rounded-md border-2 transition-all duration-150',
deleteFromPanel
? 'border-error-500 bg-error-500 shadow-[0_0_8px_rgba(239,68,68,0.4)]'
: 'border-dark-500 bg-dark-700/60 hover:border-error-500/50 hover:bg-dark-600/60',
)}
aria-pressed={deleteFromPanel}
>
{deleteFromPanel && }
{t('admin.bulkActions.deleteUser.deleteFromPanel')}
);
default:
return null;
}
};
return createPortal(
{/* Backdrop — no close on click during loading */}
{/* Modal — role/aria-modal live on the focus-trapped element so AT
announces and Tab cycles correctly. */}
{/* 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 */}
{t('common.close')}
) : (
<>
{/* Affected count */}
{t('admin.bulkActions.selectedCount', { count: selectedCount })}
{/* Action-specific inputs */}
{renderInputs()}
{/* Buttons */}
{t('admin.bulkActions.cancel')}
{t('admin.bulkActions.confirm')}
>
)}
,
document.body,
);
}
// ============ Floating Action Bar ============
interface FloatingActionBarProps {
selectedUserCount: number;
selectedSubscriptionCount: number;
isMultiTariff: boolean;
totalVisibleSubscriptionCount: number;
onAction: (type: BulkActionType) => void;
onToggleAllSubscriptions: () => void;
}
function FloatingActionBar({
selectedUserCount,
selectedSubscriptionCount,
isMultiTariff,
totalVisibleSubscriptionCount,
onAction,
onToggleAllSubscriptions,
}: 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: 'set_devices',
labelKey: 'admin.bulkActions.actions.setDevices',
icon: (
),
colorClass: 'text-accent-400 hover:bg-accent-500/10',
},
{
type: 'delete_subscription',
labelKey: 'admin.bulkActions.actions.deleteSubscription',
icon: (
),
colorClass: 'text-error-400 hover:bg-error-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',
},
{
type: 'delete_user',
labelKey: 'admin.bulkActions.actions.deleteUser',
icon: (
),
colorClass: 'text-error-400 hover:bg-error-500/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,
})}
)}
{/* Toggle all subscriptions button */}
{isMultiTariff && totalVisibleSubscriptionCount > 0 && (
<>
{selectedSubscriptionCount === totalVisibleSubscriptionCount &&
selectedSubscriptionCount > 0
? t('admin.bulkActions.deselectAllSubs')
: t('admin.bulkActions.selectAllSubs')}
>
)}
{/* Actions dropdown */}
setOpen(!open)}
className="flex items-center gap-2 rounded-xl bg-accent-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-accent-600"
>
{t('common.actions')}
{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 (
{
if (disabled) return;
setOpen(false);
onAction(a.type);
}}
disabled={disabled}
className={cn(
'flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium transition-colors',
disabled ? 'cursor-not-allowed opacity-40' : a.colorClass,
)}
>
{a.icon}
{t(a.labelKey)}
);
})}
{/* User-level actions */}
{isMultiTariff && (
{t('admin.bulkActions.userTarget')}
)}
{actions
.filter((a) => !isSubscriptionLevelAction(a.type))
.map((a) => {
const disabled = selectedUserCount === 0;
return (
{
if (disabled) return;
setOpen(false);
onAction(a.type);
}}
disabled={disabled}
className={cn(
'flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium transition-colors',
disabled ? 'cursor-not-allowed opacity-40' : a.colorClass,
)}
>
{a.icon}
{t(a.labelKey)}
);
})}
)}
);
}
// ============ 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 */}
{table.getIsAllRowsSelected() && }
{table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && (
)}
{/* Select all subscriptions */}
{isMultiTariff && allVisibleSubscriptionIds.length > 0 && (
{allSubsSelected && }
{someSubsSelected &&
}
)}
);
},
cell: ({ row }) => {
const userName =
row.original.full_name || row.original.username || String(row.original.telegram_id);
return (
{row.getIsSelected() && }
);
},
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 ? (
{
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}
>
) : (
{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 && (
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')}
>
)}
{t('admin.bulkActions.title')}
{total.toLocaleString()}
{t('admin.bulkActions.subtitle')}
{/* Filters */}
{/* Search */}
{/* Filter dropdowns + trial toggle */}
{/* Campaign & Partner filters */}
{/* Trial filter checkbox */}
{
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 && }
{t('admin.bulkActions.filters.trialOnly')}
{/* Table */}
{loading && users.length === 0 ? (
) : users.length === 0 ? (
{t('admin.bulkActions.noResults')}
) : (
0 && 'opacity-60',
)}
>
{table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => (
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
))}
))}
{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) => (
{flexRender(cell.column.columnDef.cell, cell.getContext())}
))}
{/* 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) */}
);
})}
)}
{/* Pagination + per-page selector */}
{offset + 1}–{Math.min(offset + limit, total)} / {total}
{t('admin.bulkActions.perPage')}
{
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) => (
{n}
))}
{totalPages > 1 && (
{
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')}
>
{currentPage} / {totalPages}
{
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')}
>
)}
{/* 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 */}
);
}