From d2160f891011378a71dcd428a1e4c8151a63a171 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:54:25 +0500 Subject: [PATCH] =?UTF-8?q?feat(percent-input):=20=D0=BA=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=20+=20inline-=D0=BD=D0=B0=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D0=BA=D0=B0=20=D0=B2=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B5=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/food-market.web/src/components/Field.tsx | 61 +++++++++++++++++++ .../src/pages/ProductGroupsPage.tsx | 32 ++++++++-- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/food-market.web/src/components/Field.tsx b/src/food-market.web/src/components/Field.tsx index aeb54f7..5f8c40d 100644 --- a/src/food-market.web/src/components/Field.tsx +++ b/src/food-market.web/src/components/Field.tsx @@ -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(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 ( +
+ { 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')} + /> + % +
+ ) +} + interface NumberInputProps { value: number | null | undefined onChange: (v: number | null) => void diff --git a/src/food-market.web/src/pages/ProductGroupsPage.tsx b/src/food-market.web/src/pages/ProductGroupsPage.tsx index e48d031..9d6aa2b 100644 --- a/src/food-market.web/src/pages/ProductGroupsPage.tsx +++ b/src/food-market.web/src/pages/ProductGroupsPage.tsx @@ -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(URL) const { create, update, remove } = useCatalogMutations(URL, URL) const [form, setForm] = useState
(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) => {r.path} }, - { header: 'Наценка', width: '110px', className: 'text-right', cell: (r) => r.markupPercent != null ? `${r.markupPercent.toFixed(2)}%` : '—' }, + { header: 'Наценка', width: '140px', cell: (r) => ( +
e.stopPropagation()}> + { void saveMarkup(r, n) }} + placeholder="—" + /> +
+ )}, { header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder }, ]} /> @@ -104,11 +125,10 @@ export function ProductGroupsPage() { /> - setForm({ ...form, markupPercent: n })} placeholder="нет автонаценки" - onChange={(e) => setForm({ ...form, markupPercent: e.target.value === '' ? null : Number(e.target.value) })} />

При проведении приёмки розничная цена товара = ⌈Себестоимость × (1 + наценка/100)⌉.