import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Link } from 'react-router-dom' import { promocodesApi, PromoCode, PromoCodeDetail, PromoCodeType, PromoCodeCreateRequest, PromoCodeUpdateRequest, PromoGroup, PromoGroupCreateRequest, PromoGroupUpdateRequest, } from '../api/promocodes' // Icons const BackIcon = () => ( ) const PlusIcon = () => ( ) const EditIcon = () => ( ) const TrashIcon = () => ( ) const CheckIcon = () => ( ) const XIcon = () => ( ) const CopyIcon = () => ( ) const UsersIcon = () => ( ) const ChartIcon = () => ( ) const ClockIcon = () => ( ) const UserIcon = () => ( ) // Helper functions const getTypeLabel = (type: PromoCodeType): string => { const labels: Record = { balance: 'Баланс', subscription_days: 'Дни подписки', trial_subscription: 'Тестовая подписка', promo_group: 'Группа скидок', discount: 'Скидка %', } return labels[type] || type } const getTypeColor = (type: PromoCodeType): string => { const colors: Record = { balance: 'bg-emerald-500/20 text-emerald-400', subscription_days: 'bg-blue-500/20 text-blue-400', trial_subscription: 'bg-purple-500/20 text-purple-400', promo_group: 'bg-amber-500/20 text-amber-400', discount: 'bg-pink-500/20 text-pink-400', } return colors[type] || 'bg-dark-600 text-dark-300' } const formatDate = (date: string | null): string => { if (!date) return '-' return new Date(date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', }) } const formatDateTime = (date: string | null): string => { if (!date) return '-' return new Date(date).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }) } // Promocode Modal interface PromocodeModalProps { promocode?: PromoCodeDetail | null promoGroups: PromoGroup[] onSave: (data: PromoCodeCreateRequest | PromoCodeUpdateRequest) => void onClose: () => void isLoading?: boolean } function PromocodeModal({ promocode, promoGroups, onSave, onClose, isLoading }: PromocodeModalProps) { const isEdit = !!promocode const [code, setCode] = useState(promocode?.code || '') const [type, setType] = useState(promocode?.type || 'balance') const [balanceBonusRubles, setBalanceBonusRubles] = useState(promocode?.balance_bonus_rubles || 0) const [subscriptionDays, setSubscriptionDays] = useState(promocode?.subscription_days || 0) const [maxUses, setMaxUses] = useState(promocode?.max_uses || 1) const [isActive, setIsActive] = useState(promocode?.is_active ?? true) const [firstPurchaseOnly, setFirstPurchaseOnly] = useState(promocode?.first_purchase_only || false) const [validUntil, setValidUntil] = useState( promocode?.valid_until ? promocode.valid_until.split('T')[0] : '' ) const [promoGroupId, setPromoGroupId] = useState(promocode?.promo_group_id || null) const handleSubmit = () => { // Для discount: balance_bonus_kopeks = процент (целое число), subscription_days = часы // Для balance: balance_bonus_kopeks = рубли * 100 const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = { code: code.trim().toUpperCase(), type, balance_bonus_kopeks: type === 'discount' ? Math.round(balanceBonusRubles) // процент как целое число : Math.round(balanceBonusRubles * 100), // рубли в копейки subscription_days: subscriptionDays, max_uses: maxUses, is_active: isActive, first_purchase_only: firstPurchaseOnly, valid_until: validUntil ? new Date(validUntil).toISOString() : null, promo_group_id: type === 'promo_group' ? promoGroupId : null, } onSave(data) } const isValid = () => { if (!code.trim()) return false if (type === 'balance' && balanceBonusRubles <= 0) return false if ((type === 'subscription_days' || type === 'trial_subscription') && subscriptionDays <= 0) return false if (type === 'promo_group' && !promoGroupId) return false if (type === 'discount' && (balanceBonusRubles <= 0 || balanceBonusRubles > 100 || subscriptionDays <= 0)) return false return true } return (
{/* Header */}

