mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat: move user action buttons to detail page and fix full delete
- Add fullDeleteUser API method calling DELETE /admin/users/{id}/full
- Add Reset trial, Reset subscription, Disable, Delete buttons to AdminUserDetail info tab
- Implement inline confirm pattern (click → "Are you sure?" → execute, 3s timeout)
- Delete now calls /full endpoint removing user from bot DB and Remnawave panel
- Remove UserActionsMenu dropdown, ConfirmationModal and related code from AdminUsers list
- Update delete confirmation text in ru/en locales to reflect full deletion
This commit is contained in:
@@ -415,7 +415,7 @@ export const adminUsersApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete user
|
// Delete user (soft delete, does NOT remove from Remnawave)
|
||||||
deleteUser: async (
|
deleteUser: async (
|
||||||
userId: number,
|
userId: number,
|
||||||
softDelete = true,
|
softDelete = true,
|
||||||
@@ -427,6 +427,20 @@ export const adminUsersApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Full delete user (removes from bot DB + Remnawave panel)
|
||||||
|
fullDeleteUser: async (
|
||||||
|
userId: number,
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
deleted_from_bot: boolean;
|
||||||
|
deleted_from_panel: boolean;
|
||||||
|
panel_error: string | null;
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/full`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Get referrals
|
// Get referrals
|
||||||
getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => {
|
getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => {
|
||||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, {
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, {
|
||||||
|
|||||||
@@ -1808,7 +1808,7 @@
|
|||||||
},
|
},
|
||||||
"confirmDelete": {
|
"confirmDelete": {
|
||||||
"title": "Delete user?",
|
"title": "Delete user?",
|
||||||
"message": "Are you sure? This action is irreversible. All user data will be deleted."
|
"message": "Are you sure? This action is irreversible. The user will be fully deleted from the bot and from the Remnawave panel."
|
||||||
},
|
},
|
||||||
"confirmResetTrial": {
|
"confirmResetTrial": {
|
||||||
"title": "Reset trial?",
|
"title": "Reset trial?",
|
||||||
@@ -1848,6 +1848,10 @@
|
|||||||
"subscription": "• Subscription purchase prohibited",
|
"subscription": "• Subscription purchase prohibited",
|
||||||
"reason": "Reason"
|
"reason": "Reason"
|
||||||
},
|
},
|
||||||
|
"actions": {
|
||||||
|
"title": "Actions",
|
||||||
|
"areYouSure": "Are you sure?"
|
||||||
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"current": "Current subscription",
|
"current": "Current subscription",
|
||||||
"tariff": "Tariff",
|
"tariff": "Tariff",
|
||||||
|
|||||||
@@ -2335,7 +2335,7 @@
|
|||||||
},
|
},
|
||||||
"confirmDelete": {
|
"confirmDelete": {
|
||||||
"title": "Удалить пользователя?",
|
"title": "Удалить пользователя?",
|
||||||
"message": "Вы уверены? Это действие необратимо. Все данные пользователя будут удалены."
|
"message": "Вы уверены? Это действие необратимо. Пользователь будет полностью удалён из бота и из панели Remnawave."
|
||||||
},
|
},
|
||||||
"confirmResetTrial": {
|
"confirmResetTrial": {
|
||||||
"title": "Сбросить триал?",
|
"title": "Сбросить триал?",
|
||||||
@@ -2375,6 +2375,10 @@
|
|||||||
"subscription": "• Запрет покупки подписки",
|
"subscription": "• Запрет покупки подписки",
|
||||||
"reason": "Причина"
|
"reason": "Причина"
|
||||||
},
|
},
|
||||||
|
"actions": {
|
||||||
|
"title": "Действия",
|
||||||
|
"areYouSure": "Уверены?"
|
||||||
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"current": "Текущая подписка",
|
"current": "Текущая подписка",
|
||||||
"tariff": "Тариф",
|
"tariff": "Тариф",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import i18n from '../i18n';
|
import i18n from '../i18n';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
|
import { useNotify } from '../platform/hooks/useNotify';
|
||||||
import {
|
import {
|
||||||
adminUsersApi,
|
adminUsersApi,
|
||||||
type UserDetailResponse,
|
type UserDetailResponse,
|
||||||
@@ -80,6 +81,7 @@ export default function AdminUserDetail() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { formatWithCurrency } = useCurrency();
|
const { formatWithCurrency } = useCurrency();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const notify = useNotify();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
|
||||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||||
@@ -92,6 +94,10 @@ export default function AdminUserDetail() {
|
|||||||
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]);
|
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]);
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
|
|
||||||
|
// Inline confirm state
|
||||||
|
const [confirmingAction, setConfirmingAction] = useState<string | null>(null);
|
||||||
|
const confirmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Balance form
|
// Balance form
|
||||||
const [balanceAmount, setBalanceAmount] = useState<number | ''>('');
|
const [balanceAmount, setBalanceAmount] = useState<number | ''>('');
|
||||||
const [balanceDescription, setBalanceDescription] = useState('');
|
const [balanceDescription, setBalanceDescription] = useState('');
|
||||||
@@ -256,6 +262,90 @@ export default function AdminUserDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleInlineConfirm = (actionKey: string, executeFn: () => Promise<void>) => {
|
||||||
|
if (confirmingAction === actionKey) {
|
||||||
|
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
|
||||||
|
setConfirmingAction(null);
|
||||||
|
executeFn();
|
||||||
|
} else {
|
||||||
|
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
|
||||||
|
setConfirmingAction(actionKey);
|
||||||
|
confirmTimerRef.current = setTimeout(() => setConfirmingAction(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetTrial = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await adminUsersApi.resetTrial(userId);
|
||||||
|
if (result.success) {
|
||||||
|
notify.success(t('admin.users.userActions.success.resetTrial'), t('common.success'));
|
||||||
|
await loadUser();
|
||||||
|
} else {
|
||||||
|
notify.error(result.message || t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetSubscription = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await adminUsersApi.resetSubscription(userId);
|
||||||
|
if (result.success) {
|
||||||
|
notify.success(t('admin.users.userActions.success.resetSubscription'), t('common.success'));
|
||||||
|
await loadUser();
|
||||||
|
} else {
|
||||||
|
notify.error(result.message || t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDisableUser = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await adminUsersApi.disableUser(userId);
|
||||||
|
if (result.success) {
|
||||||
|
notify.success(t('admin.users.userActions.success.disable'), t('common.success'));
|
||||||
|
await loadUser();
|
||||||
|
} else {
|
||||||
|
notify.error(result.message || t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFullDeleteUser = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await adminUsersApi.fullDeleteUser(userId);
|
||||||
|
if (result.success) {
|
||||||
|
notify.success(t('admin.users.userActions.success.delete'), t('common.success'));
|
||||||
|
navigate('/admin/users');
|
||||||
|
} else {
|
||||||
|
notify.error(result.message || t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (date: string | null) => {
|
const formatDate = (date: string | null) => {
|
||||||
if (!date) return '-';
|
if (!date) return '-';
|
||||||
return new Date(date).toLocaleDateString(locale, {
|
return new Date(date).toLocaleDateString(locale, {
|
||||||
@@ -459,6 +549,67 @@ export default function AdminUserDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
|
<div className="mb-3 text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.users.detail.actions.title')}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleInlineConfirm('resetTrial', handleResetTrial)}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className={`rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
|
||||||
|
confirmingAction === 'resetTrial'
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmingAction === 'resetTrial'
|
||||||
|
? t('admin.users.detail.actions.areYouSure')
|
||||||
|
: t('admin.users.userActions.resetTrial')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleInlineConfirm('resetSubscription', handleResetSubscription)}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className={`rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
|
||||||
|
confirmingAction === 'resetSubscription'
|
||||||
|
? 'bg-amber-500 text-white'
|
||||||
|
: 'bg-amber-500/15 text-amber-400 hover:bg-amber-500/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmingAction === 'resetSubscription'
|
||||||
|
? t('admin.users.detail.actions.areYouSure')
|
||||||
|
: t('admin.users.userActions.resetSubscription')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleInlineConfirm('disable', handleDisableUser)}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className={`rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
|
||||||
|
confirmingAction === 'disable'
|
||||||
|
? 'bg-dark-500 text-white'
|
||||||
|
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmingAction === 'disable'
|
||||||
|
? t('admin.users.detail.actions.areYouSure')
|
||||||
|
: t('admin.users.userActions.disable')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleInlineConfirm('fullDelete', handleFullDeleteUser)}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className={`rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
|
||||||
|
confirmingAction === 'fullDelete'
|
||||||
|
? 'bg-rose-500 text-white'
|
||||||
|
: 'bg-rose-500/15 text-rose-400 hover:bg-rose-500/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmingAction === 'fullDelete'
|
||||||
|
? t('admin.users.detail.actions.areYouSure')
|
||||||
|
: t('admin.users.userActions.delete')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers';
|
import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
import { useNotify } from '../platform/hooks/useNotify';
|
|
||||||
|
|
||||||
// ============ Icons ============
|
// ============ Icons ============
|
||||||
|
|
||||||
@@ -58,204 +57,6 @@ const TelegramIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const DotsVerticalIcon = () => (
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M12 6.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 12.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 18.75a.75.75 0 110-1.5.75.75 0 010 1.5z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const TrashIcon = () => (
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ArrowPathIcon = () => (
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const NoSymbolIcon = () => (
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ExclamationTriangleIcon = () => (
|
|
||||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
// ============ Confirmation Modal ============
|
|
||||||
|
|
||||||
interface ConfirmationModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
confirmText: string;
|
|
||||||
confirmButtonClass?: string;
|
|
||||||
isLoading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConfirmationModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
confirmText,
|
|
||||||
confirmButtonClass = 'bg-rose-500 hover:bg-rose-600',
|
|
||||||
isLoading = false,
|
|
||||||
}: ConfirmationModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 backdrop-blur-sm">
|
|
||||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
|
||||||
<div className="relative w-full max-w-md rounded-2xl border border-dark-700 bg-dark-800 p-6 shadow-xl">
|
|
||||||
<div className="mb-4 flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-rose-500/20 text-rose-400">
|
|
||||||
<ExclamationTriangleIcon />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-dark-100">{title}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="mb-6 text-dark-300">{message}</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 py-2.5 text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={isLoading}
|
|
||||||
className={`flex-1 rounded-xl py-2.5 text-white transition-colors disabled:opacity-50 ${confirmButtonClass}`}
|
|
||||||
>
|
|
||||||
{isLoading ? t('common.loading') : confirmText}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ User Actions Menu ============
|
|
||||||
|
|
||||||
type UserAction = 'delete' | 'resetTrial' | 'resetSubscription' | 'disable';
|
|
||||||
|
|
||||||
interface UserActionsMenuProps {
|
|
||||||
user: UserListItem;
|
|
||||||
onAction: (action: UserAction, user: UserListItem) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function UserActionsMenu({ user, onAction }: UserActionsMenuProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleAction = (action: UserAction) => {
|
|
||||||
setIsOpen(false);
|
|
||||||
onAction(action, user);
|
|
||||||
};
|
|
||||||
|
|
||||||
const actions = [
|
|
||||||
{
|
|
||||||
key: 'resetTrial' as const,
|
|
||||||
label: t('admin.users.userActions.resetTrial'),
|
|
||||||
icon: <ArrowPathIcon />,
|
|
||||||
className: 'text-blue-400 hover:bg-blue-500/10',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'resetSubscription' as const,
|
|
||||||
label: t('admin.users.userActions.resetSubscription'),
|
|
||||||
icon: <ArrowPathIcon />,
|
|
||||||
className: 'text-amber-400 hover:bg-amber-500/10',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'disable' as const,
|
|
||||||
label: t('admin.users.userActions.disable'),
|
|
||||||
icon: <NoSymbolIcon />,
|
|
||||||
className: 'text-dark-400 hover:bg-dark-700',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'delete' as const,
|
|
||||||
label: t('admin.users.userActions.delete'),
|
|
||||||
icon: <TrashIcon />,
|
|
||||||
className: 'text-rose-400 hover:bg-rose-500/10',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative" ref={menuRef}>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsOpen(!isOpen);
|
|
||||||
}}
|
|
||||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
|
||||||
>
|
|
||||||
<DotsVerticalIcon />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
|
|
||||||
{actions.map((action) => (
|
|
||||||
<button
|
|
||||||
key={action.key}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleAction(action.key);
|
|
||||||
}}
|
|
||||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors ${action.className}`}
|
|
||||||
>
|
|
||||||
{action.icon}
|
|
||||||
{action.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Components ============
|
// ============ Components ============
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
@@ -305,11 +106,10 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
interface UserRowProps {
|
interface UserRowProps {
|
||||||
user: UserListItem;
|
user: UserListItem;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
onAction: (action: UserAction, user: UserListItem) => void;
|
|
||||||
formatAmount: (rubAmount: number) => string;
|
formatAmount: (rubAmount: number) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function UserRow({ user, onClick, onAction, formatAmount }: UserRowProps) {
|
function UserRow({ user, onClick, formatAmount }: UserRowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -372,29 +172,17 @@ function UserRow({ user, onClick, onAction, formatAmount }: UserRowProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions Menu - hide chevron on mobile, show only dots */}
|
<ChevronRightIcon />
|
||||||
<UserActionsMenu user={user} onAction={onAction} />
|
|
||||||
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<ChevronRightIcon />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Main Page ============
|
// ============ Main Page ============
|
||||||
|
|
||||||
interface ConfirmModalState {
|
|
||||||
isOpen: boolean;
|
|
||||||
action: UserAction | null;
|
|
||||||
user: UserListItem | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminUsers() {
|
export default function AdminUsers() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { formatWithCurrency } = useCurrency();
|
const { formatWithCurrency } = useCurrency();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const notify = useNotify();
|
|
||||||
const { capabilities } = usePlatform();
|
const { capabilities } = usePlatform();
|
||||||
|
|
||||||
const [users, setUsers] = useState<UserListItem[]>([]);
|
const [users, setUsers] = useState<UserListItem[]>([]);
|
||||||
@@ -406,12 +194,6 @@ export default function AdminUsers() {
|
|||||||
const [sortBy, setSortBy] = useState<string>('created_at');
|
const [sortBy, setSortBy] = useState<string>('created_at');
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [confirmModal, setConfirmModal] = useState<ConfirmModalState>({
|
|
||||||
isOpen: false,
|
|
||||||
action: null,
|
|
||||||
user: null,
|
|
||||||
});
|
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
|
||||||
|
|
||||||
const limit = 20;
|
const limit = 20;
|
||||||
|
|
||||||
@@ -458,92 +240,6 @@ export default function AdminUsers() {
|
|||||||
loadUsers();
|
loadUsers();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUserAction = (action: UserAction, user: UserListItem) => {
|
|
||||||
setConfirmModal({ isOpen: true, action, user });
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeConfirmModal = () => {
|
|
||||||
setConfirmModal({ isOpen: false, action: null, user: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const executeAction = async () => {
|
|
||||||
const { action, user } = confirmModal;
|
|
||||||
if (!action || !user) return;
|
|
||||||
|
|
||||||
setActionLoading(true);
|
|
||||||
try {
|
|
||||||
let result: { success: boolean; message: string };
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'delete':
|
|
||||||
result = await adminUsersApi.deleteUser(user.id);
|
|
||||||
break;
|
|
||||||
case 'resetTrial':
|
|
||||||
result = await adminUsersApi.resetTrial(user.id);
|
|
||||||
break;
|
|
||||||
case 'resetSubscription':
|
|
||||||
result = await adminUsersApi.resetSubscription(user.id);
|
|
||||||
break;
|
|
||||||
case 'disable':
|
|
||||||
result = await adminUsersApi.disableUser(user.id);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error('Unknown action');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
notify.success(t(`admin.users.userActions.success.${action}`), t('common.success'));
|
|
||||||
loadUsers();
|
|
||||||
loadStats();
|
|
||||||
} else {
|
|
||||||
notify.error(result.message || t('admin.users.userActions.error'), t('common.error'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Action failed:', error);
|
|
||||||
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
|
||||||
} finally {
|
|
||||||
setActionLoading(false);
|
|
||||||
closeConfirmModal();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getConfirmModalContent = () => {
|
|
||||||
const { action } = confirmModal;
|
|
||||||
if (!action) return { title: '', message: '', confirmText: '', confirmButtonClass: '' };
|
|
||||||
|
|
||||||
const configs: Record<
|
|
||||||
UserAction,
|
|
||||||
{ title: string; message: string; confirmText: string; confirmButtonClass: string }
|
|
||||||
> = {
|
|
||||||
delete: {
|
|
||||||
title: t('admin.users.userActions.confirmDelete.title'),
|
|
||||||
message: t('admin.users.userActions.confirmDelete.message'),
|
|
||||||
confirmText: t('admin.users.userActions.delete'),
|
|
||||||
confirmButtonClass: 'bg-rose-500 hover:bg-rose-600',
|
|
||||||
},
|
|
||||||
resetTrial: {
|
|
||||||
title: t('admin.users.userActions.confirmResetTrial.title'),
|
|
||||||
message: t('admin.users.userActions.confirmResetTrial.message'),
|
|
||||||
confirmText: t('admin.users.userActions.resetTrial'),
|
|
||||||
confirmButtonClass: 'bg-blue-500 hover:bg-blue-600',
|
|
||||||
},
|
|
||||||
resetSubscription: {
|
|
||||||
title: t('admin.users.userActions.confirmResetSubscription.title'),
|
|
||||||
message: t('admin.users.userActions.confirmResetSubscription.message'),
|
|
||||||
confirmText: t('admin.users.userActions.resetSubscription'),
|
|
||||||
confirmButtonClass: 'bg-amber-500 hover:bg-amber-600',
|
|
||||||
},
|
|
||||||
disable: {
|
|
||||||
title: t('admin.users.userActions.confirmDisable.title'),
|
|
||||||
message: t('admin.users.userActions.confirmDisable.message'),
|
|
||||||
confirmText: t('admin.users.userActions.disable'),
|
|
||||||
confirmButtonClass: 'bg-dark-600 hover:bg-dark-500',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return configs[action];
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
const currentPage = Math.floor(offset / limit) + 1;
|
const currentPage = Math.floor(offset / limit) + 1;
|
||||||
|
|
||||||
@@ -682,7 +378,6 @@ export default function AdminUsers() {
|
|||||||
key={user.id}
|
key={user.id}
|
||||||
user={user}
|
user={user}
|
||||||
onClick={() => navigate(`/admin/users/${user.id}`)}
|
onClick={() => navigate(`/admin/users/${user.id}`)}
|
||||||
onAction={handleUserAction}
|
|
||||||
formatAmount={(amount) => formatWithCurrency(amount)}
|
formatAmount={(amount) => formatWithCurrency(amount)}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -720,15 +415,6 @@ export default function AdminUsers() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User action confirmation modal */}
|
|
||||||
<ConfirmationModal
|
|
||||||
isOpen={confirmModal.isOpen}
|
|
||||||
onClose={closeConfirmModal}
|
|
||||||
onConfirm={executeAction}
|
|
||||||
isLoading={actionLoading}
|
|
||||||
{...getConfirmModalContent()}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user