mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
Merge pull request #194 from BEDOLAGA-DEV/dev
chore: release cabinet updates
This commit is contained in:
34
package-lock.json
generated
34
package-lock.json
generated
@@ -24,6 +24,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||||
"@tanstack/react-query": "^5.8.0",
|
"@tanstack/react-query": "^5.8.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@telegram-apps/sdk-react": "^3.3.9",
|
"@telegram-apps/sdk-react": "^3.3.9",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
@@ -2626,6 +2627,39 @@
|
|||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-table": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/table-core": "8.21.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8",
|
||||||
|
"react-dom": ">=16.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/table-core": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@telegram-apps/bridge": {
|
"node_modules/@telegram-apps/bridge": {
|
||||||
"version": "2.11.0",
|
"version": "2.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/@telegram-apps/bridge/-/bridge-2.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/@telegram-apps/bridge/-/bridge-2.11.0.tgz",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||||
"@tanstack/react-query": "^5.8.0",
|
"@tanstack/react-query": "^5.8.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@telegram-apps/sdk-react": "^3.3.9",
|
"@telegram-apps/sdk-react": "^3.3.9",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
11
src/App.tsx
11
src/App.tsx
@@ -69,6 +69,7 @@ const AdminPromoOfferSend = lazy(() => import('./pages/AdminPromoOfferSend'));
|
|||||||
const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'));
|
const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'));
|
||||||
const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail'));
|
const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail'));
|
||||||
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
|
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
|
||||||
|
const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage'));
|
||||||
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
||||||
const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail'));
|
const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail'));
|
||||||
const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview'));
|
const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview'));
|
||||||
@@ -565,6 +566,16 @@ function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/traffic-usage"
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<AdminTrafficUsage />
|
||||||
|
</LazyPage>
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/payment-methods"
|
path="/admin/payment-methods"
|
||||||
element={
|
element={
|
||||||
|
|||||||
120
src/api/adminTraffic.ts
Normal file
120
src/api/adminTraffic.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
export interface TrafficNodeInfo {
|
||||||
|
node_uuid: string;
|
||||||
|
node_name: string;
|
||||||
|
country_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserTrafficItem {
|
||||||
|
user_id: number;
|
||||||
|
telegram_id: number | null;
|
||||||
|
username: string | null;
|
||||||
|
full_name: string;
|
||||||
|
tariff_name: string | null;
|
||||||
|
subscription_status: string | null;
|
||||||
|
traffic_limit_gb: number;
|
||||||
|
device_limit: number;
|
||||||
|
node_traffic: Record<string, number>;
|
||||||
|
total_bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrafficUsageResponse {
|
||||||
|
items: UserTrafficItem[];
|
||||||
|
nodes: TrafficNodeInfo[];
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
period_days: number;
|
||||||
|
available_tariffs: string[];
|
||||||
|
available_statuses: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportCsvResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TrafficParams = {
|
||||||
|
period?: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
search?: string;
|
||||||
|
sort_by?: string;
|
||||||
|
sort_desc?: boolean;
|
||||||
|
tariffs?: string;
|
||||||
|
statuses?: string;
|
||||||
|
nodes?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
const trafficCache = new Map<string, { data: TrafficUsageResponse; timestamp: number }>();
|
||||||
|
|
||||||
|
function buildCacheKey(params: TrafficParams): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
period: params.period ?? 30,
|
||||||
|
limit: params.limit ?? 50,
|
||||||
|
offset: params.offset ?? 0,
|
||||||
|
search: params.search ?? '',
|
||||||
|
sort_by: params.sort_by ?? 'total_bytes',
|
||||||
|
sort_desc: params.sort_desc ?? true,
|
||||||
|
tariffs: params.tariffs ?? '',
|
||||||
|
statuses: params.statuses ?? '',
|
||||||
|
nodes: params.nodes ?? '',
|
||||||
|
start_date: params.start_date ?? '',
|
||||||
|
end_date: params.end_date ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminTrafficApi = {
|
||||||
|
getTrafficUsage: async (
|
||||||
|
params: TrafficParams,
|
||||||
|
options?: { skipCache?: boolean },
|
||||||
|
): Promise<TrafficUsageResponse> => {
|
||||||
|
const key = buildCacheKey(params);
|
||||||
|
|
||||||
|
if (!options?.skipCache) {
|
||||||
|
const cached = trafficCache.get(key);
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiClient.get('/cabinet/admin/traffic', { params });
|
||||||
|
const data: TrafficUsageResponse = response.data;
|
||||||
|
|
||||||
|
trafficCache.set(key, { data, timestamp: Date.now() });
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCached: (params: TrafficParams): TrafficUsageResponse | null => {
|
||||||
|
const key = buildCacheKey(params);
|
||||||
|
const cached = trafficCache.get(key);
|
||||||
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
||||||
|
return cached.data;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
invalidateCache: () => {
|
||||||
|
trafficCache.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
exportCsv: async (data: {
|
||||||
|
period: number;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
tariffs?: string;
|
||||||
|
statuses?: string;
|
||||||
|
nodes?: string;
|
||||||
|
total_threshold_gb?: number;
|
||||||
|
node_threshold_gb?: number;
|
||||||
|
}): Promise<ExportCsvResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/traffic/export-csv', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -183,4 +183,12 @@ export const promocodesApi = {
|
|||||||
deletePromoGroup: async (id: number): Promise<void> => {
|
deletePromoGroup: async (id: number): Promise<void> => {
|
||||||
await apiClient.delete(`/cabinet/admin/promo-groups/${id}`);
|
await apiClient.delete(`/cabinet/admin/promo-groups/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Deactivate user's active discount (admin)
|
||||||
|
deactivateDiscount: async (userId: number): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/cabinet/admin/promocodes/deactivate-discount/${userId}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -761,7 +761,8 @@
|
|||||||
"campaigns": "Campaigns",
|
"campaigns": "Campaigns",
|
||||||
"promoOffers": "Promo Offers",
|
"promoOffers": "Promo Offers",
|
||||||
"promocodes": "Promo Codes",
|
"promocodes": "Promo Codes",
|
||||||
"promoGroups": "Discount Groups"
|
"promoGroups": "Discount Groups",
|
||||||
|
"trafficUsage": "Traffic Usage"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Admin Panel",
|
"title": "Admin Panel",
|
||||||
@@ -783,7 +784,45 @@
|
|||||||
"campaignsDesc": "Advertising campaigns",
|
"campaignsDesc": "Advertising campaigns",
|
||||||
"promoOffersDesc": "Personal discounts",
|
"promoOffersDesc": "Personal discounts",
|
||||||
"promocodesDesc": "Manage promo codes",
|
"promocodesDesc": "Manage promo codes",
|
||||||
"promoGroupsDesc": "Discount groups for users"
|
"promoGroupsDesc": "Discount groups for users",
|
||||||
|
"trafficUsageDesc": "Per-user traffic by nodes"
|
||||||
|
},
|
||||||
|
"trafficUsage": {
|
||||||
|
"title": "Traffic Usage",
|
||||||
|
"subtitle": "Per-user traffic statistics by nodes",
|
||||||
|
"period": "Period",
|
||||||
|
"days": "d",
|
||||||
|
"exportCsv": "Export CSV",
|
||||||
|
"exportSuccess": "CSV sent to your Telegram",
|
||||||
|
"exportError": "Failed to export",
|
||||||
|
"user": "User",
|
||||||
|
"tariff": "Tariff",
|
||||||
|
"devices": "Devices",
|
||||||
|
"trafficLimit": "Limit",
|
||||||
|
"total": "Total",
|
||||||
|
"noData": "No traffic data",
|
||||||
|
"loading": "Loading traffic data...",
|
||||||
|
"noTariff": "\u2014",
|
||||||
|
"search": "Search by name or username",
|
||||||
|
"allTariffs": "All tariffs",
|
||||||
|
"nodes": "Nodes",
|
||||||
|
"allNodes": "All nodes",
|
||||||
|
"status": "Status",
|
||||||
|
"allStatuses": "All",
|
||||||
|
"statusActive": "Active",
|
||||||
|
"statusTrial": "Trial",
|
||||||
|
"statusExpired": "Expired",
|
||||||
|
"statusDisabled": "Disabled",
|
||||||
|
"customDates": "Custom dates",
|
||||||
|
"dateFrom": "From",
|
||||||
|
"dateTo": "To",
|
||||||
|
"totalThreshold": "Total GB/d",
|
||||||
|
"nodeThreshold": "Node GB/d",
|
||||||
|
"risk": "Risk",
|
||||||
|
"riskLow": "Low",
|
||||||
|
"riskMedium": "Medium",
|
||||||
|
"riskHigh": "High",
|
||||||
|
"riskCritical": "Critical"
|
||||||
},
|
},
|
||||||
"emailTemplates": {
|
"emailTemplates": {
|
||||||
"title": "Email Templates",
|
"title": "Email Templates",
|
||||||
@@ -1886,6 +1925,22 @@
|
|||||||
"title": "Actions",
|
"title": "Actions",
|
||||||
"areYouSure": "Are you sure?"
|
"areYouSure": "Are you sure?"
|
||||||
},
|
},
|
||||||
|
"promoGroup": "Promo group",
|
||||||
|
"noPromoGroup": "Not set",
|
||||||
|
"changePromoGroup": "Change",
|
||||||
|
"selectPromoGroup": "Select group",
|
||||||
|
"removePromoGroup": "Remove",
|
||||||
|
"activePromoOffer": "Active promo offer",
|
||||||
|
"discount": "Discount",
|
||||||
|
"source": "Source",
|
||||||
|
"expiresAt": "Expires",
|
||||||
|
"deactivateOffer": "Deactivate",
|
||||||
|
"sendOffer": "Send offer",
|
||||||
|
"discountPercent": "Discount %",
|
||||||
|
"validHours": "Valid hours",
|
||||||
|
"offerSent": "Offer sent",
|
||||||
|
"offerSendError": "Failed to send offer",
|
||||||
|
"offerDeactivated": "Offer deactivated",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"current": "Current subscription",
|
"current": "Current subscription",
|
||||||
"tariff": "Tariff",
|
"tariff": "Tariff",
|
||||||
|
|||||||
@@ -642,7 +642,8 @@
|
|||||||
"promocodes": "کدهای تخفیف",
|
"promocodes": "کدهای تخفیف",
|
||||||
"promoGroups": "گروههای تخفیف",
|
"promoGroups": "گروههای تخفیف",
|
||||||
"remnawave": "RemnaWave",
|
"remnawave": "RemnaWave",
|
||||||
"users": "کاربران"
|
"users": "کاربران",
|
||||||
|
"trafficUsage": "مصرف ترافیک"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "پنل مدیریت",
|
"title": "پنل مدیریت",
|
||||||
@@ -664,7 +665,45 @@
|
|||||||
"promocodesDesc": "مدیریت کدهای تخفیف",
|
"promocodesDesc": "مدیریت کدهای تخفیف",
|
||||||
"promoGroupsDesc": "گروههای تخفیف برای کاربران",
|
"promoGroupsDesc": "گروههای تخفیف برای کاربران",
|
||||||
"remnawaveDesc": "مدیریت پنل و آمار",
|
"remnawaveDesc": "مدیریت پنل و آمار",
|
||||||
"usersDesc": "مدیریت کاربران ربات"
|
"usersDesc": "مدیریت کاربران ربات",
|
||||||
|
"trafficUsageDesc": "ترافیک هر کاربر بر اساس نود"
|
||||||
|
},
|
||||||
|
"trafficUsage": {
|
||||||
|
"title": "مصرف ترافیک",
|
||||||
|
"subtitle": "آمار ترافیک کاربران بر اساس نودها",
|
||||||
|
"period": "دوره",
|
||||||
|
"days": "روز",
|
||||||
|
"exportCsv": "خروجی CSV",
|
||||||
|
"exportSuccess": "CSV به تلگرام شما ارسال شد",
|
||||||
|
"exportError": "خطا در خروجی",
|
||||||
|
"user": "کاربر",
|
||||||
|
"tariff": "تعرفه",
|
||||||
|
"devices": "دستگاهها",
|
||||||
|
"trafficLimit": "محدودیت",
|
||||||
|
"total": "کل",
|
||||||
|
"noData": "داده ترافیکی موجود نیست",
|
||||||
|
"loading": "در حال بارگذاری دادههای ترافیک...",
|
||||||
|
"noTariff": "\u2014",
|
||||||
|
"search": "جستجو بر اساس نام یا نام کاربری",
|
||||||
|
"allTariffs": "همه تعرفهها",
|
||||||
|
"nodes": "نودها",
|
||||||
|
"allNodes": "همه نودها",
|
||||||
|
"status": "وضعیت",
|
||||||
|
"allStatuses": "همه",
|
||||||
|
"statusActive": "فعال",
|
||||||
|
"statusTrial": "آزمایشی",
|
||||||
|
"statusExpired": "منقضی",
|
||||||
|
"statusDisabled": "غیرفعال",
|
||||||
|
"customDates": "تاریخ دلخواه",
|
||||||
|
"dateFrom": "از",
|
||||||
|
"dateTo": "تا",
|
||||||
|
"totalThreshold": "کل GB/روز",
|
||||||
|
"nodeThreshold": "نود GB/روز",
|
||||||
|
"risk": "ریسک",
|
||||||
|
"riskLow": "کم",
|
||||||
|
"riskMedium": "متوسط",
|
||||||
|
"riskHigh": "بالا",
|
||||||
|
"riskCritical": "بحرانی"
|
||||||
},
|
},
|
||||||
"emailTemplates": {
|
"emailTemplates": {
|
||||||
"title": "قالبهای ایمیل",
|
"title": "قالبهای ایمیل",
|
||||||
@@ -1585,6 +1624,22 @@
|
|||||||
"subscription": "• خرید اشتراک ممنوع",
|
"subscription": "• خرید اشتراک ممنوع",
|
||||||
"reason": "دلیل"
|
"reason": "دلیل"
|
||||||
},
|
},
|
||||||
|
"promoGroup": "گروه تبلیغاتی",
|
||||||
|
"noPromoGroup": "تنظیم نشده",
|
||||||
|
"changePromoGroup": "تغییر",
|
||||||
|
"selectPromoGroup": "انتخاب گروه",
|
||||||
|
"removePromoGroup": "حذف",
|
||||||
|
"activePromoOffer": "پیشنهاد فعال",
|
||||||
|
"discount": "تخفیف",
|
||||||
|
"source": "منبع",
|
||||||
|
"expiresAt": "انقضا",
|
||||||
|
"deactivateOffer": "غیرفعال کردن",
|
||||||
|
"sendOffer": "ارسال پیشنهاد",
|
||||||
|
"discountPercent": "تخفیف %",
|
||||||
|
"validHours": "مدت اعتبار (ساعت)",
|
||||||
|
"offerSent": "پیشنهاد ارسال شد",
|
||||||
|
"offerSendError": "خطا در ارسال پیشنهاد",
|
||||||
|
"offerDeactivated": "پیشنهاد غیرفعال شد",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"current": "اشتراک فعلی",
|
"current": "اشتراک فعلی",
|
||||||
"tariff": "تعرفه",
|
"tariff": "تعرفه",
|
||||||
|
|||||||
@@ -782,7 +782,8 @@
|
|||||||
"campaigns": "Кампании",
|
"campaigns": "Кампании",
|
||||||
"promoOffers": "Промопредложения",
|
"promoOffers": "Промопредложения",
|
||||||
"promocodes": "Промокоды",
|
"promocodes": "Промокоды",
|
||||||
"promoGroups": "Группы скидок"
|
"promoGroups": "Группы скидок",
|
||||||
|
"trafficUsage": "Расход трафика"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Панель администратора",
|
"title": "Панель администратора",
|
||||||
@@ -804,7 +805,45 @@
|
|||||||
"campaignsDesc": "Рекламные кампании",
|
"campaignsDesc": "Рекламные кампании",
|
||||||
"promoOffersDesc": "Персональные скидки",
|
"promoOffersDesc": "Персональные скидки",
|
||||||
"promocodesDesc": "Управление промокодами",
|
"promocodesDesc": "Управление промокодами",
|
||||||
"promoGroupsDesc": "Группы скидок для пользователей"
|
"promoGroupsDesc": "Группы скидок для пользователей",
|
||||||
|
"trafficUsageDesc": "Трафик пользователей по нодам"
|
||||||
|
},
|
||||||
|
"trafficUsage": {
|
||||||
|
"title": "Расход трафика",
|
||||||
|
"subtitle": "Статистика трафика по пользователям и нодам",
|
||||||
|
"period": "Период",
|
||||||
|
"days": "д",
|
||||||
|
"exportCsv": "Экспорт CSV",
|
||||||
|
"exportSuccess": "CSV отправлен в ваш Telegram",
|
||||||
|
"exportError": "Ошибка экспорта",
|
||||||
|
"user": "Пользователь",
|
||||||
|
"tariff": "Тариф",
|
||||||
|
"devices": "Устройства",
|
||||||
|
"trafficLimit": "Лимит",
|
||||||
|
"total": "Всего",
|
||||||
|
"noData": "Нет данных о трафике",
|
||||||
|
"loading": "Загрузка данных о трафике...",
|
||||||
|
"noTariff": "\u2014",
|
||||||
|
"search": "Поиск по имени или юзернейму",
|
||||||
|
"allTariffs": "Все тарифы",
|
||||||
|
"nodes": "Ноды",
|
||||||
|
"allNodes": "Все ноды",
|
||||||
|
"status": "Статус",
|
||||||
|
"allStatuses": "Все",
|
||||||
|
"statusActive": "Активная",
|
||||||
|
"statusTrial": "Пробная",
|
||||||
|
"statusExpired": "Истекла",
|
||||||
|
"statusDisabled": "Отключена",
|
||||||
|
"customDates": "Выбрать даты",
|
||||||
|
"dateFrom": "От",
|
||||||
|
"dateTo": "До",
|
||||||
|
"totalThreshold": "Всего ГБ/д",
|
||||||
|
"nodeThreshold": "Нода ГБ/д",
|
||||||
|
"risk": "Риск",
|
||||||
|
"riskLow": "Низкий",
|
||||||
|
"riskMedium": "Средний",
|
||||||
|
"riskHigh": "Высокий",
|
||||||
|
"riskCritical": "Критический"
|
||||||
},
|
},
|
||||||
"emailTemplates": {
|
"emailTemplates": {
|
||||||
"title": "Email-шаблоны",
|
"title": "Email-шаблоны",
|
||||||
@@ -2408,6 +2447,22 @@
|
|||||||
"subscription": "• Запрет покупки подписки",
|
"subscription": "• Запрет покупки подписки",
|
||||||
"reason": "Причина"
|
"reason": "Причина"
|
||||||
},
|
},
|
||||||
|
"promoGroup": "Промо-группа",
|
||||||
|
"noPromoGroup": "Не задана",
|
||||||
|
"changePromoGroup": "Изменить",
|
||||||
|
"selectPromoGroup": "Выберите группу",
|
||||||
|
"removePromoGroup": "Убрать",
|
||||||
|
"activePromoOffer": "Активное промо-предложение",
|
||||||
|
"discount": "Скидка",
|
||||||
|
"source": "Источник",
|
||||||
|
"expiresAt": "Истекает",
|
||||||
|
"deactivateOffer": "Отключить",
|
||||||
|
"sendOffer": "Отправить предложение",
|
||||||
|
"discountPercent": "Скидка %",
|
||||||
|
"validHours": "Срок действия (часы)",
|
||||||
|
"offerSent": "Предложение отправлено",
|
||||||
|
"offerSendError": "Ошибка отправки предложения",
|
||||||
|
"offerDeactivated": "Предложение отключено",
|
||||||
"actions": {
|
"actions": {
|
||||||
"title": "Действия",
|
"title": "Действия",
|
||||||
"areYouSure": "Уверены?"
|
"areYouSure": "Уверены?"
|
||||||
@@ -2957,164 +3012,6 @@
|
|||||||
"uptime": "Аптайм"
|
"uptime": "Аптайм"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adminUsers": {
|
|
||||||
"title": "Пользователи",
|
|
||||||
"subtitle": "Управление пользователями",
|
|
||||||
"searchPlaceholder": "Поиск по ID, username, имени...",
|
|
||||||
"filters": {
|
|
||||||
"all": "Все",
|
|
||||||
"active": "Активные",
|
|
||||||
"blocked": "Заблокированные",
|
|
||||||
"deleted": "Удалённые"
|
|
||||||
},
|
|
||||||
"sort": {
|
|
||||||
"created_at": "Дата регистрации",
|
|
||||||
"balance": "Баланс",
|
|
||||||
"traffic": "Трафик",
|
|
||||||
"last_activity": "Активность",
|
|
||||||
"total_spent": "Потрачено",
|
|
||||||
"purchase_count": "Покупки"
|
|
||||||
},
|
|
||||||
"stats": {
|
|
||||||
"totalUsers": "Всего пользователей",
|
|
||||||
"activeUsers": "Активных",
|
|
||||||
"blockedUsers": "Заблокировано",
|
|
||||||
"withSubscription": "С подпиской",
|
|
||||||
"newToday": "Новых сегодня",
|
|
||||||
"newWeek": "За неделю",
|
|
||||||
"newMonth": "За месяц",
|
|
||||||
"totalBalance": "Общий баланс",
|
|
||||||
"activeToday": "Активны сегодня",
|
|
||||||
"activeWeek": "За неделю",
|
|
||||||
"activeMonth": "За месяц"
|
|
||||||
},
|
|
||||||
"table": {
|
|
||||||
"user": "Пользователь",
|
|
||||||
"status": "Статус",
|
|
||||||
"balance": "Баланс",
|
|
||||||
"subscription": "Подписка",
|
|
||||||
"spent": "Потрачено",
|
|
||||||
"registered": "Регистрация",
|
|
||||||
"activity": "Активность",
|
|
||||||
"noUsers": "Пользователи не найдены"
|
|
||||||
},
|
|
||||||
"userStatus": {
|
|
||||||
"active": "Активен",
|
|
||||||
"blocked": "Заблокирован",
|
|
||||||
"deleted": "Удалён"
|
|
||||||
},
|
|
||||||
"subscriptionStatus": {
|
|
||||||
"active": "Активна",
|
|
||||||
"trial": "Триал",
|
|
||||||
"expired": "Истекла",
|
|
||||||
"none": "Нет подписки"
|
|
||||||
},
|
|
||||||
"detail": {
|
|
||||||
"title": "Информация о пользователе",
|
|
||||||
"tabs": {
|
|
||||||
"info": "Инфо",
|
|
||||||
"subscription": "Подписка",
|
|
||||||
"balance": "Баланс",
|
|
||||||
"sync": "Синхронизация"
|
|
||||||
},
|
|
||||||
"telegramId": "Telegram ID",
|
|
||||||
"username": "Username",
|
|
||||||
"fullName": "Имя",
|
|
||||||
"email": "Email",
|
|
||||||
"emailVerified": "Подтверждён",
|
|
||||||
"emailNotVerified": "Не подтверждён",
|
|
||||||
"language": "Язык",
|
|
||||||
"registeredAt": "Регистрация",
|
|
||||||
"lastActivity": "Последняя активность",
|
|
||||||
"cabinetLogin": "Вход в кабинет",
|
|
||||||
"referralCode": "Реферальный код",
|
|
||||||
"referralsCount": "Рефералов",
|
|
||||||
"referralEarnings": "Заработано",
|
|
||||||
"referredBy": "Приглашён",
|
|
||||||
"promoGroup": "Промо-группа",
|
|
||||||
"noPromoGroup": "Не задана",
|
|
||||||
"restrictions": "Ограничения",
|
|
||||||
"restrictionTopup": "Пополнение баланса",
|
|
||||||
"restrictionSubscription": "Покупка подписки",
|
|
||||||
"restrictionReason": "Причина",
|
|
||||||
"noRestrictions": "Нет ограничений",
|
|
||||||
"totalSpent": "Всего потрачено",
|
|
||||||
"purchaseCount": "Покупок",
|
|
||||||
"usedPromocodes": "Использовано промокодов",
|
|
||||||
"lifetimeTraffic": "Трафик за всё время",
|
|
||||||
"recentTransactions": "Последние транзакции",
|
|
||||||
"noTransactions": "Нет транзакций"
|
|
||||||
},
|
|
||||||
"subscription": {
|
|
||||||
"noSubscription": "Подписка отсутствует",
|
|
||||||
"currentTariff": "Текущий тариф",
|
|
||||||
"status": "Статус",
|
|
||||||
"endDate": "Окончание",
|
|
||||||
"daysRemaining": "Осталось дней",
|
|
||||||
"trafficUsed": "Использовано трафика",
|
|
||||||
"deviceLimit": "Лимит устройств",
|
|
||||||
"autopay": "Автоплатёж",
|
|
||||||
"enabled": "Включён",
|
|
||||||
"disabled": "Отключён",
|
|
||||||
"actions": {
|
|
||||||
"extend": "Продлить",
|
|
||||||
"changeTariff": "Сменить тариф",
|
|
||||||
"setTraffic": "Установить трафик",
|
|
||||||
"toggleAutopay": "Вкл/выкл автоплатёж",
|
|
||||||
"cancel": "Отменить подписку",
|
|
||||||
"create": "Создать подписку"
|
|
||||||
},
|
|
||||||
"extendDays": "Дней",
|
|
||||||
"selectTariff": "Выберите тариф",
|
|
||||||
"trafficLimitGb": "Лимит ГБ",
|
|
||||||
"trafficUsedGb": "Использовано ГБ",
|
|
||||||
"isTrial": "Пробный период"
|
|
||||||
},
|
|
||||||
"balance": {
|
|
||||||
"currentBalance": "Текущий баланс",
|
|
||||||
"addFunds": "Пополнить",
|
|
||||||
"deductFunds": "Списать",
|
|
||||||
"amount": "Сумма",
|
|
||||||
"description": "Описание",
|
|
||||||
"createTransaction": "Создать транзакцию",
|
|
||||||
"transactionHistory": "История транзакций"
|
|
||||||
},
|
|
||||||
"sync": {
|
|
||||||
"title": "Синхронизация с панелью",
|
|
||||||
"status": "Статус синхронизации",
|
|
||||||
"lastSync": "Последняя синхронизация",
|
|
||||||
"neverSynced": "Никогда",
|
|
||||||
"botData": "Данные бота",
|
|
||||||
"panelData": "Данные панели",
|
|
||||||
"notFound": "Не найден в панели",
|
|
||||||
"differences": "Различия",
|
|
||||||
"noDifferences": "Различий нет",
|
|
||||||
"syncFromPanel": "Синхронизировать из панели",
|
|
||||||
"syncToPanel": "Синхронизировать в панель",
|
|
||||||
"remawave_uuid": "Remnawave UUID",
|
|
||||||
"notLinked": "Не привязан"
|
|
||||||
},
|
|
||||||
"actions": {
|
|
||||||
"block": "Заблокировать",
|
|
||||||
"unblock": "Разблокировать",
|
|
||||||
"delete": "Удалить",
|
|
||||||
"blockReason": "Причина блокировки",
|
|
||||||
"confirmBlock": "Подтвердить блокировку",
|
|
||||||
"confirmDelete": "Подтвердить удаление"
|
|
||||||
},
|
|
||||||
"messages": {
|
|
||||||
"loadError": "Не удалось загрузить пользователей",
|
|
||||||
"userLoadError": "Не удалось загрузить пользователя",
|
|
||||||
"balanceUpdated": "Баланс обновлён",
|
|
||||||
"balanceError": "Ошибка обновления баланса",
|
|
||||||
"subscriptionUpdated": "Подписка обновлена",
|
|
||||||
"subscriptionError": "Ошибка обновления подписки",
|
|
||||||
"statusUpdated": "Статус обновлён",
|
|
||||||
"statusError": "Ошибка обновления статуса",
|
|
||||||
"syncSuccess": "Синхронизация выполнена",
|
|
||||||
"syncError": "Ошибка синхронизации"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"profile": {
|
"profile": {
|
||||||
"title": "Профиль",
|
"title": "Профиль",
|
||||||
"accountInfo": "Информация об аккаунте",
|
"accountInfo": "Информация об аккаунте",
|
||||||
|
|||||||
@@ -642,7 +642,8 @@
|
|||||||
"promocodes": "促销码",
|
"promocodes": "促销码",
|
||||||
"promoGroups": "折扣组",
|
"promoGroups": "折扣组",
|
||||||
"remnawave": "RemnaWave",
|
"remnawave": "RemnaWave",
|
||||||
"users": "用户"
|
"users": "用户",
|
||||||
|
"trafficUsage": "流量使用"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "管理面板",
|
"title": "管理面板",
|
||||||
@@ -664,7 +665,45 @@
|
|||||||
"promocodesDesc": "管理促销码",
|
"promocodesDesc": "管理促销码",
|
||||||
"promoGroupsDesc": "用户折扣组",
|
"promoGroupsDesc": "用户折扣组",
|
||||||
"remnawaveDesc": "面板管理和统计",
|
"remnawaveDesc": "面板管理和统计",
|
||||||
"usersDesc": "管理机器人用户"
|
"usersDesc": "管理机器人用户",
|
||||||
|
"trafficUsageDesc": "按节点统计用户流量"
|
||||||
|
},
|
||||||
|
"trafficUsage": {
|
||||||
|
"title": "流量使用",
|
||||||
|
"subtitle": "按节点的用户流量统计",
|
||||||
|
"period": "周期",
|
||||||
|
"days": "天",
|
||||||
|
"exportCsv": "导出CSV",
|
||||||
|
"exportSuccess": "CSV已发送到您的Telegram",
|
||||||
|
"exportError": "导出失败",
|
||||||
|
"user": "用户",
|
||||||
|
"tariff": "套餐",
|
||||||
|
"devices": "设备",
|
||||||
|
"trafficLimit": "限制",
|
||||||
|
"total": "总计",
|
||||||
|
"noData": "无流量数据",
|
||||||
|
"loading": "正在加载流量数据...",
|
||||||
|
"noTariff": "\u2014",
|
||||||
|
"search": "按名称或用户名搜索",
|
||||||
|
"allTariffs": "所有套餐",
|
||||||
|
"nodes": "节点",
|
||||||
|
"allNodes": "所有节点",
|
||||||
|
"status": "状态",
|
||||||
|
"allStatuses": "全部",
|
||||||
|
"statusActive": "活跃",
|
||||||
|
"statusTrial": "试用",
|
||||||
|
"statusExpired": "已过期",
|
||||||
|
"statusDisabled": "已禁用",
|
||||||
|
"customDates": "自定义日期",
|
||||||
|
"dateFrom": "从",
|
||||||
|
"dateTo": "到",
|
||||||
|
"totalThreshold": "总流量GB/天",
|
||||||
|
"nodeThreshold": "节点GB/天",
|
||||||
|
"risk": "风险",
|
||||||
|
"riskLow": "低",
|
||||||
|
"riskMedium": "中",
|
||||||
|
"riskHigh": "高",
|
||||||
|
"riskCritical": "严重"
|
||||||
},
|
},
|
||||||
"paymentMethods": {
|
"paymentMethods": {
|
||||||
"title": "支付方法",
|
"title": "支付方法",
|
||||||
@@ -1584,6 +1623,22 @@
|
|||||||
"subscription": "• 禁止购买订阅",
|
"subscription": "• 禁止购买订阅",
|
||||||
"reason": "原因"
|
"reason": "原因"
|
||||||
},
|
},
|
||||||
|
"promoGroup": "优惠组",
|
||||||
|
"noPromoGroup": "未设置",
|
||||||
|
"changePromoGroup": "更改",
|
||||||
|
"selectPromoGroup": "选择组",
|
||||||
|
"removePromoGroup": "移除",
|
||||||
|
"activePromoOffer": "活跃促销优惠",
|
||||||
|
"discount": "折扣",
|
||||||
|
"source": "来源",
|
||||||
|
"expiresAt": "到期时间",
|
||||||
|
"deactivateOffer": "停用",
|
||||||
|
"sendOffer": "发送优惠",
|
||||||
|
"discountPercent": "折扣 %",
|
||||||
|
"validHours": "有效期(小时)",
|
||||||
|
"offerSent": "优惠已发送",
|
||||||
|
"offerSendError": "发送优惠失败",
|
||||||
|
"offerDeactivated": "优惠已停用",
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"current": "当前订阅",
|
"current": "当前订阅",
|
||||||
"tariff": "套餐",
|
"tariff": "套餐",
|
||||||
|
|||||||
@@ -216,6 +216,16 @@ const EnvelopeIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ArrowsUpDownIcon = () => (
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
const ChevronRightIcon = () => (
|
const ChevronRightIcon = () => (
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
@@ -317,6 +327,12 @@ export default function AdminPanel() {
|
|||||||
title: t('admin.nav.payments'),
|
title: t('admin.nav.payments'),
|
||||||
description: t('admin.panel.paymentsDesc'),
|
description: t('admin.panel.paymentsDesc'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/admin/traffic-usage',
|
||||||
|
icon: <ArrowsUpDownIcon />,
|
||||||
|
title: t('admin.nav.trafficUsage'),
|
||||||
|
description: t('admin.panel.trafficUsageDesc'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
1788
src/pages/AdminTrafficUsage.tsx
Normal file
1788
src/pages/AdminTrafficUsage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@ import {
|
|||||||
type UpdateSubscriptionRequest,
|
type UpdateSubscriptionRequest,
|
||||||
} from '../api/adminUsers';
|
} from '../api/adminUsers';
|
||||||
import { adminApi, type AdminTicket, type AdminTicketDetail } from '../api/admin';
|
import { adminApi, type AdminTicket, type AdminTicketDetail } from '../api/admin';
|
||||||
|
import { promocodesApi, type PromoGroup } from '../api/promocodes';
|
||||||
|
import { promoOffersApi } from '../api/promoOffers';
|
||||||
import { ticketsApi } from '../api/tickets';
|
import { ticketsApi } from '../api/tickets';
|
||||||
import { AdminBackButton } from '../components/admin';
|
import { AdminBackButton } from '../components/admin';
|
||||||
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
||||||
@@ -180,6 +182,15 @@ export default function AdminUserDetail() {
|
|||||||
const [subDays, setSubDays] = useState<number | ''>(30);
|
const [subDays, setSubDays] = useState<number | ''>(30);
|
||||||
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
|
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Promo group
|
||||||
|
const [promoGroups, setPromoGroups] = useState<PromoGroup[]>([]);
|
||||||
|
const [editingPromoGroup, setEditingPromoGroup] = useState(false);
|
||||||
|
|
||||||
|
// Send promo offer
|
||||||
|
const [offerDiscountPercent, setOfferDiscountPercent] = useState<number | ''>('');
|
||||||
|
const [offerValidHours, setOfferValidHours] = useState<number | ''>(24);
|
||||||
|
const [offerSending, setOfferSending] = useState(false);
|
||||||
|
|
||||||
const userId = id ? parseInt(id, 10) : null;
|
const userId = id ? parseInt(id, 10) : null;
|
||||||
|
|
||||||
const loadUser = useCallback(async () => {
|
const loadUser = useCallback(async () => {
|
||||||
@@ -282,6 +293,15 @@ export default function AdminUserDetail() {
|
|||||||
await Promise.all([loadPanelInfo(), loadNodeUsage()]);
|
await Promise.all([loadPanelInfo(), loadNodeUsage()]);
|
||||||
}, [loadPanelInfo, loadNodeUsage]);
|
}, [loadPanelInfo, loadNodeUsage]);
|
||||||
|
|
||||||
|
const loadPromoGroups = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await promocodesApi.getPromoGroups({ limit: 100 });
|
||||||
|
setPromoGroups(data.items);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleTicketReply = async () => {
|
const handleTicketReply = async () => {
|
||||||
if (!selectedTicketId || !replyText.trim()) return;
|
if (!selectedTicketId || !replyText.trim()) return;
|
||||||
setReplySending(true);
|
setReplySending(true);
|
||||||
@@ -332,14 +352,25 @@ export default function AdminUserDetail() {
|
|||||||
}, [userId, loadUser, navigate]);
|
}, [userId, loadUser, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'info') loadReferrals();
|
if (activeTab === 'info') {
|
||||||
|
loadReferrals();
|
||||||
|
loadPromoGroups();
|
||||||
|
}
|
||||||
if (activeTab === 'sync') loadSyncStatus();
|
if (activeTab === 'sync') loadSyncStatus();
|
||||||
if (activeTab === 'subscription') {
|
if (activeTab === 'subscription') {
|
||||||
loadTariffs();
|
loadTariffs();
|
||||||
loadSubscriptionData();
|
loadSubscriptionData();
|
||||||
}
|
}
|
||||||
if (activeTab === 'tickets') loadTickets();
|
if (activeTab === 'tickets') loadTickets();
|
||||||
}, [activeTab, loadSyncStatus, loadTariffs, loadTickets, loadReferrals, loadSubscriptionData]);
|
}, [
|
||||||
|
activeTab,
|
||||||
|
loadSyncStatus,
|
||||||
|
loadTariffs,
|
||||||
|
loadTickets,
|
||||||
|
loadReferrals,
|
||||||
|
loadSubscriptionData,
|
||||||
|
loadPromoGroups,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleUpdateBalance = async (isAdd: boolean) => {
|
const handleUpdateBalance = async (isAdd: boolean) => {
|
||||||
if (balanceAmount === '' || !userId) return;
|
if (balanceAmount === '' || !userId) return;
|
||||||
@@ -364,17 +395,16 @@ export default function AdminUserDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateSubscription = async () => {
|
const handleUpdateSubscription = async (overrideAction?: string) => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
|
const action = overrideAction || subAction;
|
||||||
const data: UpdateSubscriptionRequest = {
|
const data: UpdateSubscriptionRequest = {
|
||||||
action: subAction as UpdateSubscriptionRequest['action'],
|
action: action as UpdateSubscriptionRequest['action'],
|
||||||
...(subAction === 'extend' ? { days: toNumber(subDays, 30) } : {}),
|
...(action === 'extend' ? { days: toNumber(subDays, 30) } : {}),
|
||||||
...(subAction === 'change_tariff' && selectedTariffId
|
...(action === 'change_tariff' && selectedTariffId ? { tariff_id: selectedTariffId } : {}),
|
||||||
? { tariff_id: selectedTariffId }
|
...(action === 'create'
|
||||||
: {}),
|
|
||||||
...(subAction === 'create'
|
|
||||||
? {
|
? {
|
||||||
days: toNumber(subDays, 30),
|
days: toNumber(subDays, 30),
|
||||||
...(selectedTariffId ? { tariff_id: selectedTariffId } : {}),
|
...(selectedTariffId ? { tariff_id: selectedTariffId } : {}),
|
||||||
@@ -451,7 +481,7 @@ export default function AdminUserDetail() {
|
|||||||
if (confirmingAction === actionKey) {
|
if (confirmingAction === actionKey) {
|
||||||
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
|
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
|
||||||
setConfirmingAction(null);
|
setConfirmingAction(null);
|
||||||
executeFn();
|
executeFn().catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
|
if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current);
|
||||||
setConfirmingAction(actionKey);
|
setConfirmingAction(actionKey);
|
||||||
@@ -459,6 +489,57 @@ export default function AdminUserDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleChangePromoGroup = async (groupId: number | null) => {
|
||||||
|
if (!userId) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await adminUsersApi.updatePromoGroup(userId, groupId);
|
||||||
|
await loadUser();
|
||||||
|
setEditingPromoGroup(false);
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeactivateOffer = async () => {
|
||||||
|
if (!userId) return;
|
||||||
|
setActionLoading(true);
|
||||||
|
try {
|
||||||
|
await promocodesApi.deactivateDiscount(userId);
|
||||||
|
notify.success(t('admin.users.detail.offerDeactivated'), t('common.success'));
|
||||||
|
await loadUser();
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendOffer = async () => {
|
||||||
|
if (!userId || offerDiscountPercent === '' || offerValidHours === '') return;
|
||||||
|
setOfferSending(true);
|
||||||
|
try {
|
||||||
|
await promoOffersApi.broadcastOffer({
|
||||||
|
user_id: userId,
|
||||||
|
notification_type: 'admin_personal',
|
||||||
|
discount_percent: toNumber(offerDiscountPercent),
|
||||||
|
valid_hours: toNumber(offerValidHours, 24),
|
||||||
|
effect_type: 'percent_discount',
|
||||||
|
send_notification: true,
|
||||||
|
});
|
||||||
|
notify.success(t('admin.users.detail.offerSent'), t('common.success'));
|
||||||
|
setOfferDiscountPercent('');
|
||||||
|
setOfferValidHours(24);
|
||||||
|
await loadUser();
|
||||||
|
} catch {
|
||||||
|
notify.error(t('admin.users.detail.offerSendError'), t('common.error'));
|
||||||
|
} finally {
|
||||||
|
setOfferSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleResetTrial = async () => {
|
const handleResetTrial = async () => {
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
@@ -717,6 +798,56 @@ export default function AdminUserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Promo Group */}
|
||||||
|
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-xs text-dark-500">{t('admin.users.detail.promoGroup')}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingPromoGroup(!editingPromoGroup)}
|
||||||
|
className="text-xs text-accent-400 transition-colors hover:text-accent-300"
|
||||||
|
>
|
||||||
|
{editingPromoGroup
|
||||||
|
? t('common.cancel')
|
||||||
|
: t('admin.users.detail.changePromoGroup')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{editingPromoGroup ? (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<select
|
||||||
|
value={user.promo_group?.id ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
handleChangePromoGroup(val ? parseInt(val, 10) : null);
|
||||||
|
}}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="input text-sm"
|
||||||
|
>
|
||||||
|
<option value="">{t('admin.users.detail.selectPromoGroup')}</option>
|
||||||
|
{promoGroups.map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{user.promo_group && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleChangePromoGroup(null)}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className="w-full rounded-lg bg-dark-700 py-1.5 text-xs text-dark-300 transition-colors hover:bg-dark-600"
|
||||||
|
>
|
||||||
|
{t('admin.users.detail.removePromoGroup')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm font-medium text-dark-100">
|
||||||
|
{user.promo_group?.name || (
|
||||||
|
<span className="text-dark-500">{t('admin.users.detail.noPromoGroup')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Referral */}
|
{/* Referral */}
|
||||||
<div className="rounded-xl bg-dark-800/50 p-3">
|
<div className="rounded-xl bg-dark-800/50 p-3">
|
||||||
<div className="mb-2 text-sm font-medium text-dark-200">
|
<div className="mb-2 text-sm font-medium text-dark-200">
|
||||||
@@ -982,7 +1113,7 @@ export default function AdminUserDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleUpdateSubscription}
|
onClick={() => handleUpdateSubscription()}
|
||||||
disabled={actionLoading}
|
disabled={actionLoading}
|
||||||
className="btn-primary w-full"
|
className="btn-primary w-full"
|
||||||
>
|
>
|
||||||
@@ -1023,10 +1154,7 @@ export default function AdminUserDetail() {
|
|||||||
max={3650}
|
max={3650}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => handleUpdateSubscription('create')}
|
||||||
setSubAction('create');
|
|
||||||
handleUpdateSubscription();
|
|
||||||
}}
|
|
||||||
disabled={actionLoading}
|
disabled={actionLoading}
|
||||||
className="btn-primary w-full"
|
className="btn-primary w-full"
|
||||||
>
|
>
|
||||||
@@ -1314,6 +1442,86 @@ export default function AdminUserDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Active promo offer */}
|
||||||
|
{user.promo_offer_discount_percent > 0 && (
|
||||||
|
<div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-accent-400">
|
||||||
|
{t('admin.users.detail.activePromoOffer')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleInlineConfirm('deactivateOffer', handleDeactivateOffer)}
|
||||||
|
disabled={actionLoading}
|
||||||
|
className={`rounded-lg px-3 py-1 text-xs font-medium transition-all disabled:opacity-50 ${
|
||||||
|
confirmingAction === 'deactivateOffer'
|
||||||
|
? 'bg-error-500 text-white'
|
||||||
|
: 'bg-error-500/15 text-error-400 hover:bg-error-500/25'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmingAction === 'deactivateOffer'
|
||||||
|
? t('admin.users.detail.actions.areYouSure')
|
||||||
|
: t('admin.users.detail.deactivateOffer')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-dark-100">
|
||||||
|
{user.promo_offer_discount_percent}%
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-dark-500">{t('admin.users.detail.discount')}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-dark-100">
|
||||||
|
{user.promo_offer_discount_source || '-'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-dark-500">{t('admin.users.detail.source')}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-dark-100">
|
||||||
|
{user.promo_offer_discount_expires_at
|
||||||
|
? formatDate(user.promo_offer_discount_expires_at)
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-dark-500">{t('admin.users.detail.expiresAt')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Send promo offer */}
|
||||||
|
<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.sendOffer')}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={offerDiscountPercent}
|
||||||
|
onChange={createNumberInputHandler(setOfferDiscountPercent, 1)}
|
||||||
|
placeholder={t('admin.users.detail.discountPercent')}
|
||||||
|
className="input"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={offerValidHours}
|
||||||
|
onChange={createNumberInputHandler(setOfferValidHours, 1)}
|
||||||
|
placeholder={t('admin.users.detail.validHours')}
|
||||||
|
className="input"
|
||||||
|
min={1}
|
||||||
|
max={8760}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSendOffer}
|
||||||
|
disabled={offerSending || offerDiscountPercent === '' || offerValidHours === ''}
|
||||||
|
className="btn-primary w-full disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{offerSending ? t('common.loading') : t('admin.users.detail.sendOffer')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Recent transactions */}
|
{/* Recent transactions */}
|
||||||
{user.recent_transactions.length > 0 && (
|
{user.recent_transactions.length > 0 && (
|
||||||
<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