Files
bedolaga-cabinet/src/pages/AdminTariffs.tsx
2026-01-15 19:18:17 +03:00

894 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
tariffsApi,
TariffListItem,
TariffDetail,
TariffCreateRequest,
TariffUpdateRequest,
PeriodPrice,
ServerInfo,
ServerTrafficLimit
} from '../api/tariffs'
// Icons
const TariffIcon = () => (
<svg className="w-6 h-6" 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 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 InfinityIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 12a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zm0 0c0 1.657 1.007 3 2.25 3S21 13.657 21 12a9 9 0 10-2.636 6.364M16.5 12V8.25" />
</svg>
)
// Default period options
const DEFAULT_PERIODS = [7, 14, 30, 90, 180, 365]
interface TariffModalProps {
tariff?: TariffDetail | null
servers: ServerInfo[]
onSave: (data: TariffCreateRequest | TariffUpdateRequest) => void
onClose: () => void
isLoading?: boolean
}
function TariffModal({ tariff, servers, onSave, onClose, isLoading }: TariffModalProps) {
const { t } = useTranslation()
const isEdit = !!tariff
const [name, setName] = useState(tariff?.name || '')
const [description, setDescription] = useState(tariff?.description || '')
const [trafficLimitGb, setTrafficLimitGb] = useState(tariff?.traffic_limit_gb || 0)
const [deviceLimit, setDeviceLimit] = useState(tariff?.device_limit || 1)
const [devicePriceKopeks, setDevicePriceKopeks] = useState(tariff?.device_price_kopeks || 0)
const [tierLevel, setTierLevel] = useState(tariff?.tier_level || 1)
const [periodPrices, setPeriodPrices] = useState<PeriodPrice[]>(
tariff?.period_prices || DEFAULT_PERIODS.map(d => ({ days: d, price_kopeks: 0 }))
)
const [selectedSquads, setSelectedSquads] = useState<string[]>(tariff?.allowed_squads || [])
const [serverTrafficLimits, setServerTrafficLimits] = useState<Record<string, ServerTrafficLimit>>(
tariff?.server_traffic_limits || {}
)
// Произвольное количество дней
const [customDaysEnabled, setCustomDaysEnabled] = useState(tariff?.custom_days_enabled || false)
const [pricePerDayKopeks, setPricePerDayKopeks] = useState(tariff?.price_per_day_kopeks || 0)
const [minDays, setMinDays] = useState(tariff?.min_days || 1)
const [maxDays, setMaxDays] = useState(tariff?.max_days || 365)
// Произвольный трафик
const [customTrafficEnabled, setCustomTrafficEnabled] = useState(tariff?.custom_traffic_enabled || false)
const [trafficPricePerGbKopeks, setTrafficPricePerGbKopeks] = useState(tariff?.traffic_price_per_gb_kopeks || 0)
const [minTrafficGb, setMinTrafficGb] = useState(tariff?.min_traffic_gb || 1)
const [maxTrafficGb, setMaxTrafficGb] = useState(tariff?.max_traffic_gb || 1000)
// Докупка трафика
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(tariff?.traffic_topup_enabled || false)
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(tariff?.max_topup_traffic_gb || 0)
const [trafficTopupPackages, setTrafficTopupPackages] = useState<Record<string, number>>(
tariff?.traffic_topup_packages || {}
)
// Дневной тариф
const [isDaily, setIsDaily] = useState(tariff?.is_daily || false)
const [dailyPriceKopeks, setDailyPriceKopeks] = useState(tariff?.daily_price_kopeks || 0)
const [activeTab, setActiveTab] = useState<'basic' | 'prices' | 'servers' | 'custom'>('basic')
const handleSubmit = () => {
// Фильтруем лимиты только для выбранных серверов и только если они заданы (> 0)
const filteredLimits: Record<string, ServerTrafficLimit> = {}
for (const uuid of selectedSquads) {
if (serverTrafficLimits[uuid] && serverTrafficLimits[uuid].traffic_limit_gb > 0) {
filteredLimits[uuid] = serverTrafficLimits[uuid]
}
}
const data: TariffCreateRequest | TariffUpdateRequest = {
name,
description: description || undefined,
traffic_limit_gb: trafficLimitGb,
device_limit: deviceLimit,
device_price_kopeks: devicePriceKopeks > 0 ? devicePriceKopeks : undefined,
tier_level: tierLevel,
period_prices: periodPrices.filter(p => p.price_kopeks > 0),
allowed_squads: selectedSquads,
server_traffic_limits: Object.keys(filteredLimits).length > 0 ? filteredLimits : {},
// Произвольное количество дней
custom_days_enabled: customDaysEnabled,
price_per_day_kopeks: pricePerDayKopeks,
min_days: minDays,
max_days: maxDays,
// Произвольный трафик
custom_traffic_enabled: customTrafficEnabled,
traffic_price_per_gb_kopeks: trafficPricePerGbKopeks,
min_traffic_gb: minTrafficGb,
max_traffic_gb: maxTrafficGb,
// Докупка трафика
traffic_topup_enabled: trafficTopupEnabled,
traffic_topup_packages: trafficTopupPackages,
max_topup_traffic_gb: maxTopupTrafficGb,
// Дневной тариф
is_daily: isDaily,
daily_price_kopeks: dailyPriceKopeks,
}
onSave(data)
}
const updateServerTrafficLimit = (uuid: string, limitGb: number) => {
setServerTrafficLimits(prev => ({
...prev,
[uuid]: { traffic_limit_gb: limitGb }
}))
}
const toggleServer = (uuid: string) => {
setSelectedSquads(prev =>
prev.includes(uuid)
? prev.filter(s => s !== uuid)
: [...prev, uuid]
)
}
const updatePeriodPrice = (days: number, priceKopeks: number) => {
setPeriodPrices(prev => {
const existing = prev.find(p => p.days === days)
if (existing) {
return prev.map(p => p.days === days ? { ...p, price_kopeks: priceKopeks } : p)
}
return [...prev, { days, price_kopeks: priceKopeks }]
})
}
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">
<h2 className="text-lg font-semibold text-dark-100">
{isEdit ? t('admin.tariffs.edit') : t('admin.tariffs.create')}
</h2>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
<XIcon />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-dark-700">
{(['basic', 'prices', 'servers', 'custom'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab
? 'text-accent-400 border-b-2 border-accent-400'
: 'text-dark-400 hover:text-dark-200'
}`}
>
{tab === 'basic' && t('admin.tariffs.tabs.basic')}
{tab === 'prices' && t('admin.tariffs.tabs.prices')}
{tab === 'servers' && t('admin.tariffs.tabs.servers')}
{tab === 'custom' && 'Гибкие опции'}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === 'basic' && (
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.tariffs.name')}</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={t('admin.tariffs.namePlaceholder')}
/>
</div>
{/* Description */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.tariffs.description')}</label>
<textarea
value={description}
onChange={e => setDescription(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 resize-none"
rows={3}
placeholder={t('admin.tariffs.descriptionPlaceholder')}
/>
</div>
{/* Traffic Limit */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.tariffs.trafficLimit')}</label>
<div className="flex items-center gap-2">
<input
type="number"
value={trafficLimitGb}
onChange={e => setTrafficLimitGb(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}
/>
<span className="text-dark-400">GB</span>
{trafficLimitGb === 0 && (
<span className="flex items-center gap-1 text-sm text-success-500">
<InfinityIcon />
{t('admin.tariffs.unlimited')}
</span>
)}
</div>
<p className="text-xs text-dark-500 mt-1">{t('admin.tariffs.trafficHint')}</p>
</div>
{/* Device Limit */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.tariffs.deviceLimit')}</label>
<input
type="number"
value={deviceLimit}
onChange={e => setDeviceLimit(Math.max(1, parseInt(e.target.value) || 1))}
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}
/>
</div>
{/* Tier Level */}
<div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.tariffs.tierLevel')}</label>
<input
type="number"
value={tierLevel}
onChange={e => setTierLevel(Math.min(10, Math.max(1, parseInt(e.target.value) || 1)))}
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={10}
/>
<p className="text-xs text-dark-500 mt-1">{t('admin.tariffs.tierHint')}</p>
</div>
</div>
)}
{activeTab === 'prices' && (
<div className="space-y-3">
<p className="text-sm text-dark-400 mb-4">{t('admin.tariffs.pricesHint')}</p>
{DEFAULT_PERIODS.map(days => {
const price = periodPrices.find(p => p.days === days)?.price_kopeks || 0
const isEnabled = price > 0
return (
<div
key={days}
className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
isEnabled
? 'bg-dark-700/50'
: 'bg-dark-800/30 opacity-60'
}`}
>
{/* Toggle */}
<button
type="button"
onClick={() => {
if (isEnabled) {
updatePeriodPrice(days, 0)
} else {
updatePeriodPrice(days, 10000) // Default 100₽
}
}}
className={`w-10 h-6 rounded-full transition-colors relative ${
isEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
isEnabled ? 'left-5' : 'left-1'
}`}
/>
</button>
<span className="w-20 text-dark-300">{days} {t('admin.tariffs.days')}</span>
<input
type="number"
value={price / 100}
onChange={e => updatePeriodPrice(days, Math.max(0, parseFloat(e.target.value) || 0) * 100)}
disabled={!isEnabled}
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 ${
!isEnabled ? 'opacity-50 cursor-not-allowed' : ''
}`}
min={0}
step={1}
/>
<span className="text-dark-400"></span>
{!isEnabled && (
<span className="text-xs text-dark-500">{t('admin.tariffs.periodDisabled')}</span>
)}
</div>
)
})}
</div>
)}
{activeTab === 'servers' && (
<div className="space-y-2">
<p className="text-sm text-dark-400 mb-4">{t('admin.tariffs.serversHint')}</p>
{servers.length === 0 ? (
<p className="text-dark-500 text-center py-4">{t('admin.tariffs.noServers')}</p>
) : (
servers.map(server => {
const isSelected = selectedSquads.includes(server.squad_uuid)
const serverLimit = serverTrafficLimits[server.squad_uuid]?.traffic_limit_gb || 0
return (
<div
key={server.id}
className={`p-3 rounded-lg transition-colors ${
isSelected
? 'bg-accent-500/20 border border-accent-500/50'
: 'bg-dark-700 hover:bg-dark-600 border border-transparent'
}`}
>
<div
onClick={() => toggleServer(server.squad_uuid)}
className="flex items-center gap-3 cursor-pointer"
>
<div className={`w-5 h-5 rounded flex items-center justify-center ${
isSelected
? 'bg-accent-500 text-white'
: 'bg-dark-600'
}`}>
{isSelected && <CheckIcon />}
</div>
<span className="text-dark-200 flex-1">{server.display_name}</span>
{server.country_code && (
<span className="text-xs text-dark-500">{server.country_code}</span>
)}
</div>
{/* Лимит трафика для сервера */}
{isSelected && (
<div className="mt-2 ml-8 flex items-center gap-2">
<span className="text-xs text-dark-400">{t('admin.tariffs.serverTrafficLimit')}:</span>
<input
type="number"
value={serverLimit}
onClick={e => e.stopPropagation()}
onChange={e => {
e.stopPropagation()
updateServerTrafficLimit(server.squad_uuid, Math.max(0, parseInt(e.target.value) || 0))
}}
className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-sm text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
placeholder="0"
/>
<span className="text-xs text-dark-400">GB</span>
{serverLimit === 0 && (
<span className="text-xs text-dark-500">({t('admin.tariffs.useDefault')})</span>
)}
</div>
)}
</div>
)
})
)}
</div>
)}
{activeTab === 'custom' && (
<div className="space-y-6">
{/* Дневной тариф */}
<div className="p-4 bg-dark-700/50 rounded-lg border border-amber-500/30">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-medium text-amber-400">🌙 Дневной тариф</h4>
<p className="text-xs text-dark-500 mt-1">Ежедневное списание с баланса. Можно ставить на паузу.</p>
</div>
<button
type="button"
onClick={() => setIsDaily(!isDaily)}
className={`w-10 h-6 rounded-full transition-colors relative ${
isDaily ? 'bg-amber-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
isDaily ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{isDaily && (
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Цена за день:</span>
<input
type="number"
value={dailyPriceKopeks / 100}
onChange={e => setDailyPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-amber-500"
min={0}
step={0.1}
/>
<span className="text-dark-400">/день</span>
</div>
)}
</div>
{/* Цена за доп. устройство */}
<div className="p-4 bg-dark-700/50 rounded-lg">
<h4 className="text-sm font-medium text-dark-200 mb-3">Докупка устройств</h4>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400">Цена за устройство (30 дней):</span>
<input
type="number"
value={devicePriceKopeks / 100}
onChange={e => setDevicePriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={1}
/>
<span className="text-dark-400"></span>
</div>
<p className="text-xs text-dark-500 mt-1">0 = докупка недоступна</p>
</div>
{/* Произвольное количество дней */}
<div className="p-4 bg-dark-700/50 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-dark-200">Произвольное кол-во дней</h4>
<button
type="button"
onClick={() => setCustomDaysEnabled(!customDaysEnabled)}
className={`w-10 h-6 rounded-full transition-colors relative ${
customDaysEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
customDaysEnabled ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{customDaysEnabled && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Цена за день:</span>
<input
type="number"
value={pricePerDayKopeks / 100}
onChange={e => setPricePerDayKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={0.1}
/>
<span className="text-dark-400"></span>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Мин. дней:</span>
<input
type="number"
value={minDays}
onChange={e => setMinDays(Math.max(1, parseInt(e.target.value) || 1))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Макс. дней:</span>
<input
type="number"
value={maxDays}
onChange={e => setMaxDays(Math.max(1, parseInt(e.target.value) || 365))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
</div>
)}
</div>
{/* Произвольный трафик */}
<div className="p-4 bg-dark-700/50 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-dark-200">Произвольный трафик при покупке</h4>
<button
type="button"
onClick={() => setCustomTrafficEnabled(!customTrafficEnabled)}
className={`w-10 h-6 rounded-full transition-colors relative ${
customTrafficEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
customTrafficEnabled ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{customTrafficEnabled && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Цена за 1 ГБ:</span>
<input
type="number"
value={trafficPricePerGbKopeks / 100}
onChange={e => setTrafficPricePerGbKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={0.1}
/>
<span className="text-dark-400"></span>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Мин. ГБ:</span>
<input
type="number"
value={minTrafficGb}
onChange={e => setMinTrafficGb(Math.max(1, parseInt(e.target.value) || 1))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Макс. ГБ:</span>
<input
type="number"
value={maxTrafficGb}
onChange={e => setMaxTrafficGb(Math.max(1, parseInt(e.target.value) || 1000))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={1}
/>
</div>
</div>
)}
</div>
{/* Докупка трафика */}
<div className="p-4 bg-dark-700/50 rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-dark-200">Докупка трафика (после покупки)</h4>
<button
type="button"
onClick={() => setTrafficTopupEnabled(!trafficTopupEnabled)}
className={`w-10 h-6 rounded-full transition-colors relative ${
trafficTopupEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
trafficTopupEnabled ? 'left-5' : 'left-1'
}`}
/>
</button>
</div>
{trafficTopupEnabled && (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="text-sm text-dark-400 w-32">Макс. лимит:</span>
<input
type="number"
value={maxTopupTrafficGb}
onChange={e => setMaxTopupTrafficGb(Math.max(0, parseInt(e.target.value) || 0))}
className="w-24 px-3 py-2 bg-dark-600 border border-dark-500 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
/>
<span className="text-dark-400">ГБ</span>
<span className="text-xs text-dark-500">(0 = без ограничений)</span>
</div>
<div className="mt-3">
<span className="text-sm text-dark-400">Пакеты трафика (ГБ: цена ):</span>
<div className="mt-2 grid grid-cols-2 gap-2">
{[5, 10, 20, 50].map(gb => (
<div key={gb} className="flex items-center gap-2">
<span className="text-sm text-dark-300 w-12">{gb} ГБ:</span>
<input
type="number"
value={(trafficTopupPackages[String(gb)] || 0) / 100}
onChange={e => {
const price = Math.max(0, parseFloat(e.target.value) || 0) * 100
setTrafficTopupPackages(prev => ({
...prev,
[String(gb)]: price
}))
}}
className="w-20 px-2 py-1 bg-dark-600 border border-dark-500 rounded text-sm text-dark-100 focus:outline-none focus:border-accent-500"
min={0}
step={1}
/>
<span className="text-xs text-dark-400"></span>
</div>
))}
</div>
</div>
</div>
)}
</div>
</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"
>
{t('common.cancel')}
</button>
<button
onClick={handleSubmit}
disabled={!name || 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 ? t('common.loading') : t('common.save')}
</button>
</div>
</div>
</div>
)
}
export default function AdminTariffs() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [showModal, setShowModal] = useState(false)
const [editingTariff, setEditingTariff] = useState<TariffDetail | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
// Queries
const { data: tariffsData, isLoading } = useQuery({
queryKey: ['admin-tariffs'],
queryFn: () => tariffsApi.getTariffs(true),
})
const { data: servers = [] } = useQuery({
queryKey: ['admin-tariffs-servers'],
queryFn: () => tariffsApi.getAvailableServers(),
})
// Mutations
const createMutation = useMutation({
mutationFn: tariffsApi.createTariff,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
setShowModal(false)
},
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: TariffUpdateRequest }) =>
tariffsApi.updateTariff(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
setShowModal(false)
setEditingTariff(null)
},
})
const deleteMutation = useMutation({
mutationFn: tariffsApi.deleteTariff,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
setDeleteConfirm(null)
},
})
const toggleMutation = useMutation({
mutationFn: tariffsApi.toggleTariff,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
},
})
const toggleTrialMutation = useMutation({
mutationFn: tariffsApi.toggleTrial,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
},
})
const handleEdit = async (tariffId: number) => {
try {
const detail = await tariffsApi.getTariff(tariffId)
setEditingTariff(detail)
setShowModal(true)
} catch (error) {
console.error('Failed to load tariff:', error)
}
}
const handleSave = (data: TariffCreateRequest | TariffUpdateRequest) => {
if (editingTariff) {
updateMutation.mutate({ id: editingTariff.id, data })
} else {
createMutation.mutate(data as TariffCreateRequest)
}
}
const tariffs = tariffsData?.tariffs || []
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-accent-500/20 rounded-lg">
<TariffIcon />
</div>
<div>
<h1 className="text-xl font-semibold text-dark-100">{t('admin.tariffs.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.tariffs.subtitle')}</p>
</div>
</div>
<button
onClick={() => { setEditingTariff(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 />
{t('admin.tariffs.create')}
</button>
</div>
{/* Tariffs 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>
) : tariffs.length === 0 ? (
<div className="text-center py-12">
<p className="text-dark-400">{t('admin.tariffs.noTariffs')}</p>
</div>
) : (
<div className="space-y-3">
{tariffs.map((tariff: TariffListItem) => (
<div
key={tariff.id}
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
tariff.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">{tariff.name}</h3>
{tariff.is_trial_available && (
<span className="px-2 py-0.5 text-xs bg-success-500/20 text-success-400 rounded">
{t('admin.tariffs.trial')}
</span>
)}
{!tariff.is_active && (
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
{t('admin.tariffs.inactive')}
</span>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
<span>
{tariff.traffic_limit_gb === 0
? t('admin.tariffs.unlimited')
: `${tariff.traffic_limit_gb} GB`
}
</span>
<span>{tariff.device_limit} {t('admin.tariffs.devices')}</span>
<span>{tariff.servers_count} {t('admin.tariffs.servers')}</span>
<span>{tariff.subscriptions_count} {t('admin.tariffs.subscriptions')}</span>
</div>
</div>
<div className="flex items-center gap-2">
{/* Toggle Active */}
<button
onClick={() => toggleMutation.mutate(tariff.id)}
className={`p-2 rounded-lg transition-colors ${
tariff.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={tariff.is_active ? t('admin.tariffs.deactivate') : t('admin.tariffs.activate')}
>
{tariff.is_active ? <CheckIcon /> : <XIcon />}
</button>
{/* Toggle Trial */}
<button
onClick={() => toggleTrialMutation.mutate(tariff.id)}
className={`p-2 rounded-lg transition-colors ${
tariff.is_trial_available
? 'bg-accent-500/20 text-accent-400 hover:bg-accent-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`}
title={t('admin.tariffs.toggleTrial')}
>
T
</button>
{/* Edit */}
<button
onClick={() => handleEdit(tariff.id)}
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors"
title={t('admin.tariffs.edit')}
>
<EditIcon />
</button>
{/* Delete */}
<button
onClick={() => setDeleteConfirm(tariff.id)}
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-error-500/20 hover:text-error-400 transition-colors"
title={t('admin.tariffs.delete')}
disabled={tariff.subscriptions_count > 0}
>
<TrashIcon />
</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Create/Edit Modal */}
{showModal && (
<TariffModal
tariff={editingTariff}
servers={servers}
onSave={handleSave}
onClose={() => { setShowModal(false); setEditingTariff(null) }}
isLoading={createMutation.isPending || updateMutation.isPending}
/>
)}
{/* 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">{t('admin.tariffs.confirmDelete')}</h3>
<p className="text-dark-400 mb-6">{t('admin.tariffs.confirmDeleteText')}</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"
>
{t('common.cancel')}
</button>
<button
onClick={() => deleteMutation.mutate(deleteConfirm)}
className="px-4 py-2 bg-error-500 text-white rounded-lg hover:bg-error-600 transition-colors"
>
{t('common.delete')}
</button>
</div>
</div>
</div>
)}
</div>
)
}