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 {
|
interface NumberInputProps {
|
||||||
value: number | null | undefined
|
value: number | null | undefined
|
||||||
onChange: (v: number | null) => void
|
onChange: (v: number | null) => void
|
||||||
|
|
|
||||||
|
|
@ -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)⌉.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue