diff --git a/src/food-market.web/src/components/Field.tsx b/src/food-market.web/src/components/Field.tsx index 8c90034..d910c7e 100644 --- a/src/food-market.web/src/components/Field.tsx +++ b/src/food-market.web/src/components/Field.tsx @@ -1,4 +1,4 @@ -import { useEffect, type InputHTMLAttributes, type SelectHTMLAttributes, type ReactNode, type TextareaHTMLAttributes } from 'react' +import { useEffect, useState, type InputHTMLAttributes, type SelectHTMLAttributes, type ReactNode, type TextareaHTMLAttributes } from 'react' import { cn } from '@/lib/utils' import { useOrgSettings } from '@/lib/useOrgSettings' @@ -48,51 +48,84 @@ interface MoneyInputProps { } /** Денежное поле: только цифры (+ точка/запятая если allowFractional), - * справа — символ валюты (₸/$/€). При allowFractional=false дробная часть - * отбрасывается на лету и сразу синхронизируется в state наружу; иначе — - * две цифры после запятой. Пустое поле → null. */ + * справа — символ валюты (₸/$/€). Хранит локальный draft string чтобы + * пользователь мог посимвольно ввести "100.50" без того чтобы точка + * пропадала на пути value→Number(...)→back. Снаружи прокидывается number + * (или null если пусто); финальная нормализация — на onBlur. */ export function MoneyInput({ value, onChange, currencyCode, currencySymbol, allowFractional, disabled, placeholder = '0', className, }: MoneyInputProps) { const org = useOrgSettings() - // Пока org.data не загружен и prop не задан — фактическое поведение - // оставляем «как сейчас в state», чтобы не резать дробь до приезда настройки. const settingKnown = allowFractional !== undefined || org.data !== undefined const fractional = allowFractional ?? org.data?.allowFractionalPrices ?? true - // Когда настройка известна и она запрещает дробное, а в state лежит дробь — - // синхронизируем округлённое значение наружу. Это нужно чтобы при сохранении - // не уходило значение, которое юзер не видит на экране. + const formatFromValue = (v: number | null | undefined, frac: boolean) => + v == null ? '' : (frac ? String(v) : String(Math.round(v))) + + // Локальный draft — то что юзер реально печатает. Может быть промежуточным + // ("100.", "100.5", "0."). Sync с внешним value только когда поле не в фокусе. + const [draft, setDraft] = useState(() => formatFromValue(value, fractional)) + const [focused, setFocused] = useState(false) + useEffect(() => { - if (!settingKnown || fractional) return + if (focused) return + setDraft(formatFromValue(value, fractional)) + }, [value, fractional, focused]) + + // Если настройка fractional=false, а в state лежит дробь — округляем наружу, + // чтобы при сохранении ушло то, что пользователь видит. Не делаем пока юзер + // активно печатает (focused) — иначе перебьём его ввод. + useEffect(() => { + if (!settingKnown || fractional || focused) return if (value == null) return const rounded = Math.round(value) if (rounded !== value) onChange(rounded) - }, [settingKnown, fractional, value, onChange]) + }, [settingKnown, fractional, focused, value, onChange]) const suffix = currencySymbol || currencyCode || '₸' - const display = value == null ? '' : (fractional ? String(value) : String(Math.round(value))) + + const commitDraft = (raw: string) => { + const cleaned = fractional ? raw.replace(/[^\d.]/g, '') : raw.replace(/[^\d]/g, '') + if (cleaned === '' || cleaned === '.') { onChange(null); setDraft(''); return } + const parts = cleaned.split('.') + const normalized = parts.length > 1 ? parts[0] + '.' + parts.slice(1).join('') : cleaned + const n = Number(normalized) + if (!Number.isFinite(n)) { onChange(null); setDraft(''); return } + const final = fractional ? n : Math.round(n) + onChange(final) + setDraft(formatFromValue(final, fractional)) + } + return (
e.currentTarget.select()} + onFocus={(e) => { setFocused(true); e.currentTarget.select() }} + onBlur={() => { setFocused(false); commitDraft(draft) }} onChange={(e) => { let raw = e.target.value.replace(',', '.') raw = fractional ? raw.replace(/[^\d.]/g, '') : raw.replace(/[^\d]/g, '') - if (raw === '') return onChange(null) if (fractional) { const parts = raw.split('.') - raw = parts.length > 1 ? parts[0] + '.' + parts.slice(1).join('') : raw + if (parts.length > 2) raw = parts[0] + '.' + parts.slice(1).join('') + } + // Draft сохраняем как есть — даже промежуточное "100." с точкой на конце, + // чтобы пользователь мог продолжать печатать дробную часть. + setDraft(raw) + if (raw === '' || raw === '.') { onChange(null); return } + // Промежуточное «100.» — снаружи отдаём 100 как число, draft не трогаем. + if (fractional && raw.endsWith('.')) { + const n = Number(raw.slice(0, -1)) + if (Number.isFinite(n)) onChange(n) + return } const n = Number(raw) - if (!Number.isFinite(n)) return - onChange(fractional ? n : Math.round(n)) + if (Number.isFinite(n)) onChange(fractional ? n : Math.round(n)) }} className={cn(inputClass, 'pr-10 text-right tabular-nums')} />