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:
c0mrade
2026-02-02 04:27:52 +03:00
parent 8cd95b84fb
commit d6edb659a3
6 changed files with 430 additions and 297 deletions

View File

@@ -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;
},
}; };

View File

@@ -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",

View File

@@ -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": "مدیریت سرورها",

View File

@@ -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": "Управление серверами",

View File

@@ -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": "服务器管理",

View File

@@ -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>
); );
} }