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');
|
||||
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",
|
||||
"nameExamplePeriod": "E.g.: Standard",
|
||||
"nameExampleDaily": "E.g.: Daily",
|
||||
"nameHint": "2 to 50 characters",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionPlaceholder2": "Brief tariff description",
|
||||
"trafficLimitLabel": "Traffic limit",
|
||||
@@ -1267,8 +1268,12 @@
|
||||
"addButton": "Add",
|
||||
"noPeriodsHint": "No added periods. Add at least one period.",
|
||||
"daysShort": "d.",
|
||||
"serversTitle": "Servers",
|
||||
"serversTabHint": "Select servers available on this tariff.",
|
||||
"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",
|
||||
"devicePriceLabel": "Device price (30 days):",
|
||||
"devicePriceHint": "0 = device purchase unavailable",
|
||||
@@ -1278,6 +1283,8 @@
|
||||
"trafficMaxLimitLabel": "Max. limit:",
|
||||
"trafficLimitHint2": "(0 = no limits)",
|
||||
"trafficPackagesLabel": "Traffic packages:",
|
||||
"addPackageTitle": "Add package",
|
||||
"noPackagesHint": "No packages added. Add at least one package.",
|
||||
"gbPackageUnit": "GB:",
|
||||
"customDaysTitle": "Custom number of days",
|
||||
"customDaysDesc": "User selects subscription duration",
|
||||
@@ -1309,7 +1316,18 @@
|
||||
"dailyPriceLabel": "Price per day",
|
||||
"currencyPerDay": "\u20bd/day",
|
||||
"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": {
|
||||
"title": "Server Management",
|
||||
|
||||
@@ -1021,6 +1021,7 @@
|
||||
"nameLabel": "نام تعرفه",
|
||||
"nameExamplePeriod": "مثال: استاندارد",
|
||||
"nameExampleDaily": "مثال: روزانه",
|
||||
"nameHint": "۲ تا ۵۰ کاراکتر",
|
||||
"descriptionLabel": "توضیحات",
|
||||
"descriptionPlaceholder2": "توضیح مختصر تعرفه",
|
||||
"trafficLimitLabel": "محدودیت ترافیک",
|
||||
@@ -1037,8 +1038,12 @@
|
||||
"addButton": "افزودن",
|
||||
"noPeriodsHint": "هیچ دورهای اضافه نشده. حداقل یک دوره اضافه کنید.",
|
||||
"daysShort": "روز",
|
||||
"serversTitle": "سرورها",
|
||||
"serversTabHint": "سرورهای موجود در این تعرفه را انتخاب کنید.",
|
||||
"noServersAvailable": "هیچ سروری در دسترس نیست",
|
||||
"promoGroupsTitle": "گروههای تبلیغاتی",
|
||||
"promoGroupsHint": "گروههای تبلیغاتی که به این تعرفه دسترسی دارند را انتخاب کنید. اگر هیچکدام انتخاب نشود، همه دسترسی دارند.",
|
||||
"noPromoGroups": "هیچ گروه تبلیغاتی وجود ندارد",
|
||||
"extraDeviceTitle": "دستگاههای اضافی",
|
||||
"devicePriceLabel": "قیمت دستگاه (30 روز):",
|
||||
"devicePriceHint": "0 = خرید دستگاه در دسترس نیست",
|
||||
@@ -1048,6 +1053,8 @@
|
||||
"trafficMaxLimitLabel": "حداکثر محدودیت:",
|
||||
"trafficLimitHint2": "(0 = بدون محدودیت)",
|
||||
"trafficPackagesLabel": "بستههای ترافیک:",
|
||||
"addPackageTitle": "افزودن بسته",
|
||||
"noPackagesHint": "هیچ بستهای اضافه نشده است. حداقل یک بسته اضافه کنید.",
|
||||
"gbPackageUnit": "GB:",
|
||||
"customDaysTitle": "تعداد روزهای سفارشی",
|
||||
"customDaysDesc": "کاربر مدت اشتراک را انتخاب میکند",
|
||||
@@ -1079,7 +1086,18 @@
|
||||
"dailyPriceLabel": "قیمت روزانه",
|
||||
"currencyPerDay": "₽/روز",
|
||||
"dailyDeductionDesc": "هر روز از موجودی کاربر کسر میشود",
|
||||
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید"
|
||||
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید",
|
||||
"cannotSave": "امکان ذخیره تعرفه وجود ندارد:",
|
||||
"validation": {
|
||||
"nameRequired": "نام تعرفه را وارد کنید",
|
||||
"nameLength": "نام باید ۲ تا ۵۰ کاراکتر باشد",
|
||||
"deviceLimitRequired": "تعداد دستگاهها را مشخص کنید (حداقل ۱)",
|
||||
"tierLevelInvalid": "سطح تعرفه باید بین ۱ تا ۱۰ باشد",
|
||||
"fieldsRequired": "فیلدهای اجباری را پر کنید (دستگاهها، سطح)",
|
||||
"periodsRequired": "حداقل یک دوره اضافه کنید",
|
||||
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید",
|
||||
"trafficPackagesRequired": "حداقل یک بسته ترافیک اضافه کنید (یا خرید ترافیک را غیرفعال کنید)"
|
||||
}
|
||||
},
|
||||
"servers": {
|
||||
"title": "مدیریت سرورها",
|
||||
|
||||
@@ -1762,6 +1762,7 @@
|
||||
"nameLabel": "Название тарифа",
|
||||
"nameExamplePeriod": "Например: Стандарт",
|
||||
"nameExampleDaily": "Например: Суточный",
|
||||
"nameHint": "От 2 до 50 символов",
|
||||
"descriptionLabel": "Описание",
|
||||
"descriptionPlaceholder": "Краткое описание тарифа",
|
||||
"trafficLimitLabel": "Лимит трафика",
|
||||
@@ -1778,8 +1779,12 @@
|
||||
"addButton": "Добавить",
|
||||
"noPeriodsHint": "Нет добавленных периодов. Добавьте хотя бы один период.",
|
||||
"daysShort": "дн.",
|
||||
"serversTitle": "Серверы",
|
||||
"serversTabHint": "Выберите серверы, доступные на этом тарифе.",
|
||||
"noServersAvailable": "Нет доступных серверов",
|
||||
"promoGroupsTitle": "Промо-группы",
|
||||
"promoGroupsHint": "Выберите промо-группы, которым доступен этот тариф. Если не выбрано ничего — доступен всем.",
|
||||
"noPromoGroups": "Нет промо-групп",
|
||||
"extraDeviceTitle": "Докупка устройств",
|
||||
"devicePriceLabel": "Цена за устройство (30 дней):",
|
||||
"devicePriceHint": "0 = докупка недоступна",
|
||||
@@ -1789,6 +1794,8 @@
|
||||
"trafficMaxLimitLabel": "Макс. лимит:",
|
||||
"trafficLimitHint2": "(0 = без ограничений)",
|
||||
"trafficPackagesLabel": "Пакеты трафика:",
|
||||
"addPackageTitle": "Добавить пакет",
|
||||
"noPackagesHint": "Нет добавленных пакетов. Добавьте хотя бы один пакет.",
|
||||
"gbPackageUnit": "ГБ:",
|
||||
"customDaysTitle": "Произвольное количество дней",
|
||||
"customDaysDesc": "Пользователь сам выбирает срок подписки",
|
||||
@@ -1820,7 +1827,18 @@
|
||||
"dailyPriceLabel": "Цена за день",
|
||||
"currencyPerDay": "₽/день",
|
||||
"dailyDeductionDesc": "Списывается ежедневно с баланса пользователя",
|
||||
"dailyPriceRequired": "Укажите цену за день больше 0"
|
||||
"dailyPriceRequired": "Укажите цену за день больше 0",
|
||||
"cannotSave": "Невозможно сохранить тариф:",
|
||||
"validation": {
|
||||
"nameRequired": "Введите название тарифа",
|
||||
"nameLength": "Название должно быть от 2 до 50 символов",
|
||||
"deviceLimitRequired": "Укажите количество устройств (минимум 1)",
|
||||
"tierLevelInvalid": "Уровень тарифа должен быть от 1 до 10",
|
||||
"fieldsRequired": "Заполните обязательные поля (устройства, уровень)",
|
||||
"periodsRequired": "Добавьте хотя бы один период",
|
||||
"dailyPriceRequired": "Укажите цену за день больше 0",
|
||||
"trafficPackagesRequired": "Добавьте хотя бы один пакет трафика (или отключите докупку)"
|
||||
}
|
||||
},
|
||||
"servers": {
|
||||
"title": "Управление серверами",
|
||||
|
||||
@@ -1059,6 +1059,7 @@
|
||||
"nameLabel": "套餐名称",
|
||||
"nameExamplePeriod": "例如:标准",
|
||||
"nameExampleDaily": "例如:日套餐",
|
||||
"nameHint": "2到50个字符",
|
||||
"descriptionLabel": "描述",
|
||||
"trafficLimitLabel": "流量限制",
|
||||
"gbUnit": "GB",
|
||||
@@ -1074,8 +1075,12 @@
|
||||
"addButton": "添加",
|
||||
"noPeriodsHint": "没有添加的周期。请至少添加一个周期。",
|
||||
"daysShort": "天",
|
||||
"serversTitle": "服务器",
|
||||
"serversTabHint": "选择此套餐上可用的服务器。",
|
||||
"noServersAvailable": "没有可用的服务器",
|
||||
"promoGroupsTitle": "促销组",
|
||||
"promoGroupsHint": "选择可以访问此套餐的促销组。如果未选择任何组,则所有人都可以访问。",
|
||||
"noPromoGroups": "没有促销组",
|
||||
"extraDeviceTitle": "额外设备",
|
||||
"devicePriceLabel": "设备价格(30天):",
|
||||
"devicePriceHint": "0 = 无法购买设备",
|
||||
@@ -1085,6 +1090,8 @@
|
||||
"trafficMaxLimitLabel": "最大限制:",
|
||||
"trafficLimitHint2": "(0 = 无限制)",
|
||||
"trafficPackagesLabel": "流量包:",
|
||||
"addPackageTitle": "添加流量包",
|
||||
"noPackagesHint": "没有添加流量包。请至少添加一个流量包。",
|
||||
"gbPackageUnit": "GB:",
|
||||
"customDaysTitle": "自定义天数",
|
||||
"customDaysDesc": "用户自行选择订阅期限",
|
||||
@@ -1116,7 +1123,18 @@
|
||||
"dailyPriceLabel": "每天价格",
|
||||
"currencyPerDay": "₽/天",
|
||||
"dailyDeductionDesc": "每天从用户余额中扣除",
|
||||
"dailyPriceRequired": "请输入大于0的每日价格"
|
||||
"dailyPriceRequired": "请输入大于0的每日价格",
|
||||
"cannotSave": "无法保存套餐:",
|
||||
"validation": {
|
||||
"nameRequired": "请输入套餐名称",
|
||||
"nameLength": "名称必须为2到50个字符",
|
||||
"deviceLimitRequired": "请指定设备数量(至少1个)",
|
||||
"tierLevelInvalid": "等级必须在1到10之间",
|
||||
"fieldsRequired": "请填写必填字段(设备数、等级)",
|
||||
"periodsRequired": "请添加至少一个期限",
|
||||
"dailyPriceRequired": "请输入大于0的每日价格",
|
||||
"trafficPackagesRequired": "请添加至少一个流量包(或关闭流量购买)"
|
||||
}
|
||||
},
|
||||
"servers": {
|
||||
"title": "服务器管理",
|
||||
|
||||
@@ -94,16 +94,17 @@ export default function AdminTariffCreate() {
|
||||
// Step: null = type selection, 'period' or 'daily' = form
|
||||
const [tariffType, setTariffType] = useState<TariffType>(null);
|
||||
|
||||
// Form state
|
||||
// Form state - matches bot fields
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [trafficLimitGb, setTrafficLimitGb] = useState<number | ''>('');
|
||||
const [trafficLimitGb, setTrafficLimitGb] = useState<number | ''>(100);
|
||||
const [deviceLimit, setDeviceLimit] = useState<number | ''>(1);
|
||||
const [devicePriceKopeks, setDevicePriceKopeks] = useState<number | ''>(0);
|
||||
const [maxDeviceLimit, setMaxDeviceLimit] = useState<number | ''>(0);
|
||||
const [tierLevel, setTierLevel] = useState<number | ''>(1);
|
||||
const [periodPrices, setPeriodPrices] = useState<PeriodPrice[]>([]);
|
||||
const [selectedSquads, setSelectedSquads] = useState<string[]>([]);
|
||||
const [selectedPromoGroups, setSelectedPromoGroups] = useState<number[]>([]);
|
||||
const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
|
||||
|
||||
// Traffic topup
|
||||
@@ -111,25 +112,23 @@ export default function AdminTariffCreate() {
|
||||
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState<number | ''>(0);
|
||||
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
|
||||
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
|
||||
const [newPeriodDays, setNewPeriodDays] = useState<number | ''>(30);
|
||||
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');
|
||||
|
||||
// Fetch servers
|
||||
@@ -138,6 +137,12 @@ export default function AdminTariffCreate() {
|
||||
queryFn: () => tariffsApi.getAvailableServers(),
|
||||
});
|
||||
|
||||
// Fetch promo groups
|
||||
const { data: promoGroups = [] } = useQuery({
|
||||
queryKey: ['admin-tariffs-promo-groups'],
|
||||
queryFn: () => tariffsApi.getAvailablePromoGroups(),
|
||||
});
|
||||
|
||||
// Fetch tariff for editing
|
||||
const { isLoading: isLoadingTariff } = useQuery({
|
||||
queryKey: ['admin-tariff', id],
|
||||
@@ -158,19 +163,14 @@ export default function AdminTariffCreate() {
|
||||
setTierLevel(data.tier_level || 1);
|
||||
setPeriodPrices(data.period_prices?.length ? data.period_prices : []);
|
||||
setSelectedSquads(data.allowed_squads || []);
|
||||
setSelectedPromoGroups(
|
||||
data.promo_groups?.filter((pg) => pg.is_selected).map((pg) => pg.id) || [],
|
||||
);
|
||||
setDailyPriceKopeks(data.daily_price_kopeks || 0);
|
||||
setTrafficTopupEnabled(data.traffic_topup_enabled || false);
|
||||
setMaxTopupTrafficGb(data.max_topup_traffic_gb || 0);
|
||||
setTrafficTopupPackages(data.traffic_topup_packages || {});
|
||||
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;
|
||||
}, []),
|
||||
});
|
||||
@@ -200,7 +200,7 @@ export default function AdminTariffCreate() {
|
||||
const data: TariffCreateRequest | TariffUpdateRequest = {
|
||||
name,
|
||||
description: description || undefined,
|
||||
traffic_limit_gb: toNumber(trafficLimitGb, 0),
|
||||
traffic_limit_gb: toNumber(trafficLimitGb, 100),
|
||||
device_limit: toNumber(deviceLimit, 1),
|
||||
device_price_kopeks:
|
||||
toNumber(devicePriceKopeks) > 0 ? toNumber(devicePriceKopeks) : undefined,
|
||||
@@ -208,6 +208,7 @@ export default function AdminTariffCreate() {
|
||||
tier_level: toNumber(tierLevel, 1),
|
||||
period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0),
|
||||
allowed_squads: selectedSquads,
|
||||
promo_group_ids: selectedPromoGroups.length > 0 ? selectedPromoGroups : undefined,
|
||||
traffic_topup_enabled: trafficTopupEnabled,
|
||||
traffic_topup_packages: trafficTopupPackages,
|
||||
max_topup_traffic_gb: toNumber(maxTopupTrafficGb),
|
||||
@@ -216,17 +217,6 @@ export default function AdminTariffCreate() {
|
||||
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) {
|
||||
updateMutation.mutate({ id: Number(id), data });
|
||||
} 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 days = toNumber(newPeriodDays, 0);
|
||||
const price = toNumber(newPeriodPrice, 0);
|
||||
@@ -267,13 +263,48 @@ export default function AdminTariffCreate() {
|
||||
|
||||
const isLoading = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Validate required numeric fields are not empty
|
||||
const hasRequiredFields = deviceLimit !== '' && tierLevel !== '';
|
||||
const isValidPeriod = name && hasRequiredFields && (periodPrices.length > 0 || customDaysEnabled);
|
||||
const isValidDaily = name && hasRequiredFields && toNumber(dailyPriceKopeks) > 0;
|
||||
// Validation like bot: name 2-50 chars, device_limit >= 1, tier_level 1-10
|
||||
const isNameValid = name.length >= 2 && name.length <= 50;
|
||||
const isDeviceLimitValid = deviceLimit !== '' && toNumber(deviceLimit) >= 1;
|
||||
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 =
|
||||
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
|
||||
if (isEdit && isLoadingTariff) {
|
||||
return (
|
||||
@@ -394,16 +425,24 @@ export default function AdminTariffCreate() {
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.tariffs.nameLabel')}
|
||||
<span className="text-error-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="input"
|
||||
className={`input ${!isNameValid && name.length > 0 ? 'border-error-500/50' : ''}`}
|
||||
placeholder={
|
||||
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>
|
||||
|
||||
{/* Description */}
|
||||
@@ -442,16 +481,11 @@ export default function AdminTariffCreate() {
|
||||
className={`input w-32 ${dailyPriceKopeks === '' || dailyPriceKopeks === 0 ? 'border-error-500/50' : ''}`}
|
||||
min={0}
|
||||
step={0.1}
|
||||
placeholder="0.00"
|
||||
placeholder="50"
|
||||
/>
|
||||
<span className="text-dark-400">{t('admin.tariffs.currencyPerDay')}</span>
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -467,7 +501,7 @@ export default function AdminTariffCreate() {
|
||||
onChange={createNumberInputHandler(setTrafficLimitGb, 0)}
|
||||
className="input w-32"
|
||||
min={0}
|
||||
placeholder="0"
|
||||
placeholder="100"
|
||||
/>
|
||||
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span>
|
||||
{(trafficLimitGb === 0 || trafficLimitGb === '') && (
|
||||
@@ -490,13 +524,10 @@ export default function AdminTariffCreate() {
|
||||
type="number"
|
||||
value={deviceLimit}
|
||||
onChange={createNumberInputHandler(setDeviceLimit, 1)}
|
||||
className={`input w-32 ${deviceLimit === '' ? 'border-error-500/50' : ''}`}
|
||||
className={`input w-32 ${!isDeviceLimitValid ? 'border-error-500/50' : ''}`}
|
||||
min={1}
|
||||
placeholder="1"
|
||||
/>
|
||||
{deviceLimit === '' && (
|
||||
<p className="mt-1 text-xs text-error-400">{t('admin.tariffs.fieldRequired')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tier Level */}
|
||||
@@ -509,15 +540,12 @@ export default function AdminTariffCreate() {
|
||||
type="number"
|
||||
value={tierLevel}
|
||||
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}
|
||||
max={10}
|
||||
placeholder="1"
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
@@ -541,7 +569,6 @@ export default function AdminTariffCreate() {
|
||||
value={newPeriodDays}
|
||||
onChange={createNumberInputHandler(setNewPeriodDays, 1)}
|
||||
className="input w-24"
|
||||
min={1}
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
@@ -554,7 +581,6 @@ export default function AdminTariffCreate() {
|
||||
value={newPeriodPrice}
|
||||
onChange={createNumberInputHandler(setNewPeriodPrice, 1)}
|
||||
className="input w-28"
|
||||
min={1}
|
||||
placeholder="300"
|
||||
/>
|
||||
</div>
|
||||
@@ -584,12 +610,33 @@ export default function AdminTariffCreate() {
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={period.price_kopeks / 100}
|
||||
onChange={(e) =>
|
||||
updatePeriodPrice(period.days, Math.max(0, parseFloat(e.target.value) || 0))
|
||||
value={
|
||||
editingPeriodPrices[period.days] !== undefined
|
||||
? 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"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="0"
|
||||
/>
|
||||
@@ -609,49 +656,97 @@ export default function AdminTariffCreate() {
|
||||
)}
|
||||
|
||||
{activeTab === 'servers' && (
|
||||
<div className="card space-y-4">
|
||||
<p className="text-sm text-dark-400">{t('admin.tariffs.serversTabHint')}</p>
|
||||
{servers.length === 0 ? (
|
||||
<p className="py-4 text-center text-dark-500">
|
||||
{t('admin.tariffs.noServersAvailable')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-64 space-y-2 overflow-y-auto">
|
||||
{servers.map((server: ServerInfo) => {
|
||||
const isSelected = selectedSquads.includes(server.squad_uuid);
|
||||
return (
|
||||
<button
|
||||
key={server.id}
|
||||
type="button"
|
||||
onClick={() => toggleServer(server.squad_uuid)}
|
||||
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 ${
|
||||
<div className="space-y-4">
|
||||
{/* Servers */}
|
||||
<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>
|
||||
{servers.length === 0 ? (
|
||||
<p className="py-4 text-center text-dark-500">
|
||||
{t('admin.tariffs.noServersAvailable')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{servers.map((server: ServerInfo) => {
|
||||
const isSelected = selectedSquads.includes(server.squad_uuid);
|
||||
return (
|
||||
<button
|
||||
key={server.id}
|
||||
type="button"
|
||||
onClick={() => toggleServer(server.squad_uuid)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg p-3 text-left transition-colors ${
|
||||
isSelected
|
||||
? isDaily
|
||||
? 'bg-warning-500 text-white'
|
||||
: 'bg-accent-500 text-white'
|
||||
: 'bg-dark-600'
|
||||
? 'bg-warning-500/20 text-warning-300'
|
||||
: 'bg-accent-500/20 text-accent-300'
|
||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
{isSelected && <CheckIcon />}
|
||||
</div>
|
||||
<span className="flex-1 text-sm font-medium">{server.display_name}</span>
|
||||
{server.country_code && (
|
||||
<span className="text-xs text-dark-500">{server.country_code}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<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">{server.display_name}</span>
|
||||
{server.country_code && (
|
||||
<span className="text-xs text-dark-500">{server.country_code}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -737,200 +832,146 @@ export default function AdminTariffCreate() {
|
||||
/>
|
||||
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span>
|
||||
</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>
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('admin.tariffs.trafficPackagesLabel')}
|
||||
</span>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{[5, 10, 20, 50].map((gb) => (
|
||||
<div key={gb} className="flex items-center gap-2">
|
||||
<span className="w-12 text-sm text-dark-300">
|
||||
{gb} {t('admin.tariffs.gbPackageUnit')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={(trafficTopupPackages[String(gb)] || 0) / 100}
|
||||
onChange={(e) => {
|
||||
const price = Math.max(0, parseFloat(e.target.value) || 0) * 100;
|
||||
setTrafficTopupPackages((prev) => ({
|
||||
...prev,
|
||||
[String(gb)]: price,
|
||||
}));
|
||||
}}
|
||||
className="input w-20"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="text-xs text-dark-400">₽</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{Object.keys(trafficTopupPackages).length === 0 ? (
|
||||
<div className="mt-2 py-4 text-center text-sm text-dark-500">
|
||||
{t('admin.tariffs.noPackagesHint')}
|
||||
</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')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={
|
||||
editingPackagePrices[gb] !== undefined
|
||||
? editingPackagePrices[gb]
|
||||
: priceKopeks / 100
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setEditingPackagePrices((prev) => ({ ...prev, [gb]: val }));
|
||||
if (val !== '') {
|
||||
const num = parseFloat(val);
|
||||
if (!isNaN(num)) {
|
||||
setTrafficTopupPackages((prev) => ({
|
||||
...prev,
|
||||
[gb]: Math.max(0, num) * 100,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
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}
|
||||
placeholder="0"
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="card space-y-3">
|
||||
<h4 className="text-sm font-medium text-dark-200">
|
||||
@@ -966,15 +1007,29 @@ export default function AdminTariffCreate() {
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="card flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || isLoading}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{isLoading && <RefreshIcon />}
|
||||
{isLoading ? t('admin.tariffs.savingButton') : t('admin.tariffs.saveButton')}
|
||||
</button>
|
||||
<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
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || isLoading}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{isLoading && <RefreshIcon />}
|
||||
{isLoading ? t('admin.tariffs.savingButton') : t('admin.tariffs.saveButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user