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
This commit is contained in:
c0mrade
2026-02-02 03:10:54 +03:00
parent 36cc01ca7e
commit 47e28ee78f
10 changed files with 234 additions and 136 deletions

View File

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

View File

@@ -1029,6 +1029,7 @@
"deviceLimitLabel": "دستگاه‌ها در تعرفه",
"tierLevelLabel": "سطح تعرفه",
"tierLevelHint": "بر دسترسی ارتقا بین تعرفه‌ها تأثیر می‌گذارد",
"fieldRequired": "فیلد اجباری",
"periodsTabHint": "دوره‌ها و قیمت‌ها را برای تعرفه اضافه کنید. کاربران می‌توانند از دوره‌های اضافه شده انتخاب کنند.",
"addPeriodTitle": "افزودن دوره",
"daysLabel": "روز",
@@ -1077,7 +1078,8 @@
"periodType": "دوره‌ای",
"dailyPriceLabel": "قیمت روزانه",
"currencyPerDay": "₽/روز",
"dailyDeductionDesc": "هر روز از موجودی کاربر کسر می‌شود"
"dailyDeductionDesc": "هر روز از موجودی کاربر کسر می‌شود",
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید"
},
"servers": {
"title": "مدیریت سرورها",

View File

@@ -1770,6 +1770,7 @@
"deviceLimitLabel": "Устройств в тарифе",
"tierLevelLabel": "Уровень тарифа",
"tierLevelHint": "Влияет на доступность перехода между тарифами",
"fieldRequired": "Обязательное поле",
"periodsTabHint": "Добавьте периоды и цены для тарифа. Пользователи смогут выбирать из добавленных периодов.",
"addPeriodTitle": "Добавить период",
"daysLabel": "Дней",
@@ -1818,7 +1819,8 @@
"periodType": "Периодный",
"dailyPriceLabel": "Цена за день",
"currencyPerDay": "₽/день",
"dailyDeductionDesc": "Списывается ежедневно с баланса пользователя"
"dailyDeductionDesc": "Списывается ежедневно с баланса пользователя",
"dailyPriceRequired": "Укажите цену за день больше 0"
},
"servers": {
"title": "Управление серверами",

View File

@@ -1066,6 +1066,7 @@
"deviceLimitLabel": "套餐中的设备数",
"tierLevelLabel": "套餐等级",
"tierLevelHint": "影响套餐之间的升级可用性",
"fieldRequired": "必填字段",
"periodsTabHint": "为套餐添加周期和价格。用户将能够从添加的周期中选择。",
"addPeriodTitle": "添加周期",
"daysLabel": "天",
@@ -1114,7 +1115,8 @@
"periodType": "周期套餐",
"dailyPriceLabel": "每天价格",
"currencyPerDay": "₽/天",
"dailyDeductionDesc": "每天从用户余额中扣除"
"dailyDeductionDesc": "每天从用户余额中扣除",
"dailyPriceRequired": "请输入大于0的每日价格"
},
"servers": {
"title": "服务器管理",

View File

@@ -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<number | ''>(0);
// Subscription bonus
const [subscriptionDays, setSubscriptionDays] = useState(7);
const [subscriptionTraffic, setSubscriptionTraffic] = useState(10);
const [subscriptionDevices, setSubscriptionDevices] = useState(1);
const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(7);
const [subscriptionTraffic, setSubscriptionTraffic] = useState<number | ''>(10);
const [subscriptionDevices, setSubscriptionDevices] = useState<number | ''>(1);
const [selectedSquads, setSelectedSquads] = useState<string[]>([]);
// Tariff bonus
const [tariffId, setTariffId] = useState<number | null>(null);
const [tariffDays, setTariffDays] = useState(30);
const [tariffDays, setTariffDays] = useState<number | ''>(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() {
<input
type="number"
value={balanceBonusRubles}
onChange={(e) => 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() {
<input
type="number"
value={subscriptionDays}
onChange={(e) => 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() {
<input
type="number"
value={subscriptionTraffic}
onChange={(e) => 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() {
<input
type="number"
value={subscriptionDevices}
onChange={(e) => 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() {
<input
type="number"
value={tariffDays}
onChange={(e) => setTariffDays(Math.max(1, parseInt(e.target.value) || 1))}
onChange={createNumberInputHandler(setTariffDays, 1)}
className="input w-32"
min={1}
/>

View File

@@ -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<number | ''>(
(campaign.balance_bonus_kopeks || 0) / 100,
);
// Subscription bonus
const [subscriptionDays, setSubscriptionDays] = useState(
const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(
campaign.subscription_duration_days || 7,
);
const [subscriptionTraffic, setSubscriptionTraffic] = useState(
const [subscriptionTraffic, setSubscriptionTraffic] = useState<number | ''>(
campaign.subscription_traffic_gb || 10,
);
const [subscriptionDevices, setSubscriptionDevices] = useState(
const [subscriptionDevices, setSubscriptionDevices] = useState<number | ''>(
campaign.subscription_device_limit || 1,
);
const [selectedSquads, setSelectedSquads] = useState<string[]>(
@@ -194,7 +195,7 @@ function CampaignModal({
// Tariff bonus
const [tariffId, setTariffId] = useState<number | null>(campaign.tariff_id || null);
const [tariffDays, setTariffDays] = useState(campaign.tariff_duration_days || 30);
const [tariffDays, setTariffDays] = useState<number | ''>(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({
<input
type="number"
value={balanceBonusRubles}
onChange={(e) =>
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({
<input
type="number"
value={subscriptionDays}
onChange={(e) =>
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({
<input
type="number"
value={subscriptionTraffic}
onChange={(e) =>
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({
<input
type="number"
value={subscriptionDevices}
onChange={(e) =>
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({
<input
type="number"
value={tariffDays}
onChange={(e) => 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}
/>

View File

@@ -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<number | ''>(template.valid_hours);
const [discountPercent, setDiscountPercent] = useState<number | ''>(template.discount_percent);
const [activeDiscountHours, setActiveDiscountHours] = useState<number | ''>(
template.active_discount_hours || 0,
);
const [testDurationHours, setTestDurationHours] = useState(template.test_duration_hours || 0);
const [testDurationHours, setTestDurationHours] = useState<number | ''>(
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
<input
type="number"
value={validHours}
onChange={(e) => 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
<input
type="number"
value={discountPercent}
onChange={(e) =>
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
<input
type="number"
value={testDurationHours}
onChange={(e) => 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
<input
type="number"
value={activeDiscountHours}
onChange={(e) => 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}
/>

View File

@@ -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<number | ''>(server.price_kopeks);
const [maxUsers, setMaxUsers] = useState<number | null>(server.max_users);
const [sortOrder, setSortOrder] = useState(server.sort_order);
const [sortOrder, setSortOrder] = useState<number | ''>(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) {
<div className="flex items-center gap-2">
<input
type="number"
value={priceKopeks / 100}
onChange={(e) => 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) {
<input
type="number"
value={sortOrder}
onChange={(e) => 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"
/>
</div>

View File

@@ -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<number | ''>('');
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 [dailyPriceKopeks, setDailyPriceKopeks] = useState(0);
const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
// Traffic topup
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(false);
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(0);
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState<number | ''>(0);
const [trafficTopupPackages, setTrafficTopupPackages] = useState<Record<string, number>>({});
// 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<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(0);
const [minTrafficGb, setMinTrafficGb] = useState(1);
const [maxTrafficGb, setMaxTrafficGb] = useState(1000);
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(30);
const [newPeriodPrice, setNewPeriodPrice] = useState(300);
const [newPeriodDays, setNewPeriodDays] = useState<number | ''>(30);
const [newPeriodPrice, setNewPeriodPrice] = useState<number | ''>(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() {
<div className="rounded-lg border border-warning-500/30 bg-warning-500/10 p-4">
<label className="mb-2 block text-sm font-medium text-warning-400">
{t('admin.tariffs.dailyPriceLabel')}
<span className="text-error-400">*</span>
</label>
<div className="flex items-center gap-2">
<input
type="number"
value={dailyPriceKopeks / 100}
onChange={(e) =>
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"
/>
<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>
)}
@@ -447,12 +464,13 @@ export default function AdminTariffCreate() {
<input
type="number"
value={trafficLimitGb}
onChange={(e) => setTrafficLimitGb(Math.max(0, parseInt(e.target.value) || 0))}
onChange={createNumberInputHandler(setTrafficLimitGb, 0)}
className="input w-32"
min={0}
placeholder="0"
/>
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span>
{trafficLimitGb === 0 && (
{(trafficLimitGb === 0 || trafficLimitGb === '') && (
<span className="flex items-center gap-1 text-sm text-success-500">
<InfinityIcon />
{t('admin.tariffs.unlimited')}
@@ -466,32 +484,40 @@ export default function AdminTariffCreate() {
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.tariffs.deviceLimitLabel')}
<span className="text-error-400">*</span>
</label>
<input
type="number"
value={deviceLimit}
onChange={(e) => 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 === '' && (
<p className="mt-1 text-xs text-error-400">{t('admin.tariffs.fieldRequired')}</p>
)}
</div>
{/* Tier Level */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.tariffs.tierLevelLabel')}
<span className="text-error-400">*</span>
</label>
<input
type="number"
value={tierLevel}
onChange={(e) =>
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"
/>
<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>
)}
@@ -513,7 +539,7 @@ export default function AdminTariffCreate() {
<input
type="number"
value={newPeriodDays}
onChange={(e) => 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() {
<input
type="number"
value={newPeriodPrice}
onChange={(e) => setNewPeriodPrice(Math.max(1, parseInt(e.target.value) || 1))}
onChange={createNumberInputHandler(setNewPeriodPrice, 1)}
className="input w-28"
min={1}
/>
</div>
<button
onClick={addPeriod}
disabled={periodPrices.some((p) => p.days === newPeriodDays)}
disabled={periodPrices.some((p) => p.days === toNumber(newPeriodDays, 0))}
className="btn-primary flex items-center gap-2"
>
<PlusIcon />
@@ -639,10 +665,15 @@ export default function AdminTariffCreate() {
</span>
<input
type="number"
value={devicePriceKopeks / 100}
onChange={(e) =>
setDevicePriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)
}
value={devicePriceKopeks === '' ? '' : devicePriceKopeks / 100}
onChange={(e) => {
const val = e.target.value;
if (val === '') {
setDevicePriceKopeks('');
} else {
setDevicePriceKopeks(Math.max(0, parseFloat(val) || 0) * 100);
}
}}
className="input w-24"
min={0}
step={1}
@@ -657,7 +688,7 @@ export default function AdminTariffCreate() {
<input
type="number"
value={maxDeviceLimit}
onChange={(e) => setMaxDeviceLimit(Math.max(0, parseInt(e.target.value) || 0))}
onChange={createNumberInputHandler(setMaxDeviceLimit, 0)}
className="input w-24"
min={0}
/>
@@ -694,9 +725,7 @@ export default function AdminTariffCreate() {
<input
type="number"
value={maxTopupTrafficGb}
onChange={(e) =>
setMaxTopupTrafficGb(Math.max(0, parseInt(e.target.value) || 0))
}
onChange={createNumberInputHandler(setMaxTopupTrafficGb, 0)}
className="input w-24"
min={0}
/>
@@ -767,10 +796,15 @@ export default function AdminTariffCreate() {
</span>
<input
type="number"
value={pricePerDayKopeks / 100}
onChange={(e) =>
setPricePerDayKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)
}
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}
@@ -784,7 +818,7 @@ export default function AdminTariffCreate() {
<input
type="number"
value={minDays}
onChange={(e) => setMinDays(Math.max(1, parseInt(e.target.value) || 1))}
onChange={createNumberInputHandler(setMinDays, 1)}
className="input w-24"
min={1}
/>
@@ -796,7 +830,7 @@ export default function AdminTariffCreate() {
<input
type="number"
value={maxDays}
onChange={(e) => setMaxDays(Math.max(1, parseInt(e.target.value) || 1))}
onChange={createNumberInputHandler(setMaxDays, 1)}
className="input w-24"
min={1}
/>
@@ -840,12 +874,15 @@ export default function AdminTariffCreate() {
</span>
<input
type="number"
value={trafficPricePerGbKopeks / 100}
onChange={(e) =>
setTrafficPricePerGbKopeks(
Math.max(0, parseFloat(e.target.value) || 0) * 100,
)
}
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}
@@ -859,7 +896,7 @@ export default function AdminTariffCreate() {
<input
type="number"
value={minTrafficGb}
onChange={(e) => setMinTrafficGb(Math.max(1, parseInt(e.target.value) || 1))}
onChange={createNumberInputHandler(setMinTrafficGb, 1)}
className="input w-24"
min={1}
/>
@@ -871,7 +908,7 @@ export default function AdminTariffCreate() {
<input
type="number"
value={maxTrafficGb}
onChange={(e) => setMaxTrafficGb(Math.max(1, parseInt(e.target.value) || 1))}
onChange={createNumberInputHandler(setMaxTrafficGb, 1)}
className="input w-24"
min={1}
/>

48
src/utils/inputHelpers.ts Normal file
View File

@@ -0,0 +1,48 @@
import { ChangeEvent } from 'react';
/**
* Creates an onChange handler for number inputs that allows empty values while typing.
* This fixes the issue where inputs can't be cleared because `parseInt('') || 0` immediately fills with 0.
*
* @param setter - The state setter function
* @param min - Optional minimum value constraint
* @param max - Optional maximum value constraint
*/
export function createNumberInputHandler(
setter: (value: number | '') => void,
min?: number,
max?: number,
) {
return (e: ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
if (val === '') {
setter('');
return;
}
let num = parseFloat(val);
if (isNaN(num)) return;
if (min !== undefined) num = Math.max(min, num);
if (max !== undefined) num = Math.min(max, num);
setter(num);
};
}
/**
* Converts a number|'' state value to a number, using a default for empty.
*/
export function toNumber(value: number | '', defaultValue = 0): number {
return value === '' ? defaultValue : value;
}
/**
* Gets the display value for a kopeks field (converts to rubles).
* Returns '' if the value is empty, otherwise divides by 100.
*/
export function kopeksToDisplay(value: number | ''): number | '' {
return value === '' ? '' : value / 100;
}