diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 56ace51..f774774 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -519,6 +519,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 4520e2d..ef1ef93 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3650,7 +3650,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 f135511..e2a4d74 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3109,7 +3109,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 a54c5ad..7cfcb5a 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4047,7 +4047,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 754fb98..bcb9953 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3108,7 +3108,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..09a1e18 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -28,6 +28,7 @@ import { ActivityTab } from '../components/admin/userDetail/ActivityTab'; import { TicketsTab } from '../components/admin/userDetail/TicketsTab'; import { InfoTab } from '../components/admin/userDetail/InfoTab'; import { SubscriptionTab } from '../components/admin/userDetail/SubscriptionTab'; +import { getApiErrorMessage } from '../utils/api-error'; import { toNumber } from '../utils/inputHelpers'; import { usePermissionStore } from '../store/permissions'; @@ -662,6 +663,28 @@ 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 (err) { + // Отказы тут осмысленные и действенные: открытый временный доступ + // (409, «сначала заверши или восстанови grace»), активная платная без + // force (409), подписки нет (404). Общее «Ошибка» оставило бы админа + // гадать, почему кнопка не сработала, — показываем текст сервера. + notify.error(getApiErrorMessage(err, t('admin.users.userActions.error')), t('common.error')); + } finally { + setActionLoading(false); + } + }; + const handleDisableUser = async () => { if (!userId) return; setActionLoading(true); @@ -874,6 +897,7 @@ export default function AdminUserDetail() { userSubscriptions={userSubscriptions} selectedSub={selectedSub} onCancelSbpRecurring={handleCancelSbpRecurring} + onDeleteSubscription={handleDeleteSubscription} activeSubscriptionId={activeSubscriptionId} onActiveSubscriptionChange={setActiveSubscriptionId} subscriptionDetailView={subscriptionDetailView}