diff --git a/src/components/admin/userDetail/InfoTab.tsx b/src/components/admin/userDetail/InfoTab.tsx new file mode 100644 index 0000000..358afb5 --- /dev/null +++ b/src/components/admin/userDetail/InfoTab.tsx @@ -0,0 +1,540 @@ +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; +import { useCurrency } from '../../../hooks/useCurrency'; +import { createNumberInputHandler } from '../../../utils/inputHelpers'; +import type { + UserDetailResponse, + UserListItem, + UserPanelInfo, + UserSubscriptionInfo, +} from '../../../api/adminUsers'; +import type { PromoGroup } from '../../../api/promocodes'; + +// ────────────────────────────────────────────────────────────────── +// Local status badge (parent has its own — duplicating here to keep +// this file self-contained for the tab's smallest unit of meaning). +// ────────────────────────────────────────────────────────────────── + +function StatusBadge({ status }: { status: string }) { + const { t } = useTranslation(); + const styles: Record = { + active: 'bg-success-500/20 text-success-400 border-success-500/30', + blocked: 'bg-error-500/20 text-error-400 border-error-500/30', + deleted: 'bg-dark-600 text-dark-400 border-dark-500', + }; + return ( + + {t(`admin.users.status.${status}`, status)} + + ); +} + +// ────────────────────────────────────────────────────────────────── +// Info tab — user metadata + VPN connection + promo group + referral +// + restrictions + 4 destructive actions (reset/disable/delete). +// +// The tab is a "view facade" — it reads parent state (panelInfo, +// promoGroups, etc. live up there because subscription tab also +// consumes them) and delegates every mutation to a parent handler. +// Local UI state is parent-owned for the same reason: the parent +// already maintains the inline-confirm armer for actions shared +// with the subscription tab. +// ────────────────────────────────────────────────────────────────── + +export interface InfoTabProps { + user: UserDetailResponse; + hasPermission: (perm: string) => boolean; + formatDate: (date: string | null) => string; + locale: string; + + // Subscription / panel info (also used by Subscription tab in parent) + panelInfo: UserPanelInfo | null; + panelInfoLoading: boolean; + userSubscriptions: UserSubscriptionInfo[]; + activeSubscriptionId: number | null; + onActiveSubscriptionChange: (id: number) => void; + + // Promo-group editor + promoGroups: PromoGroup[]; + editingPromoGroup: boolean; + onToggleEditingPromoGroup: () => void; + onChangePromoGroup: (groupId: number | null) => void; + + // Referral commission editor + editingReferralCommission: boolean; + referralCommissionValue: number | ''; + onSetReferralCommissionValue: (value: number | '') => void; + onToggleEditingReferralCommission: () => void; + onUpdateReferralCommission: () => void; + + // Referrals mini-list + referrals: UserListItem[]; + referralsLoading: boolean; + + // Status block/unblock + actionLoading: boolean; + onBlockUser: () => void; + onUnblockUser: () => void; + + // Destructive actions + inline-confirm armer + confirmingAction: string | null; + onInlineConfirm: (actionKey: string, executeFn: () => Promise) => void; + onResetTrial: () => Promise; + onResetSubscription: () => Promise; + onDisableUser: () => Promise; + onFullDeleteUser: () => Promise; +} + +export function InfoTab(props: InfoTabProps) { + const { t } = useTranslation(); + const { formatWithCurrency } = useCurrency(); + const navigate = useNavigate(); + const { + user, + hasPermission, + formatDate, + locale, + panelInfo, + panelInfoLoading, + userSubscriptions, + activeSubscriptionId, + onActiveSubscriptionChange, + promoGroups, + editingPromoGroup, + onToggleEditingPromoGroup, + onChangePromoGroup, + editingReferralCommission, + referralCommissionValue, + onSetReferralCommissionValue, + onToggleEditingReferralCommission, + onUpdateReferralCommission, + referrals, + referralsLoading, + actionLoading, + onBlockUser, + onUnblockUser, + confirmingAction, + onInlineConfirm, + onResetTrial, + onResetSubscription, + onDisableUser, + onFullDeleteUser, + } = props; + + return ( +
+ {/* Status */} +
+ {t('admin.users.detail.status')} +
+ + {user.status === 'active' ? ( + + ) : user.status === 'blocked' ? ( + + ) : null} +
+
+ + {/* Details grid */} +
+
+
Email
+
{user.email || '-'}
+
+
+
{t('admin.users.detail.language')}
+
{user.language}
+
+
+
{t('admin.users.detail.registration')}
+
{formatDate(user.created_at)}
+
+
+
{t('admin.users.detail.botActivity')}
+
{formatDate(user.last_activity)}
+
+
+
+ {t('admin.users.detail.cabinetLastLogin')} +
+
{formatDate(user.cabinet_last_login)}
+
+
+
{t('admin.users.detail.totalSpent')}
+
{formatWithCurrency(user.total_spent_kopeks / 100)}
+
+
+
{t('admin.users.detail.purchases')}
+
{user.purchase_count}
+
+
+ + {/* VPN Connection Info */} + {(panelInfo || userSubscriptions.length > 0) && ( +
+
+ + {t('admin.users.detail.vpnConnection')} + + {userSubscriptions.length > 1 && ( + + )} +
+ {panelInfoLoading && !panelInfo?.found && ( +
+
+
+ )} + {panelInfo?.found && ( +
+
+
+ {t('admin.users.detail.lastConnection')} +
+
+ {panelInfo.online_at && + (() => { + const onlineDate = new Date(panelInfo.online_at); + const isRecent = Date.now() - onlineDate.getTime() < 5 * 60 * 1000; + return ( + <> + + + {isRecent + ? t('admin.users.detail.online') + : formatDate(panelInfo.online_at)} + + + ); + })()} + {!panelInfo.online_at && -} +
+
+
+
+ {t('admin.users.detail.firstConnection')} +
+
+ {panelInfo.first_connected_at + ? new Date(panelInfo.first_connected_at).toLocaleDateString(locale, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + : '-'} +
+
+ {panelInfo.last_connected_node_name && ( +
+
+ {t('admin.users.detail.lastNode')} +
+
+ + + + {panelInfo.last_connected_node_name} +
+
+ )} +
+ )} + {!panelInfoLoading && !panelInfo?.found && userSubscriptions.length > 0 && ( +
+ {t('admin.users.detail.noVpnData')} +
+ )} +
+ )} + + {/* Campaign */} + {user.campaign_name && ( +
+
{t('admin.users.detail.campaign')}
+
{user.campaign_name}
+
+ )} + + {/* Promo Group */} +
+
+ {t('admin.users.detail.promoGroup')} + {hasPermission('users:promo_group') && ( + + )} +
+ {editingPromoGroup ? ( +
+ + {user.promo_group && ( + + )} +
+ ) : ( +
+ {user.promo_group?.name || ( + {t('admin.users.detail.noPromoGroup')} + )} +
+ )} +
+ + {/* Referral */} +
+
+ + {t('admin.users.detail.referral.title')} + + {hasPermission('users:referral') && ( + + )} +
+
+
+
{user.referral.referrals_count}
+
+ {t('admin.users.detail.referral.referrals')} +
+
+
+
+ {formatWithCurrency(user.referral.total_earnings_kopeks / 100)} +
+
{t('admin.users.detail.referral.earned')}
+
+
+ {editingReferralCommission ? ( +
+ + +
+ ) : ( + <> +
+ {user.referral.commission_percent != null + ? `${user.referral.commission_percent}%` + : t('admin.users.detail.referral.default')} +
+
+ {t('admin.users.detail.referral.commission')} +
+ + )} +
+
+
+ + {/* Referrals list */} + {user.referral.referrals_count > 0 && ( +
+
+ {t('admin.users.detail.referralsList')} +
+ {referralsLoading ? ( +
+
+
+ ) : referrals.length === 0 ? ( +
+ {t('admin.users.detail.noReferrals')} +
+ ) : ( +
+ {referrals.map((ref) => ( + + ))} +
+ )} +
+ )} + + {/* Restrictions */} + {(user.restriction_topup || user.restriction_subscription) && ( +
+
+ {t('admin.users.detail.restrictions.title')} +
+ {user.restriction_topup && ( +
+ {t('admin.users.detail.restrictions.topup')} +
+ )} + {user.restriction_subscription && ( +
+ {t('admin.users.detail.restrictions.subscription')} +
+ )} + {user.restriction_reason && ( +
+ {t('admin.users.detail.restrictions.reason')}: {user.restriction_reason} +
+ )} +
+ )} + + {/* Actions */} +
+
+ {t('admin.users.detail.actions.title')} +
+
+ + + + +
+
+
+ ); +} diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index d4574b8..71dd8fd 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import i18n from '../i18n'; import { DEVICE_ALIAS_MAX_LENGTH } from '../constants/devices'; -import { useCurrency } from '../hooks/useCurrency'; import { useNotify } from '../platform/hooks/useNotify'; import { copyToClipboard as copyText } from '../utils/clipboard'; import { @@ -26,6 +25,7 @@ import { SyncTab } from '../components/admin/userDetail/SyncTab'; import { ReferralsTab } from '../components/admin/userDetail/ReferralsTab'; import { BalanceTab } from '../components/admin/userDetail/BalanceTab'; import { TicketsTab } from '../components/admin/userDetail/TicketsTab'; +import { InfoTab } from '../components/admin/userDetail/InfoTab'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; import { usePermissionStore } from '../store/permissions'; import { getFlagEmoji } from '../utils/subscriptionHelpers'; @@ -95,7 +95,6 @@ function StatusBadge({ status }: { status: string }) { export default function AdminUserDetail() { const { t } = useTranslation(); - const { formatWithCurrency } = useCurrency(); const navigate = useNavigate(); const notify = useNotify(); const { id } = useParams<{ id: string }>(); @@ -861,444 +860,42 @@ export default function AdminUserDetail() {
{/* Info Tab */} {activeTab === 'info' && ( -
- {/* Status */} -
- {t('admin.users.detail.status')} -
- - {user.status === 'active' ? ( - - ) : user.status === 'blocked' ? ( - - ) : null} -
-
- - {/* Details grid */} -
-
-
Email
-
{user.email || '-'}
-
-
-
{t('admin.users.detail.language')}
-
{user.language}
-
-
-
- {t('admin.users.detail.registration')} -
-
{formatDate(user.created_at)}
-
-
-
- {t('admin.users.detail.botActivity')} -
-
{formatDate(user.last_activity)}
-
-
-
- {t('admin.users.detail.cabinetLastLogin')} -
-
{formatDate(user.cabinet_last_login)}
-
-
-
- {t('admin.users.detail.totalSpent')} -
-
- {formatWithCurrency(user.total_spent_kopeks / 100)} -
-
-
-
- {t('admin.users.detail.purchases')} -
-
{user.purchase_count}
-
-
- - {/* VPN Connection Info */} - {(panelInfo || userSubscriptions.length > 0) && ( -
-
- - {t('admin.users.detail.vpnConnection')} - - {userSubscriptions.length > 1 && ( - - )} -
- {panelInfoLoading && !panelInfo?.found && ( -
-
-
- )} - {panelInfo?.found && ( -
-
-
- {t('admin.users.detail.lastConnection')} -
-
- {panelInfo.online_at && - (() => { - const onlineDate = new Date(panelInfo.online_at); - const isRecent = Date.now() - onlineDate.getTime() < 5 * 60 * 1000; - return ( - <> - - - {isRecent - ? t('admin.users.detail.online') - : formatDate(panelInfo.online_at)} - - - ); - })()} - {!panelInfo.online_at && -} -
-
-
-
- {t('admin.users.detail.firstConnection')} -
-
- {panelInfo.first_connected_at - ? new Date(panelInfo.first_connected_at).toLocaleDateString(locale, { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }) - : '-'} -
-
- {panelInfo.last_connected_node_name && ( -
-
- {t('admin.users.detail.lastNode')} -
-
- - - - {panelInfo.last_connected_node_name} -
-
- )} -
- )} - {!panelInfoLoading && !panelInfo?.found && userSubscriptions.length > 0 && ( -
- {t('admin.users.detail.noVpnData')} -
- )} -
- )} - - {/* Campaign */} - {user.campaign_name && ( -
-
{t('admin.users.detail.campaign')}
-
{user.campaign_name}
-
- )} - - {/* Promo Group */} -
-
- {t('admin.users.detail.promoGroup')} - {hasPermission('users:promo_group') && ( - - )} -
- {editingPromoGroup ? ( -
- - {user.promo_group && ( - - )} -
- ) : ( -
- {user.promo_group?.name || ( - {t('admin.users.detail.noPromoGroup')} - )} -
- )} -
- - {/* Referral */} -
-
- - {t('admin.users.detail.referral.title')} - - {hasPermission('users:referral') && ( - - )} -
-
-
-
- {user.referral.referrals_count} -
-
- {t('admin.users.detail.referral.referrals')} -
-
-
-
- {formatWithCurrency(user.referral.total_earnings_kopeks / 100)} -
-
- {t('admin.users.detail.referral.earned')} -
-
-
- {editingReferralCommission ? ( -
- - -
- ) : ( - <> -
- {user.referral.commission_percent != null - ? `${user.referral.commission_percent}%` - : t('admin.users.detail.referral.default')} -
-
- {t('admin.users.detail.referral.commission')} -
- - )} -
-
-
- - {/* Referrals list */} - {user.referral.referrals_count > 0 && ( -
-
- {t('admin.users.detail.referralsList')} -
- {referralsLoading ? ( -
-
-
- ) : referrals.length === 0 ? ( -
- {t('admin.users.detail.noReferrals')} -
- ) : ( -
- {referrals.map((ref) => ( - - ))} -
- )} -
- )} - - {/* Restrictions */} - {(user.restriction_topup || user.restriction_subscription) && ( -
-
- {t('admin.users.detail.restrictions.title')} -
- {user.restriction_topup && ( -
- {t('admin.users.detail.restrictions.topup')} -
- )} - {user.restriction_subscription && ( -
- {t('admin.users.detail.restrictions.subscription')} -
- )} - {user.restriction_reason && ( -
- {t('admin.users.detail.restrictions.reason')}: {user.restriction_reason} -
- )} -
- )} - - {/* Actions */} -
-
- {t('admin.users.detail.actions.title')} -
-
- - - - -
-
-
+ setEditingPromoGroup(!editingPromoGroup)} + onChangePromoGroup={handleChangePromoGroup} + editingReferralCommission={editingReferralCommission} + referralCommissionValue={referralCommissionValue} + onSetReferralCommissionValue={setReferralCommissionValue} + onToggleEditingReferralCommission={() => { + if (!editingReferralCommission) { + setReferralCommissionValue(user.referral.commission_percent ?? ''); + } + setEditingReferralCommission(!editingReferralCommission); + }} + onUpdateReferralCommission={handleUpdateReferralCommission} + referrals={referrals} + referralsLoading={referralsLoading} + actionLoading={actionLoading} + onBlockUser={handleBlockUser} + onUnblockUser={handleUnblockUser} + confirmingAction={confirmingAction} + onInlineConfirm={handleInlineConfirm} + onResetTrial={handleResetTrial} + onResetSubscription={handleResetSubscription} + onDisableUser={handleDisableUser} + onFullDeleteUser={handleFullDeleteUser} + /> )} {/* Subscription Tab */}