mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -1029,6 +1029,7 @@
|
||||
"deviceLimitLabel": "دستگاهها در تعرفه",
|
||||
"tierLevelLabel": "سطح تعرفه",
|
||||
"tierLevelHint": "بر دسترسی ارتقا بین تعرفهها تأثیر میگذارد",
|
||||
"fieldRequired": "فیلد اجباری",
|
||||
"periodsTabHint": "دورهها و قیمتها را برای تعرفه اضافه کنید. کاربران میتوانند از دورههای اضافه شده انتخاب کنند.",
|
||||
"addPeriodTitle": "افزودن دوره",
|
||||
"daysLabel": "روز",
|
||||
@@ -1077,7 +1078,8 @@
|
||||
"periodType": "دورهای",
|
||||
"dailyPriceLabel": "قیمت روزانه",
|
||||
"currencyPerDay": "₽/روز",
|
||||
"dailyDeductionDesc": "هر روز از موجودی کاربر کسر میشود"
|
||||
"dailyDeductionDesc": "هر روز از موجودی کاربر کسر میشود",
|
||||
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید"
|
||||
},
|
||||
"servers": {
|
||||
"title": "مدیریت سرورها",
|
||||
|
||||
@@ -1770,6 +1770,7 @@
|
||||
"deviceLimitLabel": "Устройств в тарифе",
|
||||
"tierLevelLabel": "Уровень тарифа",
|
||||
"tierLevelHint": "Влияет на доступность перехода между тарифами",
|
||||
"fieldRequired": "Обязательное поле",
|
||||
"periodsTabHint": "Добавьте периоды и цены для тарифа. Пользователи смогут выбирать из добавленных периодов.",
|
||||
"addPeriodTitle": "Добавить период",
|
||||
"daysLabel": "Дней",
|
||||
@@ -1818,7 +1819,8 @@
|
||||
"periodType": "Периодный",
|
||||
"dailyPriceLabel": "Цена за день",
|
||||
"currencyPerDay": "₽/день",
|
||||
"dailyDeductionDesc": "Списывается ежедневно с баланса пользователя"
|
||||
"dailyDeductionDesc": "Списывается ежедневно с баланса пользователя",
|
||||
"dailyPriceRequired": "Укажите цену за день больше 0"
|
||||
},
|
||||
"servers": {
|
||||
"title": "Управление серверами",
|
||||
|
||||
@@ -1066,6 +1066,7 @@
|
||||
"deviceLimitLabel": "套餐中的设备数",
|
||||
"tierLevelLabel": "套餐等级",
|
||||
"tierLevelHint": "影响套餐之间的升级可用性",
|
||||
"fieldRequired": "必填字段",
|
||||
"periodsTabHint": "为套餐添加周期和价格。用户将能够从添加的周期中选择。",
|
||||
"addPeriodTitle": "添加周期",
|
||||
"daysLabel": "天",
|
||||
@@ -1114,7 +1115,8 @@
|
||||
"periodType": "周期套餐",
|
||||
"dailyPriceLabel": "每天价格",
|
||||
"currencyPerDay": "₽/天",
|
||||
"dailyDeductionDesc": "每天从用户余额中扣除"
|
||||
"dailyDeductionDesc": "每天从用户余额中扣除",
|
||||
"dailyPriceRequired": "请输入大于0的每日价格"
|
||||
},
|
||||
"servers": {
|
||||
"title": "服务器管理",
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
48
src/utils/inputHelpers.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user