fix(money-input): сохранять промежуточный ввод точки в draft

Реальная причина бага: classic problem controlled numeric input.
Когда юзер печатал «100.» (хотел «100.50»), цикл value→Number("100.")→100
→ display=String(100)=«100» съедал точку, и продолжать ввод дроби
становилось невозможно.

Фикс: MoneyInput хранит локальный draft string, который и показывается
в input. Снаружи value всё равно прокидывается числом, но draft не
синхронизируется с ним пока поле в фокусе. Промежуточные состояния
типа «100.» теперь живут в draft и не теряются.

- Добавлены useState<draft> и useState<focused>.
- onChange: пишем в draft as-is (только фильтр символов и одна точка),
  наружу onChange(number) отдаём сразу когда draft парсится в число
  (включая случай «100.» → отдаём 100, но draft оставляем «100.»).
- onBlur: commitDraft нормализует draft и в number, и обратно в draft.
- useEffect синхронизирует draft с value только когда !focused.
- Округление при !fractional не выполняется во время focus — иначе
  перебивает ввод пользователя.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-25 12:44:18 +05:00
parent 7d86b7ed73
commit 87271281b0

View file

@ -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" без того чтобы точка
* пропадала на пути valueNumber(...)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<string>(() => 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 (
<div className={cn('relative', className)}>
<input
type="text"
inputMode={fractional ? 'decimal' : 'numeric'}
disabled={disabled}
value={display}
value={draft}
placeholder={placeholder}
onFocus={(e) => 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')}
/>