Add admin features for broadcasts, campaigns, and users

- Introduced new pages and API integrations for managing broadcasts, campaigns, and users in the admin panel.
- Added localization support for new features in English, Persian, Russian, and Chinese.
- Enhanced the AdminPanel to include navigation for the new sections, improving overall admin experience.
This commit is contained in:
PEDZEO
2026-01-17 07:50:17 +03:00
14 changed files with 5284 additions and 63 deletions

View File

@@ -26,6 +26,10 @@ import AdminServers from './pages/AdminServers'
import AdminPanel from './pages/AdminPanel'
import AdminDashboard from './pages/AdminDashboard'
import AdminBanSystem from './pages/AdminBanSystem'
import AdminBroadcasts from './pages/AdminBroadcasts'
import AdminPromocodes from './pages/AdminPromocodes'
import AdminCampaigns from './pages/AdminCampaigns'
import AdminUsers from './pages/AdminUsers'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuthStore()
@@ -226,6 +230,38 @@ function App() {
</AdminRoute>
}
/>
<Route
path="/admin/broadcasts"
element={
<AdminRoute>
<AdminBroadcasts />
</AdminRoute>
}
/>
<Route
path="/admin/promocodes"
element={
<AdminRoute>
<AdminPromocodes />
</AdminRoute>
}
/>
<Route
path="/admin/campaigns"
element={
<AdminRoute>
<AdminCampaigns />
</AdminRoute>
}
/>
<Route
path="/admin/users"
element={
<AdminRoute>
<AdminUsers />
</AdminRoute>
}
/>
{/* Catch all */}
<Route path="*" element={<Navigate to="/" replace />} />

168
src/api/adminBroadcasts.ts Normal file
View File

@@ -0,0 +1,168 @@
import apiClient from './client'
// Types
export interface BroadcastFilter {
key: string
label: string
count: number | null
group: string | null
}
export interface TariffFilter {
key: string
label: string
tariff_id: number
count: number
}
export interface BroadcastFiltersResponse {
filters: BroadcastFilter[]
tariff_filters: TariffFilter[]
custom_filters: BroadcastFilter[]
}
export interface TariffForBroadcast {
id: number
name: string
filter_key: string
active_users_count: number
}
export interface BroadcastTariffsResponse {
tariffs: TariffForBroadcast[]
}
export interface BroadcastButton {
key: string
label: string
default: boolean
}
export interface BroadcastButtonsResponse {
buttons: BroadcastButton[]
}
export interface BroadcastMedia {
type: 'photo' | 'video' | 'document'
file_id: string
caption?: string
}
export interface BroadcastCreateRequest {
target: string
message_text: string
selected_buttons: string[]
media?: BroadcastMedia
}
export interface Broadcast {
id: number
target_type: string
message_text: string
has_media: boolean
media_type: string | null
media_file_id: string | null
media_caption: string | null
total_count: number
sent_count: number
failed_count: number
status: 'queued' | 'in_progress' | 'completed' | 'partial' | 'failed' | 'cancelled' | 'cancelling'
admin_id: number | null
admin_name: string | null
created_at: string
completed_at: string | null
progress_percent: number
}
export interface BroadcastListResponse {
items: Broadcast[]
total: number
limit: number
offset: number
}
export interface BroadcastPreviewRequest {
target: string
}
export interface BroadcastPreviewResponse {
target: string
count: number
}
export interface MediaUploadResponse {
media_type: string
file_id: string
file_unique_id: string | null
media_url: string
}
export const adminBroadcastsApi = {
// Get all available filters with counts
getFilters: async (): Promise<BroadcastFiltersResponse> => {
const response = await apiClient.get<BroadcastFiltersResponse>('/cabinet/admin/broadcasts/filters')
return response.data
},
// Get tariffs for filtering
getTariffs: async (): Promise<BroadcastTariffsResponse> => {
const response = await apiClient.get<BroadcastTariffsResponse>('/cabinet/admin/broadcasts/tariffs')
return response.data
},
// Get available buttons
getButtons: async (): Promise<BroadcastButtonsResponse> => {
const response = await apiClient.get<BroadcastButtonsResponse>('/cabinet/admin/broadcasts/buttons')
return response.data
},
// Preview broadcast (get recipients count)
preview: async (target: string): Promise<BroadcastPreviewResponse> => {
const response = await apiClient.post<BroadcastPreviewResponse>('/cabinet/admin/broadcasts/preview', {
target,
})
return response.data
},
// Create and start broadcast
create: async (data: BroadcastCreateRequest): Promise<Broadcast> => {
const response = await apiClient.post<Broadcast>('/cabinet/admin/broadcasts', data)
return response.data
},
// Get list of broadcasts
list: async (limit = 20, offset = 0): Promise<BroadcastListResponse> => {
const response = await apiClient.get<BroadcastListResponse>('/cabinet/admin/broadcasts', {
params: { limit, offset },
})
return response.data
},
// Get broadcast details
get: async (id: number): Promise<Broadcast> => {
const response = await apiClient.get<Broadcast>(`/cabinet/admin/broadcasts/${id}`)
return response.data
},
// Stop broadcast
stop: async (id: number): Promise<Broadcast> => {
const response = await apiClient.post<Broadcast>(`/cabinet/admin/broadcasts/${id}/stop`)
return response.data
},
// Upload media (uses existing media endpoint)
uploadMedia: async (file: File, mediaType: 'photo' | 'video' | 'document'): Promise<MediaUploadResponse> => {
const formData = new FormData()
formData.append('file', file)
formData.append('media_type', mediaType)
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
},
}
export default adminBroadcastsApi

418
src/api/adminUsers.ts Normal file
View File

@@ -0,0 +1,418 @@
import apiClient from './client'
// ============ Types ============
export interface UserSubscriptionInfo {
id: number
status: string
is_trial: boolean
start_date: string | null
end_date: string | null
traffic_limit_gb: number
traffic_used_gb: number
device_limit: number
tariff_id: number | null
tariff_name: string | null
autopay_enabled: boolean
is_active: boolean
days_remaining: number
}
export interface UserPromoGroupInfo {
id: number
name: string
is_default: boolean
}
export interface UserListItem {
id: number
telegram_id: number
username: string | null
first_name: string | null
last_name: string | null
full_name: string
status: string
balance_kopeks: number
balance_rubles: number
created_at: string
last_activity: string | null
has_subscription: boolean
subscription_status: string | null
subscription_is_trial: boolean
subscription_end_date: string | null
promo_group_id: number | null
promo_group_name: string | null
total_spent_kopeks: number
purchase_count: number
has_restrictions: boolean
restriction_topup: boolean
restriction_subscription: boolean
}
export interface UsersListResponse {
users: UserListItem[]
total: number
offset: number
limit: number
}
export interface UserTransactionItem {
id: number
type: string
amount_kopeks: number
amount_rubles: number
description: string | null
payment_method: string | null
is_completed: boolean
created_at: string
}
export interface UserReferralInfo {
referral_code: string
referrals_count: number
total_earnings_kopeks: number
commission_percent: number | null
referred_by_id: number | null
referred_by_username: string | null
}
export interface UserDetailResponse {
id: number
telegram_id: number
username: string | null
first_name: string | null
last_name: string | null
full_name: string
status: string
language: string
balance_kopeks: number
balance_rubles: number
email: string | null
email_verified: boolean
created_at: string
updated_at: string | null
last_activity: string | null
cabinet_last_login: string | null
subscription: UserSubscriptionInfo | null
promo_group: UserPromoGroupInfo | null
referral: UserReferralInfo
total_spent_kopeks: number
purchase_count: number
used_promocodes: number
has_had_paid_subscription: boolean
lifetime_used_traffic_bytes: number
restriction_topup: boolean
restriction_subscription: boolean
restriction_reason: string | null
promo_offer_discount_percent: number
promo_offer_discount_source: string | null
promo_offer_discount_expires_at: string | null
recent_transactions: UserTransactionItem[]
remnawave_uuid: string | null
}
export interface UsersStatsResponse {
total_users: number
active_users: number
blocked_users: number
deleted_users: number
new_today: number
new_week: number
new_month: number
users_with_subscription: number
users_with_active_subscription: number
users_with_trial: number
users_with_expired_subscription: number
total_balance_kopeks: number
total_balance_rubles: number
avg_balance_kopeks: number
active_today: number
active_week: number
active_month: number
}
// Available tariffs
export interface PeriodPriceInfo {
days: number
price_kopeks: number
price_rubles: number
}
export interface UserAvailableTariff {
id: number
name: string
description: string | null
is_active: boolean
is_trial_available: boolean
traffic_limit_gb: number
device_limit: number
tier_level: number
display_order: number
period_prices: PeriodPriceInfo[]
is_daily: boolean
daily_price_kopeks: number
custom_days_enabled: boolean
price_per_day_kopeks: number
min_days: number
max_days: number
is_available: boolean
requires_promo_group: boolean
}
export interface UserAvailableTariffsResponse {
user_id: number
promo_group_id: number | null
promo_group_name: string | null
tariffs: UserAvailableTariff[]
total: number
current_tariff_id: number | null
current_tariff_name: string | null
}
// Sync types
export interface PanelUserInfo {
uuid: string | null
short_uuid: string | null
username: string | null
status: string | null
expire_at: string | null
traffic_limit_gb: number
traffic_used_gb: number
device_limit: number
subscription_url: string | null
active_squads: string[]
}
export interface SyncFromPanelResponse {
success: boolean
message: string
panel_user: PanelUserInfo | null
changes: Record<string, unknown>
errors: string[]
}
export interface SyncToPanelResponse {
success: boolean
message: string
action: string
panel_uuid: string | null
changes: Record<string, unknown>
errors: string[]
}
export interface PanelSyncStatusResponse {
user_id: number
telegram_id: number
remnawave_uuid: string | null
last_sync: string | null
bot_subscription_status: string | null
bot_subscription_end_date: string | null
bot_traffic_limit_gb: number
bot_traffic_used_gb: number
bot_device_limit: number
bot_squads: string[]
panel_found: boolean
panel_status: string | null
panel_expire_at: string | null
panel_traffic_limit_gb: number
panel_traffic_used_gb: number
panel_device_limit: number
panel_squads: string[]
has_differences: boolean
differences: string[]
}
// Update types
export interface UpdateBalanceRequest {
amount_kopeks: number
description?: string
create_transaction?: boolean
}
export interface UpdateBalanceResponse {
success: boolean
old_balance_kopeks: number
new_balance_kopeks: number
message: string
}
export interface UpdateSubscriptionRequest {
action: 'extend' | 'set_end_date' | 'change_tariff' | 'set_traffic' | 'toggle_autopay' | 'cancel' | 'activate' | 'create'
days?: number
end_date?: string
tariff_id?: number
traffic_limit_gb?: number
traffic_used_gb?: number
autopay_enabled?: boolean
is_trial?: boolean
device_limit?: number
}
export interface UpdateSubscriptionResponse {
success: boolean
message: string
subscription: UserSubscriptionInfo | null
}
export interface UpdateUserStatusResponse {
success: boolean
old_status: string
new_status: string
message: string
}
export interface UpdateRestrictionsRequest {
restriction_topup?: boolean
restriction_subscription?: boolean
restriction_reason?: string
}
export interface UpdateRestrictionsResponse {
success: boolean
restriction_topup: boolean
restriction_subscription: boolean
restriction_reason: string | null
message: string
}
export interface SyncFromPanelRequest {
update_subscription?: boolean
update_traffic?: boolean
create_if_missing?: boolean
}
export interface SyncToPanelRequest {
create_if_missing?: boolean
update_status?: boolean
update_traffic_limit?: boolean
update_expire_date?: boolean
update_squads?: boolean
}
// ============ API ============
export const adminUsersApi = {
// List users
getUsers: async (params: {
offset?: number
limit?: number
search?: string
status?: 'active' | 'blocked' | 'deleted'
sort_by?: 'created_at' | 'balance' | 'traffic' | 'last_activity' | 'total_spent' | 'purchase_count'
} = {}): Promise<UsersListResponse> => {
const response = await apiClient.get('/cabinet/admin/users', { params })
return response.data
},
// Get users stats
getStats: async (): Promise<UsersStatsResponse> => {
const response = await apiClient.get('/cabinet/admin/users/stats')
return response.data
},
// Get user detail
getUser: async (userId: number): Promise<UserDetailResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}`)
return response.data
},
// Get user by telegram ID
getUserByTelegram: async (telegramId: number): Promise<UserDetailResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/by-telegram/${telegramId}`)
return response.data
},
// Get available tariffs for user
getAvailableTariffs: async (userId: number, includeInactive = false): Promise<UserAvailableTariffsResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/available-tariffs`, {
params: { include_inactive: includeInactive }
})
return response.data
},
// Update balance
updateBalance: async (userId: number, data: UpdateBalanceRequest): Promise<UpdateBalanceResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/balance`, data)
return response.data
},
// Update subscription
updateSubscription: async (userId: number, data: UpdateSubscriptionRequest): Promise<UpdateSubscriptionResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/subscription`, data)
return response.data
},
// Update status
updateStatus: async (userId: number, status: 'active' | 'blocked' | 'deleted', reason?: string): Promise<UpdateUserStatusResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/status`, { status, reason })
return response.data
},
// Block user
blockUser: async (userId: number, reason?: string): Promise<UpdateUserStatusResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/block`, null, { params: { reason } })
return response.data
},
// Unblock user
unblockUser: async (userId: number): Promise<UpdateUserStatusResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/unblock`)
return response.data
},
// Update restrictions
updateRestrictions: async (userId: number, data: UpdateRestrictionsRequest): Promise<UpdateRestrictionsResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/restrictions`, data)
return response.data
},
// Update promo group
updatePromoGroup: async (userId: number, promoGroupId: number | null): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/promo-group`, { promo_group_id: promoGroupId })
return response.data
},
// Delete user
deleteUser: async (userId: number, softDelete = true, reason?: string): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(`/cabinet/admin/users/${userId}`, {
data: { soft_delete: softDelete, reason }
})
return response.data
},
// Get referrals
getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, {
params: { offset, limit }
})
return response.data
},
// Get transactions
getTransactions: async (userId: number, params: {
offset?: number
limit?: number
transaction_type?: string
} = {}): Promise<{ transactions: UserTransactionItem[]; total: number; offset: number; limit: number }> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/transactions`, { params })
return response.data
},
// Sync status
getSyncStatus: async (userId: number): Promise<PanelSyncStatusResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`)
return response.data
},
// Sync from panel
syncFromPanel: async (userId: number, data: SyncFromPanelRequest = {}): Promise<SyncFromPanelResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/from-panel`, data)
return response.data
},
// Sync to panel
syncToPanel: async (userId: number, data: SyncToPanelRequest = {}): Promise<SyncToPanelResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data)
return response.data
},
}

230
src/api/campaigns.ts Normal file
View File

@@ -0,0 +1,230 @@
import apiClient from './client'
// Types
export type CampaignBonusType = 'balance' | 'subscription' | 'none' | 'tariff'
export interface TariffInfo {
id: number
name: string
}
export interface CampaignListItem {
id: number
name: string
start_parameter: string
bonus_type: CampaignBonusType
is_active: boolean
registrations_count: number
total_revenue_kopeks: number
conversion_rate: number
created_at: string
}
export interface CampaignListResponse {
campaigns: CampaignListItem[]
total: number
}
export interface CampaignDetail {
id: number
name: string
start_parameter: string
bonus_type: CampaignBonusType
is_active: boolean
balance_bonus_kopeks: number
balance_bonus_rubles: number
subscription_duration_days: number | null
subscription_traffic_gb: number | null
subscription_device_limit: number | null
subscription_squads: string[]
tariff_id: number | null
tariff_duration_days: number | null
tariff: TariffInfo | null
created_by: number | null
created_at: string
updated_at: string | null
deep_link: string | null
}
export interface CampaignCreateRequest {
name: string
start_parameter: string
bonus_type: CampaignBonusType
is_active?: boolean
balance_bonus_kopeks?: number
subscription_duration_days?: number
subscription_traffic_gb?: number
subscription_device_limit?: number
subscription_squads?: string[]
tariff_id?: number
tariff_duration_days?: number
}
export interface CampaignUpdateRequest {
name?: string
start_parameter?: string
bonus_type?: CampaignBonusType
is_active?: boolean
balance_bonus_kopeks?: number
subscription_duration_days?: number
subscription_traffic_gb?: number
subscription_device_limit?: number
subscription_squads?: string[]
tariff_id?: number
tariff_duration_days?: number
}
export interface CampaignToggleResponse {
id: number
is_active: boolean
message: string
}
export interface CampaignStatistics {
id: number
name: string
start_parameter: string
bonus_type: CampaignBonusType
is_active: boolean
registrations: number
balance_issued_kopeks: number
balance_issued_rubles: number
subscription_issued: number
last_registration: string | null
total_revenue_kopeks: number
total_revenue_rubles: number
avg_revenue_per_user_kopeks: number
avg_revenue_per_user_rubles: number
avg_first_payment_kopeks: number
avg_first_payment_rubles: number
trial_users_count: number
active_trials_count: number
conversion_count: number
paid_users_count: number
conversion_rate: number
trial_conversion_rate: number
deep_link: string | null
}
export interface CampaignRegistrationItem {
id: number
user_id: number
telegram_id: number
username: string | null
first_name: string | null
bonus_type: string
balance_bonus_kopeks: number
subscription_duration_days: number | null
tariff_id: number | null
tariff_duration_days: number | null
created_at: string
user_balance_kopeks: number
has_subscription: boolean
has_paid: boolean
}
export interface CampaignRegistrationsResponse {
registrations: CampaignRegistrationItem[]
total: number
page: number
per_page: number
}
export interface CampaignsOverview {
total: number
active: number
inactive: number
total_registrations: number
total_balance_issued_kopeks: number
total_balance_issued_rubles: number
total_subscription_issued: number
total_tariff_issued: number
}
export interface ServerSquadInfo {
id: number
squad_uuid: string
display_name: string
country_code: string | null
}
export interface TariffListItem {
id: number
name: string
description: string | null
is_active: boolean
traffic_limit_gb: number
device_limit: number
}
export const campaignsApi = {
// Get campaigns overview
getOverview: async (): Promise<CampaignsOverview> => {
const response = await apiClient.get('/cabinet/admin/campaigns/overview')
return response.data
},
// Get all campaigns
getCampaigns: async (includeInactive = true, offset = 0, limit = 50): Promise<CampaignListResponse> => {
const response = await apiClient.get('/cabinet/admin/campaigns', {
params: { include_inactive: includeInactive, offset, limit }
})
return response.data
},
// Get single campaign
getCampaign: async (campaignId: number): Promise<CampaignDetail> => {
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}`)
return response.data
},
// Get campaign statistics
getCampaignStats: async (campaignId: number): Promise<CampaignStatistics> => {
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/stats`)
return response.data
},
// Get campaign registrations
getCampaignRegistrations: async (campaignId: number, page = 1, perPage = 50): Promise<CampaignRegistrationsResponse> => {
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/registrations`, {
params: { page, per_page: perPage }
})
return response.data
},
// Create campaign
createCampaign: async (data: CampaignCreateRequest): Promise<CampaignDetail> => {
const response = await apiClient.post('/cabinet/admin/campaigns', data)
return response.data
},
// Update campaign
updateCampaign: async (campaignId: number, data: CampaignUpdateRequest): Promise<CampaignDetail> => {
const response = await apiClient.put(`/cabinet/admin/campaigns/${campaignId}`, data)
return response.data
},
// Delete campaign
deleteCampaign: async (campaignId: number): Promise<{ message: string }> => {
const response = await apiClient.delete(`/cabinet/admin/campaigns/${campaignId}`)
return response.data
},
// Toggle campaign active status
toggleCampaign: async (campaignId: number): Promise<CampaignToggleResponse> => {
const response = await apiClient.post(`/cabinet/admin/campaigns/${campaignId}/toggle`)
return response.data
},
// Get available servers for subscription bonus
getAvailableServers: async (): Promise<ServerSquadInfo[]> => {
const response = await apiClient.get('/cabinet/admin/campaigns/available-servers')
return response.data
},
// Get available tariffs for tariff bonus
getAvailableTariffs: async (): Promise<TariffListItem[]> => {
const response = await apiClient.get('/cabinet/admin/campaigns/available-tariffs')
return response.data
},
}

