= {
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 */}
{/* 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) => (
|
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
|
))}
))}
{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) => (
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
))}
{/* 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) */}
);
})}
)}
{/* 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) && (
)}
);
}