{isEdit ? 'Редактирование промокода' : 'Новый промокод'}

{/* Content */}
{/* Code */}
setCode(e.target.value.toUpperCase())} 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 uppercase" placeholder="SUMMER2025" />
{/* Type */}
{/* Type-specific fields */} {type === 'balance' && (
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-accent-500" min={0} step={1} /> руб.
)} {(type === 'subscription_days' || type === 'trial_subscription') && (
setSubscriptionDays(Math.max(0, parseInt(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-accent-500" min={1} /> дней
)} {type === 'promo_group' && (
)} {type === 'discount' && ( <>
setBalanceBonusRubles(Math.min(100, 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-accent-500" min={1} max={100} /> %

Скидка применяется один раз при оплате

setSubscriptionDays(Math.max(0, parseInt(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-accent-500" min={1} /> часов

Сколько часов после активации скидка действует

)} {/* Max Uses */}
setMaxUses(Math.max(0, parseInt(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-accent-500" min={0} /> 0 = безлимит
{/* Valid Until */}
setValidUntil(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" />

Оставьте пустым для бессрочного промокода

{/* Options */}
{/* Footer */}
) } // PromoGroup Modal interface PromoGroupModalProps { group?: PromoGroup | null onSave: (data: PromoGroupCreateRequest | PromoGroupUpdateRequest) => void onClose: () => void isLoading?: boolean } function PromoGroupModal({ group, onSave, onClose, isLoading }: PromoGroupModalProps) { const isEdit = !!group const [name, setName] = useState(group?.name || '') const [serverDiscount, setServerDiscount] = useState(group?.server_discount_percent || 0) const [trafficDiscount, setTrafficDiscount] = useState(group?.traffic_discount_percent || 0) const [deviceDiscount, setDeviceDiscount] = useState(group?.device_discount_percent || 0) const [applyToAddons, setApplyToAddons] = useState(group?.apply_discounts_to_addons ?? true) const [autoAssignSpent, setAutoAssignSpent] = useState( group?.auto_assign_total_spent_kopeks ? group.auto_assign_total_spent_kopeks / 100 : 0 ) const handleSubmit = () => { const data: PromoGroupCreateRequest | PromoGroupUpdateRequest = { name, server_discount_percent: serverDiscount, traffic_discount_percent: trafficDiscount, device_discount_percent: deviceDiscount, apply_discounts_to_addons: applyToAddons, auto_assign_total_spent_kopeks: autoAssignSpent > 0 ? Math.round(autoAssignSpent * 100) : null, } onSave(data) } return (
{/* Header */}

{isEdit ? 'Редактирование группы' : 'Новая группа скидок'}

{/* Content */}
{/* Name */}
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="VIP клиенты" />
{/* Discounts */}

Скидки

На серверы: setServerDiscount(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))} className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-dark-100 focus:outline-none focus:border-accent-500" min={0} max={100} /> %
На трафик: setTrafficDiscount(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))} className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-dark-100 focus:outline-none focus:border-accent-500" min={0} max={100} /> %
На устройства: setDeviceDiscount(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))} className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-dark-100 focus:outline-none focus:border-accent-500" min={0} max={100} /> %
{/* Auto-assign */}
setAutoAssignSpent(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-accent-500" min={0} /> руб.

0 = не назначать автоматически

{/* Apply to addons */}
{/* Footer */}
) } // Promocode Stats Modal interface PromocodeStatsModalProps { promocode: PromoCodeDetail onClose: () => void onEdit: () => void } function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModalProps) { return (
{/* Header */}
{promocode.code}
{getTypeLabel(promocode.type)} {!promocode.is_active && ( Неактивен )}
{/* Content */}
{/* Stats Cards */}
{promocode.total_uses}
Всего использований
{promocode.today_uses}
Сегодня
{promocode.max_uses === 0 ? '∞' : promocode.uses_left}
Осталось
{/* Details */}

Детали промокода

Тип: {getTypeLabel(promocode.type)}
{promocode.type === 'balance' && (
Бонус: +{promocode.balance_bonus_rubles} руб.
)} {(promocode.type === 'subscription_days' || promocode.type === 'trial_subscription') && (
Дней: +{promocode.subscription_days}
)} {promocode.type === 'discount' && ( <>
Скидка: -{promocode.balance_bonus_kopeks}%
Действует: {promocode.subscription_days} часов
)}
Лимит: {promocode.current_uses}/{promocode.max_uses === 0 ? '∞' : promocode.max_uses}
Статус: {promocode.is_valid ? 'Активен' : 'Неактивен'}
Создан: {formatDateTime(promocode.created_at)}
Действует до: {promocode.valid_until ? formatDate(promocode.valid_until) : 'Бессрочно'}
{promocode.first_purchase_only && (
Ограничение: Только первая покупка
)}
{/* Usage History */}

История использования

{promocode.recent_uses.length === 0 ? (

Промокод еще не использовался

) : (
{promocode.recent_uses.map((use) => (
{use.user_full_name || use.user_username || `User #${use.user_id}`}
{use.user_username && (
@{use.user_username}
)}
{formatDateTime(use.used_at)}
))}
)}
{/* Footer */}
) } export default function AdminPromocodes() { const queryClient = useQueryClient() const [activeTab, setActiveTab] = useState<'promocodes' | 'groups'>('promocodes') const [viewingPromocode, setViewingPromocode] = useState(null) const [showPromocodeModal, setShowPromocodeModal] = useState(false) const [showGroupModal, setShowGroupModal] = useState(false) const [editingPromocode, setEditingPromocode] = useState(null) const [editingGroup, setEditingGroup] = useState(null) const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'promocode' | 'group'; id: number } | null>(null) const [copiedCode, setCopiedCode] = useState(null) // Queries const { data: promocodesData, isLoading: promocodesLoading } = useQuery({ queryKey: ['admin-promocodes'], queryFn: () => promocodesApi.getPromocodes({ limit: 100 }), }) const { data: groupsData, isLoading: groupsLoading } = useQuery({ queryKey: ['admin-promo-groups'], queryFn: () => promocodesApi.getPromoGroups({ limit: 100 }), }) // Promocode Mutations const createPromocodeMutation = useMutation({ mutationFn: promocodesApi.createPromocode, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-promocodes'] }) setShowPromocodeModal(false) }, }) const updatePromocodeMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: PromoCodeUpdateRequest }) => promocodesApi.updatePromocode(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-promocodes'] }) setShowPromocodeModal(false) setEditingPromocode(null) }, }) const deletePromocodeMutation = useMutation({ mutationFn: promocodesApi.deletePromocode, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-promocodes'] }) setDeleteConfirm(null) }, }) // PromoGroup Mutations const createGroupMutation = useMutation({ mutationFn: promocodesApi.createPromoGroup, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-promo-groups'] }) setShowGroupModal(false) }, }) const updateGroupMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: PromoGroupUpdateRequest }) => promocodesApi.updatePromoGroup(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-promo-groups'] }) setShowGroupModal(false) setEditingGroup(null) }, }) const deleteGroupMutation = useMutation({ mutationFn: promocodesApi.deletePromoGroup, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-promo-groups'] }) setDeleteConfirm(null) }, }) const handleEditPromocode = async (id: number) => { try { const detail = await promocodesApi.getPromocode(id) setEditingPromocode(detail) setShowPromocodeModal(true) } catch (error) { console.error('Failed to load promocode:', error) } } const handleViewStats = async (id: number) => { try { const detail = await promocodesApi.getPromocode(id) setViewingPromocode(detail) } catch (error) { console.error('Failed to load promocode stats:', error) } } const handleSavePromocode = (data: PromoCodeCreateRequest | PromoCodeUpdateRequest) => { if (editingPromocode) { updatePromocodeMutation.mutate({ id: editingPromocode.id, data }) } else { createPromocodeMutation.mutate(data as PromoCodeCreateRequest) } } const handleSaveGroup = (data: PromoGroupCreateRequest | PromoGroupUpdateRequest) => { if (editingGroup) { updateGroupMutation.mutate({ id: editingGroup.id, data }) } else { createGroupMutation.mutate(data as PromoGroupCreateRequest) } } const handleCopyCode = (code: string) => { navigator.clipboard.writeText(code) setCopiedCode(code) setTimeout(() => setCopiedCode(null), 2000) } const handleDelete = () => { if (!deleteConfirm) return if (deleteConfirm.type === 'promocode') { deletePromocodeMutation.mutate(deleteConfirm.id) } else { deleteGroupMutation.mutate(deleteConfirm.id) } } const promocodes = promocodesData?.items || [] const groups = groupsData?.items || [] return (
{/* Header */}

Промокоды

Управление промокодами и группами скидок

{/* Stats Overview */} {activeTab === 'promocodes' && promocodes.length > 0 && (
{promocodes.length}
Всего промокодов
{promocodes.filter(p => p.is_active && p.is_valid).length}
Активных
{promocodes.reduce((sum, p) => sum + p.current_uses, 0)}
Использований
{promocodes.filter(p => p.uses_left === 0 && p.max_uses > 0).length}
Исчерпано
)} {/* Tabs */}
{/* Promocodes List */} {activeTab === 'promocodes' && ( <> {promocodesLoading ? (
) : promocodes.length === 0 ? (

Нет промокодов

) : (
{promocodes.map((promo: PromoCode) => (
{getTypeLabel(promo.type)} {!promo.is_active && ( Неактивен )} {promo.first_purchase_only && ( Первая покупка )}
{promo.type === 'balance' && ( +{promo.balance_bonus_rubles} руб. )} {(promo.type === 'subscription_days' || promo.type === 'trial_subscription') && ( +{promo.subscription_days} дней )} {promo.type === 'discount' && ( -{promo.balance_bonus_kopeks}% на {promo.subscription_days}ч )} Использовано: {promo.current_uses}/{promo.max_uses === 0 ? '∞' : promo.max_uses} {promo.valid_until && ( До: {formatDate(promo.valid_until)} )}
))}
)} )} {/* Groups List */} {activeTab === 'groups' && ( <> {groupsLoading ? (
) : groups.length === 0 ? (

Нет групп скидок

) : (
{groups.map((group: PromoGroup) => (

{group.name}

{group.is_default && ( По умолчанию )}
{group.server_discount_percent > 0 && ( Серверы: -{group.server_discount_percent}% )} {group.traffic_discount_percent > 0 && ( Трафик: -{group.traffic_discount_percent}% )} {group.device_discount_percent > 0 && ( Устройства: -{group.device_discount_percent}% )} {group.members_count} участников
))}
)} )} {/* Promocode Modal */} {showPromocodeModal && ( { setShowPromocodeModal(false) setEditingPromocode(null) }} isLoading={createPromocodeMutation.isPending || updatePromocodeMutation.isPending} /> )} {/* PromoGroup Modal */} {showGroupModal && ( { setShowGroupModal(false) setEditingGroup(null) }} isLoading={createGroupMutation.isPending || updateGroupMutation.isPending} /> )} {/* Promocode Stats Modal */} {viewingPromocode && ( setViewingPromocode(null)} onEdit={() => { setEditingPromocode(viewingPromocode) setViewingPromocode(null) setShowPromocodeModal(true) }} /> )} {/* Delete Confirmation */} {deleteConfirm && (

{deleteConfirm.type === 'promocode' ? 'Удалить промокод?' : 'Удалить группу?'}

{deleteConfirm.type === 'promocode' ? 'Промокод будет удален безвозвратно.' : 'Группа будет удалена. Пользователи потеряют скидки этой группы.' }

)}
) }