diff --git a/src/components/admin/userDetail/SubscriptionTab.tsx b/src/components/admin/userDetail/SubscriptionTab.tsx new file mode 100644 index 0000000..c45c52a --- /dev/null +++ b/src/components/admin/userDetail/SubscriptionTab.tsx @@ -0,0 +1,1199 @@ +import { useTranslation } from 'react-i18next'; +import { DEVICE_ALIAS_MAX_LENGTH } from '../../../constants/devices'; +import { createNumberInputHandler } from '../../../utils/inputHelpers'; +import { getFlagEmoji } from '../../../utils/subscriptionHelpers'; +import type { + UserAvailableTariff, + UserPanelInfo, + UserSubscriptionInfo, + UserNodeUsageItem, + SubscriptionRequestRecord, +} from '../../../api/adminUsers'; + +// ────────────────────────────────────────────────────────────────── +// Local helpers / icons. Each is small enough to live inline; the +// equivalents in the parent are kept because they're consumed by +// other code paths there. +// ────────────────────────────────────────────────────────────────── + +const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code); + +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', + expired: 'bg-warning-500/20 text-warning-400 border-warning-500/30', + trial: 'bg-accent-500/20 text-accent-400 border-accent-500/30', + limited: 'bg-warning-500/20 text-warning-400 border-warning-500/30', + deleted: 'bg-dark-600 text-dark-400 border-dark-500', + }; + return ( + + {t(`admin.users.status.${status}`, status)} + + ); +} + +const PlusIcon = () => ( + + + +); + +const MinusIcon = () => ( + + + +); + +const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( + + + +); + +// Local device row type (matches the parent's inline type) +type DeviceRow = { + hwid: string; + platform: string; + device_model: string; + created_at: string | null; + local_name?: string | null; +}; + +// ────────────────────────────────────────────────────────────────── +// Subscription tab — single-sub detail view OR multi-sub list + +// detail. Many of the inputs (panelInfo, devices, tariffs, etc.) +// live in the parent because they're shared with the Info tab; this +// is a view facade. +// ────────────────────────────────────────────────────────────────── + +export interface SubscriptionTabProps { + // Selection + userSubscriptions: UserSubscriptionInfo[]; + selectedSub: UserSubscriptionInfo | null; + activeSubscriptionId: number | null; + onActiveSubscriptionChange: (id: number) => void; + subscriptionDetailView: boolean; + onSubscriptionDetailViewChange: (open: boolean) => void; + + // Tariffs / current tariff + tariffs: UserAvailableTariff[]; + currentTariff: UserAvailableTariff | null; + + // Action form (extend/shorten/create/etc.) + subAction: string; + subDays: number | ''; + onSubActionChange: (s: string) => void; + onSubDaysChange: (d: number | '') => void; + selectedTariffId: number | null; + onSelectedTariffIdChange: (id: number | null) => void; + + // Traffic add form + selectedTrafficGb: string; + onSelectedTrafficGbChange: (gb: string) => void; + + // Panel info + panelInfo: UserPanelInfo | null; + panelInfoLoading: boolean; + copyToClipboard: (text: string) => void | Promise; + formatBytes: (bytes: number) => string; + + // Node usage + nodeUsageDays: number; + onNodeUsageDaysChange: (d: number) => void; + nodeUsageForPeriod: (UserNodeUsageItem & { total_bytes: number })[]; + + // Devices + devices: DeviceRow[]; + devicesLoading: boolean; + devicesTotal: number; + deviceLimit: number; + editingDeviceHwid: string | null; + editingDeviceName: string; + onEditingDeviceHwidChange: (hwid: string | null) => void; + onEditingDeviceNameChange: (name: string) => void; + renameSaving: boolean; + + // Request history + requestHistory: SubscriptionRequestRecord[]; + requestHistoryLoading: boolean; + requestHistoryTotal: number; + requestHistoryOffset: number; + requestHistorySubId: number | null; + requestHistoryExpanded: boolean; + onRequestHistoryExpandedChange: (open: boolean) => void; + onRequestHistorySubIdChange: (id: number | null) => void; + + // Mutation handlers (all parent-owned) + actionLoading: boolean; + confirmingAction: string | null; + onInlineConfirm: (key: string, fn: () => Promise) => void; + onUpdateSubscription: (overrideAction?: string) => Promise; + onSetDeviceLimit: (newLimit: number) => Promise; + onAddTraffic: (gb: number) => Promise; + onRemoveTraffic: (purchaseId: number) => Promise; + onResetDevices: () => Promise; + onDeleteDevice: (hwid: string) => Promise; + onRenameDevice: (hwid: string) => Promise; + onLoadDevices: () => Promise; + onLoadSubscriptionData: () => Promise; + onLoadRequestHistory: (offset: number, append?: boolean) => Promise; + + // Misc + hasPermission: (perm: string) => boolean; + formatDate: (date: string | null) => string; + locale: string; +} + +export function SubscriptionTab(props: SubscriptionTabProps) { + const { t } = useTranslation(); + const { + userSubscriptions, + selectedSub, + activeSubscriptionId, + onActiveSubscriptionChange, + subscriptionDetailView, + onSubscriptionDetailViewChange, + tariffs, + currentTariff, + subAction, + subDays, + onSubActionChange, + onSubDaysChange, + selectedTariffId, + onSelectedTariffIdChange, + selectedTrafficGb, + onSelectedTrafficGbChange, + panelInfo, + panelInfoLoading, + copyToClipboard, + formatBytes, + nodeUsageDays, + onNodeUsageDaysChange, + nodeUsageForPeriod, + devices, + devicesLoading, + devicesTotal, + deviceLimit, + editingDeviceHwid, + editingDeviceName, + onEditingDeviceHwidChange, + onEditingDeviceNameChange, + renameSaving, + requestHistory, + requestHistoryLoading, + requestHistoryTotal, + requestHistoryOffset, + requestHistorySubId, + requestHistoryExpanded, + onRequestHistoryExpandedChange, + onRequestHistorySubIdChange, + actionLoading, + confirmingAction, + onInlineConfirm, + onUpdateSubscription, + onSetDeviceLimit, + onAddTraffic, + onRemoveTraffic, + onResetDevices, + onDeleteDevice, + onRenameDevice, + onLoadDevices, + onLoadSubscriptionData, + onLoadRequestHistory, + hasPermission, + formatDate, + locale, + } = props; + // Suppress activeSubscriptionId-unused; the parent uses it for query keys. + void activeSubscriptionId; + + return ( +
+ {/* Multi-subscription: Level 1 — subscription list */} + {userSubscriptions.length > 1 && !subscriptionDetailView && ( + <> +
+ {userSubscriptions.map((sub) => ( + + ))} +
+ + {/* Create new subscription — at list level */} + {hasPermission('users:subscription') && ( +
+
+ {t('admin.users.detail.subscription.createNew', 'Создать подписку')} +
+
+ + + +
+
+ )} + + )} + + {/* Level 2 — subscription detail (or single subscription) */} + {(subscriptionDetailView || userSubscriptions.length <= 1) && selectedSub ? ( + <> + {/* Back to list (multi-subscription) */} + {subscriptionDetailView && userSubscriptions.length > 1 && ( + + )} + + {/* Current subscription */} +
+
+ + {t('admin.users.detail.subscription.current')} + {userSubscriptions.length > 1 && ( + #{selectedSub.id} + )} + + +
+
+
+
+ {t('admin.users.detail.subscription.tariff')} +
+
+ {selectedSub.tariff_name || t('admin.users.detail.subscription.notSpecified')} +
+
+
+
+ {t('admin.users.detail.subscription.validUntil')} +
+
{formatDate(selectedSub.end_date)}
+
+
+
+ {t('admin.users.detail.subscription.traffic')} +
+
+ {panelInfo?.found + ? (panelInfo.used_traffic_bytes / (1024 * 1024 * 1024)).toFixed(1) + : selectedSub.traffic_used_gb.toFixed(1)}{' '} + / {selectedSub.traffic_limit_gb} {t('common.units.gb')} +
+
+
+
+ {t('admin.users.detail.subscription.devices')} +
+
+ + + {selectedSub.device_limit} + + +
+
+
+
+ + {/* Traffic Packages */} + {selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && ( +
+
+ + {t('admin.users.detail.subscription.trafficPackages')} + {selectedSub.purchased_traffic_gb > 0 && ( + + ({selectedSub.purchased_traffic_gb} {t('common.units.gb')}) + + )} + +
+
+ {selectedSub.traffic_purchases.map((tp) => ( +
+
+
+ + {tp.traffic_gb} {t('common.units.gb')} + + {tp.is_expired ? ( + + {t('admin.users.detail.subscription.expired')} + + ) : ( + + {tp.days_remaining} {t('admin.users.detail.subscription.daysLeft')} + + )} +
+
+ {!tp.is_expired && ( + + )} +
+ ))} +
+
+ )} + + {/* Add Traffic */} + {currentTariff && + currentTariff.traffic_topup_enabled && + Object.keys(currentTariff.traffic_topup_packages).length > 0 && ( +
+
+ {t('admin.users.detail.subscription.addTraffic')} +
+
+ + +
+
+ {t('admin.users.detail.subscription.addTrafficNote')} +
+
+ )} + + {/* Actions */} + {hasPermission('users:subscription') && ( +
+
+ {t('admin.users.detail.subscription.actions')} +
+
+ + + {(subAction === 'extend' || subAction === 'shorten') && ( + + )} + + {subAction === 'change_tariff' && ( + + )} + + +
+
+ )} + + ) : null} + + {/* Create new subscription — only for single-sub users or no subs */} + {hasPermission('users:subscription') && userSubscriptions.length <= 1 && ( +
+ {userSubscriptions.length === 0 && ( +
+ {t('admin.users.detail.subscription.noActive')} +
+ )} +
+ {t('admin.users.detail.subscription.createNew', 'Создать подписку')} +
+
+ + + +
+
+ )} + + {/* Panel Info, Traffic, Devices — only inside subscription detail */} + {(subscriptionDetailView || userSubscriptions.length <= 1) && ( + <> + {panelInfoLoading ? ( +
+
+
+ ) : panelInfo && !panelInfo.found ? ( +
+ {t('admin.users.detail.panelNotFound')} +
+ ) : panelInfo && panelInfo.found ? ( + <> + {/* Links */} + {(panelInfo.subscription_url || panelInfo.happ_link) && ( +
+
+ {t('admin.users.detail.subscriptionUrl')} / {t('admin.users.detail.happLink')} +
+
+ {panelInfo.subscription_url && ( + + )} + {panelInfo.happ_link && ( + + )} +
+
+ )} + + {/* Config */} + {(panelInfo.trojan_password || panelInfo.vless_uuid || panelInfo.ss_password) && ( +
+
+ {t('admin.users.detail.panelConfig')} +
+
+ {panelInfo.trojan_password && ( + + )} + {panelInfo.vless_uuid && ( + + )} + {panelInfo.ss_password && ( + + )} +
+
+ )} + + {/* Connection info */} +
+
+
+
+ {t('admin.users.detail.firstConnected')} +
+
+ {formatDate(panelInfo.first_connected_at)} +
+
+
+
+ {t('admin.users.detail.lastOnline')} +
+
{formatDate(panelInfo.online_at)}
+
+ {panelInfo.last_connected_node_name && ( +
+
+ {t('admin.users.detail.lastNode')} +
+
+ {panelInfo.last_connected_node_name} +
+
+ )} +
+
+ + {/* Live traffic */} +
+
+ {t('admin.users.detail.liveTraffic')} +
+
+
+ + {formatBytes(panelInfo.used_traffic_bytes)} + + + {panelInfo.traffic_limit_bytes > 0 + ? formatBytes(panelInfo.traffic_limit_bytes) + : '∞'} + +
+
+
0 + ? `${Math.min(100, (panelInfo.used_traffic_bytes / panelInfo.traffic_limit_bytes) * 100)}%` + : '0%', + }} + /> +
+
+
+ {t('admin.users.detail.lifetime')}:{' '} + {formatBytes(panelInfo.lifetime_used_traffic_bytes)} +
+
+ + {/* Node usage */} +
+
+ + {t('admin.users.detail.nodeUsage')} + +
+
+ {[1, 3, 7, 14, 30].map((d) => ( + + ))} +
+ +
+
+ {nodeUsageForPeriod.length > 0 ? ( +
+ {nodeUsageForPeriod.map((item) => { + const maxBytes = nodeUsageForPeriod[0].total_bytes; + const pct = maxBytes > 0 ? (item.total_bytes / maxBytes) * 100 : 0; + return ( +
+
+ + {item.country_code && ( + {getCountryFlag(item.country_code)} + )} + {item.node_name} + + {formatBytes(item.total_bytes)} +
+
+
+
+
+ ); + })} +
+ ) : ( +
-
+ )} +
+ + ) : null} + + {/* Devices */} +
+
+ + {t('admin.users.detail.devices.title')} ({devicesTotal}/{deviceLimit}) + +
+ + {devices.length > 0 && ( + + )} +
+
+ {devicesLoading ? ( +
+
+
+ ) : devices.length > 0 ? ( +
+ {devices.map((device) => { + const isEditing = editingDeviceHwid === device.hwid; + const displayName = + (device.local_name && device.local_name.trim()) || + device.platform || + device.device_model || + device.hwid.slice(0, 12); + return ( +
+
+ {isEditing ? ( + onEditingDeviceNameChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + onRenameDevice(device.hwid); + } else if (e.key === 'Escape') { + e.preventDefault(); + onEditingDeviceHwidChange(null); + onEditingDeviceNameChange(''); + } + }} + className="w-full rounded-md bg-dark-900/70 px-2 py-1 text-xs font-medium text-dark-50 outline-none ring-1 ring-dark-600/60 focus:ring-accent-500/50" + /> + ) : ( +
+ {displayName} +
+ )} +
+ {device.device_model && device.platform && ( + {device.device_model} + )} + {device.hwid.slice(0, 8)}... + {device.created_at && ( + {new Date(device.created_at).toLocaleDateString(locale)} + )} +
+
+
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ ); + })} +
+ ) : ( +
+ {t('admin.users.detail.devices.none')} +
+ )} +
+ + {/* Subscription Request History */} +
+ + + {requestHistoryExpanded && ( +
+ {/* Subscription selector for multi-tariff */} + {userSubscriptions.length > 1 && ( +
+ +
+ )} + + {requestHistoryLoading && requestHistory.length === 0 ? ( +
+
+
+ ) : requestHistory.length === 0 && !requestHistoryLoading ? ( +
+ {t('admin.users.detail.noRequests')} +
+ ) : ( + <> +
+ {t('admin.users.detail.requestHistoryTotal')}: {requestHistoryTotal} +
+ + {/* Table */} +
+ + + + + + + + + + {requestHistory.map((record, idx) => ( + + + + + + ))} + +
+ {t('admin.users.detail.requestAt')} + + {t('admin.users.detail.requestIp')} + + {t('admin.users.detail.requestUserAgent')} +
+ {formatDate(record.requestAt)} + + {record.requestIp || '—'} + + {record.userAgent + ? record.userAgent.length > 60 + ? `${record.userAgent.slice(0, 60)}...` + : record.userAgent + : '—'} +
+
+ + {/* Load more */} + {requestHistory.length < requestHistoryTotal && ( + + )} + + )} +
+ )} +
+ + )} +
+ ); +} diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 71dd8fd..9ea40f1 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -3,7 +3,6 @@ import { useParams, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import i18n from '../i18n'; -import { DEVICE_ALIAS_MAX_LENGTH } from '../constants/devices'; import { useNotify } from '../platform/hooks/useNotify'; import { copyToClipboard as copyText } from '../utils/clipboard'; import { @@ -26,19 +25,15 @@ 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 { SubscriptionTab } from '../components/admin/userDetail/SubscriptionTab'; +import { toNumber } from '../utils/inputHelpers'; import { usePermissionStore } from '../store/permissions'; -import { getFlagEmoji } from '../utils/subscriptionHelpers'; -// ============ Helpers ============ - -// Алгоритмический ISO 3166-1 alpha-2 → regional indicator (вместо хардкод-словаря, -// который не покрывал все страны: например, EE раньше плыл сырым текстом в одних -// местах, MX/AR/EG до сих пор отсутствуют в других). Единая точка истины в utils. -const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code); - -// ============ Icons ============ +// (Subscription-tab helpers: getCountryFlag / PlusIcon / MinusIcon / +// StatusBadge / GiftStatusBadge / GiftCard moved to +// components/admin/userDetail/{SubscriptionTab,GiftsTab,InfoTab,SyncTab}.tsx) +// RefreshIcon stays here — the page header's reload button uses it. const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( ( ); -const PlusIcon = () => ( - - - -); - -const MinusIcon = () => ( - - - -); - -// (ArrowDownIcon / ArrowUpIcon moved to components/admin/userDetail/SyncTab.tsx) - const TelegramIcon = () => ( ); -// ============ Components ============ - -function StatusBadge({ status }: { status: string }) { - 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', - trial: 'bg-accent-500/20 text-accent-400 border-accent-500/30', - expired: 'bg-warning-500/20 text-warning-400 border-warning-500/30', - limited: 'bg-warning-500/20 text-warning-400 border-warning-500/30', - disabled: 'bg-dark-600 text-dark-400 border-dark-500', - }; - - return ( - - {status} - - ); -} - -// (GiftStatusBadge / GiftCard / GiftsTab JSX moved to components/admin/userDetail/GiftsTab.tsx) - // ============ Main Page ============ export default function AdminUserDetail() { @@ -900,1045 +859,64 @@ export default function AdminUserDetail() { {/* Subscription Tab */} {activeTab === 'subscription' && ( -
- {/* Multi-subscription: Level 1 — subscription list */} - {userSubscriptions.length > 1 && !subscriptionDetailView && ( - <> -
- {userSubscriptions.map((sub) => ( - - ))} -
- - {/* Create new subscription — at list level */} - {hasPermission('users:subscription') && ( -
-
- {t('admin.users.detail.subscription.createNew', 'Создать подписку')} -
-
- - - -
-
- )} - - )} - - {/* Level 2 — subscription detail (or single subscription) */} - {(subscriptionDetailView || userSubscriptions.length <= 1) && selectedSub ? ( - <> - {/* Back to list (multi-subscription) */} - {subscriptionDetailView && userSubscriptions.length > 1 && ( - - )} - - {/* Current subscription */} -
-
- - {t('admin.users.detail.subscription.current')} - {userSubscriptions.length > 1 && ( - #{selectedSub.id} - )} - - -
-
-
-
- {t('admin.users.detail.subscription.tariff')} -
-
- {selectedSub.tariff_name || - t('admin.users.detail.subscription.notSpecified')} -
-
-
-
- {t('admin.users.detail.subscription.validUntil')} -
-
{formatDate(selectedSub.end_date)}
-
-
-
- {t('admin.users.detail.subscription.traffic')} -
-
- {panelInfo?.found - ? (panelInfo.used_traffic_bytes / (1024 * 1024 * 1024)).toFixed(1) - : selectedSub.traffic_used_gb.toFixed(1)}{' '} - / {selectedSub.traffic_limit_gb} {t('common.units.gb')} -
-
-
-
- {t('admin.users.detail.subscription.devices')} -
-
- - - {selectedSub.device_limit} - - -
-
-
-
- - {/* Traffic Packages */} - {selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && ( -
-
- - {t('admin.users.detail.subscription.trafficPackages')} - {selectedSub.purchased_traffic_gb > 0 && ( - - ({selectedSub.purchased_traffic_gb} {t('common.units.gb')}) - - )} - -
-
- {selectedSub.traffic_purchases.map((tp) => ( -
-
-
- - {tp.traffic_gb} {t('common.units.gb')} - - {tp.is_expired ? ( - - {t('admin.users.detail.subscription.expired')} - - ) : ( - - {tp.days_remaining}{' '} - {t('admin.users.detail.subscription.daysLeft')} - - )} -
-
- {!tp.is_expired && ( - - )} -
- ))} -
-
- )} - - {/* Add Traffic */} - {currentTariff && - currentTariff.traffic_topup_enabled && - Object.keys(currentTariff.traffic_topup_packages).length > 0 && ( -
-
- {t('admin.users.detail.subscription.addTraffic')} -
-
- - -
-
- {t('admin.users.detail.subscription.addTrafficNote')} -
-
- )} - - {/* Actions */} - {hasPermission('users:subscription') && ( -
-
- {t('admin.users.detail.subscription.actions')} -
-
- - - {(subAction === 'extend' || subAction === 'shorten') && ( - - )} - - {subAction === 'change_tariff' && ( - - )} - - -
-
- )} - - ) : null} - - {/* Create new subscription — only for single-sub users or no subs */} - {hasPermission('users:subscription') && userSubscriptions.length <= 1 && ( -
- {userSubscriptions.length === 0 && ( -
- {t('admin.users.detail.subscription.noActive')} -
- )} -
- {t('admin.users.detail.subscription.createNew', 'Создать подписку')} -
-
- - - -
-
- )} - - {/* Panel Info, Traffic, Devices — only inside subscription detail */} - {(subscriptionDetailView || userSubscriptions.length <= 1) && ( - <> - {panelInfoLoading ? ( -
-
-
- ) : panelInfo && !panelInfo.found ? ( -
- {t('admin.users.detail.panelNotFound')} -
- ) : panelInfo && panelInfo.found ? ( - <> - {/* Links */} - {(panelInfo.subscription_url || panelInfo.happ_link) && ( -
-
- {t('admin.users.detail.subscriptionUrl')} /{' '} - {t('admin.users.detail.happLink')} -
-
- {panelInfo.subscription_url && ( - - )} - {panelInfo.happ_link && ( - - )} -
-
- )} - - {/* Config */} - {(panelInfo.trojan_password || - panelInfo.vless_uuid || - panelInfo.ss_password) && ( -
-
- {t('admin.users.detail.panelConfig')} -
-
- {panelInfo.trojan_password && ( - - )} - {panelInfo.vless_uuid && ( - - )} - {panelInfo.ss_password && ( - - )} -
-
- )} - - {/* Connection info */} -
-
-
-
- {t('admin.users.detail.firstConnected')} -
-
- {formatDate(panelInfo.first_connected_at)} -
-
-
-
- {t('admin.users.detail.lastOnline')} -
-
- {formatDate(panelInfo.online_at)} -
-
- {panelInfo.last_connected_node_name && ( -
-
- {t('admin.users.detail.lastNode')} -
-
- {panelInfo.last_connected_node_name} -
-
- )} -
-
- - {/* Live traffic */} -
-
- {t('admin.users.detail.liveTraffic')} -
-
-
- - {formatBytes(panelInfo.used_traffic_bytes)} - - - {panelInfo.traffic_limit_bytes > 0 - ? formatBytes(panelInfo.traffic_limit_bytes) - : '∞'} - -
-
-
0 - ? `${Math.min(100, (panelInfo.used_traffic_bytes / panelInfo.traffic_limit_bytes) * 100)}%` - : '0%', - }} - /> -
-
-
- {t('admin.users.detail.lifetime')}:{' '} - {formatBytes(panelInfo.lifetime_used_traffic_bytes)} -
-
- - {/* Node usage */} -
-
- - {t('admin.users.detail.nodeUsage')} - -
-
- {[1, 3, 7, 14, 30].map((d) => ( - - ))} -
- -
-
- {nodeUsageForPeriod.length > 0 ? ( -
- {nodeUsageForPeriod.map((item) => { - const maxBytes = nodeUsageForPeriod[0].total_bytes; - const pct = maxBytes > 0 ? (item.total_bytes / maxBytes) * 100 : 0; - return ( -
-
- - {item.country_code && ( - - {getCountryFlag(item.country_code)} - - )} - {item.node_name} - - - {formatBytes(item.total_bytes)} - -
-
-
-
-
- ); - })} -
- ) : ( -
-
- )} -
- - ) : null} - - {/* Devices */} -
-
- - {t('admin.users.detail.devices.title')} ({devicesTotal}/{deviceLimit}) - -
- - {devices.length > 0 && ( - - )} -
-
- {devicesLoading ? ( -
-
-
- ) : devices.length > 0 ? ( -
- {devices.map((device) => { - const isEditing = editingDeviceHwid === device.hwid; - // Display priority: alias \u2192 model \u2192 platform \u2192 hwid prefix. - const displayName = - (device.local_name && device.local_name.trim()) || - device.platform || - device.device_model || - device.hwid.slice(0, 12); - - return ( -
-
- {isEditing ? ( - setEditingDeviceName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleRenameDevice(device.hwid); - } else if (e.key === 'Escape') { - e.preventDefault(); - setEditingDeviceHwid(null); - setEditingDeviceName(''); - } - }} - className="w-full rounded-md bg-dark-900/70 px-2 py-1 text-xs font-medium text-dark-50 outline-none ring-1 ring-dark-600/60 focus:ring-accent-500/50" - /> - ) : ( -
- {displayName} -
- )} -
- {device.device_model && device.platform && ( - {device.device_model} - )} - {device.hwid.slice(0, 8)}... - {device.created_at && ( - - {new Date(device.created_at).toLocaleDateString(locale)} - - )} -
-
-
- {isEditing ? ( - <> - - - - ) : ( - <> - - - - )} -
-
- ); - })} -
- ) : ( -
- {t('admin.users.detail.devices.none')} -
- )} -
- - {/* Subscription Request History */} -
- - - {requestHistoryExpanded && ( -
- {/* Subscription selector for multi-tariff */} - {userSubscriptions.length > 1 && ( -
- -
- )} - - {requestHistoryLoading && requestHistory.length === 0 ? ( -
-
-
- ) : requestHistory.length === 0 && !requestHistoryLoading ? ( -
- {t('admin.users.detail.noRequests')} -
- ) : ( - <> -
- {t('admin.users.detail.requestHistoryTotal')}: {requestHistoryTotal} -
- - {/* Table */} -
- - - - - - - - - - {requestHistory.map((record, idx) => ( - - - - - - ))} - -
- {t('admin.users.detail.requestAt')} - - {t('admin.users.detail.requestIp')} - - {t('admin.users.detail.requestUserAgent')} -
- {formatDate(record.requestAt)} - - {record.requestIp || '\u2014'} - - {record.userAgent - ? record.userAgent.length > 60 - ? `${record.userAgent.slice(0, 60)}...` - : record.userAgent - : '\u2014'} -
-
- - {/* Load more */} - {requestHistory.length < requestHistoryTotal && ( - - )} - - )} -
- )} -
- - )} -
+ )} {/* Balance Tab */}