From b01ffe33099a94e8b0f83e0f2ac700a6d24b696e Mon Sep 17 00:00:00 2001
From: Fringg
Date: Fri, 24 Apr 2026 16:04:20 +0300
Subject: [PATCH] feat: add campaign/partner filters, delete_user action, and
fix modal positioning in AdminBulkActions
Add campaign and partner filter dropdowns for user filtering, implement
delete_user bulk action with delete_from_panel checkbox, fix ActionModal
to use createPortal with viewport-fixed positioning and safe area insets.
---
src/api/adminBulkActions.ts | 4 +-
src/api/adminUsers.ts | 5 ++
src/locales/en.json | 10 ++-
src/locales/fa.json | 107 +++++++++++++++++++++++++
src/locales/ru.json | 10 ++-
src/locales/zh.json | 107 +++++++++++++++++++++++++
src/pages/AdminBulkActions.tsx | 139 +++++++++++++++++++++++++++++++--
7 files changed, 372 insertions(+), 10 deletions(-)
diff --git a/src/api/adminBulkActions.ts b/src/api/adminBulkActions.ts
index 1088dee..43bb19f 100644
--- a/src/api/adminBulkActions.ts
+++ b/src/api/adminBulkActions.ts
@@ -12,7 +12,8 @@ export type BulkActionType =
| 'assign_promo_group'
| 'grant_subscription'
| 'set_devices'
- | 'delete_subscription';
+ | 'delete_subscription'
+ | 'delete_user';
export interface BulkActionRequest {
action: BulkActionType;
@@ -30,6 +31,7 @@ export interface BulkActionParams {
balance_description?: string;
promo_group_id?: number | null;
device_limit?: number;
+ delete_from_panel?: boolean;
}
export interface BulkActionErrorItem {
diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts
index 96737d8..8697196 100644
--- a/src/api/adminUsers.ts
+++ b/src/api/adminUsers.ts
@@ -411,6 +411,11 @@ export const adminUsersApi = {
search?: string;
email?: string;
status?: 'active' | 'blocked' | 'deleted';
+ subscription_status?: string;
+ tariff_id?: string;
+ promo_group_id?: number;
+ campaign_id?: number;
+ partner_id?: number;
sort_by?:
| 'created_at'
| 'balance'
diff --git a/src/locales/en.json b/src/locales/en.json
index ccf241b..4263969 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -2043,12 +2043,18 @@
"assignPromoGroup": "Assign promo group",
"grantSubscription": "Grant subscription",
"setDevices": "Set devices",
- "deleteSubscription": "Delete subscriptions"
+ "deleteSubscription": "Delete subscriptions",
+ "deleteUser": "Delete users"
},
"deleteSubscription": {
"warning": "Selected subscriptions will be permanently deleted from the bot and RemnaWave panel!",
"hint": "Users will lose VPN access for deleted subscriptions. This action cannot be undone."
},
+ "deleteUser": {
+ "warning": "Selected users will be permanently deleted from the bot! All their subscriptions, balance, and data will be lost!",
+ "hint": "This action cannot be undone. Users will need to restart the bot to create a new account.",
+ "deleteFromPanel": "Also delete from RemnaWave panel"
+ },
"params": {
"days": "Number of days",
"tariff": "Select tariff",
@@ -2088,6 +2094,8 @@
"allStatuses": "All statuses",
"allTariffs": "All tariffs",
"allGroups": "All groups",
+ "allCampaigns": "All campaigns",
+ "allPartners": "All partners",
"trialOnly": "Trial only",
"tariffsSelected": "{{count}} tariffs",
"selectAll": "Select all",
diff --git a/src/locales/fa.json b/src/locales/fa.json
index cd99f83..3c29a4a 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -3437,6 +3437,113 @@
"zh": "چینی",
"fa": "فارسی"
}
+ },
+ "bulkActions": {
+ "title": "عملیات دستهای",
+ "subtitle": "مدیریت کاربران",
+ "selectedCount": "انتخاب شده: {{count}}",
+ "selectAll": "انتخاب همه",
+ "deselectAll": "لغو انتخاب همه",
+ "selectUser": "انتخاب کاربر {{name}}",
+ "deselectUser": "لغو انتخاب کاربر {{name}}",
+ "noResults": "کاربری یافت نشد",
+ "perPage": "در هر صفحه",
+ "actions": {
+ "extend": "تمدید اشتراک",
+ "cancel": "غیرفعالسازی اشتراکها",
+ "activate": "فعالسازی اشتراکها",
+ "changeTariff": "تغییر تعرفه",
+ "addTraffic": "افزودن ترافیک",
+ "addBalance": "افزودن موجودی",
+ "assignPromoGroup": "اختصاص گروه تبلیغاتی",
+ "grantSubscription": "اعطای اشتراک",
+ "setDevices": "تنظیم دستگاهها",
+ "deleteSubscription": "حذف اشتراکها",
+ "deleteUser": "حذف کاربران"
+ },
+ "deleteSubscription": {
+ "warning": "اشتراکهای انتخاب شده برای همیشه از ربات و پنل RemnaWave حذف خواهند شد!",
+ "hint": "کاربران دسترسی VPN اشتراکهای حذف شده را از دست خواهند داد. این عملیات قابل بازگشت نیست."
+ },
+ "deleteUser": {
+ "warning": "کاربران انتخاب شده برای همیشه از ربات حذف خواهند شد! تمام اشتراکها، موجودی و دادههای آنها از بین خواهد رفت!",
+ "hint": "این عملیات قابل بازگشت نیست. کاربران باید ربات را دوباره راهاندازی کنند تا حساب جدید بسازند.",
+ "deleteFromPanel": "همچنین از پنل RemnaWave حذف شود"
+ },
+ "params": {
+ "days": "تعداد روز",
+ "tariff": "انتخاب تعرفه",
+ "trafficGb": "حجم ترافیک (GB)",
+ "balanceRub": "مبلغ (روبل)",
+ "promoGroup": "گروه تبلیغاتی",
+ "removePromoGroup": "حذف گروه تبلیغاتی",
+ "deviceLimit": "تعداد دستگاه"
+ },
+ "grantSubscription": {
+ "warning": "کاربرانی که قبلا اشتراک تعرفه انتخاب شده را دارند رد خواهند شد"
+ },
+ "confirm": "اعمال",
+ "cancel": "انصراف",
+ "executing": "در حال اجرا...",
+ "complete": "انجام شد",
+ "successCount": "موفق: {{count}}",
+ "errorCount": "خطا: {{count}}",
+ "progress": {
+ "processed": "پردازش شده: {{current}} / {{total}}",
+ "succeeded": "موفق",
+ "failed": "ناموفق",
+ "ok": "OK",
+ "errorGeneric": "خطا",
+ "doNotClose": "لطفا تا پایان عملیات این پنجره را نبندید",
+ "summarySuccess": "{{count}} موفق",
+ "summaryErrors": "{{count}} خطا"
+ },
+ "errors": {
+ "title": "{{count}} خطا — نمایش جزئیات"
+ },
+ "filters": {
+ "search": "جستجو بر اساس شناسه، نام کاربری، ایمیل",
+ "status": "وضعیت اشتراک",
+ "tariff": "تعرفه",
+ "promoGroup": "گروه تبلیغاتی",
+ "allStatuses": "همه وضعیتها",
+ "allTariffs": "همه تعرفهها",
+ "allGroups": "همه گروهها",
+ "allCampaigns": "همه کمپینها",
+ "allPartners": "همه شرکا",
+ "trialOnly": "فقط آزمایشی",
+ "tariffsSelected": "{{count}} تعرفه",
+ "selectAll": "انتخاب همه",
+ "deselectAll": "لغو انتخاب همه"
+ },
+ "statuses": {
+ "active": "فعال",
+ "expired": "منقضی",
+ "trial": "آزمایشی",
+ "limited": "محدود",
+ "disabled": "غیرفعال"
+ },
+ "columns": {
+ "user": "کاربر",
+ "status": "وضعیت",
+ "tariff": "تعرفه",
+ "balance": "موجودی",
+ "daysRemaining": "روز",
+ "promoGroup": "گروه",
+ "spent": "خرج شده"
+ },
+ "subscriptionsSelected": "{{count}} اشتراک انتخاب شده",
+ "usersSelected": "{{count}} کاربر انتخاب شده",
+ "expandSubscriptions": "نمایش اشتراکها",
+ "collapseSubscriptions": "پنهان کردن اشتراکها",
+ "noSubscriptions": "بدون اشتراک",
+ "subscriptionTarget": "هدف: اشتراکها",
+ "userTarget": "هدف: کاربران",
+ "daysUnit": "روز",
+ "trafficOf": "از",
+ "trafficGbUnit": "GB",
+ "selectAllSubs": "انتخاب همه اشتراکها",
+ "deselectAllSubs": "لغو انتخاب همه اشتراکها"
}
},
"adminUpdates": {
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 23503d1..6de3b43 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -3791,12 +3791,18 @@
"assignPromoGroup": "Назначить промогруппу",
"grantSubscription": "Выдать подписку",
"setDevices": "Изменить устройства",
- "deleteSubscription": "Удалить подписки"
+ "deleteSubscription": "Удалить подписки",
+ "deleteUser": "Удалить пользователей"
},
"deleteSubscription": {
"warning": "Выбранные подписки будут безвозвратно удалены из бота и панели RemnaWave!",
"hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить."
},
+ "deleteUser": {
+ "warning": "Выбранные пользователи будут безвозвратно удалены из бота! Все их подписки, баланс и данные будут потеряны!",
+ "hint": "Это действие нельзя отменить. Пользователям придётся заново запустить бота для создания нового аккаунта.",
+ "deleteFromPanel": "Также удалить из панели RemnaWave"
+ },
"params": {
"days": "Количество дней",
"tariff": "Выберите тариф",
@@ -3836,6 +3842,8 @@
"allStatuses": "Все статусы",
"allTariffs": "Все тарифы",
"allGroups": "Все группы",
+ "allCampaigns": "Все кампании",
+ "allPartners": "Все партнёры",
"trialOnly": "Только триал",
"tariffsSelected": "{{count}} тарифов",
"selectAll": "Выбрать все",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index a21b631..8661527 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -3436,6 +3436,113 @@
"zh": "中文",
"fa": "波斯语"
}
+ },
+ "bulkActions": {
+ "title": "批量操作",
+ "subtitle": "用户管理",
+ "selectedCount": "已选择: {{count}}",
+ "selectAll": "全选",
+ "deselectAll": "取消全选",
+ "selectUser": "选择用户 {{name}}",
+ "deselectUser": "取消选择用户 {{name}}",
+ "noResults": "未找到用户",
+ "perPage": "每页",
+ "actions": {
+ "extend": "延长订阅",
+ "cancel": "停用订阅",
+ "activate": "激活订阅",
+ "changeTariff": "更改套餐",
+ "addTraffic": "添加流量",
+ "addBalance": "添加余额",
+ "assignPromoGroup": "分配促销组",
+ "grantSubscription": "授予订阅",
+ "setDevices": "设置设备数",
+ "deleteSubscription": "删除订阅",
+ "deleteUser": "删除用户"
+ },
+ "deleteSubscription": {
+ "warning": "选中的订阅将从机器人和RemnaWave面板中永久删除!",
+ "hint": "用户将失去已删除订阅的VPN访问权限。此操作无法撤销。"
+ },
+ "deleteUser": {
+ "warning": "选中的用户将从机器人中永久删除!他们的所有订阅、余额和数据将丢失!",
+ "hint": "此操作无法撤销。用户需要重新启动机器人以创建新账户。",
+ "deleteFromPanel": "同时从RemnaWave面板删除"
+ },
+ "params": {
+ "days": "天数",
+ "tariff": "选择套餐",
+ "trafficGb": "流量 (GB)",
+ "balanceRub": "金额 (卢布)",
+ "promoGroup": "促销组",
+ "removePromoGroup": "移除促销组",
+ "deviceLimit": "设备数量"
+ },
+ "grantSubscription": {
+ "warning": "已拥有所选套餐订阅的用户将被跳过"
+ },
+ "confirm": "应用",
+ "cancel": "取消",
+ "executing": "执行中...",
+ "complete": "完成",
+ "successCount": "成功: {{count}}",
+ "errorCount": "错误: {{count}}",
+ "progress": {
+ "processed": "已处理: {{current}} / {{total}}",
+ "succeeded": "成功",
+ "failed": "失败",
+ "ok": "OK",
+ "errorGeneric": "错误",
+ "doNotClose": "操作进行中请勿关闭此窗口",
+ "summarySuccess": "{{count}} 成功",
+ "summaryErrors": "{{count}} 错误"
+ },
+ "errors": {
+ "title": "{{count}} 个错误 — 显示详情"
+ },
+ "filters": {
+ "search": "按ID、用户名、邮箱搜索",
+ "status": "订阅状态",
+ "tariff": "套餐",
+ "promoGroup": "促销组",
+ "allStatuses": "所有状态",
+ "allTariffs": "所有套餐",
+ "allGroups": "所有组",
+ "allCampaigns": "所有活动",
+ "allPartners": "所有合作伙伴",
+ "trialOnly": "仅试用",
+ "tariffsSelected": "{{count}} 个套餐",
+ "selectAll": "全选",
+ "deselectAll": "取消全选"
+ },
+ "statuses": {
+ "active": "活跃",
+ "expired": "已过期",
+ "trial": "试用",
+ "limited": "受限",
+ "disabled": "已禁用"
+ },
+ "columns": {
+ "user": "用户",
+ "status": "状态",
+ "tariff": "套餐",
+ "balance": "余额",
+ "daysRemaining": "天数",
+ "promoGroup": "组",
+ "spent": "已消费"
+ },
+ "subscriptionsSelected": "已选择 {{count}} 个订阅",
+ "usersSelected": "已选择 {{count}} 个用户",
+ "expandSubscriptions": "显示订阅",
+ "collapseSubscriptions": "隐藏订阅",
+ "noSubscriptions": "无订阅",
+ "subscriptionTarget": "目标: 订阅",
+ "userTarget": "目标: 用户",
+ "daysUnit": "天",
+ "trafficOf": "/",
+ "trafficGbUnit": "GB",
+ "selectAllSubs": "选择所有订阅",
+ "deselectAllSubs": "取消选择所有订阅"
}
},
"adminUpdates": {
diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx
index 42cdc01..1619529 100644
--- a/src/pages/AdminBulkActions.tsx
+++ b/src/pages/AdminBulkActions.tsx
@@ -12,6 +12,8 @@ import {
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,
@@ -760,6 +762,7 @@ function ActionModal({
const [grantTariffId, setGrantTariffId] = useState(tariffs[0]?.id ?? 0);
const [grantDays, setGrantDays] = useState(30);
const [deviceLimit, setDeviceLimit] = useState(1);
+ const [deleteFromPanel, setDeleteFromPanel] = useState(true);
useEffect(() => {
if (tariffs.length > 0 && tariffId === 0) {
@@ -802,6 +805,7 @@ function ActionModal({
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 = () => {
@@ -829,6 +833,9 @@ function ActionModal({
case 'set_devices':
params.device_limit = deviceLimit;
break;
+ case 'delete_user':
+ params.delete_from_panel = deleteFromPanel;
+ break;
}
onExecute(params);
};
@@ -979,14 +986,55 @@ function ActionModal({
);
+ case 'delete_user':
+ return (
+
+
+
+ {t('admin.bulkActions.deleteUser.warning')}
+
+
+ {t('admin.bulkActions.deleteUser.hint')}
+
+
+
+
+ );
default:
return null;
}
};
- return (
+ return createPortal(
@@ -997,7 +1045,7 @@ function ActionModal({
/>
{/* Modal */}
-
+
{/* Header */}
{t(actionLabelKeys[modal.action])}
@@ -1112,7 +1160,8 @@ function ActionModal({
>
)}
-
+
,
+ document.body,
);
}
@@ -1241,6 +1290,26 @@ function FloatingActionBar({
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 (
@@ -1397,6 +1466,8 @@ export default function AdminBulkActions() {
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
@@ -1406,6 +1477,8 @@ export default function AdminBulkActions() {
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);
@@ -1447,6 +1520,8 @@ export default function AdminBulkActions() {
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);
const data = await adminUsersApi.getUsers(
params as Parameters[0],
@@ -1466,22 +1541,35 @@ export default function AdminBulkActions() {
} finally {
setLoading(false);
}
- }, [offset, limit, committedSearch, statusFilter, tariffFilter, promoGroupFilter]);
+ }, [
+ offset,
+ limit,
+ committedSearch,
+ statusFilter,
+ tariffFilter,
+ promoGroupFilter,
+ campaignFilter,
+ partnerFilter,
+ ]);
useEffect(() => {
loadUsers();
}, [loadUsers]);
- // Load tariffs and promo groups once
+ // Load tariffs, promo groups, campaigns, and partners once
useEffect(() => {
const load = async () => {
try {
- const [tariffData, pgData] = await Promise.all([
+ const [tariffData, pgData, campaignData, partnerData] = await Promise.all([
tariffsApi.getTariffs(true),
promocodesApi.getPromoGroups({ limit: 200 }),
+ campaignsApi.getCampaigns(true, 0, 200),
+ partnerApi.getPartners({ limit: 200 }),
]);
setTariffs(tariffData.tariffs);
setPromoGroups(pgData.items);
+ setCampaigns(campaignData.campaigns);
+ setPartners(partnerData.items);
} catch {
// silently fail
}
@@ -1533,6 +1621,16 @@ export default function AdminBulkActions() {
setOffset(0);
};
+ const handleCampaignFilterChange = (v: string) => {
+ setCampaignFilter(v);
+ setOffset(0);
+ };
+
+ const handlePartnerFilterChange = (v: string) => {
+ setPartnerFilter(v);
+ setOffset(0);
+ };
+
const handleRefresh = () => {
loadUsers();
};
@@ -2017,6 +2115,19 @@ export default function AdminBulkActions() {
...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 (
@@ -2091,6 +2202,20 @@ export default function AdminBulkActions() {
/>
+ {/* Campaign & Partner filters */}
+
+
+
+
+
{/* Trial filter checkbox */}