mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +00:00
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:
@@ -2,6 +2,15 @@ import apiClient from './client';
|
|||||||
|
|
||||||
// ============ Types ============
|
// ============ Types ============
|
||||||
|
|
||||||
|
export interface TrafficPurchaseInfo {
|
||||||
|
id: number;
|
||||||
|
traffic_gb: number;
|
||||||
|
expires_at: string;
|
||||||
|
created_at: string;
|
||||||
|
days_remaining: number;
|
||||||
|
is_expired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserSubscriptionInfo {
|
export interface UserSubscriptionInfo {
|
||||||
id: number;
|
id: number;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -16,6 +25,8 @@ export interface UserSubscriptionInfo {
|
|||||||
autopay_enabled: boolean;
|
autopay_enabled: boolean;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
days_remaining: number;
|
days_remaining: number;
|
||||||
|
purchased_traffic_gb: number;
|
||||||
|
traffic_purchases: TrafficPurchaseInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserPromoGroupInfo {
|
export interface UserPromoGroupInfo {
|
||||||
@@ -187,6 +198,11 @@ export interface UserAvailableTariff {
|
|||||||
price_per_day_kopeks: number;
|
price_per_day_kopeks: number;
|
||||||
min_days: number;
|
min_days: number;
|
||||||
max_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;
|
is_available: boolean;
|
||||||
requires_promo_group: boolean;
|
requires_promo_group: boolean;
|
||||||
}
|
}
|
||||||
@@ -277,7 +293,10 @@ export interface UpdateSubscriptionRequest {
|
|||||||
| 'toggle_autopay'
|
| 'toggle_autopay'
|
||||||
| 'cancel'
|
| 'cancel'
|
||||||
| 'activate'
|
| 'activate'
|
||||||
| 'create';
|
| 'create'
|
||||||
|
| 'add_traffic'
|
||||||
|
| 'remove_traffic'
|
||||||
|
| 'set_device_limit';
|
||||||
days?: number;
|
days?: number;
|
||||||
end_date?: string;
|
end_date?: string;
|
||||||
tariff_id?: number;
|
tariff_id?: number;
|
||||||
@@ -286,6 +305,8 @@ export interface UpdateSubscriptionRequest {
|
|||||||
autopay_enabled?: boolean;
|
autopay_enabled?: boolean;
|
||||||
is_trial?: boolean;
|
is_trial?: boolean;
|
||||||
device_limit?: number;
|
device_limit?: number;
|
||||||
|
traffic_gb?: number;
|
||||||
|
traffic_purchase_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateSubscriptionResponse {
|
export interface UpdateSubscriptionResponse {
|
||||||
|
|||||||
@@ -1967,7 +1967,17 @@
|
|||||||
"unavailable": "(unavailable)",
|
"unavailable": "(unavailable)",
|
||||||
"noActive": "No active subscription",
|
"noActive": "No active subscription",
|
||||||
"create": "Create 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": {
|
"balance": {
|
||||||
"current": "Current balance",
|
"current": "Current balance",
|
||||||
|
|||||||
@@ -1666,7 +1666,17 @@
|
|||||||
"unavailable": "(غیرقابل دسترس)",
|
"unavailable": "(غیرقابل دسترس)",
|
||||||
"noActive": "اشتراک فعالی وجود ندارد",
|
"noActive": "اشتراک فعالی وجود ندارد",
|
||||||
"create": "ایجاد اشتراک",
|
"create": "ایجاد اشتراک",
|
||||||
"creating": "در حال ایجاد..."
|
"creating": "در حال ایجاد...",
|
||||||
|
"trafficPackages": "بستههای ترافیک",
|
||||||
|
"addTraffic": "افزودن ترافیک",
|
||||||
|
"selectPackage": "انتخاب بسته",
|
||||||
|
"addButton": "افزودن",
|
||||||
|
"addTrafficNote": "مدیر: رایگان، ۳۰ روز اعتبار",
|
||||||
|
"trafficRemoved": "بسته ترافیک حذف شد",
|
||||||
|
"trafficAdded": "بسته ترافیک اضافه شد",
|
||||||
|
"expired": "منقضی شده",
|
||||||
|
"daysLeft": "روز باقیمانده",
|
||||||
|
"deviceLimitUpdated": "محدودیت دستگاه بهروز شد"
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"current": "موجودی فعلی",
|
"current": "موجودی فعلی",
|
||||||
|
|||||||
@@ -2493,7 +2493,17 @@
|
|||||||
"unavailable": "(недоступен)",
|
"unavailable": "(недоступен)",
|
||||||
"noActive": "Нет активной подписки",
|
"noActive": "Нет активной подписки",
|
||||||
"create": "Создать подписку",
|
"create": "Создать подписку",
|
||||||
"creating": "Создание..."
|
"creating": "Создание...",
|
||||||
|
"trafficPackages": "Пакеты трафика",
|
||||||
|
"addTraffic": "Добавить трафик",
|
||||||
|
"selectPackage": "Выберите пакет",
|
||||||
|
"addButton": "Добавить",
|
||||||
|
"addTrafficNote": "Админ: бесплатно, действует 30 дней",
|
||||||
|
"trafficRemoved": "Пакет трафика удалён",
|
||||||
|
"trafficAdded": "Пакет трафика добавлен",
|
||||||
|
"expired": "истёк",
|
||||||
|
"daysLeft": "дн. осталось",
|
||||||
|
"deviceLimitUpdated": "Лимит устройств обновлён"
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"current": "Текущий баланс",
|
"current": "Текущий баланс",
|
||||||
|
|||||||
@@ -1665,7 +1665,17 @@
|
|||||||
"unavailable": "(不可用)",
|
"unavailable": "(不可用)",
|
||||||
"noActive": "无活跃订阅",
|
"noActive": "无活跃订阅",
|
||||||
"create": "创建订阅",
|
"create": "创建订阅",
|
||||||
"creating": "创建中..."
|
"creating": "创建中...",
|
||||||
|
"trafficPackages": "流量包",
|
||||||
|
"addTraffic": "添加流量",
|
||||||
|
"selectPackage": "选择套餐",
|
||||||
|
"addButton": "添加",
|
||||||
|
"addTrafficNote": "管理员:免费,有效期30天",
|
||||||
|
"trafficRemoved": "流量包已删除",
|
||||||
|
"trafficAdded": "流量包已添加",
|
||||||
|
"expired": "已过期",
|
||||||
|
"daysLeft": "天剩余",
|
||||||
|
"deviceLimitUpdated": "设备限制已更新"
|
||||||
},
|
},
|
||||||
"balance": {
|
"balance": {
|
||||||
"current": "当前余额",
|
"current": "当前余额",
|
||||||
|
|||||||
@@ -195,6 +195,9 @@ export default function AdminUserDetail() {
|
|||||||
const [offerValidHours, setOfferValidHours] = useState<number | ''>(24);
|
const [offerValidHours, setOfferValidHours] = useState<number | ''>(24);
|
||||||
const [offerSending, setOfferSending] = useState(false);
|
const [offerSending, setOfferSending] = useState(false);
|
||||||
|
|
||||||
|
// Traffic packages
|
||||||
|
const [selectedTrafficGb, setSelectedTrafficGb] = useState<string>('');
|
||||||
|
|
||||||
// Devices
|
// Devices
|
||||||
const [devices, setDevices] = useState<
|
const [devices, setDevices] = useState<
|
||||||
{ hwid: string; platform: string; device_model: string; created_at: string | null }[]
|
{ 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) => {
|
const handleChangePromoGroup = async (groupId: number | null) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
@@ -1166,10 +1220,135 @@ export default function AdminUserDetail() {
|
|||||||
<div className="text-xs text-dark-500">
|
<div className="text-xs text-dark-500">
|
||||||
{t('admin.users.detail.subscription.devices')}
|
{t('admin.users.detail.subscription.devices')}
|
||||||
</div>
|
</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>
|
||||||
</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 */}
|
{/* Actions */}
|
||||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user