Merge pull request #542 from Case211/feat/admin-delete-subscription

This commit is contained in:
Fringg
2026-08-06 02:25:10 +03:00
7 changed files with 94 additions and 4 deletions

View File

@@ -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,

View File

@@ -131,6 +131,7 @@ export interface SubscriptionTabProps {
onRemoveTraffic: (purchaseId: number) => Promise<void>;
onResetDevices: () => Promise<void>;
onCancelSbpRecurring: () => Promise<void>;
onDeleteSubscription: () => Promise<void>;
onDeleteDevice: (hwid: string) => Promise<void>;
onRenameDevice: (hwid: string) => Promise<void>;
onLoadDevices: () => Promise<void>;
@@ -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) {
</div>
)}
{/* 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') && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-dark-200">
{t('admin.users.detail.subscription.deleteTitle')}
</div>
<div className="mt-0.5 text-xs text-dark-400">
{t('admin.users.detail.subscription.deleteHint')}
</div>
</div>
<button
// Per-subscription confirm key: an armed confirm must not
// survive switching to another subscription in the picker.
onClick={() =>
onInlineConfirm(`deleteSubscription_${selectedSub.id}`, onDeleteSubscription)
}
disabled={actionLoading}
className={`shrink-0 rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
confirmingAction === `deleteSubscription_${selectedSub.id}`
? 'bg-error-500 text-white'
: 'bg-error-500/15 text-error-400 hover:bg-error-500/25'
}`}
>
{confirmingAction === `deleteSubscription_${selectedSub.id}`
? t('admin.users.detail.actions.areYouSure')
: t('admin.users.detail.subscription.deleteButton')}
</button>
</div>
</div>
)}
{/* Traffic Packages */}
{selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
<div className="rounded-xl bg-dark-800/50 p-4">

View File

@@ -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",

View File

@@ -3109,7 +3109,11 @@
"sbpCancelled": "پرداخت خودکار SBP غیرفعال شد",
"sbpStatus_PENDING": "در انتظار",
"sbpStatus_ACTIVE": "فعال",
"sbpStatus_PAST_DUE": "پرداخت ناموفق"
"sbpStatus_PAST_DUE": "پرداخت ناموفق",
"deleteTitle": "حذف اشتراک",
"deleteHint": "اشتراک و دستگاه‌های آن برای همیشه حذف می‌شوند",
"deleteButton": "حذف اشتراک",
"deleted": "اشتراک حذف شد"
},
"balance": {
"current": "موجودی فعلی",

View File

@@ -4047,7 +4047,11 @@
"sbpCancelled": "Автоплатёж по СБП отключён",
"sbpStatus_PENDING": "Ожидает подтверждения",
"sbpStatus_ACTIVE": "Активен",
"sbpStatus_PAST_DUE": "Платёж не прошёл"
"sbpStatus_PAST_DUE": "Платёж не прошёл",
"deleteTitle": "Удаление подписки",
"deleteHint": "Подписка и её устройства будут удалены безвозвратно",
"deleteButton": "Удалить подписку",
"deleted": "Подписка удалена"
},
"balance": {
"current": "Текущий баланс",

View File

@@ -3108,7 +3108,11 @@
"sbpCancelled": "SBP 自动扣款已关闭",
"sbpStatus_PENDING": "待确认",
"sbpStatus_ACTIVE": "已启用",
"sbpStatus_PAST_DUE": "扣款失败"
"sbpStatus_PAST_DUE": "扣款失败",
"deleteTitle": "删除订阅",
"deleteHint": "订阅及其设备将被永久删除",
"deleteButton": "删除订阅",
"deleted": "订阅已删除"
},
"balance": {
"current": "当前余额",

View File

@@ -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}