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 && ( )} )}
)}
)}
); }