-
- {isEdit ? t('admin.tariffs.edit') : t('admin.tariffs.create')}
-
+
+
+
+
+
+
+ {isEdit ? 'Редактирование тарифа' : 'Новый периодный тариф'}
+
+
Оплата за период
+
+
@@ -186,7 +250,7 @@ function TariffModal({ tariff, servers, onSave, onClose, isLoading }: TariffModa
{/* Tabs */}
- {(['basic', 'prices', 'servers', 'custom'] as const).map(tab => (
+ {(['basic', 'periods', 'servers', 'extra'] as const).map(tab => (
))}
@@ -210,31 +274,31 @@ function TariffModal({ tariff, servers, onSave, onClose, isLoading }: TariffModa
{/* 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={t('admin.tariffs.namePlaceholder')}
+ placeholder="Например: Стандарт"
/>
{/* Description */}
-
+
{/* Traffic Limit */}
-
+
- GB
+ ГБ
{trafficLimitGb === 0 && (
- {t('admin.tariffs.unlimited')}
+ Безлимит
)}
-
{t('admin.tariffs.trafficHint')}
+
0 = безлимитный трафик
{/* Device Limit */}
-
+
-
+
-
{t('admin.tariffs.tierHint')}
+
Влияет на доступность перехода между тарифами
)}
- {activeTab === 'prices' && (
-
-
{t('admin.tariffs.pricesHint')}
- {DEFAULT_PERIODS.map(days => {
- const price = periodPrices.find(p => p.days === days)?.price_kopeks || 0
- const isEnabled = price > 0
- return (
-
- {/* Toggle */}
-
-
{days} {t('admin.tariffs.days')}
+ {activeTab === 'periods' && (
+
+
+ Добавьте периоды и цены для тарифа. Пользователи смогут выбирать из добавленных периодов.
+
+
+ {/* Add new period */}
+
+
Добавить период
+
+
+
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}
+ value={newPeriodDays}
+ onChange={e => setNewPeriodDays(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}
/>
- ₽
- {!isEnabled && (
- {t('admin.tariffs.periodDisabled')}
- )}
- )
- })}
+
+
+ setNewPeriodPrice(Math.max(1, parseInt(e.target.value) || 1))}
+ className="w-28 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}
+ />
+
+
+
+
+
+ {/* Period list */}
+ {periodPrices.length === 0 ? (
+
+ Нет добавленных периодов. Добавьте хотя бы один период.
+
+ ) : (
+
+ {periodPrices.map(period => (
+
+
{period.days} дн.
+
updatePeriodPrice(period.days, Math.max(0, parseFloat(e.target.value) || 0))}
+ className="w-28 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}
+ />
+
₽
+
+
+
+ ))}
+
+ )}
)}
{activeTab === 'servers' && (
-
{t('admin.tariffs.serversHint')}
+
+ Выберите серверы, доступные на этом тарифе. Можно задать индивидуальный лимит трафика для каждого.
+
{servers.length === 0 ? (
-
{t('admin.tariffs.noServers')}
+
Нет доступных серверов
) : (
servers.map(server => {
const isSelected = selectedSquads.includes(server.squad_uuid)
@@ -373,10 +459,9 @@ function TariffModal({ tariff, servers, onSave, onClose, isLoading }: TariffModa
{server.country_code}
)}
- {/* Лимит трафика для сервера */}
{isSelected && (
- {t('admin.tariffs.serverTrafficLimit')}:
+ Лимит трафика:
- GB
+ ГБ
{serverLimit === 0 && (
- ({t('admin.tariffs.useDefault')})
+ (использовать общий)
)}
)}
@@ -402,46 +487,9 @@ function TariffModal({ tariff, servers, onSave, onClose, isLoading }: TariffModa
)}
- {activeTab === 'custom' && (
+ {activeTab === 'extra' && (
- {/* Дневной тариф */}
-
-
-
-
🌙 Дневной тариф
-
Ежедневное списание с баланса. Можно ставить на паузу.
-
-
-
- {isDaily && (
-
- Цена за день:
- 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}
- />
- ₽/день
-
- )}
-
-
- {/* Цена за доп. устройство */}
+ {/* Докупка устройств */}
Докупка устройств
@@ -472,122 +520,10 @@ function TariffModal({ tariff, servers, onSave, onClose, isLoading }: TariffModa
- {/* Произвольное количество дней */}
-
-
-
Произвольное кол-во дней
-
-
- {customDaysEnabled && (
-
- )}
-
-
- {/* Произвольный трафик */}
-
-
-
Произвольный трафик при покупке
-
-
- {customTrafficEnabled && (
-
- )}
-
-
{/* Докупка трафика */}
-
Докупка трафика (после покупки)
+ Докупка трафика
-
Пакеты трафика (ГБ: цена ₽):
+
Пакеты трафика:
{[5, 10, 20, 50].map(gb => (
@@ -654,14 +590,404 @@ function TariffModal({ tariff, servers, onSave, onClose, isLoading }: TariffModa
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
>
- {t('common.cancel')}
+ Отмена
+
+
+
+ )
+}
+
+// Daily tariff modal
+interface DailyTariffModalProps {
+ tariff?: TariffDetail | null
+ servers: ServerInfo[]
+ onSave: (data: TariffCreateRequest | TariffUpdateRequest) => void
+ onClose: () => void
+ isLoading?: boolean
+}
+
+function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: DailyTariffModalProps) {
+ const isEdit = !!tariff
+
+ const [name, setName] = useState(tariff?.name || '')
+ const [description, setDescription] = useState(tariff?.description || '')
+ const [trafficLimitGb, setTrafficLimitGb] = useState(tariff?.traffic_limit_gb || 100)
+ const [deviceLimit, setDeviceLimit] = useState(tariff?.device_limit || 1)
+ const [devicePriceKopeks, setDevicePriceKopeks] = useState(tariff?.device_price_kopeks || 0)
+ const [maxDeviceLimit, setMaxDeviceLimit] = useState(tariff?.max_device_limit || 0)
+ const [tierLevel, setTierLevel] = useState(tariff?.tier_level || 1)
+ const [dailyPriceKopeks, setDailyPriceKopeks] = useState(tariff?.daily_price_kopeks || 0)
+ const [selectedSquads, setSelectedSquads] = useState
(tariff?.allowed_squads || [])
+ const [serverTrafficLimits, setServerTrafficLimits] = useState>(
+ tariff?.server_traffic_limits || {}
+ )
+ // Докупка трафика
+ const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(tariff?.traffic_topup_enabled || false)
+ const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(tariff?.max_topup_traffic_gb || 0)
+ const [trafficTopupPackages, setTrafficTopupPackages] = useState>(
+ tariff?.traffic_topup_packages || {}
+ )
+
+ const [activeTab, setActiveTab] = useState<'basic' | 'servers' | 'extra'>('basic')
+
+ const handleSubmit = () => {
+ const filteredLimits: Record = {}
+ 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,
+ max_device_limit: maxDeviceLimit > 0 ? maxDeviceLimit : undefined,
+ tier_level: tierLevel,
+ period_prices: [],
+ allowed_squads: selectedSquads,
+ server_traffic_limits: Object.keys(filteredLimits).length > 0 ? filteredLimits : {},
+ traffic_topup_enabled: trafficTopupEnabled,
+ traffic_topup_packages: trafficTopupPackages,
+ max_topup_traffic_gb: maxTopupTrafficGb,
+ is_daily: true,
+ 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]
+ )
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {isEdit ? 'Редактирование тарифа' : 'Новый суточный тариф'}
+
+
Ежедневное списание
+
+
+
+
+
+ {/* Tabs */}
+
+ {(['basic', 'servers', 'extra'] as const).map(tab => (
+
+ ))}
+
+
+ {/* Content */}
+
+ {activeTab === 'basic' && (
+
+ {/* 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-amber-500"
+ placeholder="Например: Суточный"
+ />
+
+
+ {/* Description */}
+
+
+
+
+ {/* Daily Price */}
+
+
+
+ setDailyPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
+ className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-amber-500"
+ min={0}
+ step={0.1}
+ />
+ ₽/день
+
+
Списывается ежедневно с баланса пользователя
+
+
+ {/* Traffic Limit */}
+
+
+
+ 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-amber-500"
+ min={0}
+ />
+ ГБ
+ {trafficLimitGb === 0 && (
+
+
+ Безлимит
+
+ )}
+
+
+
+ {/* Device Limit */}
+
+
+ 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-amber-500"
+ min={1}
+ />
+
+
+ {/* Tier Level */}
+
+
+ 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-amber-500"
+ min={1}
+ max={10}
+ />
+
+
+ )}
+
+ {activeTab === 'servers' && (
+
+
+ Выберите серверы, доступные на этом тарифе.
+
+ {servers.length === 0 ? (
+
Нет доступных серверов
+ ) : (
+ servers.map(server => {
+ const isSelected = selectedSquads.includes(server.squad_uuid)
+ const serverLimit = serverTrafficLimits[server.squad_uuid]?.traffic_limit_gb || 0
+ return (
+
+
toggleServer(server.squad_uuid)}
+ className="flex items-center gap-3 cursor-pointer"
+ >
+
+ {isSelected && }
+
+
{server.display_name}
+ {server.country_code && (
+
{server.country_code}
+ )}
+
+ {isSelected && (
+
+ Лимит трафика:
+ 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-amber-500"
+ min={0}
+ />
+ ГБ
+ {serverLimit === 0 && (
+ (использовать общий)
+ )}
+
+ )}
+
+ )
+ })
+ )}
+
+ )}
+
+ {activeTab === 'extra' && (
+
+ {/* Докупка устройств */}
+
+
Докупка устройств
+
+
+ Цена за устройство (30 дней):
+ 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-amber-500"
+ min={0}
+ step={1}
+ />
+ ₽
+
+
0 = докупка недоступна
+
+ Макс. устройств на тарифе:
+ setMaxDeviceLimit(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-amber-500"
+ min={0}
+ />
+
+
0 = без ограничений
+
+
+
+ {/* Докупка трафика */}
+
+
+
Докупка трафика
+
+
+ {trafficTopupEnabled && (
+
+
+ Макс. лимит:
+ 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-amber-500"
+ min={0}
+ />
+ ГБ
+ (0 = без ограничений)
+
+
+
Пакеты трафика:
+
+ {[5, 10, 20, 50].map(gb => (
+
+ {gb} ГБ:
+ {
+ 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-amber-500"
+ min={0}
+ step={1}
+ />
+ ₽
+
+ ))}
+
+
+
+ )}
+
+
+ )}
+
+
+ {/* Footer */}
+
+
+
@@ -673,7 +999,9 @@ export default function AdminTariffs() {
const { t } = useTranslation()
const queryClient = useQueryClient()
- const [showModal, setShowModal] = useState(false)
+ const [showTypeSelect, setShowTypeSelect] = useState(false)
+ const [showPeriodModal, setShowPeriodModal] = useState(false)
+ const [showDailyModal, setShowDailyModal] = useState(false)
const [editingTariff, setEditingTariff] = useState
(null)
const [deleteConfirm, setDeleteConfirm] = useState(null)
@@ -693,7 +1021,8 @@ export default function AdminTariffs() {
mutationFn: tariffsApi.createTariff,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
- setShowModal(false)
+ setShowPeriodModal(false)
+ setShowDailyModal(false)
},
})
@@ -702,7 +1031,8 @@ export default function AdminTariffs() {
tariffsApi.updateTariff(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-tariffs'] })
- setShowModal(false)
+ setShowPeriodModal(false)
+ setShowDailyModal(false)
setEditingTariff(null)
},
})
@@ -729,11 +1059,25 @@ export default function AdminTariffs() {
},
})
+ const handleTypeSelect = (isDaily: boolean) => {
+ setShowTypeSelect(false)
+ if (isDaily) {
+ setShowDailyModal(true)
+ } else {
+ setShowPeriodModal(true)
+ }
+ }
+
const handleEdit = async (tariffId: number) => {
try {
const detail = await tariffsApi.getTariff(tariffId)
setEditingTariff(detail)
- setShowModal(true)
+ // Открываем соответствующую модалку в зависимости от типа тарифа
+ if (detail.is_daily) {
+ setShowDailyModal(true)
+ } else {
+ setShowPeriodModal(true)
+ }
} catch (error) {
console.error('Failed to load tariff:', error)
}
@@ -747,6 +1091,12 @@ export default function AdminTariffs() {
}
}
+ const handleCloseModal = () => {
+ setShowPeriodModal(false)
+ setShowDailyModal(false)
+ setEditingTariff(null)
+ }
+
const tariffs = tariffsData?.tariffs || []
return (
@@ -763,7 +1113,7 @@ export default function AdminTariffs() {