From eafc563128b68f49f0a1f03a30c899b527ce4734 Mon Sep 17 00:00:00 2001 From: Case211 <87642841+Case211@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:38:20 +0500 Subject: [PATCH] =?UTF-8?q?feat(users):=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В мультитарифном режиме у пользователя несколько подписок, и убрать лишнюю — например, отработавший триал — можно было только через экран «Массовые действия». Во вкладке «Подписка» карточки подписки видны и открываются, но удалить выбранную нечем. Кнопка живёт в детальном виде подписки и требует подтверждения тем же inline-механизмом, что и отмена автоплатежа; ключ подтверждения включает id подписки, чтобы взведённое согласие не пережило переключение на соседнюю. Активная платная подписка на сервере защищена от случайного удаления, поэтому для неё запрос идёт с явным force — намерение админ уже подтвердил. --- src/api/adminUsers.ts | 13 +++++++ .../admin/userDetail/SubscriptionTab.tsx | 37 +++++++++++++++++++ src/locales/en.json | 6 ++- src/locales/fa.json | 6 ++- src/locales/ru.json | 6 ++- src/locales/zh.json | 6 ++- src/pages/AdminUserDetail.tsx | 19 ++++++++++ 7 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index abd4863..4df5c37 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -518,6 +518,19 @@ export const adminUsersApi = { return response.data; }, + // Delete one of the user's subscriptions (multi-tariff: trials pile up) + deleteSubscription: async ( + userId: number, + subId: number, + force = false, + ): Promise<{ status: string }> => { + const response = await apiClient.delete( + `/cabinet/admin/users/${userId}/subscriptions/${subId}`, + { params: force ? { force: true } : undefined }, + ); + return response.data; + }, + // Update status updateStatus: async ( userId: number, diff --git a/src/components/admin/userDetail/SubscriptionTab.tsx b/src/components/admin/userDetail/SubscriptionTab.tsx index a8b83c8..40e66de 100644 --- a/src/components/admin/userDetail/SubscriptionTab.tsx +++ b/src/components/admin/userDetail/SubscriptionTab.tsx @@ -131,6 +131,7 @@ export interface SubscriptionTabProps { onRemoveTraffic: (purchaseId: number) => Promise; onResetDevices: () => Promise; onCancelSbpRecurring: () => Promise; + onDeleteSubscription: () => Promise; onDeleteDevice: (hwid: string) => Promise; onRenameDevice: (hwid: string) => Promise; onLoadDevices: () => Promise; @@ -195,6 +196,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) { onRemoveTraffic, onResetDevices, onCancelSbpRecurring, + onDeleteSubscription, onDeleteDevice, onRenameDevice, onLoadDevices, @@ -429,6 +431,41 @@ export function SubscriptionTab(props: SubscriptionTabProps) { )} + {/* Delete this subscription — in multi-tariff mode spent trials + pile up in the card, and removing one used to be possible + only through the bulk-actions screen. */} + {hasPermission('users:subscription') && ( +
+
+
+
+ {t('admin.users.detail.subscription.deleteTitle')} +
+
+ {t('admin.users.detail.subscription.deleteHint')} +
+
+ +
+
+ )} + {/* Traffic Packages */} {selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
diff --git a/src/locales/en.json b/src/locales/en.json index b07b36d..133b0af 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3646,7 +3646,11 @@ "sbpCancelled": "SBP auto-payment disabled", "sbpStatus_PENDING": "Pending", "sbpStatus_ACTIVE": "Active", - "sbpStatus_PAST_DUE": "Payment failed" + "sbpStatus_PAST_DUE": "Payment failed", + "deleteTitle": "Delete subscription", + "deleteHint": "The subscription and its devices will be removed permanently", + "deleteButton": "Delete subscription", + "deleted": "Subscription deleted" }, "balance": { "current": "Current balance", diff --git a/src/locales/fa.json b/src/locales/fa.json index cbbb785..f343f7d 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3105,7 +3105,11 @@ "sbpCancelled": "پرداخت خودکار SBP غیرفعال شد", "sbpStatus_PENDING": "در انتظار", "sbpStatus_ACTIVE": "فعال", - "sbpStatus_PAST_DUE": "پرداخت ناموفق" + "sbpStatus_PAST_DUE": "پرداخت ناموفق", + "deleteTitle": "حذف اشتراک", + "deleteHint": "اشتراک و دستگاه‌های آن برای همیشه حذف می‌شوند", + "deleteButton": "حذف اشتراک", + "deleted": "اشتراک حذف شد" }, "balance": { "current": "موجودی فعلی", diff --git a/src/locales/ru.json b/src/locales/ru.json index 080e88f..cb13ebb 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4043,7 +4043,11 @@ "sbpCancelled": "Автоплатёж по СБП отключён", "sbpStatus_PENDING": "Ожидает подтверждения", "sbpStatus_ACTIVE": "Активен", - "sbpStatus_PAST_DUE": "Платёж не прошёл" + "sbpStatus_PAST_DUE": "Платёж не прошёл", + "deleteTitle": "Удаление подписки", + "deleteHint": "Подписка и её устройства будут удалены безвозвратно", + "deleteButton": "Удалить подписку", + "deleted": "Подписка удалена" }, "balance": { "current": "Текущий баланс", diff --git a/src/locales/zh.json b/src/locales/zh.json index 49b202d..797a62e 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3104,7 +3104,11 @@ "sbpCancelled": "SBP 自动扣款已关闭", "sbpStatus_PENDING": "待确认", "sbpStatus_ACTIVE": "已启用", - "sbpStatus_PAST_DUE": "扣款失败" + "sbpStatus_PAST_DUE": "扣款失败", + "deleteTitle": "删除订阅", + "deleteHint": "订阅及其设备将被永久删除", + "deleteButton": "删除订阅", + "deleted": "订阅已删除" }, "balance": { "current": "当前余额", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index db7abb9..7902e06 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -662,6 +662,24 @@ export default function AdminUserDetail() { } }; + const handleDeleteSubscription = async () => { + if (!userId || !selectedSub) return; + setActionLoading(true); + try { + // Активную платную подписку сервер по умолчанию бережёт — админ уже + // подтвердил намерение кнопкой, поэтому просим удалить именно её. + const force = Boolean(selectedSub.is_active) && !selectedSub.is_trial; + await adminUsersApi.deleteSubscription(userId, selectedSub.id, force); + notify.success(t('admin.users.detail.subscription.deleted'), t('common.success')); + setSubscriptionDetailView(false); + await loadUser(); + } catch { + notify.error(t('admin.users.userActions.error'), t('common.error')); + } finally { + setActionLoading(false); + } + }; + const handleDisableUser = async () => { if (!userId) return; setActionLoading(true); @@ -874,6 +892,7 @@ export default function AdminUserDetail() { userSubscriptions={userSubscriptions} selectedSub={selectedSub} onCancelSbpRecurring={handleCancelSbpRecurring} + onDeleteSubscription={handleDeleteSubscription} activeSubscriptionId={activeSubscriptionId} onActiveSubscriptionChange={setActiveSubscriptionId} subscriptionDetailView={subscriptionDetailView}