mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +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",
|
"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",
|
||||||
|
|||||||
@@ -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": "مدیریت سرورها",
|
||||||
|
|||||||
@@ -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": "Управление серверами",
|
||||||
|
|||||||
@@ -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": "服务器管理",
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
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