From 23e29be21b4d3a4391caa79591819962df8aaad4 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sat, 25 Apr 2026 11:17:32 +0500 Subject: [PATCH] =?UTF-8?q?feat(forms):=20MoneyInput/NumberInput=20+=20sel?= =?UTF-8?q?ect-=D0=BF=D0=B0=D0=B3=D0=B8=D0=BD=D0=B0=D1=86=D0=B8=D1=8F=20+?= =?UTF-8?q?=20Range=20=D0=BD=D0=B0=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI: - Pagination: ввод страницы заменён на select (option 1..totalPages), по выбору сразу setPage. Стрелки ← → остаются. - Field.tsx: добавлены MoneyInput (decimal + суффикс ₸/$/€) и NumberInput (decimal без валюты). Оба фильтруют ввод регулярно (только цифры + точка/запятая→точка), при focus — выделяют значение. - ProductEditPage: purchasePrice / vat / minStock / maxStock / amount в ценах продаж переведены на новые компоненты; символ валюты — из выбранной валюты позиции/закупки или из defaultCurrencySymbol орг. - SupplyEditPage / RetailSaleEditPage: quantity/unitPrice/discount в строках, paidCash/paidCard в шапке — на NumberInput/MoneyInput с символом из form.currencyId. - CountriesPage: vatRate — NumberInput. API: - ProductInput / ProductPriceInput / SupplyLineInput / RetailSaleLineInput / RetailSaleInput — добавлены [Range(0,1e10)] на денежные/количественные поля и [Range(0,100)] на проценты. ASP.NET автоматически валидирует и возвращает 400 при выходе. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Purchases/SuppliesController.cs | 6 +- .../Sales/RetailSalesController.cs | 12 ++- .../Catalog/CatalogDtos.cs | 9 +- src/food-market.web/src/components/Field.tsx | 82 +++++++++++++++++++ .../src/components/Pagination.tsx | 36 +++----- .../src/pages/CountriesPage.tsx | 8 +- .../src/pages/ProductEditPage.tsx | 38 ++++++--- .../src/pages/RetailSaleEditPage.tsx | 36 ++++---- .../src/pages/SupplyEditPage.tsx | 14 ++-- 9 files changed, 171 insertions(+), 70 deletions(-) diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs index d289a63..689a082 100644 --- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs +++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using foodmarket.Application.Common; using foodmarket.Application.Inventory; using foodmarket.Domain.Inventory; @@ -46,7 +47,10 @@ public record SupplyDto( decimal Total, DateTime? PostedAt, IReadOnlyList Lines); - public record SupplyLineInput(Guid ProductId, decimal Quantity, decimal UnitPrice); + public record SupplyLineInput( + Guid ProductId, + [Range(0, 1e10)] decimal Quantity, + [Range(0, 1e10)] decimal UnitPrice); public record SupplyInput( DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId, string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate, diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index a9a46e5..88cd012 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using foodmarket.Application.Common; using foodmarket.Application.Inventory; using foodmarket.Domain.Inventory; @@ -47,10 +48,17 @@ public record RetailSaleDto( string? Notes, DateTime? PostedAt, IReadOnlyList Lines); - public record RetailSaleLineInput(Guid ProductId, decimal Quantity, decimal UnitPrice, decimal Discount, decimal VatPercent); + public record RetailSaleLineInput( + Guid ProductId, + [Range(0, 1e10)] decimal Quantity, + [Range(0, 1e10)] decimal UnitPrice, + [Range(0, 1e10)] decimal Discount, + [Range(0, 100)] decimal VatPercent); public record RetailSaleInput( DateTime Date, Guid StoreId, Guid? RetailPointId, Guid? CustomerId, Guid CurrencyId, - PaymentMethod Payment, decimal PaidCash, decimal PaidCard, + PaymentMethod Payment, + [Range(0, 1e10)] decimal PaidCash, + [Range(0, 1e10)] decimal PaidCard, string? Notes, IReadOnlyList Lines); diff --git a/src/food-market.application/Catalog/CatalogDtos.cs b/src/food-market.application/Catalog/CatalogDtos.cs index fe2144e..0ec26ed 100644 --- a/src/food-market.application/Catalog/CatalogDtos.cs +++ b/src/food-market.application/Catalog/CatalogDtos.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using foodmarket.Domain.Catalog; namespace foodmarket.Application.Catalog; @@ -73,14 +74,14 @@ public record CounterpartyInput( string? Address, string? Phone, string? Email, string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive = true); public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ean13, bool IsPrimary = false); -public record ProductPriceInput(Guid PriceTypeId, decimal Amount, Guid CurrencyId); +public record ProductPriceInput(Guid PriceTypeId, [Range(0, 1e10)] decimal Amount, Guid CurrencyId); public record ProductInput( string Name, string? Article, string? Description, - Guid UnitOfMeasureId, decimal? Vat, bool VatEnabled, + Guid UnitOfMeasureId, [Range(0, 100)] decimal? Vat, bool VatEnabled, Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId, bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false, - decimal? MinStock = null, decimal? MaxStock = null, - decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null, + [Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null, + [Range(0, 1e10)] decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null, string? ImageUrl = null, bool IsActive = true, IReadOnlyList? Prices = null, IReadOnlyList? Barcodes = null); diff --git a/src/food-market.web/src/components/Field.tsx b/src/food-market.web/src/components/Field.tsx index 509a06f..3f3651c 100644 --- a/src/food-market.web/src/components/Field.tsx +++ b/src/food-market.web/src/components/Field.tsx @@ -33,6 +33,88 @@ export function Select(props: SelectHTMLAttributes) { return e.currentTarget.select()} + onChange={(e) => { + const raw = e.target.value.replace(',', '.').replace(/[^\d.]/g, '') + if (raw === '') return onChange(null) + // Не более одной точки. + const parts = raw.split('.') + const cleaned = parts.length > 1 ? parts[0] + '.' + parts.slice(1).join('') : raw + const n = Number(cleaned) + if (!Number.isFinite(n)) return + onChange(n) + }} + className={cn(inputClass, 'pr-10 text-right tabular-nums')} + /> + + {suffix} + + + ) +} + +interface NumberInputProps { + value: number | null | undefined + onChange: (v: number | null) => void + step?: number + disabled?: boolean + placeholder?: string + className?: string +} + +/** Числовое поле без валюты: только цифры + точка/запятая. Пустое → null. */ +export function NumberInput({ + value, onChange, disabled, placeholder = '0', className, +}: NumberInputProps) { + const display = value == null ? '' : String(value) + return ( + e.currentTarget.select()} + onChange={(e) => { + const raw = e.target.value.replace(',', '.').replace(/[^\d.]/g, '') + if (raw === '') return onChange(null) + const parts = raw.split('.') + const cleaned = parts.length > 1 ? parts[0] + '.' + parts.slice(1).join('') : raw + const n = Number(cleaned) + if (!Number.isFinite(n)) return + onChange(n) + }} + className={cn(inputClass, 'text-right tabular-nums', className)} + /> + ) +} + export function Checkbox({ label, checked, diff --git a/src/food-market.web/src/components/Pagination.tsx b/src/food-market.web/src/components/Pagination.tsx index c5c5ab5..21f48a7 100644 --- a/src/food-market.web/src/components/Pagination.tsx +++ b/src/food-market.web/src/components/Pagination.tsx @@ -1,5 +1,3 @@ -import { useEffect, useState } from 'react' - interface PaginationProps { page: number pageSize: number @@ -11,24 +9,13 @@ export function Pagination({ page, pageSize, total, onPageChange }: PaginationPr const totalPages = Math.max(1, Math.ceil(total / pageSize)) const from = (page - 1) * pageSize + 1 const to = Math.min(page * pageSize, total) - const [jumpValue, setJumpValue] = useState(String(page)) - - useEffect(() => { setJumpValue(String(page)) }, [page]) if (total === 0) return null - const commitJump = () => { - const n = parseInt(jumpValue, 10) - if (!Number.isFinite(n)) { setJumpValue(String(page)); return } - const clamped = Math.min(Math.max(1, n), totalPages) - if (clamped !== page) onPageChange(clamped) - setJumpValue(String(clamped)) - } - return ( -
+
{from}–{to} из {total} -
+
Страница - setJumpValue(e.target.value)} - onBlur={commitJump} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitJump() } }} - className="w-14 px-1.5 py-0.5 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-center tabular-nums" - /> + из {totalPages}
diff --git a/src/food-market.web/src/pages/ProductEditPage.tsx b/src/food-market.web/src/pages/ProductEditPage.tsx index 1eedddd..76db28f 100644 --- a/src/food-market.web/src/pages/ProductEditPage.tsx +++ b/src/food-market.web/src/pages/ProductEditPage.tsx @@ -4,7 +4,7 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react' import { api } from '@/lib/api' import { Button } from '@/components/Button' -import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field' +import { Field, TextInput, TextArea, Select, Checkbox, MoneyInput, NumberInput } from '@/components/Field' import { useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers, } from '@/lib/useLookups' @@ -278,10 +278,9 @@ export function ProductEditPage() { {org.data?.showVatEnabledOnProduct && form.vatEnabled && ( - setForm({ ...form, vat: Number(e.target.value) })} + onChange={(n) => setForm({ ...form, vat: n ?? 0 })} /> @@ -303,7 +302,12 @@ export function ProductEditPage() {
- setForm({ ...form, purchasePrice: e.target.value })} /> + setForm({ ...form, purchasePrice: n == null ? '' : String(n) })} + currencyCode={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.code ?? org.data?.defaultCurrencyCode ?? undefined} + currencySymbol={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.symbol ?? org.data?.defaultCurrencySymbol ?? undefined} + /> {org.data?.multiCurrencyEnabled && ( @@ -320,10 +324,18 @@ export function ProductEditPage() { - setForm({ ...form, minStock: e.target.value })} placeholder="—" /> + setForm({ ...form, minStock: n == null ? '' : String(n) })} + placeholder="—" + /> - setForm({ ...form, maxStock: e.target.value })} placeholder="—" /> + setForm({ ...form, maxStock: n == null ? '' : String(n) })} + placeholder="—" + /> @@ -344,11 +356,13 @@ export function ProductEditPage() { {priceTypes.data?.map((pt) => )}
-
- updatePrice(i, { amount: Number(e.target.value) })} /> - {!org.data?.multiCurrencyEnabled && ( - {org.data?.defaultCurrencySymbol ?? ''} - )} +
+ updatePrice(i, { amount: n ?? 0 })} + currencyCode={currencies.data?.find((c) => c.id === p.currencyId)?.code ?? org.data?.defaultCurrencyCode ?? undefined} + currencySymbol={currencies.data?.find((c) => c.id === p.currencyId)?.symbol ?? org.data?.defaultCurrencySymbol ?? undefined} + />
{org.data?.multiCurrencyEnabled && (
diff --git a/src/food-market.web/src/pages/RetailSaleEditPage.tsx b/src/food-market.web/src/pages/RetailSaleEditPage.tsx index 9b8ce8f..a22924d 100644 --- a/src/food-market.web/src/pages/RetailSaleEditPage.tsx +++ b/src/food-market.web/src/pages/RetailSaleEditPage.tsx @@ -4,7 +4,7 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react' import { api } from '@/lib/api' import { Button } from '@/components/Button' -import { Field, TextInput, TextArea, Select } from '@/components/Field' +import { Field, TextInput, TextArea, Select, MoneyInput, NumberInput } from '@/components/Field' import { ProductPicker } from '@/components/ProductPicker' import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups' import { useOrgSettings } from '@/lib/useOrgSettings' @@ -285,12 +285,16 @@ export function RetailSaleEditPage() { - setForm({ ...form, paidCash: Number(e.target.value) })} /> + setForm({ ...form, paidCash: n ?? 0 })} + currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code} + currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol} /> - setForm({ ...form, paidCard: Number(e.target.value) })} /> + setForm({ ...form, paidCard: n ?? 0 })} + currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code} + currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol} />