feat: add admin traffic packages and device limit management UI

Add device limit +/- stepper, traffic packages display with inline
delete, add traffic dropdown from tariff config, translations for
ru/en/zh/fa.
This commit is contained in:
Fringg
2026-02-08 21:13:46 +03:00
parent 6f31fbe6b5
commit 2dfa520604
6 changed files with 246 additions and 6 deletions

View File

@@ -2,6 +2,15 @@ import apiClient from './client';
// ============ Types ============
export interface TrafficPurchaseInfo {
id: number;
traffic_gb: number;
expires_at: string;
created_at: string;
days_remaining: number;
is_expired: boolean;
}
export interface UserSubscriptionInfo {
id: number;
status: string;
@@ -16,6 +25,8 @@ export interface UserSubscriptionInfo {
autopay_enabled: boolean;
is_active: boolean;
days_remaining: number;
purchased_traffic_gb: number;
traffic_purchases: TrafficPurchaseInfo[];
}
export interface UserPromoGroupInfo {
@@ -187,6 +198,11 @@ export interface UserAvailableTariff {
price_per_day_kopeks: number;
min_days: number;
max_days: number;
device_price_kopeks: number | null;
max_device_limit: number | null;
traffic_topup_enabled: boolean;
traffic_topup_packages: Record<string, number>;
max_topup_traffic_gb: number;
is_available: boolean;
requires_promo_group: boolean;
}
@@ -277,7 +293,10 @@ export interface UpdateSubscriptionRequest {
| 'toggle_autopay'
| 'cancel'
| 'activate'
| 'create';
| 'create'
| 'add_traffic'
| 'remove_traffic'
| 'set_device_limit';
days?: number;
end_date?: string;
tariff_id?: number;
@@ -286,6 +305,8 @@ export interface UpdateSubscriptionRequest {
autopay_enabled?: boolean;
is_trial?: boolean;
device_limit?: number;
traffic_gb?: number;
traffic_purchase_id?: number;
}
export interface UpdateSubscriptionResponse {

View File

@@ -1967,7 +1967,17 @@
"unavailable": "(unavailable)",
"noActive": "No active subscription",
"create": "Create subscription",
"creating": "Creating..."
"creating": "Creating...",
"trafficPackages": "Traffic packages",
"addTraffic": "Add traffic",
"selectPackage": "Select package",
"addButton": "Add",
"addTrafficNote": "Admin: free, valid 30 days",
"trafficRemoved": "Traffic package removed",
"trafficAdded": "Traffic package added",
"expired": "expired",
"daysLeft": "d left",
"deviceLimitUpdated": "Device limit updated"
},
"balance": {
"current": "Current balance",

View File

@@ -1666,7 +1666,17 @@
"unavailable": "(غیرقابل دسترس)",
"noActive": "اشتراک فعالی وجود ندارد",
"create": "ایجاد اشتراک",
"creating": "در حال ایجاد..."
"creating": "در حال ایجاد...",
"trafficPackages": "بسته‌های ترافیک",
"addTraffic": "افزودن ترافیک",
"selectPackage": "انتخاب بسته",
"addButton": "افزودن",
"addTrafficNote": "مدیر: رایگان، ۳۰ روز اعتبار",
"trafficRemoved": "بسته ترافیک حذف شد",
"trafficAdded": "بسته ترافیک اضافه شد",
"expired": "منقضی شده",
"daysLeft": "روز باقیمانده",
"deviceLimitUpdated": "محدودیت دستگاه به‌روز شد"
},
"balance": {
"current": "موجودی فعلی",

View File

@@ -2493,7 +2493,17 @@
"unavailable": "(недоступен)",
"noActive": "Нет активной подписки",
"create": "Создать подписку",
"creating": "Создание..."
"creating": "Создание...",
"trafficPackages": "Пакеты трафика",
"addTraffic": "Добавить трафик",
"selectPackage": "Выберите пакет",
"addButton": "Добавить",
"addTrafficNote": "Админ: бесплатно, действует 30 дней",
"trafficRemoved": "Пакет трафика удалён",
"trafficAdded": "Пакет трафика добавлен",
"expired": "истёк",
"daysLeft": "дн. осталось",
"deviceLimitUpdated": "Лимит устройств обновлён"
},
"balance": {
"current": "Текущий баланс",

View File

@@ -1665,7 +1665,17 @@
"unavailable": "(不可用)",
"noActive": "无活跃订阅",
"create": "创建订阅",
"creating": "创建中..."
"creating": "创建中...",
"trafficPackages": "流量包",
"addTraffic": "添加流量",
"selectPackage": "选择套餐",
"addButton": "添加",
"addTrafficNote": "管理员免费有效期30天",
"trafficRemoved": "流量包已删除",
"trafficAdded": "流量包已添加",
"expired": "已过期",
"daysLeft": "天剩余",
"deviceLimitUpdated": "设备限制已更新"
},
"balance": {
"current": "当前余额",

View File

@@ -195,6 +195,9 @@ export default function AdminUserDetail() {
const [offerValidHours, setOfferValidHours] = useState<number | ''>(24);
const [offerSending, setOfferSending] = useState(false);
// Traffic packages
const [selectedTrafficGb, setSelectedTrafficGb] = useState<string>('');
// Devices
const [devices, setDevices] = useState<
{ hwid: string; platform: string; device_model: string; created_at: string | null }[]
@@ -544,6 +547,57 @@ export default function AdminUserDetail() {
}
};
const handleAddTraffic = async (gb: number) => {
if (!userId) return;
setActionLoading(true);
try {
await adminUsersApi.updateSubscription(userId, { action: 'add_traffic', traffic_gb: gb });
notify.success(t('admin.users.detail.subscription.trafficAdded'));
setSelectedTrafficGb('');
await loadUser();
} catch {
notify.error(t('admin.users.userActions.error'), t('common.error'));
} finally {
setActionLoading(false);
}
};
const handleRemoveTraffic = async (purchaseId: number) => {
if (!userId) return;
setActionLoading(true);
try {
await adminUsersApi.updateSubscription(userId, {
action: 'remove_traffic',
traffic_purchase_id: purchaseId,
});
notify.success(t('admin.users.detail.subscription.trafficRemoved'));
await loadUser();
} catch {
notify.error(t('admin.users.userActions.error'), t('common.error'));
} finally {
setActionLoading(false);
}
};
const handleSetDeviceLimit = async (newLimit: number) => {
if (!userId) return;
setActionLoading(true);
try {
await adminUsersApi.updateSubscription(userId, {
action: 'set_device_limit',
device_limit: newLimit,
});
notify.success(t('admin.users.detail.subscription.deviceLimitUpdated'));
await loadUser();
} catch {
notify.error(t('admin.users.userActions.error'), t('common.error'));
} finally {
setActionLoading(false);
}
};
const currentTariff = tariffs.find((t) => t.id === user?.subscription?.tariff_id) || null;
const handleChangePromoGroup = async (groupId: number | null) => {
if (!userId) return;
setActionLoading(true);
@@ -1166,11 +1220,136 @@ export default function AdminUserDetail() {
<div className="text-xs text-dark-500">
{t('admin.users.detail.subscription.devices')}
</div>
<div className="text-dark-100">{user.subscription.device_limit}</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleSetDeviceLimit(user.subscription!.device_limit - 1)}
disabled={actionLoading || user.subscription.device_limit <= 1}
className="flex h-6 w-6 items-center justify-center rounded-md bg-dark-700 text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-30"
>
<MinusIcon />
</button>
<span className="min-w-[2ch] text-center text-dark-100">
{user.subscription.device_limit}
</span>
<button
onClick={() => handleSetDeviceLimit(user.subscription!.device_limit + 1)}
disabled={
actionLoading ||
(currentTariff?.max_device_limit != null &&
user.subscription.device_limit >= currentTariff.max_device_limit)
}
className="flex h-6 w-6 items-center justify-center rounded-md bg-dark-700 text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-30"
>
<PlusIcon />
</button>
</div>
</div>
</div>
</div>
{/* Traffic Packages */}
{user.subscription.traffic_purchases &&
user.subscription.traffic_purchases.length > 0 && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-medium text-dark-200">
{t('admin.users.detail.subscription.trafficPackages')}
{user.subscription.purchased_traffic_gb > 0 && (
<span className="ml-2 text-xs text-dark-400">
({user.subscription.purchased_traffic_gb} {t('common.units.gb')})
</span>
)}
</span>
</div>
<div className="space-y-2">
{user.subscription.traffic_purchases.map((tp) => (
<div
key={tp.id}
className={`flex items-center justify-between rounded-lg px-3 py-2 ${
tp.is_expired ? 'bg-dark-700/30 opacity-60' : 'bg-dark-700/50'
}`}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm text-dark-200">
<span className="font-medium">
{tp.traffic_gb} {t('common.units.gb')}
</span>
{tp.is_expired ? (
<span className="rounded-full bg-error-500/20 px-1.5 py-0.5 text-[10px] text-error-400">
{t('admin.users.detail.subscription.expired')}
</span>
) : (
<span className="text-xs text-dark-400">
{tp.days_remaining}{' '}
{t('admin.users.detail.subscription.daysLeft')}
</span>
)}
</div>
</div>
{!tp.is_expired && (
<button
onClick={() =>
handleInlineConfirm(`removeTraffic_${tp.id}`, () =>
handleRemoveTraffic(tp.id),
)
}
disabled={actionLoading}
className={`ml-2 shrink-0 rounded-lg px-2 py-1 text-xs transition-all disabled:opacity-50 ${
confirmingAction === `removeTraffic_${tp.id}`
? 'bg-error-500 text-white'
: 'text-dark-500 hover:bg-error-500/15 hover:text-error-400'
}`}
>
{confirmingAction === `removeTraffic_${tp.id}` ? '?' : '\u00D7'}
</button>
)}
</div>
))}
</div>
</div>
)}
{/* Add Traffic */}
{currentTariff &&
currentTariff.traffic_topup_enabled &&
Object.keys(currentTariff.traffic_topup_packages).length > 0 && (
<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.subscription.addTraffic')}
</div>
<div className="flex gap-2">
<select
value={selectedTrafficGb}
onChange={(e) => setSelectedTrafficGb(e.target.value)}
className="input flex-1"
>
<option value="">
{t('admin.users.detail.subscription.selectPackage')}
</option>
{Object.entries(currentTariff.traffic_topup_packages)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([gb]) => (
<option key={gb} value={gb}>
{gb} {t('common.units.gb')}
</option>
))}
</select>
<button
onClick={() =>
selectedTrafficGb && handleAddTraffic(Number(selectedTrafficGb))
}
disabled={actionLoading || !selectedTrafficGb}
className="shrink-0 rounded-lg bg-accent-500 px-4 py-2 text-sm text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{t('admin.users.detail.subscription.addButton')}
</button>
</div>
<div className="mt-2 text-xs text-dark-500">
{t('admin.users.detail.subscription.addTrafficNote')}
</div>
</div>
)}
{/* Actions */}
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="mb-3 font-medium text-dark-200">