fix(money-input): сохранять промежуточный ввод точки в draft
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 26s
CI / Web (React + Vite) (push) Successful in 22s
Docker Images / API image (push) Successful in 34s
Docker Images / Web image (push) Successful in 26s
Docker Images / Deploy stage (push) Successful in 18s
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 26s
CI / Web (React + Vite) (push) Successful in 22s
Docker Images / API image (push) Successful in 34s
Docker Images / Web image (push) Successful in 26s
Docker Images / Deploy stage (push) Successful in 18s
Реальная причина бага: 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:
parent
9ee0434829
commit
bb7ec06780
|
|
@ -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 { cn } from '@/lib/utils'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
|
|
||||||
|
|
@ -48,51 +48,84 @@ interface MoneyInputProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Денежное поле: только цифры (+ точка/запятая если allowFractional),
|
/** Денежное поле: только цифры (+ точка/запятая если allowFractional),
|
||||||
* справа — символ валюты (₸/$/€). При allowFractional=false дробная часть
|
* справа — символ валюты (₸/$/€). Хранит локальный draft string чтобы
|
||||||
* отбрасывается на лету и сразу синхронизируется в state наружу; иначе —
|
* пользователь мог посимвольно ввести "100.50" без того чтобы точка
|
||||||
* две цифры после запятой. Пустое поле → null. */
|
* пропадала на пути value→Number(...)→back. Снаружи прокидывается number
|
||||||
|
* (или null если пусто); финальная нормализация — на onBlur. */
|
||||||
export function MoneyInput({
|
export function MoneyInput({
|
||||||
value, onChange, currencyCode, currencySymbol, allowFractional,
|
value, onChange, currencyCode, currencySymbol, allowFractional,
|
||||||
disabled, placeholder = '0', className,
|
disabled, placeholder = '0', className,
|
||||||
}: MoneyInputProps) {
|
}: MoneyInputProps) {
|
||||||
const org = useOrgSettings()
|
const org = useOrgSettings()
|
||||||
// Пока org.data не загружен и prop не задан — фактическое поведение
|
|
||||||
// оставляем «как сейчас в state», чтобы не резать дробь до приезда настройки.
|
|
||||||
const settingKnown = allowFractional !== undefined || org.data !== undefined
|
const settingKnown = allowFractional !== undefined || org.data !== undefined
|
||||||
const fractional = allowFractional ?? org.data?.allowFractionalPrices ?? true
|
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(() => {
|
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
|
if (value == null) return
|
||||||
const rounded = Math.round(value)
|
const rounded = Math.round(value)
|
||||||
if (rounded !== value) onChange(rounded)
|
if (rounded !== value) onChange(rounded)
|
||||||
}, [settingKnown, fractional, value, onChange])
|
}, [settingKnown, fractional, focused, value, onChange])
|
||||||
|
|
||||||
const suffix = currencySymbol || currencyCode || '₸'
|
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 (
|
return (
|
||||||
<div className={cn('relative', className)}>
|
<div className={cn('relative', className)}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode={fractional ? 'decimal' : 'numeric'}
|
inputMode={fractional ? 'decimal' : 'numeric'}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={display}
|
value={draft}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
onFocus={(e) => { setFocused(true); e.currentTarget.select() }}
|
||||||
|
onBlur={() => { setFocused(false); commitDraft(draft) }}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
let raw = e.target.value.replace(',', '.')
|
let raw = e.target.value.replace(',', '.')
|
||||||
raw = fractional ? raw.replace(/[^\d.]/g, '') : raw.replace(/[^\d]/g, '')
|
raw = fractional ? raw.replace(/[^\d.]/g, '') : raw.replace(/[^\d]/g, '')
|
||||||
if (raw === '') return onChange(null)
|
|
||||||
if (fractional) {
|
if (fractional) {
|
||||||
const parts = raw.split('.')
|
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)
|
const n = Number(raw)
|
||||||
if (!Number.isFinite(n)) return
|
if (Number.isFinite(n)) onChange(fractional ? n : Math.round(n))
|
||||||
onChange(fractional ? n : Math.round(n))
|
|
||||||
}}
|
}}
|
||||||
className={cn(inputClass, 'pr-10 text-right tabular-nums')}
|
className={cn(inputClass, 'pr-10 text-right tabular-nums')}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue