From 47e28ee78fd417db86ad4a026f5983f68f0e1c76 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 2 Feb 2026 03:10:54 +0300 Subject: [PATCH] fix: allow clearing number inputs and add validation - Add inputHelpers utility for number inputs that allow empty values - Update tariff, campaign, promo offers, and server forms - Add validation for required fields (deviceLimit, tierLevel, dailyPrice) - Add placeholders and error indicators for empty required fields - Add fieldRequired and dailyPriceRequired translations --- src/locales/en.json | 4 +- src/locales/fa.json | 4 +- src/locales/ru.json | 4 +- src/locales/zh.json | 4 +- src/pages/AdminCampaignCreate.tsx | 31 ++--- src/pages/AdminCampaigns.tsx | 39 +++---- src/pages/AdminPromoOffers.tsx | 31 ++--- src/pages/AdminServers.tsx | 22 ++-- src/pages/AdminTariffCreate.tsx | 183 ++++++++++++++++++------------ src/utils/inputHelpers.ts | 48 ++++++++ 10 files changed, 234 insertions(+), 136 deletions(-) create mode 100644 src/utils/inputHelpers.ts diff --git a/src/locales/en.json b/src/locales/en.json index 64dc1d3..c48d9ca 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1259,6 +1259,7 @@ "deviceLimitLabel": "Devices in tariff", "tierLevelLabel": "Tariff tier level", "tierLevelHint": "Affects upgrade availability between tariffs", + "fieldRequired": "Required field", "periodsTabHint": "Add periods and prices for the tariff. Users will be able to choose from the added periods.", "addPeriodTitle": "Add period", "daysLabel": "Days", @@ -1307,7 +1308,8 @@ "periodType": "Period-based", "dailyPriceLabel": "Price per 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" }, "servers": { "title": "Server Management", diff --git a/src/locales/fa.json b/src/locales/fa.json index 2b09f14..0f3cd42 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1029,6 +1029,7 @@ "deviceLimitLabel": "دستگاه‌ها در تعرفه", "tierLevelLabel": "سطح تعرفه", "tierLevelHint": "بر دسترسی ارتقا بین تعرفه‌ها تأثیر می‌گذارد", + "fieldRequired": "فیلد اجباری", "periodsTabHint": "دوره‌ها و قیمت‌ها را برای تعرفه اضافه کنید. کاربران می‌توانند از دوره‌های اضافه شده انتخاب کنند.", "addPeriodTitle": "افزودن دوره", "daysLabel": "روز", @@ -1077,7 +1078,8 @@ "periodType": "دوره‌ای", "dailyPriceLabel": "قیمت روزانه", "currencyPerDay": "₽/روز", - "dailyDeductionDesc": "هر روز از موجودی کاربر کسر می‌شود" + "dailyDeductionDesc": "هر روز از موجودی کاربر کسر می‌شود", + "dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید" }, "servers": { "title": "مدیریت سرورها", diff --git a/src/locales/ru.json b/src/locales/ru.json index 5e8c5cb..4a0fc5d 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1770,6 +1770,7 @@ "deviceLimitLabel": "Устройств в тарифе", "tierLevelLabel": "Уровень тарифа", "tierLevelHint": "Влияет на доступность перехода между тарифами", + "fieldRequired": "Обязательное поле", "periodsTabHint": "Добавьте периоды и цены для тарифа. Пользователи смогут выбирать из добавленных периодов.", "addPeriodTitle": "Добавить период", "daysLabel": "Дней", @@ -1818,7 +1819,8 @@ "periodType": "Периодный", "dailyPriceLabel": "Цена за день", "currencyPerDay": "₽/день", - "dailyDeductionDesc": "Списывается ежедневно с баланса пользователя" + "dailyDeductionDesc": "Списывается ежедневно с баланса пользователя", + "dailyPriceRequired": "Укажите цену за день больше 0" }, "servers": { "title": "Управление серверами", diff --git a/src/locales/zh.json b/src/locales/zh.json index 5539eeb..13f1fd1 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1066,6 +1066,7 @@ "deviceLimitLabel": "套餐中的设备数", "tierLevelLabel": "套餐等级", "tierLevelHint": "影响套餐之间的升级可用性", + "fieldRequired": "必填字段", "periodsTabHint": "为套餐添加周期和价格。用户将能够从添加的周期中选择。", "addPeriodTitle": "添加周期", "daysLabel": "天", @@ -1114,7 +1115,8 @@ "periodType": "周期套餐", "dailyPriceLabel": "每天价格", "currencyPerDay": "₽/天", - "dailyDeductionDesc": "每天从用户余额中扣除" + "dailyDeductionDesc": "每天从用户余额中扣除", + "dailyPriceRequired": "请输入大于0的每日价格" }, "servers": { "title": "服务器管理", diff --git a/src/pages/AdminCampaignCreate.tsx b/src/pages/AdminCampaignCreate.tsx index fbcd7e3..ea0914e 100644 --- a/src/pages/AdminCampaignCreate.tsx +++ b/src/pages/AdminCampaignCreate.tsx @@ -10,6 +10,7 @@ import { TariffListItem, } from '../api/campaigns'; import { AdminBackButton } from '../components/admin'; +import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; // Icons const CampaignIcon = () => ( @@ -161,17 +162,17 @@ export default function AdminCampaignCreate() { const [isActive, setIsActive] = useState(true); // Balance bonus - const [balanceBonusRubles, setBalanceBonusRubles] = useState(0); + const [balanceBonusRubles, setBalanceBonusRubles] = useState(0); // Subscription bonus - const [subscriptionDays, setSubscriptionDays] = useState(7); - const [subscriptionTraffic, setSubscriptionTraffic] = useState(10); - const [subscriptionDevices, setSubscriptionDevices] = useState(1); + const [subscriptionDays, setSubscriptionDays] = useState(7); + const [subscriptionTraffic, setSubscriptionTraffic] = useState(10); + const [subscriptionDevices, setSubscriptionDevices] = useState(1); const [selectedSquads, setSelectedSquads] = useState([]); // Tariff bonus const [tariffId, setTariffId] = useState(null); - const [tariffDays, setTariffDays] = useState(30); + const [tariffDays, setTariffDays] = useState(30); // Fetch servers const { data: servers = [] } = useQuery({ @@ -210,15 +211,15 @@ export default function AdminCampaignCreate() { }; if (bonusType === 'balance') { - data.balance_bonus_kopeks = Math.round(balanceBonusRubles * 100); + data.balance_bonus_kopeks = Math.round(toNumber(balanceBonusRubles) * 100); } else if (bonusType === 'subscription') { - data.subscription_duration_days = subscriptionDays; - data.subscription_traffic_gb = subscriptionTraffic; - data.subscription_device_limit = subscriptionDevices; + data.subscription_duration_days = toNumber(subscriptionDays, 7); + data.subscription_traffic_gb = toNumber(subscriptionTraffic, 10); + data.subscription_device_limit = toNumber(subscriptionDevices, 1); data.subscription_squads = selectedSquads; } else if (bonusType === 'tariff') { data.tariff_id = tariffId || undefined; - data.tariff_duration_days = tariffDays; + data.tariff_duration_days = toNumber(tariffDays, 30); } createMutation.mutate(data); @@ -334,7 +335,7 @@ export default function AdminCampaignCreate() { setBalanceBonusRubles(Math.max(0, parseFloat(e.target.value) || 0))} + onChange={createNumberInputHandler(setBalanceBonusRubles, 0)} className="input w-32" min={0} step={1} @@ -360,7 +361,7 @@ export default function AdminCampaignCreate() { setSubscriptionDays(Math.max(1, parseInt(e.target.value) || 1))} + onChange={createNumberInputHandler(setSubscriptionDays, 1)} className="input" min={1} /> @@ -372,7 +373,7 @@ export default function AdminCampaignCreate() { setSubscriptionTraffic(Math.max(0, parseInt(e.target.value) || 0))} + onChange={createNumberInputHandler(setSubscriptionTraffic, 0)} className="input" min={0} /> @@ -384,7 +385,7 @@ export default function AdminCampaignCreate() { setSubscriptionDevices(Math.max(1, parseInt(e.target.value) || 1))} + onChange={createNumberInputHandler(setSubscriptionDevices, 1)} className="input" min={1} /> @@ -412,7 +413,7 @@ export default function AdminCampaignCreate() { setTariffDays(Math.max(1, parseInt(e.target.value) || 1))} + onChange={createNumberInputHandler(setTariffDays, 1)} className="input w-32" min={1} /> diff --git a/src/pages/AdminCampaigns.tsx b/src/pages/AdminCampaigns.tsx index 7cb1139..db449b4 100644 --- a/src/pages/AdminCampaigns.tsx +++ b/src/pages/AdminCampaigns.tsx @@ -15,6 +15,7 @@ import { CampaignRegistrationItem, } from '../api/campaigns'; import { AdminBackButton } from '../components/admin'; +import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; // Icons @@ -174,18 +175,18 @@ function CampaignModal({ const [isActive, setIsActive] = useState(campaign.is_active ?? true); // Balance bonus - const [balanceBonusRubles, setBalanceBonusRubles] = useState( + const [balanceBonusRubles, setBalanceBonusRubles] = useState( (campaign.balance_bonus_kopeks || 0) / 100, ); // Subscription bonus - const [subscriptionDays, setSubscriptionDays] = useState( + const [subscriptionDays, setSubscriptionDays] = useState( campaign.subscription_duration_days || 7, ); - const [subscriptionTraffic, setSubscriptionTraffic] = useState( + const [subscriptionTraffic, setSubscriptionTraffic] = useState( campaign.subscription_traffic_gb || 10, ); - const [subscriptionDevices, setSubscriptionDevices] = useState( + const [subscriptionDevices, setSubscriptionDevices] = useState( campaign.subscription_device_limit || 1, ); const [selectedSquads, setSelectedSquads] = useState( @@ -194,7 +195,7 @@ function CampaignModal({ // Tariff bonus const [tariffId, setTariffId] = useState(campaign.tariff_id || null); - const [tariffDays, setTariffDays] = useState(campaign.tariff_duration_days || 30); + const [tariffDays, setTariffDays] = useState(campaign.tariff_duration_days || 30); const handleSubmit = () => { const data: CampaignUpdateRequest = { @@ -205,15 +206,15 @@ function CampaignModal({ }; if (bonusType === 'balance') { - data.balance_bonus_kopeks = Math.round(balanceBonusRubles * 100); + data.balance_bonus_kopeks = Math.round(toNumber(balanceBonusRubles) * 100); } else if (bonusType === 'subscription') { - data.subscription_duration_days = subscriptionDays; - data.subscription_traffic_gb = subscriptionTraffic; - data.subscription_device_limit = subscriptionDevices; + data.subscription_duration_days = toNumber(subscriptionDays, 7); + data.subscription_traffic_gb = toNumber(subscriptionTraffic, 10); + data.subscription_device_limit = toNumber(subscriptionDevices, 1); data.subscription_squads = selectedSquads; } else if (bonusType === 'tariff') { data.tariff_id = tariffId || undefined; - data.tariff_duration_days = tariffDays; + data.tariff_duration_days = toNumber(tariffDays, 30); } onSave(data); @@ -306,9 +307,7 @@ function CampaignModal({ - setBalanceBonusRubles(Math.max(0, parseFloat(e.target.value) || 0)) - } + onChange={createNumberInputHandler(setBalanceBonusRubles, 0)} className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-success-500 focus:outline-none" min={0} step={1} @@ -331,9 +330,7 @@ function CampaignModal({ - setSubscriptionDays(Math.max(1, parseInt(e.target.value) || 1)) - } + onChange={createNumberInputHandler(setSubscriptionDays, 1)} className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" min={1} /> @@ -345,9 +342,7 @@ function CampaignModal({ - setSubscriptionTraffic(Math.max(0, parseInt(e.target.value) || 0)) - } + onChange={createNumberInputHandler(setSubscriptionTraffic, 0)} className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" min={0} /> @@ -359,9 +354,7 @@ function CampaignModal({ - setSubscriptionDevices(Math.max(1, parseInt(e.target.value) || 1)) - } + onChange={createNumberInputHandler(setSubscriptionDevices, 1)} className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" min={1} /> @@ -436,7 +429,7 @@ function CampaignModal({ setTariffDays(Math.max(1, parseInt(e.target.value) || 1))} + onChange={createNumberInputHandler(setTariffDays, 1)} className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" min={1} /> diff --git a/src/pages/AdminPromoOffers.tsx b/src/pages/AdminPromoOffers.tsx index da5928c..a0038f5 100644 --- a/src/pages/AdminPromoOffers.tsx +++ b/src/pages/AdminPromoOffers.tsx @@ -14,6 +14,7 @@ import { OfferType, } from '../api/promoOffers'; import { AdminBackButton } from '../components/admin'; +import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; // Icons @@ -135,12 +136,14 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi const [name, setName] = useState(template.name); const [messageText, setMessageText] = useState(template.message_text); const [buttonText, setButtonText] = useState(template.button_text); - const [validHours, setValidHours] = useState(template.valid_hours); - const [discountPercent, setDiscountPercent] = useState(template.discount_percent); - const [activeDiscountHours, setActiveDiscountHours] = useState( + const [validHours, setValidHours] = useState(template.valid_hours); + const [discountPercent, setDiscountPercent] = useState(template.discount_percent); + const [activeDiscountHours, setActiveDiscountHours] = useState( template.active_discount_hours || 0, ); - const [testDurationHours, setTestDurationHours] = useState(template.test_duration_hours || 0); + const [testDurationHours, setTestDurationHours] = useState( + template.test_duration_hours || 0, + ); const [isActive, setIsActive] = useState(template.is_active); const isTestAccess = template.offer_type === 'test_access'; @@ -150,14 +153,16 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi name, message_text: messageText, button_text: buttonText, - valid_hours: validHours, - discount_percent: discountPercent, + valid_hours: toNumber(validHours, 1), + discount_percent: toNumber(discountPercent), is_active: isActive, }; + const testHours = toNumber(testDurationHours); + const discountHours = toNumber(activeDiscountHours); if (isTestAccess) { - data.test_duration_hours = testDurationHours > 0 ? testDurationHours : undefined; + data.test_duration_hours = testHours > 0 ? testHours : undefined; } else { - data.active_discount_hours = activeDiscountHours > 0 ? activeDiscountHours : undefined; + data.active_discount_hours = discountHours > 0 ? discountHours : undefined; } onSave(data); }; @@ -224,7 +229,7 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi setValidHours(Math.max(1, parseInt(e.target.value) || 1))} + onChange={createNumberInputHandler(setValidHours, 1)} className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" min={1} /> @@ -241,9 +246,7 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi - setDiscountPercent(Math.min(100, Math.max(0, parseInt(e.target.value) || 0))) - } + onChange={createNumberInputHandler(setDiscountPercent, 0, 100)} className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" min={0} max={100} @@ -260,7 +263,7 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi setTestDurationHours(Math.max(0, parseInt(e.target.value) || 0))} + onChange={createNumberInputHandler(setTestDurationHours, 0)} className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" min={0} /> @@ -276,7 +279,7 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi setActiveDiscountHours(Math.max(0, parseInt(e.target.value) || 0))} + onChange={createNumberInputHandler(setActiveDiscountHours, 0)} className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" min={0} /> diff --git a/src/pages/AdminServers.tsx b/src/pages/AdminServers.tsx index aa24e9b..00f845d 100644 --- a/src/pages/AdminServers.tsx +++ b/src/pages/AdminServers.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { serversApi, ServerListItem, ServerDetail, ServerUpdateRequest } from '../api/servers'; import { AdminBackButton } from '../components/admin'; +import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; // Icons @@ -94,18 +95,18 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) { const [displayName, setDisplayName] = useState(server.display_name); const [description, setDescription] = useState(server.description || ''); const [countryCode, setCountryCode] = useState(server.country_code || ''); - const [priceKopeks, setPriceKopeks] = useState(server.price_kopeks); + const [priceKopeks, setPriceKopeks] = useState(server.price_kopeks); const [maxUsers, setMaxUsers] = useState(server.max_users); - const [sortOrder, setSortOrder] = useState(server.sort_order); + const [sortOrder, setSortOrder] = useState(server.sort_order); const handleSubmit = () => { const data: ServerUpdateRequest = { display_name: displayName, description: description || undefined, country_code: countryCode || undefined, - price_kopeks: priceKopeks, + price_kopeks: toNumber(priceKopeks), max_users: maxUsers || undefined, - sort_order: sortOrder, + sort_order: toNumber(sortOrder), }; onSave(data); }; @@ -186,8 +187,15 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
setPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)} + value={priceKopeks === '' ? '' : priceKopeks / 100} + onChange={(e) => { + const val = e.target.value; + if (val === '') { + setPriceKopeks(''); + } else { + setPriceKopeks(Math.max(0, parseFloat(val) || 0) * 100); + } + }} className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" min={0} step={1} @@ -227,7 +235,7 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) { setSortOrder(parseInt(e.target.value) || 0)} + onChange={createNumberInputHandler(setSortOrder)} className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none" />
diff --git a/src/pages/AdminTariffCreate.tsx b/src/pages/AdminTariffCreate.tsx index e162ed8..be520b4 100644 --- a/src/pages/AdminTariffCreate.tsx +++ b/src/pages/AdminTariffCreate.tsx @@ -11,6 +11,7 @@ import { ServerInfo, } from '../api/tariffs'; import { AdminBackButton } from '../components/admin'; +import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; // Icons const PlusIcon = () => ( @@ -96,18 +97,18 @@ export default function AdminTariffCreate() { // Form state const [name, setName] = useState(''); const [description, setDescription] = useState(''); - const [trafficLimitGb, setTrafficLimitGb] = useState(100); - const [deviceLimit, setDeviceLimit] = useState(1); - const [devicePriceKopeks, setDevicePriceKopeks] = useState(0); - const [maxDeviceLimit, setMaxDeviceLimit] = useState(0); - const [tierLevel, setTierLevel] = useState(1); + const [trafficLimitGb, setTrafficLimitGb] = useState(''); + const [deviceLimit, setDeviceLimit] = useState(1); + const [devicePriceKopeks, setDevicePriceKopeks] = useState(0); + const [maxDeviceLimit, setMaxDeviceLimit] = useState(0); + const [tierLevel, setTierLevel] = useState(1); const [periodPrices, setPeriodPrices] = useState([]); const [selectedSquads, setSelectedSquads] = useState([]); - const [dailyPriceKopeks, setDailyPriceKopeks] = useState(0); + const [dailyPriceKopeks, setDailyPriceKopeks] = useState(0); // Traffic topup const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(false); - const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(0); + const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(0); const [trafficTopupPackages, setTrafficTopupPackages] = useState>({}); // Traffic reset mode @@ -115,19 +116,19 @@ export default function AdminTariffCreate() { // Custom days (period tariff) const [customDaysEnabled, setCustomDaysEnabled] = useState(false); - const [pricePerDayKopeks, setPricePerDayKopeks] = useState(0); - const [minDays, setMinDays] = useState(1); - const [maxDays, setMaxDays] = useState(365); + const [pricePerDayKopeks, setPricePerDayKopeks] = useState(0); + const [minDays, setMinDays] = useState(1); + const [maxDays, setMaxDays] = useState(365); // Custom traffic (period tariff) const [customTrafficEnabled, setCustomTrafficEnabled] = useState(false); - const [trafficPricePerGbKopeks, setTrafficPricePerGbKopeks] = useState(0); - const [minTrafficGb, setMinTrafficGb] = useState(1); - const [maxTrafficGb, setMaxTrafficGb] = useState(1000); + const [trafficPricePerGbKopeks, setTrafficPricePerGbKopeks] = useState(0); + const [minTrafficGb, setMinTrafficGb] = useState(1); + const [maxTrafficGb, setMaxTrafficGb] = useState(1000); // New period for adding - const [newPeriodDays, setNewPeriodDays] = useState(30); - const [newPeriodPrice, setNewPeriodPrice] = useState(300); + const [newPeriodDays, setNewPeriodDays] = useState(30); + const [newPeriodPrice, setNewPeriodPrice] = useState(300); const [activeTab, setActiveTab] = useState<'basic' | 'periods' | 'servers' | 'extra'>('basic'); @@ -199,30 +200,31 @@ export default function AdminTariffCreate() { const data: TariffCreateRequest | TariffUpdateRequest = { name, description: description || undefined, - traffic_limit_gb: trafficLimitGb, - device_limit: deviceLimit, - device_price_kopeks: devicePriceKopeks > 0 ? devicePriceKopeks : undefined, - max_device_limit: maxDeviceLimit > 0 ? maxDeviceLimit : undefined, - tier_level: tierLevel, + traffic_limit_gb: toNumber(trafficLimitGb, 0), + device_limit: toNumber(deviceLimit, 1), + device_price_kopeks: + toNumber(devicePriceKopeks) > 0 ? toNumber(devicePriceKopeks) : undefined, + max_device_limit: toNumber(maxDeviceLimit) > 0 ? toNumber(maxDeviceLimit) : undefined, + tier_level: toNumber(tierLevel, 1), period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0), allowed_squads: selectedSquads, traffic_topup_enabled: trafficTopupEnabled, traffic_topup_packages: trafficTopupPackages, - max_topup_traffic_gb: maxTopupTrafficGb, + max_topup_traffic_gb: toNumber(maxTopupTrafficGb), is_daily: isDaily, - daily_price_kopeks: isDaily ? dailyPriceKopeks : 0, + daily_price_kopeks: isDaily ? toNumber(dailyPriceKopeks) : 0, traffic_reset_mode: trafficResetMode, }; if (!isDaily) { data.custom_days_enabled = customDaysEnabled; - data.price_per_day_kopeks = pricePerDayKopeks; - data.min_days = minDays; - data.max_days = maxDays; + 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 = trafficPricePerGbKopeks; - data.min_traffic_gb = minTrafficGb; - data.max_traffic_gb = maxTrafficGb; + data.traffic_price_per_gb_kopeks = toNumber(trafficPricePerGbKopeks); + data.min_traffic_gb = toNumber(minTrafficGb, 1); + data.max_traffic_gb = toNumber(maxTrafficGb, 1000); } if (isEdit) { @@ -239,13 +241,13 @@ export default function AdminTariffCreate() { }; const addPeriod = () => { - if (newPeriodDays > 0 && newPeriodPrice > 0) { - const exists = periodPrices.some((p) => p.days === newPeriodDays); + const days = toNumber(newPeriodDays, 0); + const price = toNumber(newPeriodPrice, 0); + if (days > 0 && price > 0) { + const exists = periodPrices.some((p) => p.days === days); if (!exists) { setPeriodPrices((prev) => - [...prev, { days: newPeriodDays, price_kopeks: newPeriodPrice * 100 }].sort( - (a, b) => a.days - b.days, - ), + [...prev, { days, price_kopeks: price * 100 }].sort((a, b) => a.days - b.days), ); setNewPeriodDays(30); setNewPeriodPrice(300); @@ -265,8 +267,10 @@ export default function AdminTariffCreate() { const isLoading = createMutation.isPending || updateMutation.isPending; - const isValidPeriod = name && (periodPrices.length > 0 || customDaysEnabled); - const isValidDaily = name && dailyPriceKopeks > 0; + // 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; const isValid = tariffType === 'period' ? isValidPeriod : tariffType === 'daily' ? isValidDaily : false; @@ -420,21 +424,34 @@ export default function AdminTariffCreate() {
- setDailyPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100) - } - className="input w-32" + value={dailyPriceKopeks === '' ? '' : dailyPriceKopeks / 100} + onChange={(e) => { + const val = e.target.value; + if (val === '') { + setDailyPriceKopeks(''); + } else { + const num = Math.max(0, parseFloat(val) || 0) * 100; + setDailyPriceKopeks(num); + } + }} + className={`input w-32 ${dailyPriceKopeks === '' || dailyPriceKopeks === 0 ? 'border-error-500/50' : ''}`} min={0} step={0.1} + placeholder="0.00" /> {t('admin.tariffs.currencyPerDay')}

{t('admin.tariffs.dailyDeductionDesc')}

+ {(dailyPriceKopeks === '' || dailyPriceKopeks === 0) && ( +

+ {t('admin.tariffs.dailyPriceRequired')} +

+ )}
)} @@ -447,12 +464,13 @@ export default function AdminTariffCreate() { setTrafficLimitGb(Math.max(0, parseInt(e.target.value) || 0))} + onChange={createNumberInputHandler(setTrafficLimitGb, 0)} className="input w-32" min={0} + placeholder="0" /> {t('admin.tariffs.gbUnit')} - {trafficLimitGb === 0 && ( + {(trafficLimitGb === 0 || trafficLimitGb === '') && ( {t('admin.tariffs.unlimited')} @@ -466,32 +484,40 @@ export default function AdminTariffCreate() {
setDeviceLimit(Math.max(1, parseInt(e.target.value) || 1))} - className="input w-32" + onChange={createNumberInputHandler(setDeviceLimit, 1)} + className={`input w-32 ${deviceLimit === '' ? 'border-error-500/50' : ''}`} min={1} + placeholder="1" /> + {deviceLimit === '' && ( +

{t('admin.tariffs.fieldRequired')}

+ )}
{/* Tier Level */}
- setTierLevel(Math.min(10, Math.max(1, parseInt(e.target.value) || 1))) - } - className="input w-32" + onChange={createNumberInputHandler(setTierLevel, 1, 10)} + className={`input w-32 ${tierLevel === '' ? 'border-error-500/50' : ''}`} min={1} max={10} + placeholder="1" />

{t('admin.tariffs.tierLevelHint')}

+ {tierLevel === '' && ( +

{t('admin.tariffs.fieldRequired')}

+ )}
)} @@ -513,7 +539,7 @@ export default function AdminTariffCreate() { setNewPeriodDays(Math.max(1, parseInt(e.target.value) || 1))} + onChange={createNumberInputHandler(setNewPeriodDays, 1)} className="input w-24" min={1} /> @@ -525,14 +551,14 @@ export default function AdminTariffCreate() { setNewPeriodPrice(Math.max(1, parseInt(e.target.value) || 1))} + onChange={createNumberInputHandler(setNewPeriodPrice, 1)} className="input w-28" min={1} />