181
src/api/promocodes.ts Normal file
View File

@@ -0,0 +1,181 @@
import apiClient from './client'
// ============== Types ==============
export type PromoCodeType = 'balance' | 'subscription_days' | 'trial_subscription' | 'promo_group'
export interface PromoCode {
id: number
code: string
type: PromoCodeType
balance_bonus_kopeks: number
balance_bonus_rubles: number
subscription_days: number
max_uses: number
current_uses: number
uses_left: number
is_active: boolean
is_valid: boolean
first_purchase_only: boolean
valid_from: string
valid_until: string | null
promo_group_id: number | null
created_by: number | null
created_at: string
updated_at: string
}
export interface PromoCodeRecentUse {
id: number
user_id: number
user_username: string | null
user_full_name: string | null
user_telegram_id: number | null
used_at: string
}
export interface PromoCodeDetail extends PromoCode {
total_uses: number
today_uses: number
recent_uses: PromoCodeRecentUse[]
}
export interface PromoCodeListResponse {
items: PromoCode[]
total: number
limit: number
offset: number
}
export interface PromoCodeCreateRequest {
code: string
type: PromoCodeType
balance_bonus_kopeks?: number
subscription_days?: number
max_uses?: number
valid_from?: string
valid_until?: string | null
is_active?: boolean
first_purchase_only?: boolean
promo_group_id?: number | null
}
export interface PromoCodeUpdateRequest {
code?: string
type?: PromoCodeType
balance_bonus_kopeks?: number
subscription_days?: number
max_uses?: number
valid_from?: string
valid_until?: string | null
is_active?: boolean
first_purchase_only?: boolean
promo_group_id?: number | null
}
// ============== PromoGroup Types ==============
export interface PromoGroup {
id: number
name: string
server_discount_percent: number
traffic_discount_percent: number
device_discount_percent: number
period_discounts: Record<number, number>
auto_assign_total_spent_kopeks: number | null
apply_discounts_to_addons: boolean
is_default: boolean
members_count: number
created_at: string | null
updated_at: string | null
}
export interface PromoGroupListResponse {
items: PromoGroup[]
total: number
limit: number
offset: number
}
export interface PromoGroupCreateRequest {
name: string
server_discount_percent?: number
traffic_discount_percent?: number
device_discount_percent?: number
period_discounts?: Record<number, number>
auto_assign_total_spent_kopeks?: number | null
apply_discounts_to_addons?: boolean
is_default?: boolean
}
export interface PromoGroupUpdateRequest {
name?: string
server_discount_percent?: number
traffic_discount_percent?: number
device_discount_percent?: number
period_discounts?: Record<number, number>
auto_assign_total_spent_kopeks?: number | null
apply_discounts_to_addons?: boolean
is_default?: boolean
}
// ============== API ==============
export const promocodesApi = {
// Promocodes
getPromocodes: async (params?: {
limit?: number
offset?: number
is_active?: boolean
}): Promise<PromoCodeListResponse> => {
const response = await apiClient.get('/cabinet/admin/promocodes', { params })
return response.data
},
getPromocode: async (id: number): Promise<PromoCodeDetail> => {
const response = await apiClient.get(`/cabinet/admin/promocodes/${id}`)
return response.data
},
createPromocode: async (data: PromoCodeCreateRequest): Promise<PromoCode> => {
const response = await apiClient.post('/cabinet/admin/promocodes', data)
return response.data
},
updatePromocode: async (id: number, data: PromoCodeUpdateRequest): Promise<PromoCode> => {
const response = await apiClient.patch(`/cabinet/admin/promocodes/${id}`, data)
return response.data
},
deletePromocode: async (id: number): Promise<void> => {
await apiClient.delete(`/cabinet/admin/promocodes/${id}`)
},
// Promo Groups
getPromoGroups: async (params?: {
limit?: number
offset?: number
}): Promise<PromoGroupListResponse> => {
const response = await apiClient.get('/cabinet/admin/promo-groups', { params })
return response.data
},
getPromoGroup: async (id: number): Promise<PromoGroup> => {
const response = await apiClient.get(`/cabinet/admin/promo-groups/${id}`)
return response.data
},
createPromoGroup: async (data: PromoGroupCreateRequest): Promise<PromoGroup> => {
const response = await apiClient.post('/cabinet/admin/promo-groups', data)
return response.data
},
updatePromoGroup: async (id: number, data: PromoGroupUpdateRequest): Promise<PromoGroup> => {
const response = await apiClient.patch(`/cabinet/admin/promo-groups/${id}`, data)
return response.data
},
deletePromoGroup: async (id: number): Promise<void> => {
await apiClient.delete(`/cabinet/admin/promo-groups/${id}`)
},
}

View File

