feat(percent-input): компонент + inline-наценка в таблице групп

- Field.tsx: новый компонент PercentInput (брат MoneyInput) — только
  цифры + точка/запятая, до 2 знаков после, суффикс «%». На onBlur
  нормализуется в Math.round(n * 100) / 100. null = пусто.
  Использует тот же draft-pattern что MoneyInput, чтобы при наборе
  «12.» точка не пропадала.
- ProductGroupsPage:
  • поле «Наценка %» в модалке заменено на PercentInput,
  • в колонке «Наценка» таблицы теперь inline-PercentInput с
    автосохранением через PUT /api/catalog/product-groups/{id} и
    инвалидацией листинга после ответа сервера. Click stopped — клик
    в инпут не открывает модалку.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-25 22:54:25 +05:00
parent ba7de0b513
commit d2160f8910
2 changed files with 87 additions and 6 deletions

View file

@ -142,6 +142,67 @@ export function MoneyInput({
) )
} }
interface PercentInputProps {
value: number | null | undefined
onChange: (v: number | null) => void
disabled?: boolean
placeholder?: string
className?: string
}
/** Поле для процента: только цифры + запятая/точка, до 2 знаков. Справа суффикс «%».
* onBlur нормализует значение до 2 знаков (Math.round * 100 / 100). null если пусто. */
export function PercentInput({ value, onChange, disabled, placeholder = '—', className }: PercentInputProps) {
const [draft, setDraft] = useState<string>(value == null ? '' : value.toFixed(2))
const [focused, setFocused] = useState(false)
useEffect(() => {
if (focused) return
setDraft(value == null ? '' : value.toFixed(2))
}, [value, focused])
const commit = (raw: string) => {
const cleaned = raw.replace(',', '.').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 rounded = Math.round(n * 100) / 100
onChange(rounded)
setDraft(rounded.toFixed(2))
}
return (
<div className={cn('relative', className)}>
<input
type="text"
inputMode="decimal"
disabled={disabled}
value={draft}
placeholder={placeholder}
onFocus={(e) => { setFocused(true); e.currentTarget.select() }}
onBlur={() => { setFocused(false); commit(draft) }}
onChange={(e) => {
let raw = e.target.value.replace(',', '.').replace(/[^\d.]/g, '')
const parts = raw.split('.')
if (parts.length > 2) raw = parts[0] + '.' + parts.slice(1).join('')
setDraft(raw)
if (raw === '' || raw === '.') { onChange(null); return }
if (raw.endsWith('.')) {
const n = Number(raw.slice(0, -1))
if (Number.isFinite(n)) onChange(n)
return
}
const n = Number(raw)
if (Number.isFinite(n)) onChange(n)
}}
className={cn(inputClass, 'pr-8 text-right tabular-nums')}
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-sm text-slate-500">%</span>
</div>
)
}
interface NumberInputProps { interface NumberInputProps {
value: number | null | undefined value: number | null | undefined
onChange: (v: number | null) => void onChange: (v: number | null) => void

View file

@ -6,9 +6,11 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal' import { Modal } from '@/components/Modal'
import { Field, TextInput, Select } from '@/components/Field' import { Field, TextInput, Select, PercentInput } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog' import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { ProductGroup } from '@/lib/types' import type { ProductGroup } from '@/lib/types'
import { useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
const URL = '/api/catalog/product-groups' const URL = '/api/catalog/product-groups'
@ -19,6 +21,17 @@ export function ProductGroupsPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<ProductGroup>(URL) const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<ProductGroup>(URL)
const { create, update, remove } = useCatalogMutations(URL, URL) const { create, update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null) const [form, setForm] = useState<Form | null>(null)
const qc = useQueryClient()
// inline-сохранение наценки прямо из таблицы — без открытия модалки.
const saveMarkup = async (g: ProductGroup, markupPercent: number | null) => {
await api.put(`/api/catalog/product-groups/${g.id}`, {
name: g.name,
parentId: g.parentId,
sortOrder: g.sortOrder,
markupPercent,
})
await qc.invalidateQueries({ queryKey: [URL] })
}
const save = async () => { const save = async () => {
if (!form) return if (!form) return
@ -54,7 +67,15 @@ export function ProductGroupsPage() {
columns={[ columns={[
{ header: 'Название', sortKey: 'name', cell: (r) => r.name }, { header: 'Название', sortKey: 'name', cell: (r) => r.name },
{ header: 'Путь', sortKey: 'path', cell: (r) => <span className="text-slate-500">{r.path}</span> }, { header: 'Путь', sortKey: 'path', cell: (r) => <span className="text-slate-500">{r.path}</span> },
{ header: 'Наценка', width: '110px', className: 'text-right', cell: (r) => r.markupPercent != null ? `${r.markupPercent.toFixed(2)}%` : '—' }, { header: 'Наценка', width: '140px', cell: (r) => (
<div onClick={(e) => e.stopPropagation()}>
<PercentInput
value={r.markupPercent}
onChange={(n) => { void saveMarkup(r, n) }}
placeholder="—"
/>
</div>
)},
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder }, { header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
]} ]}
/> />
@ -104,11 +125,10 @@ export function ProductGroupsPage() {
/> />
</Field> </Field>
<Field label="Наценка % (для авто-розничной по себестоимости)"> <Field label="Наценка % (для авто-розничной по себестоимости)">
<TextInput <PercentInput
type="number" step="0.01" value={form.markupPercent}
value={form.markupPercent ?? ''} onChange={(n) => setForm({ ...form, markupPercent: n })}
placeholder="нет автонаценки" placeholder="нет автонаценки"
onChange={(e) => setForm({ ...form, markupPercent: e.target.value === '' ? null : Number(e.target.value) })}
/> />
<p className="text-xs text-slate-500 mt-1"> <p className="text-xs text-slate-500 mt-1">
При проведении приёмки розничная цена товара = Себестоимость × (1 + наценка/100). При проведении приёмки розничная цена товара = Себестоимость × (1 + наценка/100).