mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
refactor: align tariff builder with bot functionality
- Remove unused fields: custom_days, custom_traffic (bot doesn't use them) - Add promo groups selection to tariff form - Add API endpoint for fetching available promo groups - Make traffic topup packages dynamic (add/remove any number) - Fix stuck 0 in number inputs when clearing - Add validation messages explaining why save button is blocked - Match bot validation: name 2-50 chars, device_limit >= 1, tier_level 1-10 - Add missing i18n translations for new fields and validation errors
This commit is contained in:
@@ -236,4 +236,10 @@ export const tariffsApi = {
|
|||||||
const response = await apiClient.get('/cabinet/admin/tariffs/available-servers');
|
const response = await apiClient.get('/cabinet/admin/tariffs/available-servers');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get available promo groups for selection
|
||||||
|
getAvailablePromoGroups: async (): Promise<{ id: number; name: string }[]> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/payment-methods/promo-groups');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1251,6 +1251,7 @@
|
|||||||
"nameLabel": "Tariff name",
|
"nameLabel": "Tariff name",
|
||||||
"nameExamplePeriod": "E.g.: Standard",
|
"nameExamplePeriod": "E.g.: Standard",
|
||||||
"nameExampleDaily": "E.g.: Daily",
|
"nameExampleDaily": "E.g.: Daily",
|
||||||
|
"nameHint": "2 to 50 characters",
|
||||||
"descriptionLabel": "Description",
|
"descriptionLabel": "Description",
|
||||||
"descriptionPlaceholder2": "Brief tariff description",
|
"descriptionPlaceholder2": "Brief tariff description",
|
||||||
"trafficLimitLabel": "Traffic limit",
|
"trafficLimitLabel": "Traffic limit",
|
||||||
@@ -1267,8 +1268,12 @@
|
|||||||
"addButton": "Add",
|
"addButton": "Add",
|
||||||
"noPeriodsHint": "No added periods. Add at least one period.",
|
"noPeriodsHint": "No added periods. Add at least one period.",
|
||||||
"daysShort": "d.",
|
"daysShort": "d.",
|
||||||
|
"serversTitle": "Servers",
|
||||||
"serversTabHint": "Select servers available on this tariff.",
|
"serversTabHint": "Select servers available on this tariff.",
|
||||||
"noServersAvailable": "No servers available",
|
"noServersAvailable": "No servers available",
|
||||||
|
"promoGroupsTitle": "Promo Groups",
|
||||||
|
"promoGroupsHint": "Select promo groups that have access to this tariff. If none selected — available to everyone.",
|
||||||
|
"noPromoGroups": "No promo groups",
|
||||||
"extraDeviceTitle": "Additional devices",
|
"extraDeviceTitle": "Additional devices",
|
||||||
"devicePriceLabel": "Device price (30 days):",
|
"devicePriceLabel": "Device price (30 days):",
|
||||||
"devicePriceHint": "0 = device purchase unavailable",
|
"devicePriceHint": "0 = device purchase unavailable",
|
||||||
@@ -1278,6 +1283,8 @@
|
|||||||
"trafficMaxLimitLabel": "Max. limit:",
|
"trafficMaxLimitLabel": "Max. limit:",
|
||||||
"trafficLimitHint2": "(0 = no limits)",
|
"trafficLimitHint2": "(0 = no limits)",
|
||||||
"trafficPackagesLabel": "Traffic packages:",
|
"trafficPackagesLabel": "Traffic packages:",
|
||||||
|
"addPackageTitle": "Add package",
|
||||||
|
"noPackagesHint": "No packages added. Add at least one package.",
|
||||||
"gbPackageUnit": "GB:",
|
"gbPackageUnit": "GB:",
|
||||||
"customDaysTitle": "Custom number of days",
|
"customDaysTitle": "Custom number of days",
|
||||||
"customDaysDesc": "User selects subscription duration",
|
"customDaysDesc": "User selects subscription duration",
|
||||||
@@ -1309,7 +1316,18 @@
|
|||||||
"dailyPriceLabel": "Price per day",
|
"dailyPriceLabel": "Price per day",
|
||||||
"currencyPerDay": "\u20bd/day",
|
"currencyPerDay": "\u20bd/day",
|
||||||
"dailyDeductionDesc": "Charged daily from user's balance",
|
"dailyDeductionDesc": "Charged daily from user's balance",
|
||||||
"dailyPriceRequired": "Enter a price per day greater than 0"
|
"dailyPriceRequired": "Enter a price per day greater than 0",
|
||||||
|
"cannotSave": "Cannot save tariff:",
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "Enter tariff name",
|
||||||
|
"nameLength": "Name must be 2 to 50 characters",
|
||||||
|
"deviceLimitRequired": "Specify number of devices (minimum 1)",
|
||||||
|
"tierLevelInvalid": "Tier level must be between 1 and 10",
|
||||||
|
"fieldsRequired": "Fill in required fields (devices, tier level)",
|
||||||
|
"periodsRequired": "Add at least one period",
|
||||||
|
"dailyPriceRequired": "Enter a price per day greater than 0",
|
||||||
|
"trafficPackagesRequired": "Add at least one traffic package (or disable traffic topup)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"title": "Server Management",
|
"title": "Server Management",
|
||||||
|
|||||||
@@ -1021,6 +1021,7 @@
|
|||||||
"nameLabel": "نام تعرفه",
|
"nameLabel": "نام تعرفه",
|
||||||
"nameExamplePeriod": "مثال: استاندارد",
|
"nameExamplePeriod": "مثال: استاندارد",
|
||||||
"nameExampleDaily": "مثال: روزانه",
|
"nameExampleDaily": "مثال: روزانه",
|
||||||
|
"nameHint": "۲ تا ۵۰ کاراکتر",
|
||||||
"descriptionLabel": "توضیحات",
|
"descriptionLabel": "توضیحات",
|
||||||
"descriptionPlaceholder2": "توضیح مختصر تعرفه",
|
"descriptionPlaceholder2": "توضیح مختصر تعرفه",
|
||||||
"trafficLimitLabel": "محدودیت ترافیک",
|
"trafficLimitLabel": "محدودیت ترافیک",
|
||||||
@@ -1037,8 +1038,12 @@
|
|||||||
"addButton": "افزودن",
|
"addButton": "افزودن",
|
||||||
"noPeriodsHint": "هیچ دورهای اضافه نشده. حداقل یک دوره اضافه کنید.",
|
"noPeriodsHint": "هیچ دورهای اضافه نشده. حداقل یک دوره اضافه کنید.",
|
||||||
"daysShort": "روز",
|
"daysShort": "روز",
|
||||||
|
"serversTitle": "سرورها",
|
||||||
"serversTabHint": "سرورهای موجود در این تعرفه را انتخاب کنید.",
|
"serversTabHint": "سرورهای موجود در این تعرفه را انتخاب کنید.",
|
||||||
"noServersAvailable": "هیچ سروری در دسترس نیست",
|
"noServersAvailable": "هیچ سروری در دسترس نیست",
|
||||||
|
"promoGroupsTitle": "گروههای تبلیغاتی",
|
||||||
|
"promoGroupsHint": "گروههای تبلیغاتی که به این تعرفه دسترسی دارند را انتخاب کنید. اگر هیچکدام انتخاب نشود، همه دسترسی دارند.",
|
||||||
|
"noPromoGroups": "هیچ گروه تبلیغاتی وجود ندارد",
|
||||||
"extraDeviceTitle": "دستگاههای اضافی",
|
"extraDeviceTitle": "دستگاههای اضافی",
|
||||||
"devicePriceLabel": "قیمت دستگاه (30 روز):",
|
"devicePriceLabel": "قیمت دستگاه (30 روز):",
|
||||||
"devicePriceHint": "0 = خرید دستگاه در دسترس نیست",
|
"devicePriceHint": "0 = خرید دستگاه در دسترس نیست",
|
||||||
@@ -1048,6 +1053,8 @@
|
|||||||
"trafficMaxLimitLabel": "حداکثر محدودیت:",
|
"trafficMaxLimitLabel": "حداکثر محدودیت:",
|
||||||
"trafficLimitHint2": "(0 = بدون محدودیت)",
|
"trafficLimitHint2": "(0 = بدون محدودیت)",
|
||||||
"trafficPackagesLabel": "بستههای ترافیک:",
|
"trafficPackagesLabel": "بستههای ترافیک:",
|
||||||
|
"addPackageTitle": "افزودن بسته",
|
||||||
|
"noPackagesHint": "هیچ بستهای اضافه نشده است. حداقل یک بسته اضافه کنید.",
|
||||||
"gbPackageUnit": "GB:",
|
"gbPackageUnit": "GB:",
|
||||||
"customDaysTitle": "تعداد روزهای سفارشی",
|
"customDaysTitle": "تعداد روزهای سفارشی",
|
||||||
"customDaysDesc": "کاربر مدت اشتراک را انتخاب میکند",
|
"customDaysDesc": "کاربر مدت اشتراک را انتخاب میکند",
|
||||||
@@ -1079,7 +1086,18 @@
|
|||||||
"dailyPriceLabel": "قیمت روزانه",
|
"dailyPriceLabel": "قیمت روزانه",
|
||||||
"currencyPerDay": "₽/روز",
|
"currencyPerDay": "₽/روز",
|
||||||
"dailyDeductionDesc": "هر روز از موجودی کاربر کسر میشود",
|
"dailyDeductionDesc": "هر روز از موجودی کاربر کسر میشود",
|
||||||
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید"
|
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید",
|
||||||
|
"cannotSave": "امکان ذخیره تعرفه وجود ندارد:",
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "نام تعرفه را وارد کنید",
|
||||||
|
"nameLength": "نام باید ۲ تا ۵۰ کاراکتر باشد",
|
||||||
|
"deviceLimitRequired": "تعداد دستگاهها را مشخص کنید (حداقل ۱)",
|
||||||
|
"tierLevelInvalid": "سطح تعرفه باید بین ۱ تا ۱۰ باشد",
|
||||||
|
"fieldsRequired": "فیلدهای اجباری را پر کنید (دستگاهها، سطح)",
|
||||||
|
"periodsRequired": "حداقل یک دوره اضافه کنید",
|
||||||
|
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید",
|
||||||
|
"trafficPackagesRequired": "حداقل یک بسته ترافیک اضافه کنید (یا خرید ترافیک را غیرفعال کنید)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"title": "مدیریت سرورها",
|
"title": "مدیریت سرورها",
|
||||||
|
|||||||
@@ -1762,6 +1762,7 @@
|
|||||||
"nameLabel": "Название тарифа",
|
"nameLabel": "Название тарифа",
|
||||||
"nameExamplePeriod": "Например: Стандарт",
|
"nameExamplePeriod": "Например: Стандарт",
|
||||||
"nameExampleDaily": "Например: Суточный",
|
"nameExampleDaily": "Например: Суточный",
|
||||||
|
"nameHint": "От 2 до 50 символов",
|
||||||
"descriptionLabel": "Описание",
|
"descriptionLabel": "Описание",
|
||||||
"descriptionPlaceholder": "Краткое описание тарифа",
|
"descriptionPlaceholder": "Краткое описание тарифа",
|
||||||
"trafficLimitLabel": "Лимит трафика",
|
"trafficLimitLabel": "Лимит трафика",
|
||||||
@@ -1778,8 +1779,12 @@
|
|||||||
"addButton": "Добавить",
|
"addButton": "Добавить",
|
||||||
"noPeriodsHint": "Нет добавленных периодов. Добавьте хотя бы один период.",
|
"noPeriodsHint": "Нет добавленных периодов. Добавьте хотя бы один период.",
|
||||||
"daysShort": "дн.",
|
"daysShort": "дн.",
|
||||||
|
"serversTitle": "Серверы",
|
||||||
"serversTabHint": "Выберите серверы, доступные на этом тарифе.",
|
"serversTabHint": "Выберите серверы, доступные на этом тарифе.",
|
||||||
"noServersAvailable": "Нет доступных серверов",
|
"noServersAvailable": "Нет доступных серверов",
|
||||||
|
"promoGroupsTitle": "Промо-группы",
|
||||||
|
"promoGroupsHint": "Выберите промо-группы, которым доступен этот тариф. Если не выбрано ничего — доступен всем.",
|
||||||
|
"noPromoGroups": "Нет промо-групп",
|
||||||
"extraDeviceTitle": "Докупка устройств",
|
"extraDeviceTitle": "Докупка устройств",
|
||||||
"devicePriceLabel": "Цена за устройство (30 дней):",
|
"devicePriceLabel": "Цена за устройство (30 дней):",
|
||||||
"devicePriceHint": "0 = докупка недоступна",
|
"devicePriceHint": "0 = докупка недоступна",
|
||||||
@@ -1789,6 +1794,8 @@
|
|||||||
"trafficMaxLimitLabel": "Макс. лимит:",
|
"trafficMaxLimitLabel": "Макс. лимит:",
|
||||||
"trafficLimitHint2": "(0 = без ограничений)",
|
"trafficLimitHint2": "(0 = без ограничений)",
|
||||||
"trafficPackagesLabel": "Пакеты трафика:",
|
"trafficPackagesLabel": "Пакеты трафика:",
|
||||||
|
"addPackageTitle": "Добавить пакет",
|
||||||
|
"noPackagesHint": "Нет добавленных пакетов. Добавьте хотя бы один пакет.",
|
||||||
"gbPackageUnit": "ГБ:",
|
"gbPackageUnit": "ГБ:",
|
||||||
"customDaysTitle": "Произвольное количество дней",
|
"customDaysTitle": "Произвольное количество дней",
|
||||||
"customDaysDesc": "Пользователь сам выбирает срок подписки",
|
"customDaysDesc": "Пользователь сам выбирает срок подписки",
|
||||||
@@ -1820,7 +1827,18 @@
|
|||||||
"dailyPriceLabel": "Цена за день",
|
"dailyPriceLabel": "Цена за день",
|
||||||
"currencyPerDay": "₽/день",
|
"currencyPerDay": "₽/день",
|
||||||
"dailyDeductionDesc": "Списывается ежедневно с баланса пользователя",
|
"dailyDeductionDesc": "Списывается ежедневно с баланса пользователя",
|
||||||
"dailyPriceRequired": "Укажите цену за день больше 0"
|
"dailyPriceRequired": "Укажите цену за день больше 0",
|
||||||
|
"cannotSave": "Невозможно сохранить тариф:",
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "Введите название тарифа",
|
||||||
|
"nameLength": "Название должно быть от 2 до 50 символов",
|
||||||
|
"deviceLimitRequired": "Укажите количество устройств (минимум 1)",
|
||||||
|
"tierLevelInvalid": "Уровень тарифа должен быть от 1 до 10",
|
||||||
|
"fieldsRequired": "Заполните обязательные поля (устройства, уровень)",
|
||||||
|
"periodsRequired": "Добавьте хотя бы один период",
|
||||||
|
"dailyPriceRequired": "Укажите цену за день больше 0",
|
||||||
|
"trafficPackagesRequired": "Добавьте хотя бы один пакет трафика (или отключите докупку)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"title": "Управление серверами",
|
"title": "Управление серверами",
|
||||||
|
|||||||
@@ -1059,6 +1059,7 @@
|
|||||||
"nameLabel": "套餐名称",
|
"nameLabel": "套餐名称",
|
||||||
"nameExamplePeriod": "例如:标准",
|
"nameExamplePeriod": "例如:标准",
|
||||||
"nameExampleDaily": "例如:日套餐",
|
"nameExampleDaily": "例如:日套餐",
|
||||||
|
"nameHint": "2到50个字符",
|
||||||
"descriptionLabel": "描述",
|
"descriptionLabel": "描述",
|
||||||
"trafficLimitLabel": "流量限制",
|
"trafficLimitLabel": "流量限制",
|
||||||
"gbUnit": "GB",
|
"gbUnit": "GB",
|
||||||
@@ -1074,8 +1075,12 @@
|
|||||||
"addButton": "添加",
|
"addButton": "添加",
|
||||||
"noPeriodsHint": "没有添加的周期。请至少添加一个周期。",
|
"noPeriodsHint": "没有添加的周期。请至少添加一个周期。",
|
||||||
"daysShort": "天",
|
"daysShort": "天",
|
||||||
|
"serversTitle": "服务器",
|
||||||
"serversTabHint": "选择此套餐上可用的服务器。",
|
"serversTabHint": "选择此套餐上可用的服务器。",
|
||||||
"noServersAvailable": "没有可用的服务器",
|
"noServersAvailable": "没有可用的服务器",
|
||||||
|
"promoGroupsTitle": "促销组",
|
||||||
|
"promoGroupsHint": "选择可以访问此套餐的促销组。如果未选择任何组,则所有人都可以访问。",
|
||||||
|
"noPromoGroups": "没有促销组",
|
||||||
"extraDeviceTitle": "额外设备",
|
"extraDeviceTitle": "额外设备",
|
||||||
"devicePriceLabel": "设备价格(30天):",
|
"devicePriceLabel": "设备价格(30天):",
|
||||||
"devicePriceHint": "0 = 无法购买设备",
|
"devicePriceHint": "0 = 无法购买设备",
|
||||||
@@ -1085,6 +1090,8 @@
|
|||||||
"trafficMaxLimitLabel": "最大限制:",
|
"trafficMaxLimitLabel": "最大限制:",
|
||||||
"trafficLimitHint2": "(0 = 无限制)",
|
"trafficLimitHint2": "(0 = 无限制)",
|
||||||
"trafficPackagesLabel": "流量包:",
|
"trafficPackagesLabel": "流量包:",
|
||||||
|
"addPackageTitle": "添加流量包",
|
||||||
|
"noPackagesHint": "没有添加流量包。请至少添加一个流量包。",
|
||||||
"gbPackageUnit": "GB:",
|
"gbPackageUnit": "GB:",
|
||||||
"customDaysTitle": "自定义天数",
|
"customDaysTitle": "自定义天数",
|
||||||
"customDaysDesc": "用户自行选择订阅期限",
|
"customDaysDesc": "用户自行选择订阅期限",
|
||||||
@@ -1116,7 +1123,18 @@
|
|||||||
"dailyPriceLabel": "每天价格",
|
"dailyPriceLabel": "每天价格",
|
||||||
"currencyPerDay": "₽/天",
|
"currencyPerDay": "₽/天",
|
||||||
"dailyDeductionDesc": "每天从用户余额中扣除",
|
"dailyDeductionDesc": "每天从用户余额中扣除",
|
||||||
"dailyPriceRequired": "请输入大于0的每日价格"
|
"dailyPriceRequired": "请输入大于0的每日价格",
|
||||||
|
"cannotSave": "无法保存套餐:",
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "请输入套餐名称",
|
||||||
|
"nameLength": "名称必须为2到50个字符",
|
||||||
|
"deviceLimitRequired": "请指定设备数量(至少1个)",
|
||||||
|
"tierLevelInvalid": "等级必须在1到10之间",
|
||||||
|
"fieldsRequired": "请填写必填字段(设备数、等级)",
|
||||||
|
"periodsRequired": "请添加至少一个期限",
|
||||||
|
"dailyPriceRequired": "请输入大于0的每日价格",
|
||||||
|
"trafficPackagesRequired": "请添加至少一个流量包(或关闭流量购买)"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"title": "服务器管理",
|
"title": "服务器管理",
|
||||||
|
|||||||
@@ -94,16 +94,17 @@ export default function AdminTariffCreate() {
|
|||||||
// Step: null = type selection, 'period' or 'daily' = form
|
// Step: null = type selection, 'period' or 'daily' = form
|
||||||
const [tariffType, setTariffType] = useState<TariffType>(null);
|
const [tariffType, setTariffType] = useState<TariffType>(null);
|
||||||
|
|
||||||
// Form state
|
// Form state - matches bot fields
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [trafficLimitGb, setTrafficLimitGb] = useState<number | ''>('');
|
const [trafficLimitGb, setTrafficLimitGb] = useState<number | ''>(100);
|
||||||
const [deviceLimit, setDeviceLimit] = useState<number | ''>(1);
|
const [deviceLimit, setDeviceLimit] = useState<number | ''>(1);
|
||||||
const [devicePriceKopeks, setDevicePriceKopeks] = useState<number | ''>(0);
|
const [devicePriceKopeks, setDevicePriceKopeks] = useState<number | ''>(0);
|
||||||
const [maxDeviceLimit, setMaxDeviceLimit] = useState<number | ''>(0);
|
const [maxDeviceLimit, setMaxDeviceLimit] = useState<number | ''>(0);
|
||||||
const [tierLevel, setTierLevel] = useState<number | ''>(1);
|
const [tierLevel, setTierLevel] = useState<number | ''>(1);
|
||||||
const [periodPrices, setPeriodPrices] = useState<PeriodPrice[]>([]);
|
const [periodPrices, setPeriodPrices] = useState<PeriodPrice[]>([]);
|
||||||
const [selectedSquads, setSelectedSquads] = useState<string[]>([]);
|
const [selectedSquads, setSelectedSquads] = useState<string[]>([]);
|
||||||
|
const [selectedPromoGroups, setSelectedPromoGroups] = useState<number[]>([]);
|
||||||
const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
|
const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
|
||||||
|
|
||||||
// Traffic topup
|
// Traffic topup
|
||||||
@@ -111,25 +112,23 @@ export default function AdminTariffCreate() {
|
|||||||
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState<number | ''>(0);
|
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState<number | ''>(0);
|
||||||
const [trafficTopupPackages, setTrafficTopupPackages] = useState<Record<string, number>>({});
|
const [trafficTopupPackages, setTrafficTopupPackages] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
// New traffic package for adding
|
||||||
|
const [newPackageGb, setNewPackageGb] = useState<number | ''>(10);
|
||||||
|
const [newPackagePrice, setNewPackagePrice] = useState<number | ''>(100);
|
||||||
|
|
||||||
|
// Track editing state for traffic package prices
|
||||||
|
const [editingPackagePrices, setEditingPackagePrices] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
// Traffic reset mode
|
// Traffic reset mode
|
||||||
const [trafficResetMode, setTrafficResetMode] = useState<string | null>(null);
|
const [trafficResetMode, setTrafficResetMode] = useState<string | null>(null);
|
||||||
|
|
||||||
// Custom days (period tariff)
|
|
||||||
const [customDaysEnabled, setCustomDaysEnabled] = useState(false);
|
|
||||||
const [pricePerDayKopeks, setPricePerDayKopeks] = useState<number | ''>(0);
|
|
||||||
const [minDays, setMinDays] = useState<number | ''>(1);
|
|
||||||
const [maxDays, setMaxDays] = useState<number | ''>(365);
|
|
||||||
|
|
||||||
// Custom traffic (period tariff)
|
|
||||||
const [customTrafficEnabled, setCustomTrafficEnabled] = useState(false);
|
|
||||||
const [trafficPricePerGbKopeks, setTrafficPricePerGbKopeks] = useState<number | ''>(0);
|
|
||||||
const [minTrafficGb, setMinTrafficGb] = useState<number | ''>(1);
|
|
||||||
const [maxTrafficGb, setMaxTrafficGb] = useState<number | ''>(1000);
|
|
||||||
|
|
||||||
// New period for adding
|
// New period for adding
|
||||||
const [newPeriodDays, setNewPeriodDays] = useState<number | ''>(30);
|
const [newPeriodDays, setNewPeriodDays] = useState<number | ''>(30);
|
||||||
const [newPeriodPrice, setNewPeriodPrice] = useState<number | ''>(300);
|
const [newPeriodPrice, setNewPeriodPrice] = useState<number | ''>(300);
|
||||||
|
|
||||||
|
// Track editing state for period prices
|
||||||
|
const [editingPeriodPrices, setEditingPeriodPrices] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'basic' | 'periods' | 'servers' | 'extra'>('basic');
|
const [activeTab, setActiveTab] = useState<'basic' | 'periods' | 'servers' | 'extra'>('basic');
|
||||||
|
|
||||||
// Fetch servers
|
// Fetch servers
|
||||||
@@ -138,6 +137,12 @@ export default function AdminTariffCreate() {
|
|||||||
queryFn: () => tariffsApi.getAvailableServers(),
|
queryFn: () => tariffsApi.getAvailableServers(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch promo groups
|
||||||
|
const { data: promoGroups = [] } = useQuery({
|
||||||
|
queryKey: ['admin-tariffs-promo-groups'],
|
||||||
|
queryFn: () => tariffsApi.getAvailablePromoGroups(),
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch tariff for editing
|
// Fetch tariff for editing
|
||||||
const { isLoading: isLoadingTariff } = useQuery({
|
const { isLoading: isLoadingTariff } = useQuery({
|
||||||
queryKey: ['admin-tariff', id],
|
queryKey: ['admin-tariff', id],
|
||||||
@@ -158,19 +163,14 @@ export default function AdminTariffCreate() {
|
|||||||
setTierLevel(data.tier_level || 1);
|
setTierLevel(data.tier_level || 1);
|
||||||
setPeriodPrices(data.period_prices?.length ? data.period_prices : []);
|
setPeriodPrices(data.period_prices?.length ? data.period_prices : []);
|
||||||
setSelectedSquads(data.allowed_squads || []);
|
setSelectedSquads(data.allowed_squads || []);
|
||||||
|
setSelectedPromoGroups(
|
||||||
|
data.promo_groups?.filter((pg) => pg.is_selected).map((pg) => pg.id) || [],
|
||||||
|
);
|
||||||
setDailyPriceKopeks(data.daily_price_kopeks || 0);
|
setDailyPriceKopeks(data.daily_price_kopeks || 0);
|
||||||
setTrafficTopupEnabled(data.traffic_topup_enabled || false);
|
setTrafficTopupEnabled(data.traffic_topup_enabled || false);
|
||||||
setMaxTopupTrafficGb(data.max_topup_traffic_gb || 0);
|
setMaxTopupTrafficGb(data.max_topup_traffic_gb || 0);
|
||||||
setTrafficTopupPackages(data.traffic_topup_packages || {});
|
setTrafficTopupPackages(data.traffic_topup_packages || {});
|
||||||
setTrafficResetMode(data.traffic_reset_mode || null);
|
setTrafficResetMode(data.traffic_reset_mode || null);
|
||||||
setCustomDaysEnabled(data.custom_days_enabled || false);
|
|
||||||
setPricePerDayKopeks(data.price_per_day_kopeks || 0);
|
|
||||||
setMinDays(data.min_days || 1);
|
|
||||||
setMaxDays(data.max_days || 365);
|
|
||||||
setCustomTrafficEnabled(data.custom_traffic_enabled || false);
|
|
||||||
setTrafficPricePerGbKopeks(data.traffic_price_per_gb_kopeks || 0);
|
|
||||||
setMinTrafficGb(data.min_traffic_gb || 1);
|
|
||||||
setMaxTrafficGb(data.max_traffic_gb || 1000);
|
|
||||||
return data;
|
return data;
|
||||||
}, []),
|
}, []),
|
||||||
});
|
});
|
||||||
@@ -200,7 +200,7 @@ export default function AdminTariffCreate() {
|
|||||||
const data: TariffCreateRequest | TariffUpdateRequest = {
|
const data: TariffCreateRequest | TariffUpdateRequest = {
|
||||||
name,
|
name,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
traffic_limit_gb: toNumber(trafficLimitGb, 0),
|
traffic_limit_gb: toNumber(trafficLimitGb, 100),
|
||||||
device_limit: toNumber(deviceLimit, 1),
|
device_limit: toNumber(deviceLimit, 1),
|
||||||
device_price_kopeks:
|
device_price_kopeks:
|
||||||
toNumber(devicePriceKopeks) > 0 ? toNumber(devicePriceKopeks) : undefined,
|
toNumber(devicePriceKopeks) > 0 ? toNumber(devicePriceKopeks) : undefined,
|
||||||
@@ -208,6 +208,7 @@ export default function AdminTariffCreate() {
|
|||||||
tier_level: toNumber(tierLevel, 1),
|
tier_level: toNumber(tierLevel, 1),
|
||||||
period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0),
|
period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0),
|
||||||
allowed_squads: selectedSquads,
|
allowed_squads: selectedSquads,
|
||||||
|
promo_group_ids: selectedPromoGroups.length > 0 ? selectedPromoGroups : undefined,
|
||||||
traffic_topup_enabled: trafficTopupEnabled,
|
traffic_topup_enabled: trafficTopupEnabled,
|
||||||
traffic_topup_packages: trafficTopupPackages,
|
traffic_topup_packages: trafficTopupPackages,
|
||||||
max_topup_traffic_gb: toNumber(maxTopupTrafficGb),
|
max_topup_traffic_gb: toNumber(maxTopupTrafficGb),
|
||||||
@@ -216,17 +217,6 @@ export default function AdminTariffCreate() {
|
|||||||
traffic_reset_mode: trafficResetMode,
|
traffic_reset_mode: trafficResetMode,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isDaily) {
|
|
||||||
data.custom_days_enabled = customDaysEnabled;
|
|
||||||
data.price_per_day_kopeks = toNumber(pricePerDayKopeks);
|
|
||||||
data.min_days = toNumber(minDays, 1);
|
|
||||||
data.max_days = toNumber(maxDays, 365);
|
|
||||||
data.custom_traffic_enabled = customTrafficEnabled;
|
|
||||||
data.traffic_price_per_gb_kopeks = toNumber(trafficPricePerGbKopeks);
|
|
||||||
data.min_traffic_gb = toNumber(minTrafficGb, 1);
|
|
||||||
data.max_traffic_gb = toNumber(maxTrafficGb, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
updateMutation.mutate({ id: Number(id), data });
|
updateMutation.mutate({ id: Number(id), data });
|
||||||
} else {
|
} else {
|
||||||
@@ -240,6 +230,12 @@ export default function AdminTariffCreate() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const togglePromoGroup = (groupId: number) => {
|
||||||
|
setSelectedPromoGroups((prev) =>
|
||||||
|
prev.includes(groupId) ? prev.filter((id) => id !== groupId) : [...prev, groupId],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const addPeriod = () => {
|
const addPeriod = () => {
|
||||||
const days = toNumber(newPeriodDays, 0);
|
const days = toNumber(newPeriodDays, 0);
|
||||||
const price = toNumber(newPeriodPrice, 0);
|
const price = toNumber(newPeriodPrice, 0);
|
||||||
@@ -267,13 +263,48 @@ export default function AdminTariffCreate() {
|
|||||||
|
|
||||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
// Validate required numeric fields are not empty
|
// Validation like bot: name 2-50 chars, device_limit >= 1, tier_level 1-10
|
||||||
const hasRequiredFields = deviceLimit !== '' && tierLevel !== '';
|
const isNameValid = name.length >= 2 && name.length <= 50;
|
||||||
const isValidPeriod = name && hasRequiredFields && (periodPrices.length > 0 || customDaysEnabled);
|
const isDeviceLimitValid = deviceLimit !== '' && toNumber(deviceLimit) >= 1;
|
||||||
const isValidDaily = name && hasRequiredFields && toNumber(dailyPriceKopeks) > 0;
|
const isTierLevelValid =
|
||||||
|
tierLevel !== '' && toNumber(tierLevel) >= 1 && toNumber(tierLevel) <= 10;
|
||||||
|
const hasTrafficPackages = !trafficTopupEnabled || Object.keys(trafficTopupPackages).length > 0;
|
||||||
|
const isValidPeriod =
|
||||||
|
isNameValid &&
|
||||||
|
isDeviceLimitValid &&
|
||||||
|
isTierLevelValid &&
|
||||||
|
periodPrices.length > 0 &&
|
||||||
|
hasTrafficPackages;
|
||||||
|
const isValidDaily =
|
||||||
|
isNameValid &&
|
||||||
|
isDeviceLimitValid &&
|
||||||
|
isTierLevelValid &&
|
||||||
|
toNumber(dailyPriceKopeks) > 0 &&
|
||||||
|
hasTrafficPackages;
|
||||||
const isValid =
|
const isValid =
|
||||||
tariffType === 'period' ? isValidPeriod : tariffType === 'daily' ? isValidDaily : false;
|
tariffType === 'period' ? isValidPeriod : tariffType === 'daily' ? isValidDaily : false;
|
||||||
|
|
||||||
|
// Collect validation errors for display
|
||||||
|
const validationErrors: string[] = [];
|
||||||
|
if (!isNameValid) {
|
||||||
|
if (name.length === 0) {
|
||||||
|
validationErrors.push('nameRequired');
|
||||||
|
} else if (name.length < 2 || name.length > 50) {
|
||||||
|
validationErrors.push('nameLength');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isDeviceLimitValid) validationErrors.push('deviceLimitRequired');
|
||||||
|
if (!isTierLevelValid) validationErrors.push('tierLevelInvalid');
|
||||||
|
if (tariffType === 'period' && periodPrices.length === 0) {
|
||||||
|
validationErrors.push('periodsRequired');
|
||||||
|
}
|
||||||
|
if (tariffType === 'daily' && toNumber(dailyPriceKopeks) === 0) {
|
||||||
|
validationErrors.push('dailyPriceRequired');
|
||||||
|
}
|
||||||
|
if (trafficTopupEnabled && Object.keys(trafficTopupPackages).length === 0) {
|
||||||
|
validationErrors.push('trafficPackagesRequired');
|
||||||
|
}
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isEdit && isLoadingTariff) {
|
if (isEdit && isLoadingTariff) {
|
||||||
return (
|
return (
|
||||||
@@ -394,16 +425,24 @@ export default function AdminTariffCreate() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
{t('admin.tariffs.nameLabel')}
|
{t('admin.tariffs.nameLabel')}
|
||||||
|
<span className="text-error-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
className="input"
|
className={`input ${!isNameValid && name.length > 0 ? 'border-error-500/50' : ''}`}
|
||||||
placeholder={
|
placeholder={
|
||||||
isDaily ? t('admin.tariffs.nameExampleDaily') : t('admin.tariffs.nameExamplePeriod')
|
isDaily ? t('admin.tariffs.nameExampleDaily') : t('admin.tariffs.nameExamplePeriod')
|
||||||
}
|
}
|
||||||
|
maxLength={50}
|
||||||
/>
|
/>
|
||||||
|
<p className="mt-1 text-xs text-dark-500">{t('admin.tariffs.nameHint')}</p>
|
||||||
|
{name.length > 0 && (name.length < 2 || name.length > 50) && (
|
||||||
|
<p className="mt-1 text-xs text-error-400">
|
||||||
|
{t('admin.tariffs.validation.nameLength')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
@@ -442,16 +481,11 @@ export default function AdminTariffCreate() {
|
|||||||
className={`input w-32 ${dailyPriceKopeks === '' || dailyPriceKopeks === 0 ? 'border-error-500/50' : ''}`}
|
className={`input w-32 ${dailyPriceKopeks === '' || dailyPriceKopeks === 0 ? 'border-error-500/50' : ''}`}
|
||||||
min={0}
|
min={0}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
placeholder="0.00"
|
placeholder="50"
|
||||||
/>
|
/>
|
||||||
<span className="text-dark-400">{t('admin.tariffs.currencyPerDay')}</span>
|
<span className="text-dark-400">{t('admin.tariffs.currencyPerDay')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-dark-500">{t('admin.tariffs.dailyDeductionDesc')}</p>
|
<p className="mt-2 text-xs text-dark-500">{t('admin.tariffs.dailyDeductionDesc')}</p>
|
||||||
{(dailyPriceKopeks === '' || dailyPriceKopeks === 0) && (
|
|
||||||
<p className="mt-1 text-xs text-error-400">
|
|
||||||
{t('admin.tariffs.dailyPriceRequired')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -467,7 +501,7 @@ export default function AdminTariffCreate() {
|
|||||||
onChange={createNumberInputHandler(setTrafficLimitGb, 0)}
|
onChange={createNumberInputHandler(setTrafficLimitGb, 0)}
|
||||||
className="input w-32"
|
className="input w-32"
|
||||||
min={0}
|
min={0}
|
||||||
placeholder="0"
|
placeholder="100"
|
||||||
/>
|
/>
|
||||||
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span>
|
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span>
|
||||||
{(trafficLimitGb === 0 || trafficLimitGb === '') && (
|
{(trafficLimitGb === 0 || trafficLimitGb === '') && (
|
||||||
@@ -490,13 +524,10 @@ export default function AdminTariffCreate() {
|
|||||||
type="number"
|
type="number"
|
||||||
value={deviceLimit}
|
value={deviceLimit}
|
||||||
onChange={createNumberInputHandler(setDeviceLimit, 1)}
|
onChange={createNumberInputHandler(setDeviceLimit, 1)}
|
||||||
className={`input w-32 ${deviceLimit === '' ? 'border-error-500/50' : ''}`}
|
className={`input w-32 ${!isDeviceLimitValid ? 'border-error-500/50' : ''}`}
|
||||||
min={1}
|
min={1}
|
||||||
placeholder="1"
|
placeholder="1"
|
||||||
/>
|
/>
|
||||||
{deviceLimit === '' && (
|
|
||||||
<p className="mt-1 text-xs text-error-400">{t('admin.tariffs.fieldRequired')}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tier Level */}
|
{/* Tier Level */}
|
||||||
@@ -509,15 +540,12 @@ export default function AdminTariffCreate() {
|
|||||||
type="number"
|
type="number"
|
||||||
value={tierLevel}
|
value={tierLevel}
|
||||||
onChange={createNumberInputHandler(setTierLevel, 1, 10)}
|
onChange={createNumberInputHandler(setTierLevel, 1, 10)}
|
||||||
className={`input w-32 ${tierLevel === '' ? 'border-error-500/50' : ''}`}
|
className={`input w-32 ${!isTierLevelValid ? 'border-error-500/50' : ''}`}
|
||||||
min={1}
|
min={1}
|
||||||
max={10}
|
max={10}
|
||||||
placeholder="1"
|
placeholder="1"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-dark-500">{t('admin.tariffs.tierLevelHint')}</p>
|
<p className="mt-1 text-xs text-dark-500">{t('admin.tariffs.tierLevelHint')}</p>
|
||||||
{tierLevel === '' && (
|
|
||||||
<p className="mt-1 text-xs text-error-400">{t('admin.tariffs.fieldRequired')}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -541,7 +569,6 @@ export default function AdminTariffCreate() {
|
|||||||
value={newPeriodDays}
|
value={newPeriodDays}
|
||||||
onChange={createNumberInputHandler(setNewPeriodDays, 1)}
|
onChange={createNumberInputHandler(setNewPeriodDays, 1)}
|
||||||
className="input w-24"
|
className="input w-24"
|
||||||
min={1}
|
|
||||||
placeholder="30"
|
placeholder="30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -554,7 +581,6 @@ export default function AdminTariffCreate() {
|
|||||||
value={newPeriodPrice}
|
value={newPeriodPrice}
|
||||||
onChange={createNumberInputHandler(setNewPeriodPrice, 1)}
|
onChange={createNumberInputHandler(setNewPeriodPrice, 1)}
|
||||||
className="input w-28"
|
className="input w-28"
|
||||||
min={1}
|
|
||||||
placeholder="300"
|
placeholder="300"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -584,12 +610,33 @@ export default function AdminTariffCreate() {
|
|||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={period.price_kopeks / 100}
|
value={
|
||||||
onChange={(e) =>
|
editingPeriodPrices[period.days] !== undefined
|
||||||
updatePeriodPrice(period.days, Math.max(0, parseFloat(e.target.value) || 0))
|
? editingPeriodPrices[period.days]
|
||||||
|
: period.price_kopeks / 100
|
||||||
}
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setEditingPeriodPrices((prev) => ({ ...prev, [period.days]: val }));
|
||||||
|
if (val !== '') {
|
||||||
|
const num = parseFloat(val);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
updatePeriodPrice(period.days, Math.max(0, num));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === '') {
|
||||||
|
updatePeriodPrice(period.days, 0);
|
||||||
|
}
|
||||||
|
setEditingPeriodPrices((prev) => {
|
||||||
|
const copy = { ...prev };
|
||||||
|
delete copy[period.days];
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}}
|
||||||
className="input w-28"
|
className="input w-28"
|
||||||
min={0}
|
|
||||||
step={1}
|
step={1}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
@@ -609,14 +656,17 @@ export default function AdminTariffCreate() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'servers' && (
|
{activeTab === 'servers' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Servers */}
|
||||||
<div className="card space-y-4">
|
<div className="card space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-dark-200">{t('admin.tariffs.serversTitle')}</h4>
|
||||||
<p className="text-sm text-dark-400">{t('admin.tariffs.serversTabHint')}</p>
|
<p className="text-sm text-dark-400">{t('admin.tariffs.serversTabHint')}</p>
|
||||||
{servers.length === 0 ? (
|
{servers.length === 0 ? (
|
||||||
<p className="py-4 text-center text-dark-500">
|
<p className="py-4 text-center text-dark-500">
|
||||||
{t('admin.tariffs.noServersAvailable')}
|
{t('admin.tariffs.noServersAvailable')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-64 space-y-2 overflow-y-auto">
|
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||||
{servers.map((server: ServerInfo) => {
|
{servers.map((server: ServerInfo) => {
|
||||||
const isSelected = selectedSquads.includes(server.squad_uuid);
|
const isSelected = selectedSquads.includes(server.squad_uuid);
|
||||||
return (
|
return (
|
||||||
@@ -653,6 +703,51 @@ export default function AdminTariffCreate() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Promo Groups */}
|
||||||
|
<div className="card space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.tariffs.promoGroupsTitle')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-dark-400">{t('admin.tariffs.promoGroupsHint')}</p>
|
||||||
|
{promoGroups.length === 0 ? (
|
||||||
|
<p className="py-4 text-center text-dark-500">{t('admin.tariffs.noPromoGroups')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||||
|
{promoGroups.map((group) => {
|
||||||
|
const isSelected = selectedPromoGroups.includes(group.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => togglePromoGroup(group.id)}
|
||||||
|
className={`flex w-full items-center gap-3 rounded-lg p-3 text-left transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? isDaily
|
||||||
|
? 'bg-warning-500/20 text-warning-300'
|
||||||
|
: 'bg-accent-500/20 text-accent-300'
|
||||||
|
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-5 w-5 items-center justify-center rounded ${
|
||||||
|
isSelected
|
||||||
|
? isDaily
|
||||||
|
? 'bg-warning-500 text-white'
|
||||||
|
: 'bg-accent-500 text-white'
|
||||||
|
: 'bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected && <CheckIcon />}
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 text-sm font-medium">{group.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'extra' && (
|
{activeTab === 'extra' && (
|
||||||
@@ -737,200 +832,146 @@ export default function AdminTariffCreate() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span>
|
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Add new package */}
|
||||||
|
<div className="rounded-lg border border-dashed border-dark-600 bg-dark-800/50 p-3">
|
||||||
|
<h5 className="mb-2 text-xs font-medium text-dark-400">
|
||||||
|
{t('admin.tariffs.addPackageTitle')}
|
||||||
|
</h5>
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-dark-500">
|
||||||
|
{t('admin.tariffs.gbUnit')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={newPackageGb}
|
||||||
|
onChange={createNumberInputHandler(setNewPackageGb, 1)}
|
||||||
|
className="input w-20"
|
||||||
|
placeholder="10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-dark-500">
|
||||||
|
{t('admin.tariffs.priceLabel')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={newPackagePrice}
|
||||||
|
onChange={createNumberInputHandler(setNewPackagePrice, 1)}
|
||||||
|
className="input w-24"
|
||||||
|
placeholder="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const gb = toNumber(newPackageGb, 0);
|
||||||
|
const price = toNumber(newPackagePrice, 0);
|
||||||
|
if (gb > 0 && price > 0 && !trafficTopupPackages[String(gb)]) {
|
||||||
|
setTrafficTopupPackages((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[String(gb)]: price * 100,
|
||||||
|
}));
|
||||||
|
setNewPackageGb(10);
|
||||||
|
setNewPackagePrice(100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
newPackageGb === '' ||
|
||||||
|
newPackagePrice === '' ||
|
||||||
|
!!trafficTopupPackages[String(newPackageGb)]
|
||||||
|
}
|
||||||
|
className="btn-primary flex items-center gap-1 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
{t('admin.tariffs.addButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Package list */}
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-dark-400">
|
<span className="text-sm text-dark-400">
|
||||||
{t('admin.tariffs.trafficPackagesLabel')}
|
{t('admin.tariffs.trafficPackagesLabel')}
|
||||||
</span>
|
</span>
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
{Object.keys(trafficTopupPackages).length === 0 ? (
|
||||||
{[5, 10, 20, 50].map((gb) => (
|
<div className="mt-2 py-4 text-center text-sm text-dark-500">
|
||||||
<div key={gb} className="flex items-center gap-2">
|
{t('admin.tariffs.noPackagesHint')}
|
||||||
<span className="w-12 text-sm text-dark-300">
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{Object.entries(trafficTopupPackages)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.map(([gb, priceKopeks]) => (
|
||||||
|
<div
|
||||||
|
key={gb}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-dark-800 p-2"
|
||||||
|
>
|
||||||
|
<span className="w-16 text-sm font-medium text-dark-300">
|
||||||
{gb} {t('admin.tariffs.gbPackageUnit')}
|
{gb} {t('admin.tariffs.gbPackageUnit')}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={(trafficTopupPackages[String(gb)] || 0) / 100}
|
value={
|
||||||
|
editingPackagePrices[gb] !== undefined
|
||||||
|
? editingPackagePrices[gb]
|
||||||
|
: priceKopeks / 100
|
||||||
|
}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const price = Math.max(0, parseFloat(e.target.value) || 0) * 100;
|
const val = e.target.value;
|
||||||
|
setEditingPackagePrices((prev) => ({ ...prev, [gb]: val }));
|
||||||
|
if (val !== '') {
|
||||||
|
const num = parseFloat(val);
|
||||||
|
if (!isNaN(num)) {
|
||||||
setTrafficTopupPackages((prev) => ({
|
setTrafficTopupPackages((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[String(gb)]: price,
|
[gb]: Math.max(0, num) * 100,
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
className="input w-20"
|
onBlur={(e) => {
|
||||||
min={0}
|
const val = e.target.value;
|
||||||
|
if (val === '') {
|
||||||
|
setTrafficTopupPackages((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[gb]: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setEditingPackagePrices((prev) => {
|
||||||
|
const copy = { ...prev };
|
||||||
|
delete copy[gb];
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="input w-24"
|
||||||
step={1}
|
step={1}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-dark-400">₽</span>
|
<span className="text-xs text-dark-400">₽</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTrafficTopupPackages((prev) => {
|
||||||
|
const copy = { ...prev };
|
||||||
|
delete copy[gb];
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-error-500/20 hover:text-error-400"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Custom days (period only) */}
|
|
||||||
{!isDaily && (
|
|
||||||
<div className="card space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-dark-200">
|
|
||||||
{t('admin.tariffs.customDaysTitle')}
|
|
||||||
</h4>
|
|
||||||
<p className="mt-1 text-xs text-dark-500">{t('admin.tariffs.customDaysDesc')}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCustomDaysEnabled(!customDaysEnabled)}
|
|
||||||
className={`relative h-6 w-11 rounded-full transition-colors ${
|
|
||||||
customDaysEnabled ? 'bg-accent-500' : 'bg-dark-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
|
||||||
customDaysEnabled ? 'left-6' : 'left-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{customDaysEnabled && (
|
|
||||||
<div className="space-y-3 border-t border-dark-700 pt-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="w-32 text-sm text-dark-400">
|
|
||||||
{t('admin.tariffs.pricePerDayLabel')}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={pricePerDayKopeks === '' ? '' : pricePerDayKopeks / 100}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
if (val === '') {
|
|
||||||
setPricePerDayKopeks('');
|
|
||||||
} else {
|
|
||||||
setPricePerDayKopeks(Math.max(0, parseFloat(val) || 0) * 100);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="input w-24"
|
|
||||||
min={0}
|
|
||||||
step={0.1}
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
<span className="text-dark-400">₽</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="w-32 text-sm text-dark-400">
|
|
||||||
{t('admin.tariffs.minDaysLabel')}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={minDays}
|
|
||||||
onChange={createNumberInputHandler(setMinDays, 1)}
|
|
||||||
className="input w-24"
|
|
||||||
min={1}
|
|
||||||
placeholder="1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="w-32 text-sm text-dark-400">
|
|
||||||
{t('admin.tariffs.maxDaysLabel')}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={maxDays}
|
|
||||||
onChange={createNumberInputHandler(setMaxDays, 1)}
|
|
||||||
className="input w-24"
|
|
||||||
min={1}
|
|
||||||
placeholder="365"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Custom traffic (period only) */}
|
|
||||||
{!isDaily && (
|
|
||||||
<div className="card space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-dark-200">
|
|
||||||
{t('admin.tariffs.customTrafficTitle')}
|
|
||||||
</h4>
|
|
||||||
<p className="mt-1 text-xs text-dark-500">
|
|
||||||
{t('admin.tariffs.customTrafficDesc')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCustomTrafficEnabled(!customTrafficEnabled)}
|
|
||||||
className={`relative h-6 w-11 rounded-full transition-colors ${
|
|
||||||
customTrafficEnabled ? 'bg-accent-500' : 'bg-dark-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
|
||||||
customTrafficEnabled ? 'left-6' : 'left-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{customTrafficEnabled && (
|
|
||||||
<div className="space-y-3 border-t border-dark-700 pt-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="w-32 text-sm text-dark-400">
|
|
||||||
{t('admin.tariffs.pricePerGbLabel')}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={trafficPricePerGbKopeks === '' ? '' : trafficPricePerGbKopeks / 100}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = e.target.value;
|
|
||||||
if (val === '') {
|
|
||||||
setTrafficPricePerGbKopeks('');
|
|
||||||
} else {
|
|
||||||
setTrafficPricePerGbKopeks(Math.max(0, parseFloat(val) || 0) * 100);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="input w-24"
|
|
||||||
min={0}
|
|
||||||
step={0.1}
|
|
||||||
placeholder="0"
|
|
||||||
/>
|
|
||||||
<span className="text-dark-400">₽</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="w-32 text-sm text-dark-400">
|
|
||||||
{t('admin.tariffs.minTrafficLabel')}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={minTrafficGb}
|
|
||||||
onChange={createNumberInputHandler(setMinTrafficGb, 1)}
|
|
||||||
className="input w-24"
|
|
||||||
min={1}
|
|
||||||
placeholder="1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="w-32 text-sm text-dark-400">
|
|
||||||
{t('admin.tariffs.maxTrafficLabel')}
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={maxTrafficGb}
|
|
||||||
onChange={createNumberInputHandler(setMaxTrafficGb, 1)}
|
|
||||||
className="input w-24"
|
|
||||||
min={1}
|
|
||||||
placeholder="1000"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Traffic reset mode */}
|
{/* Traffic reset mode */}
|
||||||
<div className="card space-y-3">
|
<div className="card space-y-3">
|
||||||
<h4 className="text-sm font-medium text-dark-200">
|
<h4 className="text-sm font-medium text-dark-200">
|
||||||
@@ -966,7 +1007,20 @@ export default function AdminTariffCreate() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="card flex items-center justify-end gap-3">
|
<div className="card space-y-3">
|
||||||
|
{validationErrors.length > 0 && (
|
||||||
|
<div className="rounded-lg border border-error-500/30 bg-error-500/10 p-3">
|
||||||
|
<p className="mb-1 text-sm font-medium text-error-400">
|
||||||
|
{t('admin.tariffs.cannotSave')}
|
||||||
|
</p>
|
||||||
|
<ul className="list-inside list-disc space-y-1 text-xs text-error-300">
|
||||||
|
{validationErrors.map((error) => (
|
||||||
|
<li key={error}>{t(`admin.tariffs.validation.${error}`)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!isValid || isLoading}
|
disabled={!isValid || isLoading}
|
||||||
@@ -977,5 +1031,6 @@ export default function AdminTariffCreate() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user