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:
parent
ba7de0b513
commit
d2160f8910
|
|
@ -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 {
|
||||
value: number | null | undefined
|
||||
onChange: (v: number | null) => void
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@ import { Pagination } from '@/components/Pagination'
|
|||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
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 type { ProductGroup } from '@/lib/types'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
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 { create, update, remove } = useCatalogMutations(URL, URL)
|
||||
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 () => {
|
||||
if (!form) return
|
||||
|
|
@ -54,7 +67,15 @@ export function ProductGroupsPage() {
|
|||
columns={[
|
||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
||||
{ 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 },
|
||||
]}
|
||||
/>
|
||||
|
|
@ -104,11 +125,10 @@ export function ProductGroupsPage() {
|
|||
/>
|
||||
</Field>
|
||||
<Field label="Наценка % (для авто-розничной по себестоимости)">
|
||||
<TextInput
|
||||
type="number" step="0.01"
|
||||
value={form.markupPercent ?? ''}
|
||||
<PercentInput
|
||||
value={form.markupPercent}
|
||||
onChange={(n) => setForm({ ...form, markupPercent: n })}
|
||||
placeholder="нет автонаценки"
|
||||
onChange={(e) => setForm({ ...form, markupPercent: e.target.value === '' ? null : Number(e.target.value) })}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
При проведении приёмки розничная цена товара = ⌈Себестоимость × (1 + наценка/100)⌉.
|
||||
|
|
|
|||
Loading…
Reference in a new issue