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 */}