Update AdminUsers.tsx

This commit is contained in:
Egor
2026-02-03 04:39:42 +03:00
committed by GitHub
parent eae12e2694
commit 42bb563ab6

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { useCurrency } from '../hooks/useCurrency';
import { useToast } from '../components/Toast';
import {
adminUsersApi,
type UserListItem,
@@ -81,6 +82,56 @@ const TelegramIcon = () => (
</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>
);
// ============ Components ============
interface StatCardProps {
@@ -125,15 +176,167 @@ function StatusBadge({ status }: { status: string }) {
);
}
// ============ 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>
);
}
// ============ User List Component ============
interface UserRowProps {
user: UserListItem;
onSelect: (user: UserListItem) => void;
onAction: (action: UserAction, user: UserListItem) => void;
formatAmount: (rubAmount: number) => string;
}
function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
function UserRow({ user, onSelect, onAction, formatAmount }: UserRowProps) {
const { t } = useTranslation();
return (
<div
@@ -187,6 +390,9 @@ function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
</div>
</div>
{/* Actions Menu */}
<UserActionsMenu user={user} onAction={onAction} />
<ChevronRightIcon />
</div>
);
@@ -970,9 +1176,16 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
// ============ Main Page ============
interface ConfirmModalState {
isOpen: boolean;
action: UserAction | null;
user: UserListItem | null;
}
export default function AdminUsers() {
const { t } = useTranslation();
const { formatWithCurrency } = useCurrency();
const { showToast } = useToast();
const [users, setUsers] = useState<UserListItem[]>([]);
const [stats, setStats] = useState<UsersStatsResponse | null>(null);
@@ -984,6 +1197,12 @@ export default function AdminUsers() {
const [offset, setOffset] = useState(0);
const [total, setTotal] = useState(0);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [confirmModal, setConfirmModal] = useState<ConfirmModalState>({
isOpen: false,
action: null,
user: null,
});
const [actionLoading, setActionLoading] = useState(false);
const limit = 20;
@@ -1030,6 +1249,101 @@ export default function AdminUsers() {
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) {
showToast({
type: 'success',
title: t('common.success'),
message: t(`admin.users.userActions.success.${action}`),
});
loadUsers();
loadStats();
} else {
showToast({
type: 'error',
title: t('common.error'),
message: result.message || t('admin.users.userActions.error'),
});
}
} catch (error) {
console.error('Action failed:', error);
showToast({
type: 'error',
title: t('common.error'),
message: t('admin.users.userActions.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 currentPage = Math.floor(offset / limit) + 1;
@@ -1165,6 +1479,7 @@ export default function AdminUsers() {
key={user.id}
user={user}
onSelect={(u) => setSelectedUserId(u.id)}
onAction={handleUserAction}
formatAmount={(amount) => formatWithCurrency(amount)}
/>
))
@@ -1214,6 +1529,15 @@ export default function AdminUsers() {
}}
/>
)}
{/* User action confirmation modal */}
<ConfirmationModal
isOpen={confirmModal.isOpen}
onClose={closeConfirmModal}
onConfirm={executeAction}
isLoading={actionLoading}
{...getConfirmModalContent()}
/>
</div>
);
}