@@ -268,8 +268,8 @@
"selectPaymentOption": "Select payment option",
"paymentMethods": {
"yookassa": {
"name": "YooKassa (Bank Card)",
"description": "Pay with bank card via YooKassa"
"name": "YooKassa",
"description": "Pay via YooKassa"
},
"cryptobot": {
"name": "CryptoBot",
@@ -386,7 +386,7 @@
"goToProfile": "Go to Profile",
"useExternalLink": "Please use the external link to get support",
"openSupport": "Open Support",
"contactSupport": "Please contact {username} for support",
"contactSupport": "Please contact {{username}} for support",
"contactUs": "Contact Support"
},
"wheel": {
@@ -442,7 +442,8 @@
"wheel": "Wheel",
"tariffs": "Tariffs",
"servers": "Servers",
"banSystem": "Ban Monitoring"
"banSystem": "Ban Monitoring",
"broadcasts": "Broadcasts"
},
"panel": {
"title": "Admin Panel",
@@ -454,7 +455,8 @@
"wheelDesc": "Configure fortune wheel and prizes",
"tariffsDesc": "Manage tariff plans",
"serversDesc": "Configure VPN servers",
"banSystemDesc": "Ban monitoring and violations"
"banSystemDesc": "Ban monitoring and violations",
"broadcastsDesc": "Mass messaging to users"
},
"wheel": {
"title": "Fortune Wheel Settings",
@@ -514,6 +516,32 @@
"topWins": "Top Wins"
}
},
"broadcasts": {
"title": "Broadcasts",
"subtitle": "Mass messaging to users",
"create": "Create Broadcast",
"empty": "No broadcasts yet",
"selectFilter": "Select audience",
"selectFilterPlaceholder": "Select filter...",
"recipients": "recip.",
"messageText": "Message text",
"messageTextPlaceholder": "Enter broadcast message...",
"media": "Media file",
"addMedia": "Add media",
"uploading": "Uploading...",
"buttons": "Buttons",
"willBeSent": "Will be sent to",
"users": "users",
"send": "Send",
"stop": "Stop",
"progress": "Progress",
"total": "Total",
"sent": "Sent",
"failed": "Failed",
"filter": "Filter",
"message": "Message",
"unknownAdmin": "Unknown admin"
},
"settings": {
"title": "System Settings",
"allCategories": "All categories",

View File

@@ -167,8 +167,8 @@
"topUpBalance": "شارژ موجودی",
"paymentMethods": {
"yookassa": {
"name": "کارت بانکی",
"description": "پرداخت با کارت بانکی"
"name": "YooKassa",
"description": "پرداخت از طریق YooKassa"
},
"cryptobot": {
"name": "CryptoBot",
@@ -334,7 +334,8 @@
"apps": "برنامه‌ها",
"wheel": "چرخ",
"tariffs": "تعرفه‌ها",
"servers": "سرورها"
"servers": "سرورها",
"broadcasts": "پیام‌رسانی"
},
"panel": {
"title": "پنل مدیریت",
@@ -345,7 +346,8 @@
"appsDesc": "مدیریت برنامه‌های اتصال",
"wheelDesc": "تنظیم چرخ شانس و جوایز",
"tariffsDesc": "مدیریت طرح‌های تعرفه",
"serversDesc": "تنظیم سرورهای VPN"
"serversDesc": "تنظیم سرورهای VPN",
"broadcastsDesc": "ارسال پیام گروهی به کاربران"
},
"wheel": {
"title": "تنظیمات چرخ شانس",
@@ -405,6 +407,32 @@
"topWins": "برترین برنده‌ها"
}
},
"broadcasts": {
"title": "پیام‌رسانی",
"subtitle": "ارسال پیام گروهی به کاربران",
"create": "ایجاد پیام",
"empty": "هنوز پیامی ارسال نشده",
"selectFilter": "انتخاب مخاطبین",
"selectFilterPlaceholder": "انتخاب فیلتر...",
"recipients": "نفر",
"messageText": "متن پیام",
"messageTextPlaceholder": "متن پیام را وارد کنید...",
"media": "فایل رسانه",
"addMedia": "افزودن رسانه",
"uploading": "در حال آپلود...",
"buttons": "دکمه‌ها",
"willBeSent": "ارسال می‌شود به",
"users": "کاربر",
"send": "ارسال",
"stop": "توقف",
"progress": "پیشرفت",
"total": "کل",
"sent": "ارسال شده",
"failed": "ناموفق",
"filter": "فیلتر",
"message": "پیام",
"unknownAdmin": "مدیر ناشناس"
},
"settings": {
"title": "تنظیمات سیستم",
"allCategories": "همه دسته‌ها",

View File

@@ -1,4 +1,4 @@
{
{
"common": {
"loading": "Загрузка...",
"error": "Ошибка",
@@ -268,8 +268,8 @@
"selectPaymentOption": "Выберите способ оплаты",
"paymentMethods": {
"yookassa": {
"name": "ЮKassa (Банковская карта)",
"description": "Оплата банковской картой через ЮKassa"
"name": "ЮKassa",
"description": "Оплата через ЮKassa"
},
"cryptobot": {
"name": "CryptoBot",
@@ -386,7 +386,7 @@
"goToProfile": "Перейти в профиль",
"useExternalLink": "Для получения поддержки воспользуйтесь внешней ссылкой",
"openSupport": "Открыть поддержку",
"contactSupport": "Для получения поддержки обратитесь к {username}",
"contactSupport": "Для получения поддержки обратитесь к {{username}}",
"contactUs": "Связаться с поддержкой"
},
"wheel": {
@@ -442,7 +442,9 @@
"wheel": "Колесо",
"tariffs": "Тарифы",
"servers": "Серверы",
"banSystem": "Мониторинг банов"
"banSystem": "Мониторинг банов",
"broadcasts": "Рассылки",
"users": "Пользователи"
},
"panel": {
"title": "Панель администратора",
@@ -454,7 +456,9 @@
"wheelDesc": "Настройка колеса удачи и призов",
"tariffsDesc": "Управление тарифными планами",
"serversDesc": "Настройка VPN серверов",
"banSystemDesc": "Мониторинг банов и нарушений"
"banSystemDesc": "Мониторинг банов и нарушений",
"broadcastsDesc": "Массовая отправка сообщений",
"usersDesc": "Управление пользователями бота"
},
"wheel": {
"title": "Настройки колеса удачи",
@@ -514,6 +518,32 @@
"topWins": "Топ выигрышей"
}
},
"broadcasts": {
"title": "Рассылки",
"subtitle": "Массовая отправка сообщений пользователям",
"create": "Создать рассылку",
"empty": "Рассылок пока нет",
"selectFilter": "Выберите аудиторию",
"selectFilterPlaceholder": "Выберите фильтр...",
"recipients": "получ.",
"messageText": "Текст сообщения",
"messageTextPlaceholder": "Введите текст рассылки...",
"media": "Медиафайл",
"addMedia": "Добавить медиа",
"uploading": "Загрузка...",
"buttons": "Кнопки",
"willBeSent": "Будет отправлено",
"users": "пользователям",
"send": "Отправить",
"stop": "Остановить",
"progress": "Прогресс",
"total": "Всего",
"sent": "Отправлено",
"failed": "Ошибки",
"filter": "Фильтр",
"message": "Сообщение",
"unknownAdmin": "Неизвестный админ"
},
"settings": {
"title": "Настройки системы",
"allCategories": "Все категории",
@@ -1025,6 +1055,164 @@
"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": {
"title": "Профиль",
"accountInfo": "Информация об аккаунте",

View File

@@ -167,8 +167,8 @@
"topUpBalance": "充值余额",
"paymentMethods": {
"yookassa": {
"name": "银行卡",
"description": "通过银行卡支付"
"name": "YooKassa",
"description": "通过YooKassa支付"
},
"cryptobot": {
"name": "CryptoBot",
@@ -334,7 +334,8 @@
"apps": "应用",
"wheel": "转盘",
"tariffs": "套餐",
"servers": "服务器"
"servers": "服务器",
"broadcasts": "群发"
},
"panel": {
"title": "管理面板",
@@ -345,7 +346,8 @@
"appsDesc": "管理连接应用",
"wheelDesc": "配置幸运转盘和奖品",
"tariffsDesc": "管理套餐计划",
"serversDesc": "配置VPN服务器"
"serversDesc": "配置VPN服务器",
"broadcastsDesc": "向用户群发消息"
},
"wheel": {
"title": "幸运转盘设置",
@@ -405,6 +407,32 @@
"topWins": "最高奖品"
}
},
"broadcasts": {
"title": "群发",
"subtitle": "向用户群发消息",
"create": "创建群发",
"empty": "暂无群发",
"selectFilter": "选择受众",
"selectFilterPlaceholder": "选择筛选条件...",
"recipients": "人",
"messageText": "消息内容",
"messageTextPlaceholder": "输入群发消息...",
"media": "媒体文件",
"addMedia": "添加媒体",
"uploading": "上传中...",
"buttons": "按钮",
"willBeSent": "将发送给",
"users": "用户",
"send": "发送",
"stop": "停止",
"progress": "进度",
"total": "总计",
"sent": "已发送",
"failed": "失败",
"filter": "筛选",
"message": "消息",
"unknownAdmin": "未知管理员"
},
"settings": {
"title": "系统设置",
"allCategories": "所有分类",

View File

@@ -0,0 +1,754 @@
import { useState, useRef, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
adminBroadcastsApi,
Broadcast,
BroadcastFilter,
TariffFilter,
BroadcastCreateRequest,
} from '../api/adminBroadcasts'
// Icons
const BroadcastIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
</svg>
)
const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
)
const XIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const RefreshIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
)
const StopIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z" />
</svg>
)
const PhotoIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
)
const VideoIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
</svg>
)
const DocumentIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
)
const UsersIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
const ChevronDownIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
)
// Status badge component
function StatusBadge({ status }: { status: string }) {
const statusConfig: Record<string, { bg: string; text: string; label: string }> = {
queued: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'В очереди' },
in_progress: { bg: 'bg-blue-500/20', text: 'text-blue-400', label: 'Отправляется' },
completed: { bg: 'bg-green-500/20', text: 'text-green-400', label: 'Завершено' },
partial: { bg: 'bg-orange-500/20', text: 'text-orange-400', label: 'Частично' },
failed: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Ошибка' },
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-400', label: 'Отменено' },
cancelling: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Отменяется' },
}
const config = statusConfig[status] || statusConfig.queued
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${config.bg} ${config.text}`}>
{config.label}
</span>
)
}
// Filter labels
const FILTER_GROUP_LABELS: Record<string, string> = {
basic: 'Основные',
subscription: 'По подписке',
traffic: 'По трафику',
registration: 'По регистрации',
activity: 'По активности',
source: 'По источнику',
tariff: 'По тарифу',
}
// Create broadcast modal
interface CreateModalProps {
onClose: () => void
onSuccess: () => void
}
function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [target, setTarget] = useState('')
const [messageText, setMessageText] = useState('')
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home'])
const [mediaFile, setMediaFile] = useState<File | null>(null)
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo')
const [mediaPreview, setMediaPreview] = useState<string | null>(null)
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [showFilters, setShowFilters] = useState(false)
// Fetch filters
const { data: filtersData, isLoading: filtersLoading } = useQuery({
queryKey: ['admin', 'broadcasts', 'filters'],
queryFn: adminBroadcastsApi.getFilters,
})
// Fetch buttons
const { data: buttonsData } = useQuery({
queryKey: ['admin', 'broadcasts', 'buttons'],
queryFn: adminBroadcastsApi.getButtons,
})
// Preview mutation
const previewMutation = useMutation({
mutationFn: adminBroadcastsApi.preview,
})
// Create mutation
const createMutation = useMutation({
mutationFn: adminBroadcastsApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] })
onSuccess()
onClose()
},
})
// Group filters
const groupedFilters = useMemo(() => {
if (!filtersData) return {}
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {}
// Basic filters
filtersData.filters.forEach(f => {
const group = f.group || 'basic'
if (!groups[group]) groups[group] = []
groups[group].push(f)
})
// Tariff filters
if (filtersData.tariff_filters.length > 0) {
groups['tariff'] = filtersData.tariff_filters
}
// Custom filters
filtersData.custom_filters.forEach(f => {
const group = f.group || 'custom'
if (!groups[group]) groups[group] = []
groups[group].push(f)
})
return groups
}, [filtersData])
// Selected filter info
const selectedFilter = useMemo(() => {
if (!target || !filtersData) return null
const all = [
...filtersData.filters,
...filtersData.tariff_filters,
...filtersData.custom_filters,
]
return all.find(f => f.key === target)
}, [target, filtersData])
// Handle file selection
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setMediaFile(file)
// Determine media type
if (file.type.startsWith('image/')) {
setMediaType('photo')
setMediaPreview(URL.createObjectURL(file))
} else if (file.type.startsWith('video/')) {
setMediaType('video')
setMediaPreview(null)
} else {
setMediaType('document')
setMediaPreview(null)
}
// Upload file
setIsUploading(true)
try {
const result = await adminBroadcastsApi.uploadMedia(file, mediaType)
setUploadedFileId(result.file_id)
} catch (err) {
console.error('Upload failed:', err)
setMediaFile(null)
setMediaPreview(null)
} finally {
setIsUploading(false)
}
}
// Remove media
const handleRemoveMedia = () => {
setMediaFile(null)
setMediaPreview(null)
setUploadedFileId(null)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
// Toggle button
const toggleButton = (key: string) => {
setSelectedButtons(prev =>
prev.includes(key) ? prev.filter(b => b !== key) : [...prev, key]
)
}
// Submit
const handleSubmit = () => {
if (!target || !messageText.trim()) return
const data: BroadcastCreateRequest = {
target,
message_text: messageText,
selected_buttons: selectedButtons,
}
if (uploadedFileId) {
data.media = {
type: mediaType,
file_id: uploadedFileId,
}
}
createMutation.mutate(data)
}
const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto">
<div className="bg-dark-800 rounded-xl w-full max-w-2xl my-4">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-accent-500/20 rounded-lg text-accent-400">
<BroadcastIcon />
</div>
<h2 className="text-lg font-semibold text-dark-100">{t('admin.broadcasts.create')}</h2>
</div>
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors">
<XIcon />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
{/* Filter selection */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
{t('admin.broadcasts.selectFilter')}
</label>
<div className="relative">
<button
onClick={() => setShowFilters(!showFilters)}
className="w-full p-3 bg-dark-700 rounded-lg text-left flex items-center justify-between hover:bg-dark-600 transition-colors"
>
<div className="flex items-center gap-2">
<UsersIcon />
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
{selectedFilter ? selectedFilter.label : t('admin.broadcasts.selectFilterPlaceholder')}
</span>
{recipientsCount !== null && (
<span className="px-2 py-0.5 bg-accent-500/20 text-accent-400 rounded-full text-xs">
{recipientsCount} {t('admin.broadcasts.recipients')}
</span>
)}
</div>
<ChevronDownIcon />
</button>
{showFilters && (
<div className="absolute top-full left-0 right-0 mt-1 bg-dark-700 rounded-lg shadow-xl z-10 max-h-64 overflow-y-auto">
{filtersLoading ? (
<div className="p-4 text-center text-dark-400">Loading...</div>
) : (
Object.entries(groupedFilters).map(([group, filters]) => (
<div key={group}>
<div className="px-3 py-2 text-xs font-medium text-dark-400 bg-dark-800 sticky top-0">
{FILTER_GROUP_LABELS[group] || group}
</div>
{filters.map(filter => (
<button
key={filter.key}
onClick={() => {
setTarget(filter.key)
setShowFilters(false)
previewMutation.mutate(filter.key)
}}
className={`w-full px-3 py-2 text-left hover:bg-dark-600 transition-colors flex items-center justify-between ${
target === filter.key ? 'bg-accent-500/20' : ''
}`}
>
<span className="text-dark-100">{filter.label}</span>
{filter.count !== null && filter.count !== undefined && (
<span className="text-xs text-dark-400">{filter.count}</span>
)}
</button>
))}
</div>
))
)}
</div>
)}
</div>
</div>
{/* Message text */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
{t('admin.broadcasts.messageText')}
</label>
<textarea
value={messageText}
onChange={e => setMessageText(e.target.value)}
placeholder={t('admin.broadcasts.messageTextPlaceholder')}
rows={5}
maxLength={4000}
className="w-full p-3 bg-dark-700 rounded-lg text-dark-100 placeholder-dark-400 resize-none focus:outline-none focus:ring-2 focus:ring-accent-500"
/>
<div className="text-xs text-dark-400 mt-1 text-right">
{messageText.length}/4000
</div>
</div>
{/* Media upload */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
{t('admin.broadcasts.media')}
</label>
{mediaFile ? (
<div className="p-3 bg-dark-700 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{mediaType === 'photo' && <PhotoIcon />}
{mediaType === 'video' && <VideoIcon />}
{mediaType === 'document' && <DocumentIcon />}
<div>
<p className="text-sm text-dark-100">{mediaFile.name}</p>
<p className="text-xs text-dark-400">
{(mediaFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<button
onClick={handleRemoveMedia}
className="p-2 hover:bg-dark-600 rounded-lg text-dark-400 hover:text-red-400"
disabled={isUploading}
>
<XIcon />
</button>
</div>
{mediaPreview && (
<img
src={mediaPreview}
alt="Preview"
className="mt-3 max-h-40 rounded-lg object-cover"
/>
)}
{isUploading && (
<div className="mt-2 text-sm text-accent-400">{t('admin.broadcasts.uploading')}</div>
)}
</div>
) : (
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*,video/*,application/*"
onChange={handleFileSelect}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
className="flex-1 p-3 bg-dark-700 rounded-lg text-dark-400 hover:bg-dark-600 hover:text-dark-100 transition-colors flex items-center justify-center gap-2"
>
<PhotoIcon />
<span>{t('admin.broadcasts.addMedia')}</span>
</button>
</div>
)}
</div>
{/* Buttons selection */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
{t('admin.broadcasts.buttons')}
</label>
<div className="flex flex-wrap gap-2">
{buttonsData?.buttons.map(button => (
<button
key={button.key}
onClick={() => toggleButton(button.key)}
className={`px-3 py-2 rounded-lg text-sm transition-colors ${
selectedButtons.includes(button.key)
? 'bg-accent-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
}`}
>
{button.label}
</button>
))}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-dark-700">
<div className="text-sm text-dark-400">
{recipientsCount !== null && (
<span>
{t('admin.broadcasts.willBeSent')}: <strong className="text-accent-400">{recipientsCount}</strong> {t('admin.broadcasts.users')}
</span>
)}
</div>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={!target || !messageText.trim() || createMutation.isPending || isUploading}
className="px-4 py-2 bg-accent-500 rounded-lg text-white hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{createMutation.isPending ? (
<RefreshIcon />
) : (
<BroadcastIcon />
)}
{t('admin.broadcasts.send')}
</button>
</div>
</div>
</div>
</div>
)
}
// Broadcast detail modal
interface DetailModalProps {
broadcast: Broadcast
onClose: () => void
onStop: () => void
isStopping: boolean
}
function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: DetailModalProps) {
const { t } = useTranslation()
const isRunning = ['queued', 'in_progress', 'cancelling'].includes(broadcast.status)
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-lg">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div className="flex items-center gap-3">
<StatusBadge status={broadcast.status} />
<span className="text-dark-400">#{broadcast.id}</span>
</div>
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors">
<XIcon />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
{/* Progress */}
{isRunning && (
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
<span className="text-dark-100">{broadcast.progress_percent.toFixed(1)}%</span>
</div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div
className="h-full bg-accent-500 transition-all duration-300"
style={{ width: `${broadcast.progress_percent}%` }}
/>
</div>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
<div className="p-3 bg-dark-700 rounded-lg text-center">
<p className="text-2xl font-bold text-dark-100">{broadcast.total_count}</p>
<p className="text-xs text-dark-400">{t('admin.broadcasts.total')}</p>
</div>
<div className="p-3 bg-dark-700 rounded-lg text-center">
<p className="text-2xl font-bold text-green-400">{broadcast.sent_count}</p>
<p className="text-xs text-dark-400">{t('admin.broadcasts.sent')}</p>
</div>
<div className="p-3 bg-dark-700 rounded-lg text-center">
<p className="text-2xl font-bold text-red-400">{broadcast.failed_count}</p>
<p className="text-xs text-dark-400">{t('admin.broadcasts.failed')}</p>
</div>
</div>
{/* Target */}
<div>
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.filter')}</p>
<p className="text-dark-100">{broadcast.target_type}</p>
</div>
{/* Message */}
<div>
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.message')}</p>
<div className="p-3 bg-dark-700 rounded-lg text-dark-100 whitespace-pre-wrap text-sm max-h-40 overflow-y-auto">
{broadcast.message_text}
</div>
</div>
{/* Media */}
{broadcast.has_media && (
<div>
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.media')}</p>
<div className="flex items-center gap-2 text-dark-100">
{broadcast.media_type === 'photo' && <PhotoIcon />}
{broadcast.media_type === 'video' && <VideoIcon />}
{broadcast.media_type === 'document' && <DocumentIcon />}
<span className="capitalize">{broadcast.media_type}</span>
</div>
</div>
)}
{/* Admin & Time */}
<div className="flex justify-between text-sm text-dark-400">
<span>{broadcast.admin_name || t('admin.broadcasts.unknownAdmin')}</span>
<span>{new Date(broadcast.created_at).toLocaleString()}</span>
</div>
</div>
{/* Footer */}
{isRunning && broadcast.status !== 'cancelling' && (
<div className="p-4 border-t border-dark-700">
<button
onClick={onStop}
disabled={isStopping}
className="w-full py-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
>
<StopIcon />
{t('admin.broadcasts.stop')}
</button>
</div>
)}
</div>
</div>
)
}
// Main component
export default function AdminBroadcasts() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [showCreateModal, setShowCreateModal] = useState(false)
const [selectedBroadcast, setSelectedBroadcast] = useState<Broadcast | null>(null)
const [page, setPage] = useState(0)
const limit = 20
// Fetch broadcasts
const { data, isLoading, refetch } = useQuery({
queryKey: ['admin', 'broadcasts', 'list', page],
queryFn: () => adminBroadcastsApi.list(limit, page * limit),
refetchInterval: 5000, // Auto refresh every 5s
})
// Stop mutation
const stopMutation = useMutation({
mutationFn: adminBroadcastsApi.stop,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] })
setSelectedBroadcast(null)
},
})
const broadcasts = data?.items || []
const total = data?.total || 0
const totalPages = Math.ceil(total / limit)
return (
<div className="min-h-screen bg-dark-900 p-4 pb-20 md:pb-4">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-accent-500/20 rounded-lg text-accent-400">
<BroadcastIcon />
</div>
<div>
<h1 className="text-xl font-bold text-dark-100">{t('admin.broadcasts.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.broadcasts.subtitle')}</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => refetch()}
className="p-2 bg-dark-800 rounded-lg text-dark-400 hover:text-dark-100 transition-colors"
>
<RefreshIcon />
</button>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-accent-500 rounded-lg text-white hover:bg-accent-600 transition-colors flex items-center gap-2"
>
<PlusIcon />
<span className="hidden sm:inline">{t('admin.broadcasts.create')}</span>
</button>
</div>
</div>
{/* Broadcasts list */}
<div className="bg-dark-800 rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-8 text-center text-dark-400">
<RefreshIcon />
<p className="mt-2">{t('common.loading')}</p>
</div>
) : broadcasts.length === 0 ? (
<div className="p-8 text-center text-dark-400">
<BroadcastIcon />
<p className="mt-2">{t('admin.broadcasts.empty')}</p>
</div>
) : (
<div className="divide-y divide-dark-700">
{broadcasts.map(broadcast => (
<button
key={broadcast.id}
onClick={() => setSelectedBroadcast(broadcast)}
className="w-full p-4 hover:bg-dark-700/50 transition-colors text-left"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<StatusBadge status={broadcast.status} />
<span className="text-xs text-dark-400">#{broadcast.id}</span>
{broadcast.has_media && (
<span className="text-dark-400">
{broadcast.media_type === 'photo' && <PhotoIcon />}
{broadcast.media_type === 'video' && <VideoIcon />}
{broadcast.media_type === 'document' && <DocumentIcon />}
</span>
)}
</div>
<p className="text-dark-100 text-sm truncate">
{broadcast.message_text}
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-dark-400">
<span>{broadcast.target_type}</span>
<span>
{broadcast.sent_count}/{broadcast.total_count}
</span>
<span>{new Date(broadcast.created_at).toLocaleDateString()}</span>
</div>
</div>
{['queued', 'in_progress'].includes(broadcast.status) && (
<div className="w-16">
<div className="h-1.5 bg-dark-600 rounded-full overflow-hidden">
<div
className="h-full bg-accent-500"
style={{ width: `${broadcast.progress_percent}%` }}
/>
</div>
<p className="text-xs text-dark-400 text-center mt-1">
{broadcast.progress_percent.toFixed(0)}%
</p>
</div>
)}
</div>
</button>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 p-4 border-t border-dark-700">
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('common.prev')}
</button>
<span className="text-dark-400">
{page + 1} / {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-3 py-1 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('common.next')}
</button>
</div>
)}
</div>
</div>
{/* Modals */}
{showCreateModal && (
<CreateBroadcastModal
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
refetch()
}}
/>
)}
{selectedBroadcast && (
<BroadcastDetailModal
broadcast={selectedBroadcast}
onClose={() => setSelectedBroadcast(null)}
onStop={() => stopMutation.mutate(selectedBroadcast.id)}
isStopping={stopMutation.isPending}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,928 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
campaignsApi,
CampaignListItem,
CampaignDetail,
CampaignStatistics,
CampaignCreateRequest,
CampaignUpdateRequest,
CampaignBonusType,
ServerSquadInfo,
TariffListItem,
CampaignRegistrationItem,
} from '../api/campaigns'
// Icons
const CampaignIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6" />
</svg>
)
const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
)
const EditIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
</svg>
)
const TrashIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
)
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)
const XIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const ChartIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
)
const UsersIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
const LinkIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
)
const CopyIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
)
// Bonus type labels and colors
const bonusTypeConfig: Record<CampaignBonusType, { label: string; color: string; bgColor: string }> = {
balance: { label: 'Баланс', color: 'text-emerald-400', bgColor: 'bg-emerald-500/20' },
subscription: { label: 'Подписка', color: 'text-blue-400', bgColor: 'bg-blue-500/20' },
tariff: { label: 'Тариф', color: 'text-purple-400', bgColor: 'bg-purple-500/20' },
none: { label: 'Только ссылка', color: 'text-gray-400', bgColor: 'bg-gray-500/20' },
}
// Format number as rubles
const formatRubles = (kopeks: number) => (kopeks / 100).toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 }) + ' ₽'
// Format date
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
// Campaign Modal
interface CampaignModalProps {
campaign?: CampaignDetail | null
servers: ServerSquadInfo[]
tariffs: TariffListItem[]
onSave: (data: CampaignCreateRequest | CampaignUpdateRequest) => void
onClose: () => void
isLoading?: boolean
}
function CampaignModal({ campaign, servers, tariffs, onSave, onClose, isLoading }: CampaignModalProps) {
const isEdit = !!campaign
const [name, setName] = useState(campaign?.name || '')
const [startParameter, setStartParameter] = useState(campaign?.start_parameter || '')
const [bonusType, setBonusType] = useState<CampaignBonusType>(campaign?.bonus_type || 'balance')
const [isActive, setIsActive] = useState(campaign?.is_active ?? true)
// Balance bonus
const [balanceBonusRubles, setBalanceBonusRubles] = useState((campaign?.balance_bonus_kopeks || 0) / 100)
// Subscription bonus
const [subscriptionDays, setSubscriptionDays] = useState(campaign?.subscription_duration_days || 7)
const [subscriptionTraffic, setSubscriptionTraffic] = useState(campaign?.subscription_traffic_gb || 10)
const [subscriptionDevices, setSubscriptionDevices] = useState(campaign?.subscription_device_limit || 1)
const [selectedSquads, setSelectedSquads] = useState<string[]>(campaign?.subscription_squads || [])
// Tariff bonus
const [tariffId, setTariffId] = useState<number | null>(campaign?.tariff_id || null)
const [tariffDays, setTariffDays] = useState(campaign?.tariff_duration_days || 30)
const handleSubmit = () => {
const data: CampaignCreateRequest | CampaignUpdateRequest = {
name,
start_parameter: startParameter,
bonus_type: bonusType,
is_active: isActive,
}
if (bonusType === 'balance') {
data.balance_bonus_kopeks = Math.round(balanceBonusRubles * 100)
} else if (bonusType === 'subscription') {
data.subscription_duration_days = subscriptionDays
data.subscription_traffic_gb = subscriptionTraffic
data.subscription_device_limit = subscriptionDevices
data.subscription_squads = selectedSquads
} else if (bonusType === 'tariff') {
data.tariff_id = tariffId || undefined
data.tariff_duration_days = tariffDays
}
onSave(data)
}
const toggleServer = (uuid: string) => {
setSelectedSquads(prev =>
prev.includes(uuid)
? prev.filter(s => s !== uuid)
: [...prev, uuid]
)
}
const isValid = name.trim() && startParameter.trim() && /^[a-zA-Z0-9_-]+$/.test(startParameter)
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<h2 className="text-lg font-semibold text-dark-100">
{isEdit ? 'Редактирование кампании' : 'Новая кампания'}
</h2>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
<XIcon />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Name */}
<div>
<label className="block text-sm text-dark-300 mb-1">Название</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
placeholder="Например: Instagram Реклама"
/>
</div>
{/* Start Parameter */}
<div>
<label className="block text-sm text-dark-300 mb-1">Метка (start параметр)</label>
<input
type="text"
value={startParameter}
onChange={e => setStartParameter(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
placeholder="instagram_jan2024"
/>
<p className="text-xs text-dark-500 mt-1">Только латиница, цифры, _ и -</p>
</div>
{/* Bonus Type */}
<div>
<label className="block text-sm text-dark-300 mb-2">Тип бонуса</label>
<div className="grid grid-cols-2 gap-2">
{(Object.keys(bonusTypeConfig) as CampaignBonusType[]).map(type => (
<button
key={type}
type="button"
onClick={() => setBonusType(type)}
className={`p-3 rounded-lg text-left transition-colors ${
bonusType === type
? `${bonusTypeConfig[type].bgColor} border border-current ${bonusTypeConfig[type].color}`
: 'bg-dark-700 border border-dark-600 text-dark-300 hover:border-dark-500'
}`}
>
<span className="text-sm font-medium">{bonusTypeConfig[type].label}</span>
</button>
))}
</div>
</div>
{/* Bonus Settings */}
{bonusType === 'balance' && (
<div className="p-4 bg-emerald-500/10 border border-emerald-500/30 rounded-lg">
<label className="block text-sm text-emerald-400 font-medium mb-2">Бонус на баланс</label>
<div className="flex items-center gap-2">
<input
type="number"
value={balanceBonusRubles}
onChange={e => setBalanceBonusRubles(Math.max(0, parseFloat(e.target.value) || 0))}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-emerald-500"
min={0}
step={1}
/>
<span className="text-dark-400"></span>
</div>
</div>
)}
{bonusType === 'subscription' && (
<div className="p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg space-y-3">
<label className="block text-sm text-blue-400 font-medium">Пробная подписка</label>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs text-dark-500 mb-1">Дней</label>
<input
type="number"
value={subscriptionDays}
onChange={e => setSubscriptionDays(Math.max(1, parseInt(e.target.value) || 1))}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-blue-500"
min={1}
/>
</div>
<div>
<label className="block text-xs text-dark-500 mb-1">Трафик (ГБ)</label>
<input
type="number"
value={subscriptionTraffic}
onChange={e => setSubscriptionTraffic(Math.max(0, parseInt(e.target.value) || 0))}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-blue-500"
min={0}
/>
</div>
<div>
<label className="block text-xs text-dark-500 mb-1">Устройств</label>
<input
type="number"
value={subscriptionDevices}
onChange={e => setSubscriptionDevices(Math.max(1, parseInt(e.target.value) || 1))}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-blue-500"
min={1}
/>
</div>
</div>
{servers.length > 0 && (
<div>
<label className="block text-xs text-dark-500 mb-2">Серверы</label>
<div className="space-y-1 max-h-32 overflow-y-auto">
{servers.map(server => (
<button
key={server.id}
type="button"
onClick={() => toggleServer(server.squad_uuid)}
className={`w-full flex items-center gap-2 p-2 rounded-lg text-left transition-colors ${
selectedSquads.includes(server.squad_uuid)
? 'bg-blue-500/20 text-blue-300'
: 'bg-dark-600 text-dark-300 hover:bg-dark-500'
}`}
>
<div className={`w-4 h-4 rounded flex items-center justify-center ${
selectedSquads.includes(server.squad_uuid) ? 'bg-blue-500 text-white' : 'bg-dark-500'
}`}>
{selectedSquads.includes(server.squad_uuid) && <CheckIcon />}
</div>
<span className="text-sm">{server.display_name}</span>
</button>
))}
</div>
</div>
)}
</div>
)}
{bonusType === 'tariff' && (
<div className="p-4 bg-purple-500/10 border border-purple-500/30 rounded-lg space-y-3">
<label className="block text-sm text-purple-400 font-medium">Тариф</label>
<div>
<label className="block text-xs text-dark-500 mb-1">Выберите тариф</label>
<select
value={tariffId || ''}
onChange={e => setTariffId(e.target.value ? parseInt(e.target.value) : null)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-purple-500"
>
<option value="">Не выбран</option>
{tariffs.map(tariff => (
<option key={tariff.id} value={tariff.id}>
{tariff.name} ({tariff.traffic_limit_gb} ГБ, {tariff.device_limit} уст.)
</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-dark-500 mb-1">Длительность (дней)</label>
<input
type="number"
value={tariffDays}
onChange={e => setTariffDays(Math.max(1, parseInt(e.target.value) || 1))}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-purple-500"
min={1}
/>
</div>
</div>
)}
{bonusType === 'none' && (
<div className="p-4 bg-gray-500/10 border border-gray-500/30 rounded-lg">
<p className="text-sm text-dark-400">
Кампания без бонусов - только для отслеживания переходов и регистраций.
</p>
</div>
)}
{/* Active toggle */}
<div className="flex items-center justify-between p-3 bg-dark-700 rounded-lg">
<span className="text-sm text-dark-300">Активна</span>
<button
type="button"
onClick={() => setIsActive(!isActive)}
className={`w-10 h-6 rounded-full transition-colors relative ${
isActive ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
isActive ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
>
Отмена
</button>
<button
onClick={handleSubmit}
disabled={!isValid || isLoading}
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
</div>
)
}
// Statistics Modal
interface StatsModalProps {
stats: CampaignStatistics
onClose: () => void
onViewUsers: () => void
}
function StatsModal({ stats, onClose, onViewUsers }: StatsModalProps) {
const [copied, setCopied] = useState(false)
const copyLink = () => {
if (stats.deep_link) {
navigator.clipboard.writeText(stats.deep_link)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div>
<h2 className="text-lg font-semibold text-dark-100">{stats.name}</h2>
<div className="flex items-center gap-2 mt-1">
<span className={`px-2 py-0.5 text-xs rounded ${bonusTypeConfig[stats.bonus_type].bgColor} ${bonusTypeConfig[stats.bonus_type].color}`}>
{bonusTypeConfig[stats.bonus_type].label}
</span>
{stats.is_active ? (
<span className="px-2 py-0.5 text-xs bg-success-500/20 text-success-400 rounded">Активна</span>
) : (
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">Неактивна</span>
)}
</div>
</div>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
<XIcon />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Deep Link */}
{stats.deep_link && (
<div className="p-3 bg-dark-700 rounded-lg">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<LinkIcon />
<span className="text-sm text-dark-300 truncate">{stats.deep_link}</span>
</div>
<button
onClick={copyLink}
className="flex items-center gap-1 px-3 py-1.5 bg-dark-600 text-dark-300 rounded-lg hover:bg-dark-500 transition-colors shrink-0"
>
<CopyIcon />
<span className="text-sm">{copied ? 'Скопировано!' : 'Копировать'}</span>
</button>
</div>
</div>
)}
{/* Main Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3 bg-dark-700 rounded-lg text-center">
<div className="text-2xl font-bold text-dark-100">{stats.registrations}</div>
<div className="text-xs text-dark-500">Регистраций</div>
</div>
<div className="p-3 bg-dark-700 rounded-lg text-center">
<div className="text-2xl font-bold text-emerald-400">{formatRubles(stats.total_revenue_kopeks)}</div>
<div className="text-xs text-dark-500">Доход</div>
</div>
<div className="p-3 bg-dark-700 rounded-lg text-center">
<div className="text-2xl font-bold text-blue-400">{stats.paid_users_count}</div>
<div className="text-xs text-dark-500">Оплатили</div>
</div>
<div className="p-3 bg-dark-700 rounded-lg text-center">
<div className="text-2xl font-bold text-purple-400">{stats.conversion_rate}%</div>
<div className="text-xs text-dark-500">Конверсия</div>
</div>
</div>
{/* Detailed Stats */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-dark-700/50 rounded-lg">
<div className="text-sm text-dark-400 mb-2">Бонусы выданы</div>
{stats.bonus_type === 'balance' && (
<div className="text-lg font-medium text-emerald-400">{formatRubles(stats.balance_issued_kopeks)}</div>
)}
{stats.bonus_type === 'subscription' && (
<div className="text-lg font-medium text-blue-400">{stats.subscription_issued} подписок</div>
)}
{stats.bonus_type === 'tariff' && (
<div className="text-lg font-medium text-purple-400">{stats.subscription_issued} тарифов</div>
)}
{stats.bonus_type === 'none' && (
<div className="text-lg font-medium text-dark-400">-</div>
)}
</div>
<div className="p-3 bg-dark-700/50 rounded-lg">
<div className="text-sm text-dark-400 mb-2">Средний доход с пользователя</div>
<div className="text-lg font-medium text-dark-200">{formatRubles(stats.avg_revenue_per_user_kopeks)}</div>
</div>
<div className="p-3 bg-dark-700/50 rounded-lg">
<div className="text-sm text-dark-400 mb-2">Средний первый платёж</div>
<div className="text-lg font-medium text-dark-200">{formatRubles(stats.avg_first_payment_kopeks)}</div>
</div>
<div className="p-3 bg-dark-700/50 rounded-lg">
<div className="text-sm text-dark-400 mb-2">Пробных подписок</div>
<div className="text-lg font-medium text-dark-200">{stats.trial_users_count} (активных: {stats.active_trials_count})</div>
</div>
<div className="p-3 bg-dark-700/50 rounded-lg">
<div className="text-sm text-dark-400 mb-2">Конверсия из триала</div>
<div className="text-lg font-medium text-dark-200">{stats.trial_conversion_rate}%</div>
</div>
<div className="p-3 bg-dark-700/50 rounded-lg">
<div className="text-sm text-dark-400 mb-2">Последняя регистрация</div>
<div className="text-sm font-medium text-dark-200">{formatDate(stats.last_registration)}</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-between gap-3 p-4 border-t border-dark-700">
<button
onClick={onViewUsers}
className="flex items-center gap-2 px-4 py-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 transition-colors"
>
<UsersIcon />
Пользователи
</button>
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
>
Закрыть
</button>
</div>
</div>
</div>
)
}
// Users Modal
interface UsersModalProps {
campaignId: number
campaignName: string
onClose: () => void
}
function UsersModal({ campaignId, campaignName, onClose }: UsersModalProps) {
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery({
queryKey: ['campaign-registrations', campaignId, page],
queryFn: () => campaignsApi.getCampaignRegistrations(campaignId, page, 20),
})
const registrations = data?.registrations || []
const total = data?.total || 0
const totalPages = Math.ceil(total / 20)
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div>
<h2 className="text-lg font-semibold text-dark-100">Пользователи кампании</h2>
<p className="text-sm text-dark-400">{campaignName} - {total} регистраций</p>
</div>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
<XIcon />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : registrations.length === 0 ? (
<div className="text-center py-12 text-dark-400">
Нет зарегистрированных пользователей
</div>
) : (
<table className="w-full">
<thead className="bg-dark-700 sticky top-0">
<tr>
<th className="text-left text-xs font-medium text-dark-400 p-3">Пользователь</th>
<th className="text-left text-xs font-medium text-dark-400 p-3">Бонус</th>
<th className="text-left text-xs font-medium text-dark-400 p-3">Баланс</th>
<th className="text-left text-xs font-medium text-dark-400 p-3">Статус</th>
<th className="text-left text-xs font-medium text-dark-400 p-3">Дата</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-700">
{registrations.map((reg: CampaignRegistrationItem) => (
<tr key={reg.id} className="hover:bg-dark-700/50">
<td className="p-3">
<div className="text-sm text-dark-100">
{reg.first_name || 'Без имени'}
{reg.username && <span className="text-dark-400 ml-1">@{reg.username}</span>}
</div>
<div className="text-xs text-dark-500">{reg.telegram_id}</div>
</td>
<td className="p-3">
<span className={`px-2 py-0.5 text-xs rounded ${bonusTypeConfig[reg.bonus_type as CampaignBonusType]?.bgColor || 'bg-dark-600'} ${bonusTypeConfig[reg.bonus_type as CampaignBonusType]?.color || 'text-dark-400'}`}>
{bonusTypeConfig[reg.bonus_type as CampaignBonusType]?.label || reg.bonus_type}
</span>
</td>
<td className="p-3 text-sm text-dark-300">{formatRubles(reg.user_balance_kopeks)}</td>
<td className="p-3">
<div className="flex gap-1">
{reg.has_subscription && (
<span className="px-1.5 py-0.5 text-xs bg-blue-500/20 text-blue-400 rounded">VPN</span>
)}
{reg.has_paid && (
<span className="px-1.5 py-0.5 text-xs bg-emerald-500/20 text-emerald-400 rounded">Платил</span>
)}
</div>
</td>
<td className="p-3 text-sm text-dark-400">{formatDate(reg.created_at)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 p-4 border-t border-dark-700">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1.5 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
Назад
</button>
<span className="text-sm text-dark-400">
{page} из {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-3 py-1.5 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
Далее
</button>
</div>
)}
</div>
</div>
)
}
// Main Component
export default function AdminCampaigns() {
const queryClient = useQueryClient()
const [showModal, setShowModal] = useState(false)
const [editingCampaign, setEditingCampaign] = useState<CampaignDetail | null>(null)
const [showStats, setShowStats] = useState<CampaignStatistics | null>(null)
const [showUsers, setShowUsers] = useState<{ id: number; name: string } | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
// Queries
const { data: campaignsData, isLoading } = useQuery({
queryKey: ['admin-campaigns'],
queryFn: () => campaignsApi.getCampaigns(true),
})
const { data: overview } = useQuery({
queryKey: ['admin-campaigns-overview'],
queryFn: () => campaignsApi.getOverview(),
})
const { data: servers = [] } = useQuery({
queryKey: ['admin-campaigns-servers'],
queryFn: () => campaignsApi.getAvailableServers(),
})
const { data: tariffs = [] } = useQuery({
queryKey: ['admin-campaigns-tariffs'],
queryFn: () => campaignsApi.getAvailableTariffs(),
})
// Mutations
const createMutation = useMutation({
mutationFn: campaignsApi.createCampaign,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] })
queryClient.invalidateQueries({ queryKey: ['admin-campaigns-overview'] })
setShowModal(false)
},
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: CampaignUpdateRequest }) =>
campaignsApi.updateCampaign(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] })
setShowModal(false)
setEditingCampaign(null)
},
})
const deleteMutation = useMutation({
mutationFn: campaignsApi.deleteCampaign,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] })
queryClient.invalidateQueries({ queryKey: ['admin-campaigns-overview'] })
setDeleteConfirm(null)
},
})
const toggleMutation = useMutation({
mutationFn: campaignsApi.toggleCampaign,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-campaigns'] })
},
})
const handleEdit = async (campaignId: number) => {
try {
const detail = await campaignsApi.getCampaign(campaignId)
setEditingCampaign(detail)
setShowModal(true)
} catch (error) {
console.error('Failed to load campaign:', error)
}
}
const handleViewStats = async (campaignId: number) => {
try {
const stats = await campaignsApi.getCampaignStats(campaignId)
setShowStats(stats)
} catch (error) {
console.error('Failed to load stats:', error)
}
}
const handleSave = (data: CampaignCreateRequest | CampaignUpdateRequest) => {
if (editingCampaign) {
updateMutation.mutate({ id: editingCampaign.id, data })
} else {
createMutation.mutate(data as CampaignCreateRequest)
}
}
const handleCloseModal = () => {
setShowModal(false)
setEditingCampaign(null)
}
const campaigns = campaignsData?.campaigns || []
return (
<div className="animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-500/20 rounded-lg text-orange-400">
<CampaignIcon />
</div>
<div>
<h1 className="text-xl font-semibold text-dark-100">Рекламные кампании</h1>
<p className="text-sm text-dark-400">Управление рекламными ссылками</p>
</div>
</div>
<button
onClick={() => { setEditingCampaign(null); setShowModal(true) }}
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
>
<PlusIcon />
Создать
</button>
</div>
{/* Overview */}
{overview && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div className="p-4 bg-dark-800 rounded-xl border border-dark-700">
<div className="text-2xl font-bold text-dark-100">{overview.total}</div>
<div className="text-sm text-dark-400">Всего кампаний</div>
</div>
<div className="p-4 bg-dark-800 rounded-xl border border-dark-700">
<div className="text-2xl font-bold text-success-400">{overview.active}</div>
<div className="text-sm text-dark-400">Активных</div>
</div>
<div className="p-4 bg-dark-800 rounded-xl border border-dark-700">
<div className="text-2xl font-bold text-blue-400">{overview.total_registrations}</div>
<div className="text-sm text-dark-400">Регистраций</div>
</div>
<div className="p-4 bg-dark-800 rounded-xl border border-dark-700">
<div className="text-2xl font-bold text-emerald-400">{formatRubles(overview.total_balance_issued_kopeks)}</div>
<div className="text-sm text-dark-400">Выдано бонусов</div>
</div>
</div>
)}
{/* Campaigns List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
</div>
) : campaigns.length === 0 ? (
<div className="text-center py-12">
<p className="text-dark-400">Нет рекламных кампаний</p>
</div>
) : (
<div className="space-y-3">
{campaigns.map((campaign: CampaignListItem) => (
<div
key={campaign.id}
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
campaign.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-dark-100 truncate">{campaign.name}</h3>
<span className={`px-2 py-0.5 text-xs rounded ${bonusTypeConfig[campaign.bonus_type].bgColor} ${bonusTypeConfig[campaign.bonus_type].color}`}>
{bonusTypeConfig[campaign.bonus_type].label}
</span>
{!campaign.is_active && (
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
Неактивна
</span>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
<span className="font-mono text-xs">?start={campaign.start_parameter}</span>
<span>{campaign.registrations_count} регистраций</span>
<span>{formatRubles(campaign.total_revenue_kopeks)} доход</span>
<span>{campaign.conversion_rate}% конверсия</span>
</div>
</div>
<div className="flex items-center gap-2">
{/* Stats */}
<button
onClick={() => handleViewStats(campaign.id)}
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors"
title="Статистика"
>
<ChartIcon />
</button>
{/* Toggle Active */}
<button
onClick={() => toggleMutation.mutate(campaign.id)}
className={`p-2 rounded-lg transition-colors ${
campaign.is_active
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`}
title={campaign.is_active ? 'Деактивировать' : 'Активировать'}
>
{campaign.is_active ? <CheckIcon /> : <XIcon />}
</button>
{/* Edit */}
<button
onClick={() => handleEdit(campaign.id)}
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors"
title="Редактировать"
>
<EditIcon />
</button>
{/* Delete */}
<button
onClick={() => setDeleteConfirm(campaign.id)}
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-error-500/20 hover:text-error-400 transition-colors"
title="Удалить"
disabled={campaign.registrations_count > 0}
>
<TrashIcon />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<CampaignModal
campaign={editingCampaign}
servers={servers}
tariffs={tariffs}
onSave={handleSave}
onClose={handleCloseModal}
isLoading={createMutation.isPending || updateMutation.isPending}
/>
)}
{/* Stats Modal */}
{showStats && (
<StatsModal
stats={showStats}
onClose={() => setShowStats(null)}
onViewUsers={() => {
setShowUsers({ id: showStats.id, name: showStats.name })
setShowStats(null)
}}
/>
)}
{/* Users Modal */}
{showUsers && (
<UsersModal
campaignId={showUsers.id}
campaignName={showUsers.name}
onClose={() => setShowUsers(null)}
/>
)}
{/* Delete Confirmation */}
{deleteConfirm !== null && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl p-6 max-w-sm w-full">
<h3 className="text-lg font-semibold text-dark-100 mb-2">Удалить кампанию?</h3>
<p className="text-dark-400 mb-6">Это действие нельзя отменить. Кампании с регистрациями удалить нельзя.</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
>
Отмена
</button>
<button
onClick={() => deleteMutation.mutate(deleteConfirm)}
className="px-4 py-2 bg-error-500 text-white rounded-lg hover:bg-error-600 transition-colors"
>
Удалить
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,83 +1,137 @@
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
// Icons
const TicketIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
// Icons - smaller versions for mobile
const TicketIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
</svg>
)
const CogIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
const CogIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
const PhoneIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
const PhoneIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
</svg>
)
const WheelIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
const WheelIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
</svg>
)
const TariffIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
const TariffIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
</svg>
)
const ServerIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
const ServerIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
)
const AdminIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
const AdminIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
)
const ChartIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
const ChartIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
</svg>
)
const BanSystemIcon = () => (
<svg className="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
const BanSystemIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
)
interface AdminCardProps {
const BroadcastIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
</svg>
)
const PromocodeIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
</svg>
)
const CampaignIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5M9 11.25v1.5M12 9v3.75m3-6v6" />
</svg>
)
const UsersIcon = ({ className = "w-8 h-8" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
const ChevronRightIcon = () => (
<svg className="w-4 h-4 text-dark-500 group-hover:text-dark-300 transition-colors" 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" />
</svg>
)
interface AdminSection {
to: string
icon: React.ReactNode
mobileIcon: React.ReactNode
title: string
description: string
color: string
bgColor: string
textColor: string
}
function AdminCard({ to, icon, title, description, color }: AdminCardProps) {
// Mobile compact card
function MobileAdminCard({ to, mobileIcon, title, bgColor, textColor }: AdminSection) {
return (
<Link
to={to}
className={`block p-6 bg-dark-800 rounded-xl border border-dark-700 hover:border-${color}-500/50 hover:bg-dark-750 transition-all duration-200 group`}
className="flex flex-col items-center justify-center p-3 bg-dark-800/80 rounded-2xl border border-dark-700/50 hover:border-dark-600 active:scale-95 transition-all duration-150"
>
<div className={`w-14 h-14 rounded-xl bg-${color}-500/20 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform`}>
<div className={`text-${color}-400`}>
<div className={`w-12 h-12 rounded-xl ${bgColor} flex items-center justify-center mb-2`}>
<div className={textColor}>
{mobileIcon}
</div>
</div>
<span className="text-xs font-medium text-dark-200 text-center leading-tight">{title}</span>
</Link>
)
}
// Desktop card
function DesktopAdminCard({ to, icon, title, description, bgColor, textColor }: AdminSection) {
return (
<Link
to={to}
className="group flex items-center gap-4 p-4 bg-dark-800/80 rounded-2xl border border-dark-700/50 hover:border-dark-600 hover:bg-dark-800 transition-all duration-200"
>
<div className={`w-14 h-14 rounded-xl ${bgColor} flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform`}>
<div className={textColor}>
{icon}
</div>
</div>
<h3 className="text-lg font-semibold text-dark-100 mb-1">{title}</h3>
<p className="text-sm text-dark-400">{description}</p>
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-dark-100 mb-0.5">{title}</h3>
<p className="text-sm text-dark-400 truncate">{description}</p>
</div>
<ChevronRightIcon />
</Link>
)
}
@@ -85,82 +139,153 @@ function AdminCard({ to, icon, title, description, color }: AdminCardProps) {
export default function AdminPanel() {
const { t } = useTranslation()
const adminSections = [
const adminSections: AdminSection[] = [
{
to: '/admin/dashboard',
icon: <ChartIcon />,
mobileIcon: <ChartIcon className="w-6 h-6" />,
title: t('admin.nav.dashboard'),
description: t('admin.panel.dashboardDesc'),
color: 'success'
color: 'success',
bgColor: 'bg-emerald-500/20',
textColor: 'text-emerald-400'
},
{
to: '/admin/tickets',
icon: <TicketIcon />,
mobileIcon: <TicketIcon className="w-6 h-6" />,
title: t('admin.nav.tickets'),
description: t('admin.panel.ticketsDesc'),
color: 'warning'
color: 'warning',
bgColor: 'bg-amber-500/20',
textColor: 'text-amber-400'
},
{
to: '/admin/settings',
icon: <CogIcon />,
mobileIcon: <CogIcon className="w-6 h-6" />,
title: t('admin.nav.settings'),
description: t('admin.panel.settingsDesc'),
color: 'accent'
color: 'accent',
bgColor: 'bg-blue-500/20',
textColor: 'text-blue-400'
},
{
to: '/admin/apps',
icon: <PhoneIcon />,
mobileIcon: <PhoneIcon className="w-6 h-6" />,
title: t('admin.nav.apps'),
description: t('admin.panel.appsDesc'),
color: 'success'
color: 'success',
bgColor: 'bg-teal-500/20',
textColor: 'text-teal-400'
},
{
to: '/admin/wheel',
icon: <WheelIcon />,
mobileIcon: <WheelIcon className="w-6 h-6" />,
title: t('admin.nav.wheel'),
description: t('admin.panel.wheelDesc'),
color: 'error'
color: 'error',
bgColor: 'bg-rose-500/20',
textColor: 'text-rose-400'
},
{
to: '/admin/tariffs',
icon: <TariffIcon />,
mobileIcon: <TariffIcon className="w-6 h-6" />,
title: t('admin.nav.tariffs'),
description: t('admin.panel.tariffsDesc'),
color: 'info'
color: 'info',
bgColor: 'bg-cyan-500/20',
textColor: 'text-cyan-400'
},
{
to: '/admin/servers',
icon: <ServerIcon />,
mobileIcon: <ServerIcon className="w-6 h-6" />,
title: t('admin.nav.servers'),
description: t('admin.panel.serversDesc'),
color: 'purple'
color: 'purple',
bgColor: 'bg-purple-500/20',
textColor: 'text-purple-400'
},
{
to: '/admin/broadcasts',
icon: <BroadcastIcon />,
mobileIcon: <BroadcastIcon className="w-6 h-6" />,
title: t('admin.nav.broadcasts'),
description: t('admin.panel.broadcastsDesc'),
color: 'orange',
bgColor: 'bg-orange-500/20',
textColor: 'text-orange-400'
},
{
to: '/admin/promocodes',
icon: <PromocodeIcon />,
mobileIcon: <PromocodeIcon className="w-6 h-6" />,
title: t('admin.nav.promocodes', 'Промокоды'),
description: t('admin.panel.promocodesDesc', 'Управление промокодами'),
color: 'violet',
bgColor: 'bg-violet-500/20',
textColor: 'text-violet-400'
},
{
to: '/admin/campaigns',
icon: <CampaignIcon />,
mobileIcon: <CampaignIcon className="w-6 h-6" />,
title: t('admin.nav.campaigns', 'Кампании'),
description: t('admin.panel.campaignsDesc', 'Рекламные кампании'),
color: 'orange',
bgColor: 'bg-orange-500/20',
textColor: 'text-orange-400'
},
{
to: '/admin/users',
icon: <UsersIcon />,
mobileIcon: <UsersIcon className="w-6 h-6" />,
title: t('admin.nav.users', 'Пользователи'),
description: t('admin.panel.usersDesc', 'Управление пользователями'),
color: 'indigo',
bgColor: 'bg-indigo-500/20',
textColor: 'text-indigo-400'
},
{
to: '/admin/ban-system',
icon: <BanSystemIcon />,
mobileIcon: <BanSystemIcon className="w-6 h-6" />,
title: t('admin.nav.banSystem'),
description: t('admin.panel.banSystemDesc'),
color: 'error'
color: 'error',
bgColor: 'bg-red-500/20',
textColor: 'text-red-400'
},
]
return (
<div className="animate-fade-in">
{/* Header */}
<div className="flex items-center gap-3 mb-8">
<div className="p-3 bg-warning-500/20 rounded-xl">
<AdminIcon />
{/* Header - compact on mobile */}
<div className="flex items-center gap-3 mb-6">
<div className="p-2.5 sm:p-3 bg-amber-500/20 rounded-xl">
<AdminIcon className="w-6 h-6 sm:w-8 sm:h-8 text-amber-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1>
<p className="text-dark-400">{t('admin.panel.subtitle')}</p>
<h1 className="text-xl sm:text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1>
<p className="text-sm text-dark-400 hidden sm:block">{t('admin.panel.subtitle')}</p>
</div>
</div>
{/* Grid of admin sections */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Mobile: Compact 2-column grid */}
<div className="grid grid-cols-3 gap-3 sm:hidden">
{adminSections.map((section) => (
<AdminCard key={section.to} {...section} />
<MobileAdminCard key={section.to} {...section} />
))}
</div>
{/* Tablet/Desktop: List style */}
<div className="hidden sm:grid sm:grid-cols-1 lg:grid-cols-2 gap-3">
{adminSections.map((section) => (
<DesktopAdminCard key={section.to} {...section} />
))}
</div>
</div>

File diff suppressed because it is too large Load Diff

989
src/pages/AdminUsers.tsx Normal file
View File

@@ -0,0 +1,989 @@
import { useState, useEffect, useCallback } from 'react'
import { useCurrency } from '../hooks/useCurrency'
import {
adminUsersApi,
type UserListItem,
type UserDetailResponse,
type UsersStatsResponse,
type UserAvailableTariff,
type PanelSyncStatusResponse,
} from '../api/adminUsers'
// ============ Icons ============
const UsersIcon = ({ className = "w-6 h-6" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
)
const SearchIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
)
const ChevronLeftIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
)
const ChevronRightIcon = () => (
<svg className="w-5 h-5" 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" />
</svg>
)
const XMarkIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
const RefreshIcon = ({ className = "w-5 h-5" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
)
const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
)
const MinusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12h-15" />
</svg>
)
const SyncIcon = ({ className = "w-5 h-5" }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" />
</svg>
)
const TelegramIcon = () => (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
)
// ============ Components ============
interface StatCardProps {
title: string
value: string | number
subtitle?: string
color: 'blue' | 'green' | 'yellow' | 'red' | 'purple'
}
function StatCard({ title, value, subtitle, color }: StatCardProps) {
const colors = {
blue: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
green: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
yellow: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
red: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
purple: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
}
return (
<div className={`rounded-xl border p-4 ${colors[color]}`}>
<div className="text-2xl font-bold mb-1">{value}</div>
<div className="text-sm opacity-80">{title}</div>
{subtitle && <div className="text-xs opacity-60 mt-1">{subtitle}</div>}
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
active: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
blocked: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
deleted: 'bg-dark-600 text-dark-400 border-dark-500',
trial: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
expired: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
disabled: 'bg-dark-600 text-dark-400 border-dark-500',
}
return (
<span className={`px-2 py-0.5 text-xs rounded-full border ${styles[status] || styles.active}`}>
{status}
</span>
)
}
// ============ User List Component ============
interface UserRowProps {
user: UserListItem
onSelect: (user: UserListItem) => void
formatAmount: (rubAmount: number) => string
}
function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
return (
<div
onClick={() => onSelect(user)}
className="flex items-center gap-4 p-4 bg-dark-800/50 rounded-xl border border-dark-700 hover:border-dark-600 hover:bg-dark-800 cursor-pointer transition-all"
>
{/* Avatar */}
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-medium shrink-0">
{user.first_name?.[0] || user.username?.[0] || '?'}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-dark-100 truncate">{user.full_name}</span>
{user.username && (
<span className="text-xs text-dark-500">@{user.username}</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-dark-400">
<span className="flex items-center gap-1">
<TelegramIcon />
{user.telegram_id}
</span>
{user.status !== 'active' && (
<StatusBadge status={user.status} />
)}
{user.has_subscription && user.subscription_status && (
<span className={`px-2 py-0.5 text-xs rounded-full border ${
user.subscription_status === 'active' ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' :
user.subscription_status === 'trial' ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
'bg-amber-500/20 text-amber-400 border-amber-500/30'
}`}>
{user.subscription_status === 'active' ? 'Подписка' :
user.subscription_status === 'trial' ? 'Триал' : 'Истекла'}
</span>
)}
</div>
</div>
{/* Balance */}
<div className="text-right shrink-0">
<div className="font-medium text-dark-100">{formatAmount(user.balance_rubles)}</div>
<div className="text-xs text-dark-500">
{user.purchase_count > 0 ? `${user.purchase_count} покупок` : 'Нет покупок'}
</div>
</div>
<ChevronRightIcon />
</div>
)
}
// ============ User Detail Modal ============
interface UserDetailModalProps {
userId: number
onClose: () => void
onUpdate: () => void
}
function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
const { formatWithCurrency } = useCurrency()
const [user, setUser] = useState<UserDetailResponse | null>(null)
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'info' | 'subscription' | 'balance' | 'sync'>('info')
const [syncStatus, setSyncStatus] = useState<PanelSyncStatusResponse | null>(null)
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([])
const [actionLoading, setActionLoading] = useState(false)
// Balance form
const [balanceAmount, setBalanceAmount] = useState('')
const [balanceDescription, setBalanceDescription] = useState('')
// Subscription form
const [subAction, setSubAction] = useState<string>('extend')
const [subDays, setSubDays] = useState('30')
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null)
const loadUser = useCallback(async () => {
try {
setLoading(true)
const data = await adminUsersApi.getUser(userId)
setUser(data)
} catch (error) {
console.error('Failed to load user:', error)
} finally {
setLoading(false)
}
}, [userId])
const loadSyncStatus = useCallback(async () => {
try {
const data = await adminUsersApi.getSyncStatus(userId)
setSyncStatus(data)
} catch (error) {
console.error('Failed to load sync status:', error)
}
}, [userId])
const loadTariffs = useCallback(async () => {
try {
const data = await adminUsersApi.getAvailableTariffs(userId, true)
setTariffs(data.tariffs)
} catch (error) {
console.error('Failed to load tariffs:', error)
}
}, [userId])
useEffect(() => {
loadUser()
}, [loadUser])
useEffect(() => {
if (activeTab === 'sync') loadSyncStatus()
if (activeTab === 'subscription') loadTariffs()
}, [activeTab, loadSyncStatus, loadTariffs])
const handleUpdateBalance = async (isAdd: boolean) => {
if (!balanceAmount) return
setActionLoading(true)
try {
const amount = Math.abs(parseFloat(balanceAmount) * 100)
await adminUsersApi.updateBalance(userId, {
amount_kopeks: isAdd ? amount : -amount,
description: balanceDescription || (isAdd ? 'Начисление админом' : 'Списание админом'),
})
await loadUser()
setBalanceAmount('')
setBalanceDescription('')
onUpdate()
} catch (error) {
console.error('Failed to update balance:', error)
} finally {
setActionLoading(false)
}
}
const handleUpdateSubscription = async () => {
setActionLoading(true)
try {
const data: Record<string, unknown> = { action: subAction }
if (subAction === 'extend') data.days = parseInt(subDays)
if (subAction === 'change_tariff' && selectedTariffId) data.tariff_id = selectedTariffId
if (subAction === 'create') {
data.days = parseInt(subDays)
if (selectedTariffId) data.tariff_id = selectedTariffId
}
await adminUsersApi.updateSubscription(userId, data as any)
await loadUser()
onUpdate()
} catch (error) {
console.error('Failed to update subscription:', error)
} finally {
setActionLoading(false)
}
}
const handleBlockUser = async () => {
if (!confirm('Заблокировать пользователя?')) return
setActionLoading(true)
try {
await adminUsersApi.blockUser(userId)
await loadUser()
onUpdate()
} catch (error) {
console.error('Failed to block user:', error)
} finally {
setActionLoading(false)
}
}
const handleUnblockUser = async () => {
setActionLoading(true)
try {
await adminUsersApi.unblockUser(userId)
await loadUser()
onUpdate()
} catch (error) {
console.error('Failed to unblock user:', error)
} finally {
setActionLoading(false)
}
}
const handleSyncFromPanel = async () => {
setActionLoading(true)
try {
await adminUsersApi.syncFromPanel(userId, { update_subscription: true, update_traffic: true })
await loadUser()
await loadSyncStatus()
onUpdate()
} catch (error) {
console.error('Failed to sync from panel:', error)
} finally {
setActionLoading(false)
}
}
const handleSyncToPanel = async () => {
setActionLoading(true)
try {
await adminUsersApi.syncToPanel(userId, { create_if_missing: true })
await loadUser()
await loadSyncStatus()
onUpdate()
} catch (error) {
console.error('Failed to sync to panel:', error)
} finally {
setActionLoading(false)
}
}
const formatDate = (date: string | null) => {
if (!date) return '-'
return new Date(date).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
if (loading) {
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="bg-dark-800 rounded-2xl p-8">
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full" />
</div>
</div>
)
}
if (!user) return null
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-dark-800 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-bold text-lg">
{user.first_name?.[0] || user.username?.[0] || '?'}
</div>
<div>
<div className="font-semibold text-dark-100">{user.full_name}</div>
<div className="text-sm text-dark-400 flex items-center gap-2">
<TelegramIcon />
{user.telegram_id}
{user.username && <span>@{user.username}</span>}
</div>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors">
<XMarkIcon />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-dark-700">
{(['info', 'subscription', 'balance', 'sync'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex-1 py-3 text-sm font-medium transition-colors ${
activeTab === tab
? 'text-blue-400 border-b-2 border-blue-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
{tab === 'info' && 'Информация'}
{tab === 'subscription' && 'Подписка'}
{tab === 'balance' && 'Баланс'}
{tab === 'sync' && 'Синхронизация'}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{/* Info Tab */}
{activeTab === 'info' && (
<div className="space-y-4">
{/* Status */}
<div className="flex items-center justify-between p-3 bg-dark-900/50 rounded-xl">
<span className="text-dark-400">Статус</span>
<div className="flex items-center gap-2">
<StatusBadge status={user.status} />
{user.status === 'active' ? (
<button
onClick={handleBlockUser}
disabled={actionLoading}
className="px-3 py-1 text-xs bg-rose-500/20 text-rose-400 rounded-lg hover:bg-rose-500/30 transition-colors"
>
Заблокировать
</button>
) : user.status === 'blocked' ? (
<button
onClick={handleUnblockUser}
disabled={actionLoading}
className="px-3 py-1 text-xs bg-emerald-500/20 text-emerald-400 rounded-lg hover:bg-emerald-500/30 transition-colors"
>
Разблокировать
</button>
) : null}
</div>
</div>
{/* Details grid */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 bg-dark-900/50 rounded-xl">
<div className="text-xs text-dark-500 mb-1">Email</div>
<div className="text-dark-100">{user.email || '-'}</div>
</div>
<div className="p-3 bg-dark-900/50 rounded-xl">
<div className="text-xs text-dark-500 mb-1">Язык</div>
<div className="text-dark-100">{user.language}</div>
</div>
<div className="p-3 bg-dark-900/50 rounded-xl">
<div className="text-xs text-dark-500 mb-1">Регистрация</div>
<div className="text-dark-100">{formatDate(user.created_at)}</div>
</div>
<div className="p-3 bg-dark-900/50 rounded-xl">
<div className="text-xs text-dark-500 mb-1">Последняя активность</div>
<div className="text-dark-100">{formatDate(user.last_activity)}</div>
</div>
<div className="p-3 bg-dark-900/50 rounded-xl">
<div className="text-xs text-dark-500 mb-1">Всего потрачено</div>
<div className="text-dark-100">{formatWithCurrency(user.total_spent_kopeks / 100)}</div>
</div>
<div className="p-3 bg-dark-900/50 rounded-xl">
<div className="text-xs text-dark-500 mb-1">Покупок</div>
<div className="text-dark-100">{user.purchase_count}</div>
</div>
</div>
{/* Referral */}
<div className="p-3 bg-dark-900/50 rounded-xl">
<div className="text-sm font-medium text-dark-200 mb-2">Реферальная программа</div>
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<div className="text-lg font-bold text-dark-100">{user.referral.referrals_count}</div>
<div className="text-xs text-dark-500">Рефералов</div>
</div>
<div>
<div className="text-lg font-bold text-dark-100">{formatWithCurrency(user.referral.total_earnings_kopeks / 100)}</div>
<div className="text-xs text-dark-500">Заработано</div>
</div>
<div>
<div className="text-lg font-bold text-dark-100">{user.referral.commission_percent || 0}%</div>
<div className="text-xs text-dark-500">Комиссия</div>
</div>
</div>
</div>
{/* Restrictions */}
{(user.restriction_topup || user.restriction_subscription) && (
<div className="p-3 bg-rose-500/10 border border-rose-500/30 rounded-xl">
<div className="text-sm font-medium text-rose-400 mb-2">Ограничения</div>
{user.restriction_topup && <div className="text-xs text-rose-300"> Запрет пополнения</div>}
{user.restriction_subscription && <div className="text-xs text-rose-300"> Запрет покупки подписки</div>}
{user.restriction_reason && <div className="text-xs text-dark-400 mt-1">Причина: {user.restriction_reason}</div>}
</div>
)}
</div>
)}
{/* Subscription Tab */}
{activeTab === 'subscription' && (
<div className="space-y-4">
{user.subscription ? (
<>
{/* Current subscription */}
<div className="p-4 bg-dark-900/50 rounded-xl">
<div className="flex items-center justify-between mb-3">
<span className="font-medium text-dark-200">Текущая подписка</span>
<StatusBadge status={user.subscription.status} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-dark-500">Тариф</div>
<div className="text-dark-100">{user.subscription.tariff_name || 'Не указан'}</div>
</div>
<div>
<div className="text-xs text-dark-500">Действует до</div>
<div className="text-dark-100">{formatDate(user.subscription.end_date)}</div>
</div>
<div>
<div className="text-xs text-dark-500">Трафик</div>
<div className="text-dark-100">
{user.subscription.traffic_used_gb.toFixed(1)} / {user.subscription.traffic_limit_gb} ГБ
</div>
</div>
<div>
<div className="text-xs text-dark-500">Устройств</div>
<div className="text-dark-100">{user.subscription.device_limit}</div>
</div>
</div>
</div>
{/* Actions */}
<div className="p-4 bg-dark-900/50 rounded-xl">
<div className="font-medium text-dark-200 mb-3">Действия</div>
<div className="space-y-3">
<select
value={subAction}
onChange={(e) => setSubAction(e.target.value)}
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100"
>
<option value="extend">Продлить</option>
<option value="change_tariff">Сменить тариф</option>
<option value="cancel">Отменить</option>
<option value="activate">Активировать</option>
</select>
{subAction === 'extend' && (
<input
type="number"
value={subDays}
onChange={(e) => setSubDays(e.target.value)}
placeholder="Дней"
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100"
/>
)}
{subAction === 'change_tariff' && (
<select
value={selectedTariffId || ''}
onChange={(e) => setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)}
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100"
>
<option value="">Выберите тариф</option>
{tariffs.map((t) => (
<option key={t.id} value={t.id}>
{t.name} {!t.is_available && '(недоступен)'}
</option>
))}
</select>
)}
<button
onClick={handleUpdateSubscription}
disabled={actionLoading}
className="w-full py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 transition-colors"
>
{actionLoading ? 'Применение...' : 'Применить'}
</button>
</div>
</div>
</>
) : (
<div className="p-4 bg-dark-900/50 rounded-xl">
<div className="text-center text-dark-400 mb-4">Нет активной подписки</div>
<div className="space-y-3">
<select
value={selectedTariffId || ''}
onChange={(e) => setSelectedTariffId(e.target.value ? parseInt(e.target.value) : null)}
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100"
>
<option value="">Выберите тариф</option>
{tariffs.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<input
type="number"
value={subDays}
onChange={(e) => setSubDays(e.target.value)}
placeholder="Дней"
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100"
/>
<button
onClick={() => {
setSubAction('create')
handleUpdateSubscription()
}}
disabled={actionLoading}
className="w-full py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 disabled:opacity-50 transition-colors"
>
{actionLoading ? 'Создание...' : 'Создать подписку'}
</button>
</div>
</div>
)}
</div>
)}
{/* Balance Tab */}
{activeTab === 'balance' && (
<div className="space-y-4">
{/* Current balance */}
<div className="p-4 bg-gradient-to-r from-blue-500/20 to-purple-500/20 rounded-xl border border-blue-500/30">
<div className="text-sm text-dark-400 mb-1">Текущий баланс</div>
<div className="text-3xl font-bold text-dark-100">{formatWithCurrency(user.balance_rubles)}</div>
</div>
{/* Add/subtract form */}
<div className="p-4 bg-dark-900/50 rounded-xl space-y-3">
<input
type="number"
value={balanceAmount}
onChange={(e) => setBalanceAmount(e.target.value)}
placeholder="Сумма в рублях"
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100"
/>
<input
type="text"
value={balanceDescription}
onChange={(e) => setBalanceDescription(e.target.value)}
placeholder="Описание (опционально)"
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-dark-100"
/>
<div className="flex gap-2">
<button
onClick={() => handleUpdateBalance(true)}
disabled={actionLoading || !balanceAmount}
className="flex-1 py-2 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
>
<PlusIcon /> Начислить
</button>
<button
onClick={() => handleUpdateBalance(false)}
disabled={actionLoading || !balanceAmount}
className="flex-1 py-2 bg-rose-500 text-white rounded-lg hover:bg-rose-600 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
>
<MinusIcon /> Списать
</button>
</div>
</div>
{/* Recent transactions */}
{user.recent_transactions.length > 0 && (
<div className="p-4 bg-dark-900/50 rounded-xl">
<div className="font-medium text-dark-200 mb-3">Последние транзакции</div>
<div className="space-y-2 max-h-48 overflow-y-auto">
{user.recent_transactions.map((tx) => (
<div key={tx.id} className="flex items-center justify-between py-2 border-b border-dark-700 last:border-0">
<div>
<div className="text-sm text-dark-200">{tx.description || tx.type}</div>
<div className="text-xs text-dark-500">{formatDate(tx.created_at)}</div>
</div>
<div className={tx.amount_kopeks >= 0 ? 'text-emerald-400' : 'text-rose-400'}>
{tx.amount_kopeks >= 0 ? '+' : ''}{formatWithCurrency(tx.amount_rubles)}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Sync Tab */}
{activeTab === 'sync' && (
<div className="space-y-4">
{/* Sync status */}
{syncStatus && (
<div className={`p-4 rounded-xl border ${syncStatus.has_differences ? 'bg-amber-500/10 border-amber-500/30' : 'bg-emerald-500/10 border-emerald-500/30'}`}>
<div className="flex items-center gap-2 mb-3">
{syncStatus.has_differences ? (
<span className="text-amber-400 font-medium">Есть расхождения</span>
) : (
<span className="text-emerald-400 font-medium">Синхронизировано</span>
)}
</div>
{syncStatus.differences.length > 0 && (
<div className="space-y-1 mb-3">
{syncStatus.differences.map((diff, i) => (
<div key={i} className="text-xs text-dark-300"> {diff}</div>
))}
</div>
)}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-dark-500 text-xs mb-2">Бот</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-dark-400">Статус:</span>
<span className="text-dark-200">{syncStatus.bot_subscription_status || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">До:</span>
<span className="text-dark-200">
{syncStatus.bot_subscription_end_date
? new Date(syncStatus.bot_subscription_end_date).toLocaleDateString('ru-RU')
: '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">Трафик:</span>
<span className="text-dark-200">{syncStatus.bot_traffic_used_gb.toFixed(2)} ГБ</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">Устройства:</span>
<span className="text-dark-200">{syncStatus.bot_device_limit}</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">Сквады:</span>
<span className="text-dark-200">{syncStatus.bot_squads?.length || 0}</span>
</div>
</div>
</div>
<div>
<div className="text-dark-500 text-xs mb-2">Панель</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-dark-400">Статус:</span>
<span className="text-dark-200">{syncStatus.panel_status || '-'}</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">До:</span>
<span className="text-dark-200">
{syncStatus.panel_expire_at
? new Date(syncStatus.panel_expire_at).toLocaleDateString('ru-RU')
: '-'}
</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">Трафик:</span>
<span className="text-dark-200">{syncStatus.panel_traffic_used_gb.toFixed(2)} ГБ</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">Устройства:</span>
<span className="text-dark-200">{syncStatus.panel_device_limit}</span>
</div>
<div className="flex justify-between">
<span className="text-dark-400">Сквады:</span>
<span className="text-dark-200">{syncStatus.panel_squads?.length || 0}</span>
</div>
</div>
</div>
</div>
</div>
)}
{/* UUID info */}
<div className="p-4 bg-dark-900/50 rounded-xl">
<div className="text-sm text-dark-400 mb-1">Remnawave UUID</div>
<div className="text-dark-100 font-mono text-sm break-all">
{syncStatus?.remnawave_uuid || user.remnawave_uuid || 'Не привязан'}
</div>
</div>
{/* Sync buttons */}
<div className="flex gap-3">
<button
onClick={handleSyncFromPanel}
disabled={actionLoading}
className="flex-1 py-3 bg-blue-500/20 text-blue-400 border border-blue-500/30 rounded-xl hover:bg-blue-500/30 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
>
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
Из панели в бота
</button>
<button
onClick={handleSyncToPanel}
disabled={actionLoading}
className="flex-1 py-3 bg-purple-500/20 text-purple-400 border border-purple-500/30 rounded-xl hover:bg-purple-500/30 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
>
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
Из бота в панель
</button>
</div>
</div>
)}
</div>
</div>
</div>
)
}
// ============ Main Page ============
export default function AdminUsers() {
const { formatWithCurrency } = useCurrency()
const [users, setUsers] = useState<UserListItem[]>([])
const [stats, setStats] = useState<UsersStatsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [sortBy, setSortBy] = useState<string>('created_at')
const [offset, setOffset] = useState(0)
const [total, setTotal] = useState(0)
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
const limit = 20
const loadUsers = useCallback(async () => {
try {
setLoading(true)
const params: Record<string, unknown> = { offset, limit, sort_by: sortBy }
if (search) params.search = search
if (statusFilter) params.status = statusFilter
const data = await adminUsersApi.getUsers(params as any)
setUsers(data.users)
setTotal(data.total)
} catch (error) {
console.error('Failed to load users:', error)
} finally {
setLoading(false)
}
}, [offset, search, statusFilter, sortBy])
const loadStats = useCallback(async () => {
try {
const data = await adminUsersApi.getStats()
setStats(data)
} catch (error) {
console.error('Failed to load stats:', error)
}
}, [])
useEffect(() => {
loadUsers()
}, [loadUsers])
useEffect(() => {
loadStats()
}, [loadStats])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setOffset(0)
loadUsers()
}
const totalPages = Math.ceil(total / limit)
const currentPage = Math.floor(offset / limit) + 1
return (
<div className="animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-indigo-500/20 rounded-xl">
<UsersIcon className="w-7 h-7 text-indigo-400" />
</div>
<div>
<h1 className="text-xl font-bold text-dark-100">Пользователи</h1>
<p className="text-sm text-dark-400">Управление пользователями бота</p>
</div>
</div>
<button
onClick={() => { loadUsers(); loadStats() }}
className="p-2 hover:bg-dark-700 rounded-lg transition-colors"
>
<RefreshIcon className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 mb-6">
<StatCard title="Всего" value={stats.total_users} color="blue" />
<StatCard title="Активных" value={stats.active_users} color="green" />
<StatCard title="С подпиской" value={stats.users_with_active_subscription} color="purple" />
<StatCard title="Новых сегодня" value={stats.new_today} color="yellow" />
<StatCard title="Заблокировано" value={stats.blocked_users} color="red" />
</div>
)}
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-4">
<form onSubmit={handleSearch} className="flex-1">
<div className="relative">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по ID, имени, username..."
className="w-full pl-10 pr-4 py-2 bg-dark-800 border border-dark-700 rounded-xl text-dark-100 placeholder-dark-500 focus:border-dark-600 focus:outline-none"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
<SearchIcon />
</div>
</div>
</form>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setOffset(0) }}
className="bg-dark-800 border border-dark-700 rounded-xl px-3 py-2 text-dark-100"
>
<option value="">Все статусы</option>
<option value="active">Активные</option>
<option value="blocked">Заблокированные</option>
<option value="deleted">Удалённые</option>
</select>
<select
value={sortBy}
onChange={(e) => { setSortBy(e.target.value); setOffset(0) }}
className="bg-dark-800 border border-dark-700 rounded-xl px-3 py-2 text-dark-100"
>
<option value="created_at">По дате</option>
<option value="balance">По балансу</option>
<option value="last_activity">По активности</option>
<option value="total_spent">По расходам</option>
</select>
</div>
{/* Users list */}
<div className="space-y-2 mb-4">
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full" />
</div>
) : users.length === 0 ? (
<div className="text-center py-12 text-dark-400">
Пользователи не найдены
</div>
) : (
users.map((user) => (
<UserRow
key={user.id}
user={user}
onSelect={(u) => setSelectedUserId(u.id)}
formatAmount={(amount) => formatWithCurrency(amount)}
/>
))
)}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-sm text-dark-400">
Показано {offset + 1}-{Math.min(offset + limit, total)} из {total}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setOffset(Math.max(0, offset - limit))}
disabled={offset === 0}
className="p-2 bg-dark-800 border border-dark-700 rounded-lg disabled:opacity-50 hover:bg-dark-700 transition-colors"
>
<ChevronLeftIcon />
</button>
<span className="px-3 py-2 text-dark-300">
{currentPage} / {totalPages}
</span>
<button
onClick={() => setOffset(offset + limit)}
disabled={offset + limit >= total}
className="p-2 bg-dark-800 border border-dark-700 rounded-lg disabled:opacity-50 hover:bg-dark-700 transition-colors"
>
<ChevronRightIcon />
</button>
</div>
</div>
)}
{/* User detail modal */}
{selectedUserId && (
<UserDetailModal
userId={selectedUserId}
onClose={() => setSelectedUserId(null)}
onUpdate={() => { loadUsers(); loadStats() }}
/>
)}
</div>
)
}