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", "deviceLimitLabel": "Devices in tariff",
"tierLevelLabel": "Tariff tier level", "tierLevelLabel": "Tariff tier level",
"tierLevelHint": "Affects upgrade availability between tariffs", "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.", "periodsTabHint": "Add periods and prices for the tariff. Users will be able to choose from the added periods.",
"addPeriodTitle": "Add period", "addPeriodTitle": "Add period",
"daysLabel": "Days", "daysLabel": "Days",
@@ -1307,7 +1308,8 @@
"periodType": "Period-based", "periodType": "Period-based",
"dailyPriceLabel": "Price per day", "dailyPriceLabel": "Price per day",
"currencyPerDay": "\u20bd/day", "currencyPerDay": "\u20bd/day",
"dailyDeductionDesc": "Charged daily from user's balance" "dailyDeductionDesc": "Charged daily from user's balance",
"dailyPriceRequired": "Enter a price per day greater than 0"
}, },
"servers": { "servers": {
"title": "Server Management", "title": "Server Management",

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import {
TariffListItem, TariffListItem,
} from '../api/campaigns'; } from '../api/campaigns';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
// Icons // Icons
const CampaignIcon = () => ( const CampaignIcon = () => (
@@ -161,17 +162,17 @@ export default function AdminCampaignCreate() {
const [isActive, setIsActive] = useState(true); const [isActive, setIsActive] = useState(true);
// Balance bonus // Balance bonus
const [balanceBonusRubles, setBalanceBonusRubles] = useState(0); const [balanceBonusRubles, setBalanceBonusRubles] = useState<number | ''>(0);
// Subscription bonus // Subscription bonus
const [subscriptionDays, setSubscriptionDays] = useState(7); const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(7);
const [subscriptionTraffic, setSubscriptionTraffic] = useState(10); const [subscriptionTraffic, setSubscriptionTraffic] = useState<number | ''>(10);
const [subscriptionDevices, setSubscriptionDevices] = useState(1); const [subscriptionDevices, setSubscriptionDevices] = useState<number | ''>(1);
const [selectedSquads, setSelectedSquads] = useState<string[]>([]); const [selectedSquads, setSelectedSquads] = useState<string[]>([]);
// Tariff bonus // Tariff bonus
const [tariffId, setTariffId] = useState<number | null>(null); const [tariffId, setTariffId] = useState<number | null>(null);
const [tariffDays, setTariffDays] = useState(30); const [tariffDays, setTariffDays] = useState<number | ''>(30);
// Fetch servers // Fetch servers
const { data: servers = [] } = useQuery({ const { data: servers = [] } = useQuery({
@@ -210,15 +211,15 @@ export default function AdminCampaignCreate() {
}; };
if (bonusType === 'balance') { if (bonusType === 'balance') {
data.balance_bonus_kopeks = Math.round(balanceBonusRubles * 100); data.balance_bonus_kopeks = Math.round(toNumber(balanceBonusRubles) * 100);
} else if (bonusType === 'subscription') { } else if (bonusType === 'subscription') {
data.subscription_duration_days = subscriptionDays; data.subscription_duration_days = toNumber(subscriptionDays, 7);
data.subscription_traffic_gb = subscriptionTraffic; data.subscription_traffic_gb = toNumber(subscriptionTraffic, 10);
data.subscription_device_limit = subscriptionDevices; data.subscription_device_limit = toNumber(subscriptionDevices, 1);
data.subscription_squads = selectedSquads; data.subscription_squads = selectedSquads;
} else if (bonusType === 'tariff') { } else if (bonusType === 'tariff') {
data.tariff_id = tariffId || undefined; data.tariff_id = tariffId || undefined;
data.tariff_duration_days = tariffDays; data.tariff_duration_days = toNumber(tariffDays, 30);
} }
createMutation.mutate(data); createMutation.mutate(data);
@@ -334,7 +335,7 @@ export default function AdminCampaignCreate() {
<input <input
type="number" type="number"
value={balanceBonusRubles} value={balanceBonusRubles}
onChange={(e) => setBalanceBonusRubles(Math.max(0, parseFloat(e.target.value) || 0))} onChange={createNumberInputHandler(setBalanceBonusRubles, 0)}
className="input w-32" className="input w-32"
min={0} min={0}
step={1} step={1}
@@ -360,7 +361,7 @@ export default function AdminCampaignCreate() {
<input <input
type="number" type="number"
value={subscriptionDays} value={subscriptionDays}
onChange={(e) => setSubscriptionDays(Math.max(1, parseInt(e.target.value) || 1))} onChange={createNumberInputHandler(setSubscriptionDays, 1)}
className="input" className="input"
min={1} min={1}
/> />
@@ -372,7 +373,7 @@ export default function AdminCampaignCreate() {
<input <input
type="number" type="number"
value={subscriptionTraffic} value={subscriptionTraffic}
onChange={(e) => setSubscriptionTraffic(Math.max(0, parseInt(e.target.value) || 0))} onChange={createNumberInputHandler(setSubscriptionTraffic, 0)}
className="input" className="input"
min={0} min={0}
/> />
@@ -384,7 +385,7 @@ export default function AdminCampaignCreate() {
<input <input
type="number" type="number"
value={subscriptionDevices} value={subscriptionDevices}
onChange={(e) => setSubscriptionDevices(Math.max(1, parseInt(e.target.value) || 1))} onChange={createNumberInputHandler(setSubscriptionDevices, 1)}
className="input" className="input"
min={1} min={1}
/> />
@@ -412,7 +413,7 @@ export default function AdminCampaignCreate() {
<input <input
type="number" type="number"
value={tariffDays} value={tariffDays}
onChange={(e) => setTariffDays(Math.max(1, parseInt(e.target.value) || 1))} onChange={createNumberInputHandler(setTariffDays, 1)}
className="input w-32" className="input w-32"
min={1} min={1}
/> />

View File

@@ -15,6 +15,7 @@ import {
CampaignRegistrationItem, CampaignRegistrationItem,
} from '../api/campaigns'; } from '../api/campaigns';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
// Icons // Icons
@@ -174,18 +175,18 @@ function CampaignModal({
const [isActive, setIsActive] = useState(campaign.is_active ?? true); const [isActive, setIsActive] = useState(campaign.is_active ?? true);
// Balance bonus // Balance bonus
const [balanceBonusRubles, setBalanceBonusRubles] = useState( const [balanceBonusRubles, setBalanceBonusRubles] = useState<number | ''>(
(campaign.balance_bonus_kopeks || 0) / 100, (campaign.balance_bonus_kopeks || 0) / 100,
); );
// Subscription bonus // Subscription bonus
const [subscriptionDays, setSubscriptionDays] = useState( const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(
campaign.subscription_duration_days || 7, campaign.subscription_duration_days || 7,
); );
const [subscriptionTraffic, setSubscriptionTraffic] = useState( const [subscriptionTraffic, setSubscriptionTraffic] = useState<number | ''>(
campaign.subscription_traffic_gb || 10, campaign.subscription_traffic_gb || 10,
); );
const [subscriptionDevices, setSubscriptionDevices] = useState( const [subscriptionDevices, setSubscriptionDevices] = useState<number | ''>(
campaign.subscription_device_limit || 1, campaign.subscription_device_limit || 1,
); );
const [selectedSquads, setSelectedSquads] = useState<string[]>( const [selectedSquads, setSelectedSquads] = useState<string[]>(
@@ -194,7 +195,7 @@ function CampaignModal({
// Tariff bonus // Tariff bonus
const [tariffId, setTariffId] = useState<number | null>(campaign.tariff_id || null); 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 handleSubmit = () => {
const data: CampaignUpdateRequest = { const data: CampaignUpdateRequest = {
@@ -205,15 +206,15 @@ function CampaignModal({
}; };
if (bonusType === 'balance') { if (bonusType === 'balance') {
data.balance_bonus_kopeks = Math.round(balanceBonusRubles * 100); data.balance_bonus_kopeks = Math.round(toNumber(balanceBonusRubles) * 100);
} else if (bonusType === 'subscription') { } else if (bonusType === 'subscription') {
data.subscription_duration_days = subscriptionDays; data.subscription_duration_days = toNumber(subscriptionDays, 7);
data.subscription_traffic_gb = subscriptionTraffic; data.subscription_traffic_gb = toNumber(subscriptionTraffic, 10);
data.subscription_device_limit = subscriptionDevices; data.subscription_device_limit = toNumber(subscriptionDevices, 1);
data.subscription_squads = selectedSquads; data.subscription_squads = selectedSquads;
} else if (bonusType === 'tariff') { } else if (bonusType === 'tariff') {
data.tariff_id = tariffId || undefined; data.tariff_id = tariffId || undefined;
data.tariff_duration_days = tariffDays; data.tariff_duration_days = toNumber(tariffDays, 30);
} }
onSave(data); onSave(data);
@@ -306,9 +307,7 @@ function CampaignModal({
<input <input
type="number" type="number"
value={balanceBonusRubles} value={balanceBonusRubles}
onChange={(e) => onChange={createNumberInputHandler(setBalanceBonusRubles, 0)}
setBalanceBonusRubles(Math.max(0, parseFloat(e.target.value) || 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" 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} min={0}
step={1} step={1}
@@ -331,9 +330,7 @@ function CampaignModal({
<input <input
type="number" type="number"
value={subscriptionDays} value={subscriptionDays}
onChange={(e) => onChange={createNumberInputHandler(setSubscriptionDays, 1)}
setSubscriptionDays(Math.max(1, parseInt(e.target.value) || 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" 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} min={1}
/> />
@@ -345,9 +342,7 @@ function CampaignModal({
<input <input
type="number" type="number"
value={subscriptionTraffic} value={subscriptionTraffic}
onChange={(e) => onChange={createNumberInputHandler(setSubscriptionTraffic, 0)}
setSubscriptionTraffic(Math.max(0, parseInt(e.target.value) || 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" 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} min={0}
/> />
@@ -359,9 +354,7 @@ function CampaignModal({
<input <input
type="number" type="number"
value={subscriptionDevices} value={subscriptionDevices}
onChange={(e) => onChange={createNumberInputHandler(setSubscriptionDevices, 1)}
setSubscriptionDevices(Math.max(1, parseInt(e.target.value) || 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" 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} min={1}
/> />
@@ -436,7 +429,7 @@ function CampaignModal({
<input <input
type="number" type="number"
value={tariffDays} 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" 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} min={1}
/> />

View File

@@ -14,6 +14,7 @@ import {
OfferType, OfferType,
} from '../api/promoOffers'; } from '../api/promoOffers';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
// Icons // Icons
@@ -135,12 +136,14 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
const [name, setName] = useState(template.name); const [name, setName] = useState(template.name);
const [messageText, setMessageText] = useState(template.message_text); const [messageText, setMessageText] = useState(template.message_text);
const [buttonText, setButtonText] = useState(template.button_text); const [buttonText, setButtonText] = useState(template.button_text);
const [validHours, setValidHours] = useState(template.valid_hours); const [validHours, setValidHours] = useState<number | ''>(template.valid_hours);
const [discountPercent, setDiscountPercent] = useState(template.discount_percent); const [discountPercent, setDiscountPercent] = useState<number | ''>(template.discount_percent);
const [activeDiscountHours, setActiveDiscountHours] = useState( const [activeDiscountHours, setActiveDiscountHours] = useState<number | ''>(
template.active_discount_hours || 0, 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 [isActive, setIsActive] = useState(template.is_active);
const isTestAccess = template.offer_type === 'test_access'; const isTestAccess = template.offer_type === 'test_access';
@@ -150,14 +153,16 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
name, name,
message_text: messageText, message_text: messageText,
button_text: buttonText, button_text: buttonText,
valid_hours: validHours, valid_hours: toNumber(validHours, 1),
discount_percent: discountPercent, discount_percent: toNumber(discountPercent),
is_active: isActive, is_active: isActive,
}; };
const testHours = toNumber(testDurationHours);
const discountHours = toNumber(activeDiscountHours);
if (isTestAccess) { if (isTestAccess) {
data.test_duration_hours = testDurationHours > 0 ? testDurationHours : undefined; data.test_duration_hours = testHours > 0 ? testHours : undefined;
} else { } else {
data.active_discount_hours = activeDiscountHours > 0 ? activeDiscountHours : undefined; data.active_discount_hours = discountHours > 0 ? discountHours : undefined;
} }
onSave(data); onSave(data);
}; };
@@ -224,7 +229,7 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
<input <input
type="number" type="number"
value={validHours} 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" 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} min={1}
/> />
@@ -241,9 +246,7 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
<input <input
type="number" type="number"
value={discountPercent} value={discountPercent}
onChange={(e) => onChange={createNumberInputHandler(setDiscountPercent, 0, 100)}
setDiscountPercent(Math.min(100, Math.max(0, parseInt(e.target.value) || 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" 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} min={0}
max={100} max={100}
@@ -260,7 +263,7 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
<input <input
type="number" type="number"
value={testDurationHours} 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" 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} min={0}
/> />
@@ -276,7 +279,7 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
<input <input
type="number" type="number"
value={activeDiscountHours} 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" 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} min={0}
/> />

View File

@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { serversApi, ServerListItem, ServerDetail, ServerUpdateRequest } from '../api/servers'; import { serversApi, ServerListItem, ServerDetail, ServerUpdateRequest } from '../api/servers';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
// Icons // Icons
@@ -94,18 +95,18 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
const [displayName, setDisplayName] = useState(server.display_name); const [displayName, setDisplayName] = useState(server.display_name);
const [description, setDescription] = useState(server.description || ''); const [description, setDescription] = useState(server.description || '');
const [countryCode, setCountryCode] = useState(server.country_code || ''); 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 [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 handleSubmit = () => {
const data: ServerUpdateRequest = { const data: ServerUpdateRequest = {
display_name: displayName, display_name: displayName,
description: description || undefined, description: description || undefined,
country_code: countryCode || undefined, country_code: countryCode || undefined,
price_kopeks: priceKopeks, price_kopeks: toNumber(priceKopeks),
max_users: maxUsers || undefined, max_users: maxUsers || undefined,
sort_order: sortOrder, sort_order: toNumber(sortOrder),
}; };
onSave(data); onSave(data);
}; };
@@ -186,8 +187,15 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
value={priceKopeks / 100} value={priceKopeks === '' ? '' : priceKopeks / 100}
onChange={(e) => setPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 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" 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} min={0}
step={1} step={1}
@@ -227,7 +235,7 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
<input <input
type="number" type="number"
value={sortOrder} 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" 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> </div>

View File

@@ -11,6 +11,7 @@ import {
ServerInfo, ServerInfo,
} from '../api/tariffs'; } from '../api/tariffs';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
// Icons // Icons
const PlusIcon = () => ( const PlusIcon = () => (
@@ -96,18 +97,18 @@ export default function AdminTariffCreate() {
// Form state // Form state
const [name, setName] = useState(''); const [name, setName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [trafficLimitGb, setTrafficLimitGb] = useState(100); const [trafficLimitGb, setTrafficLimitGb] = useState<number | ''>('');
const [deviceLimit, setDeviceLimit] = useState(1); const [deviceLimit, setDeviceLimit] = useState<number | ''>(1);
const [devicePriceKopeks, setDevicePriceKopeks] = useState(0); const [devicePriceKopeks, setDevicePriceKopeks] = useState<number | ''>(0);
const [maxDeviceLimit, setMaxDeviceLimit] = useState(0); const [maxDeviceLimit, setMaxDeviceLimit] = useState<number | ''>(0);
const [tierLevel, setTierLevel] = useState(1); const [tierLevel, setTierLevel] = useState<number | ''>(1);
const [periodPrices, setPeriodPrices] = useState<PeriodPrice[]>([]); const [periodPrices, setPeriodPrices] = useState<PeriodPrice[]>([]);
const [selectedSquads, setSelectedSquads] = useState<string[]>([]); const [selectedSquads, setSelectedSquads] = useState<string[]>([]);
const [dailyPriceKopeks, setDailyPriceKopeks] = useState(0); const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
// Traffic topup // Traffic topup
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(false); const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(false);
const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState(0); const [maxTopupTrafficGb, setMaxTopupTrafficGb] = useState<number | ''>(0);
const [trafficTopupPackages, setTrafficTopupPackages] = useState<Record<string, number>>({}); const [trafficTopupPackages, setTrafficTopupPackages] = useState<Record<string, number>>({});
// Traffic reset mode // Traffic reset mode
@@ -115,19 +116,19 @@ export default function AdminTariffCreate() {
// Custom days (period tariff) // Custom days (period tariff)
const [customDaysEnabled, setCustomDaysEnabled] = useState(false); const [customDaysEnabled, setCustomDaysEnabled] = useState(false);
const [pricePerDayKopeks, setPricePerDayKopeks] = useState(0); const [pricePerDayKopeks, setPricePerDayKopeks] = useState<number | ''>(0);
const [minDays, setMinDays] = useState(1); const [minDays, setMinDays] = useState<number | ''>(1);
const [maxDays, setMaxDays] = useState(365); const [maxDays, setMaxDays] = useState<number | ''>(365);
// Custom traffic (period tariff) // Custom traffic (period tariff)
const [customTrafficEnabled, setCustomTrafficEnabled] = useState(false); const [customTrafficEnabled, setCustomTrafficEnabled] = useState(false);
const [trafficPricePerGbKopeks, setTrafficPricePerGbKopeks] = useState(0); const [trafficPricePerGbKopeks, setTrafficPricePerGbKopeks] = useState<number | ''>(0);
const [minTrafficGb, setMinTrafficGb] = useState(1); const [minTrafficGb, setMinTrafficGb] = useState<number | ''>(1);
const [maxTrafficGb, setMaxTrafficGb] = useState(1000); const [maxTrafficGb, setMaxTrafficGb] = useState<number | ''>(1000);
// New period for adding // New period for adding
const [newPeriodDays, setNewPeriodDays] = useState(30); const [newPeriodDays, setNewPeriodDays] = useState<number | ''>(30);
const [newPeriodPrice, setNewPeriodPrice] = useState(300); const [newPeriodPrice, setNewPeriodPrice] = useState<number | ''>(300);
const [activeTab, setActiveTab] = useState<'basic' | 'periods' | 'servers' | 'extra'>('basic'); const [activeTab, setActiveTab] = useState<'basic' | 'periods' | 'servers' | 'extra'>('basic');
@@ -199,30 +200,31 @@ export default function AdminTariffCreate() {
const data: TariffCreateRequest | TariffUpdateRequest = { const data: TariffCreateRequest | TariffUpdateRequest = {
name, name,
description: description || undefined, description: description || undefined,
traffic_limit_gb: trafficLimitGb, traffic_limit_gb: toNumber(trafficLimitGb, 0),
device_limit: deviceLimit, device_limit: toNumber(deviceLimit, 1),
device_price_kopeks: devicePriceKopeks > 0 ? devicePriceKopeks : undefined, device_price_kopeks:
max_device_limit: maxDeviceLimit > 0 ? maxDeviceLimit : undefined, toNumber(devicePriceKopeks) > 0 ? toNumber(devicePriceKopeks) : undefined,
tier_level: tierLevel, max_device_limit: toNumber(maxDeviceLimit) > 0 ? toNumber(maxDeviceLimit) : undefined,
tier_level: toNumber(tierLevel, 1),
period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0), period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0),
allowed_squads: selectedSquads, allowed_squads: selectedSquads,
traffic_topup_enabled: trafficTopupEnabled, traffic_topup_enabled: trafficTopupEnabled,
traffic_topup_packages: trafficTopupPackages, traffic_topup_packages: trafficTopupPackages,
max_topup_traffic_gb: maxTopupTrafficGb, max_topup_traffic_gb: toNumber(maxTopupTrafficGb),
is_daily: isDaily, is_daily: isDaily,
daily_price_kopeks: isDaily ? dailyPriceKopeks : 0, daily_price_kopeks: isDaily ? toNumber(dailyPriceKopeks) : 0,
traffic_reset_mode: trafficResetMode, traffic_reset_mode: trafficResetMode,
}; };
if (!isDaily) { if (!isDaily) {
data.custom_days_enabled = customDaysEnabled; data.custom_days_enabled = customDaysEnabled;
data.price_per_day_kopeks = pricePerDayKopeks; data.price_per_day_kopeks = toNumber(pricePerDayKopeks);
data.min_days = minDays; data.min_days = toNumber(minDays, 1);
data.max_days = maxDays; data.max_days = toNumber(maxDays, 365);
data.custom_traffic_enabled = customTrafficEnabled; data.custom_traffic_enabled = customTrafficEnabled;
data.traffic_price_per_gb_kopeks = trafficPricePerGbKopeks; data.traffic_price_per_gb_kopeks = toNumber(trafficPricePerGbKopeks);
data.min_traffic_gb = minTrafficGb; data.min_traffic_gb = toNumber(minTrafficGb, 1);
data.max_traffic_gb = maxTrafficGb; data.max_traffic_gb = toNumber(maxTrafficGb, 1000);
} }
if (isEdit) { if (isEdit) {
@@ -239,13 +241,13 @@ export default function AdminTariffCreate() {
}; };
const addPeriod = () => { const addPeriod = () => {
if (newPeriodDays > 0 && newPeriodPrice > 0) { const days = toNumber(newPeriodDays, 0);
const exists = periodPrices.some((p) => p.days === newPeriodDays); const price = toNumber(newPeriodPrice, 0);
if (days > 0 && price > 0) {
const exists = periodPrices.some((p) => p.days === days);
if (!exists) { if (!exists) {
setPeriodPrices((prev) => setPeriodPrices((prev) =>
[...prev, { days: newPeriodDays, price_kopeks: newPeriodPrice * 100 }].sort( [...prev, { days, price_kopeks: price * 100 }].sort((a, b) => a.days - b.days),
(a, b) => a.days - b.days,
),
); );
setNewPeriodDays(30); setNewPeriodDays(30);
setNewPeriodPrice(300); setNewPeriodPrice(300);
@@ -265,8 +267,10 @@ export default function AdminTariffCreate() {
const isLoading = createMutation.isPending || updateMutation.isPending; const isLoading = createMutation.isPending || updateMutation.isPending;
const isValidPeriod = name && (periodPrices.length > 0 || customDaysEnabled); // Validate required numeric fields are not empty
const isValidDaily = name && dailyPriceKopeks > 0; const hasRequiredFields = deviceLimit !== '' && tierLevel !== '';
const isValidPeriod = name && hasRequiredFields && (periodPrices.length > 0 || customDaysEnabled);
const isValidDaily = name && hasRequiredFields && toNumber(dailyPriceKopeks) > 0;
const isValid = const isValid =
tariffType === 'period' ? isValidPeriod : tariffType === 'daily' ? isValidDaily : false; 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"> <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"> <label className="mb-2 block text-sm font-medium text-warning-400">
{t('admin.tariffs.dailyPriceLabel')} {t('admin.tariffs.dailyPriceLabel')}
<span className="text-error-400">*</span>
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
value={dailyPriceKopeks / 100} value={dailyPriceKopeks === '' ? '' : dailyPriceKopeks / 100}
onChange={(e) => onChange={(e) => {
setDailyPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100) const val = e.target.value;
if (val === '') {
setDailyPriceKopeks('');
} else {
const num = Math.max(0, parseFloat(val) || 0) * 100;
setDailyPriceKopeks(num);
} }
className="input w-32" }}
className={`input w-32 ${dailyPriceKopeks === '' || dailyPriceKopeks === 0 ? 'border-error-500/50' : ''}`}
min={0} min={0}
step={0.1} step={0.1}
placeholder="0.00"
/> />
<span className="text-dark-400">{t('admin.tariffs.currencyPerDay')}</span> <span className="text-dark-400">{t('admin.tariffs.currencyPerDay')}</span>
</div> </div>
<p className="mt-2 text-xs text-dark-500">{t('admin.tariffs.dailyDeductionDesc')}</p> <p className="mt-2 text-xs text-dark-500">{t('admin.tariffs.dailyDeductionDesc')}</p>
{(dailyPriceKopeks === '' || dailyPriceKopeks === 0) && (
<p className="mt-1 text-xs text-error-400">
{t('admin.tariffs.dailyPriceRequired')}
</p>
)}
</div> </div>
)} )}
@@ -447,12 +464,13 @@ export default function AdminTariffCreate() {
<input <input
type="number" type="number"
value={trafficLimitGb} value={trafficLimitGb}
onChange={(e) => setTrafficLimitGb(Math.max(0, parseInt(e.target.value) || 0))} onChange={createNumberInputHandler(setTrafficLimitGb, 0)}
className="input w-32" className="input w-32"
min={0} min={0}
placeholder="0"
/> />
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span> <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"> <span className="flex items-center gap-1 text-sm text-success-500">
<InfinityIcon /> <InfinityIcon />
{t('admin.tariffs.unlimited')} {t('admin.tariffs.unlimited')}
@@ -466,32 +484,40 @@ export default function AdminTariffCreate() {
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.tariffs.deviceLimitLabel')} {t('admin.tariffs.deviceLimitLabel')}
<span className="text-error-400">*</span>
</label> </label>
<input <input
type="number" type="number"
value={deviceLimit} value={deviceLimit}
onChange={(e) => setDeviceLimit(Math.max(1, parseInt(e.target.value) || 1))} onChange={createNumberInputHandler(setDeviceLimit, 1)}
className="input w-32" className={`input w-32 ${deviceLimit === '' ? 'border-error-500/50' : ''}`}
min={1} min={1}
placeholder="1"
/> />
{deviceLimit === '' && (
<p className="mt-1 text-xs text-error-400">{t('admin.tariffs.fieldRequired')}</p>
)}
</div> </div>
{/* Tier Level */} {/* Tier Level */}
<div> <div>
<label className="mb-2 block text-sm font-medium text-dark-300"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.tariffs.tierLevelLabel')} {t('admin.tariffs.tierLevelLabel')}
<span className="text-error-400">*</span>
</label> </label>
<input <input
type="number" type="number"
value={tierLevel} value={tierLevel}
onChange={(e) => onChange={createNumberInputHandler(setTierLevel, 1, 10)}
setTierLevel(Math.min(10, Math.max(1, parseInt(e.target.value) || 1))) className={`input w-32 ${tierLevel === '' ? 'border-error-500/50' : ''}`}
}
className="input w-32"
min={1} min={1}
max={10} max={10}
placeholder="1"
/> />
<p className="mt-1 text-xs text-dark-500">{t('admin.tariffs.tierLevelHint')}</p> <p className="mt-1 text-xs text-dark-500">{t('admin.tariffs.tierLevelHint')}</p>
{tierLevel === '' && (
<p className="mt-1 text-xs text-error-400">{t('admin.tariffs.fieldRequired')}</p>
)}
</div> </div>
</div> </div>
)} )}
@@ -513,7 +539,7 @@ export default function AdminTariffCreate() {
<input <input
type="number" type="number"
value={newPeriodDays} value={newPeriodDays}
onChange={(e) => setNewPeriodDays(Math.max(1, parseInt(e.target.value) || 1))} onChange={createNumberInputHandler(setNewPeriodDays, 1)}
className="input w-24" className="input w-24"
min={1} min={1}
/> />
@@ -525,14 +551,14 @@ export default function AdminTariffCreate() {
<input <input
type="number" type="number"
value={newPeriodPrice} value={newPeriodPrice}
onChange={(e) => setNewPeriodPrice(Math.max(1, parseInt(e.target.value) || 1))} onChange={createNumberInputHandler(setNewPeriodPrice, 1)}
className="input w-28" className="input w-28"
min={1} min={1}
/> />
</div> </div>
<button <button
onClick={addPeriod} 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" className="btn-primary flex items-center gap-2"
> >
<PlusIcon /> <PlusIcon />
@@ -639,10 +665,15 @@ export default function AdminTariffCreate() {
</span> </span>
<input <input
type="number" type="number"
value={devicePriceKopeks / 100} value={devicePriceKopeks === '' ? '' : devicePriceKopeks / 100}
onChange={(e) => onChange={(e) => {
setDevicePriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100) const val = e.target.value;
if (val === '') {
setDevicePriceKopeks('');
} else {
setDevicePriceKopeks(Math.max(0, parseFloat(val) || 0) * 100);
} }
}}
className="input w-24" className="input w-24"
min={0} min={0}
step={1} step={1}
@@ -657,7 +688,7 @@ export default function AdminTariffCreate() {
<input <input
type="number" type="number"
value={maxDeviceLimit} value={maxDeviceLimit}
onChange={(e) => setMaxDeviceLimit(Math.max(0, parseInt(e.target.value) || 0))} onChange={createNumberInputHandler(setMaxDeviceLimit, 0)}
className="input w-24" className="input w-24"
min={0} min={0}
/> />
@@ -694,9 +725,7 @@ export default function AdminTariffCreate() {
<input <input
type="number" type="number"
value={maxTopupTrafficGb} value={maxTopupTrafficGb}
onChange={(e) => onChange={createNumberInputHandler(setMaxTopupTrafficGb, 0)}
setMaxTopupTrafficGb(Math.max(0, parseInt(e.target.value) || 0))
}
className="input w-24" className="input w-24"
min={0} min={0}
/> />
@@ -767,10 +796,15 @@ export default function AdminTariffCreate() {
</span> </span>
<input <input
type="number" type="number"
value={pricePerDayKopeks / 100} value={pricePerDayKopeks === '' ? '' : pricePerDayKopeks / 100}
onChange={(e) => onChange={(e) => {
setPricePerDayKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100) const val = e.target.value;
if (val === '') {
setPricePerDayKopeks('');
} else {
setPricePerDayKopeks(Math.max(0, parseFloat(val) || 0) * 100);
} }
}}
className="input w-24" className="input w-24"
min={0} min={0}
step={0.1} step={0.1}
@@ -784,7 +818,7 @@ export default function AdminTariffCreate() {
<input <input
type="number" type="number"
value={minDays} value={minDays}
onChange={(e) => setMinDays(Math.max(1, parseInt(e.target.value) || 1))} onChange={createNumberInputHandler(setMinDays, 1)}
className="input w-24" className="input w-24"
min={1} min={1}
/> />
@@ -796,7 +830,7 @@ export default function AdminTariffCreate() {
<input <input
type="number" type="number"
value={maxDays} value={maxDays}
onChange={(e) => setMaxDays(Math.max(1, parseInt(e.target.value) || 1))} onChange={createNumberInputHandler(setMaxDays, 1)}
className="input w-24" className="input w-24"
min={1} min={1}
/> />
@@ -840,12 +874,15 @@ export default function AdminTariffCreate() {
</span> </span>
<input <input
type="number" type="number"
value={trafficPricePerGbKopeks / 100} value={trafficPricePerGbKopeks === '' ? '' : trafficPricePerGbKopeks / 100}
onChange={(e) => onChange={(e) => {
setTrafficPricePerGbKopeks( const val = e.target.value;
Math.max(0, parseFloat(e.target.value) || 0) * 100, if (val === '') {
) setTrafficPricePerGbKopeks('');
} else {
setTrafficPricePerGbKopeks(Math.max(0, parseFloat(val) || 0) * 100);
} }
}}
className="input w-24" className="input w-24"
min={0} min={0}
step={0.1} step={0.1}
@@ -859,7 +896,7 @@ export default function AdminTariffCreate() {
<input <input
type="number" type="number"
value={minTrafficGb} value={minTrafficGb}
onChange={(e) => setMinTrafficGb(Math.max(1, parseInt(e.target.value) || 1))} onChange={createNumberInputHandler(setMinTrafficGb, 1)}
className="input w-24" className="input w-24"
min={1} min={1}
/> />
@@ -871,7 +908,7 @@ export default function AdminTariffCreate() {
<input <input
type="number" type="number"
value={maxTrafficGb} value={maxTrafficGb}
onChange={(e) => setMaxTrafficGb(Math.max(1, parseInt(e.target.value) || 1))} onChange={createNumberInputHandler(setMaxTrafficGb, 1)}
className="input w-24" className="input w-24"
min={1} 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;
}