feat(forms): MoneyInput/NumberInput + select-пагинация + Range на бэкенде
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 28s
CI / Web (React + Vite) (push) Successful in 23s
Docker Images / API image (push) Successful in 39s
Docker Images / Web image (push) Successful in 26s
Docker Images / Deploy stage (push) Successful in 18s

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) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-25 11:17:32 +05:00
parent d20e131cf8
commit 4d19015d6d
9 changed files with 171 additions and 70 deletions

View file

@ -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<SupplyLineDto> 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,

View file

@ -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<RetailSaleLineDto> 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<RetailSaleLineInput> Lines);

View file

@ -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<ProductPriceInput>? Prices = null,
IReadOnlyList<ProductBarcodeInput>? Barcodes = null);

View file

@ -33,6 +33,88 @@ export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
return <select {...props} className={cn(inputClass, props.className)} />
}
interface MoneyInputProps {
value: number | null | undefined
onChange: (v: number | null) => void
currencyCode?: string | null
currencySymbol?: string | null
disabled?: boolean
placeholder?: string
className?: string
}
/** Денежное поле: только цифры + точка/запятая (запятая точка), 2 знака,
* справа символ валюты (/$/). Запятая, пробелы и буквы фильтруются.
* Пустое поле null. */
export function MoneyInput({
value, onChange, currencyCode, currencySymbol, disabled, placeholder = '0', className,
}: MoneyInputProps) {
const suffix = currencySymbol || currencyCode || '₸'
const display = value == null ? '' : String(value)
return (
<div className={cn('relative', className)}>
<input
type="text"
inputMode="decimal"
disabled={disabled}
value={display}
placeholder={placeholder}
onFocus={(e) => 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')}
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-sm text-slate-500">
{suffix}
</span>
</div>
)
}
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 (
<input
type="text"
inputMode="decimal"
disabled={disabled}
value={display}
placeholder={placeholder}
onFocus={(e) => 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,

View file

@ -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>(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 (
<div className="flex items-center justify-between mt-3 text-sm text-slate-500">
<div className="flex flex-wrap items-center justify-between gap-3 mt-3 text-sm text-slate-500">
<span>{from}{to} из {total}</span>
<div className="flex items-center gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
<button
disabled={page <= 1}
onClick={() => onPageChange(page - 1)}
@ -38,16 +25,15 @@ export function Pagination({ page, pageSize, total, onPageChange }: PaginationPr
</button>
<span className="flex items-center gap-1">
<span>Страница</span>
<input
type="number"
min={1}
max={totalPages}
value={jumpValue}
onChange={(e) => 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"
/>
<select
value={page}
onChange={(e) => onPageChange(Number(e.target.value))}
className="px-1.5 py-0.5 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 tabular-nums"
>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<option key={p} value={p}>{p}</option>
))}
</select>
<span>из {totalPages}</span>
</span>
<button

View file

@ -6,7 +6,7 @@ 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, NumberInput } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { useCurrencies } from '@/lib/useLookups'
import type { Country } from '@/lib/types'
@ -112,11 +112,9 @@ export function CountriesPage() {
</Select>
</Field>
<Field label="Ставка НДС, %">
<TextInput
type="number"
step="0.01"
<NumberInput
value={form.vatRate}
onChange={(e) => setForm({ ...form, vatRate: Number(e.target.value) })}
onChange={(n) => setForm({ ...form, vatRate: n ?? 0 })}
/>
</Field>
</div>

View file

@ -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 && (
<Grid cols={3}>
<Field label="Ставка НДС, %">
<TextInput
type="number" step="0.01" min="0"
<NumberInput
value={form.vat}
onChange={(e) => setForm({ ...form, vat: Number(e.target.value) })}
onChange={(n) => setForm({ ...form, vat: n ?? 0 })}
/>
</Field>
</Grid>
@ -303,7 +302,12 @@ export function ProductEditPage() {
<Section title="Закупка">
<Grid cols={4}>
<Field label="Закупочная цена">
<TextInput type="number" step="0.01" value={form.purchasePrice} onChange={(e) => setForm({ ...form, purchasePrice: e.target.value })} />
<MoneyInput
value={form.purchasePrice === '' ? null : Number(form.purchasePrice)}
onChange={(n) => 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}
/>
</Field>
{org.data?.multiCurrencyEnabled && (
<Field label="Валюта закупки">
@ -320,10 +324,18 @@ export function ProductEditPage() {
<AdvancedSection>
<Grid cols={4}>
<Field label="Минимальный остаток (для уведомления)">
<TextInput type="number" step="0.001" value={form.minStock} onChange={(e) => setForm({ ...form, minStock: e.target.value })} placeholder="—" />
<NumberInput
value={form.minStock === '' ? null : Number(form.minStock)}
onChange={(n) => setForm({ ...form, minStock: n == null ? '' : String(n) })}
placeholder="—"
/>
</Field>
<Field label="Максимальный остаток (для автозаказа)">
<TextInput type="number" step="0.001" value={form.maxStock} onChange={(e) => setForm({ ...form, maxStock: e.target.value })} placeholder="—" />
<NumberInput
value={form.maxStock === '' ? null : Number(form.maxStock)}
onChange={(n) => setForm({ ...form, maxStock: n == null ? '' : String(n) })}
placeholder="—"
/>
</Field>
</Grid>
</AdvancedSection>
@ -344,11 +356,13 @@ export function ProductEditPage() {
{priceTypes.data?.map((pt) => <option key={pt.id} value={pt.id}>{pt.name}</option>)}
</Select>
</div>
<div className="col-span-3 flex items-center gap-2">
<TextInput type="number" step="0.01" value={p.amount} onChange={(e) => updatePrice(i, { amount: Number(e.target.value) })} />
{!org.data?.multiCurrencyEnabled && (
<span className="text-sm text-slate-500">{org.data?.defaultCurrencySymbol ?? ''}</span>
)}
<div className="col-span-3">
<MoneyInput
value={p.amount}
onChange={(n) => 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}
/>
</div>
{org.data?.multiCurrencyEnabled && (
<div className="col-span-2">

View file

@ -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() {
</Select>
</Field>
<Field label="Получено наличными">
<TextInput type="number" step="0.01" value={form.paidCash} disabled={isPosted}
onChange={(e) => setForm({ ...form, paidCash: Number(e.target.value) })} />
<MoneyInput value={form.paidCash} disabled={isPosted}
onChange={(n) => 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} />
</Field>
<Field label="Получено картой">
<TextInput type="number" step="0.01" value={form.paidCard} disabled={isPosted}
onChange={(e) => setForm({ ...form, paidCard: Number(e.target.value) })} />
<MoneyInput value={form.paidCard} disabled={isPosted}
onChange={(n) => 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} />
</Field>
<Field label="Примечание" className="md:col-span-3">
<TextArea rows={2} value={form.notes} disabled={isPosted}
@ -332,19 +336,23 @@ export function RetailSaleEditPage() {
</td>
<td className="py-2 px-3 text-slate-500">{l.unitName}</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.001" disabled={isPosted}
className="text-right font-mono" value={l.quantity}
onChange={(e) => updateLine(i, { quantity: Number(e.target.value) })} />
<NumberInput disabled={isPosted}
value={l.quantity}
onChange={(n) => updateLine(i, { quantity: n ?? 0 })} />
</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.01" disabled={isPosted}
className="text-right font-mono" value={l.unitPrice}
onChange={(e) => updateLine(i, { unitPrice: Number(e.target.value) })} />
<MoneyInput disabled={isPosted}
value={l.unitPrice}
onChange={(n) => updateLine(i, { unitPrice: n ?? 0 })}
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code}
currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol} />
</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.01" disabled={isPosted}
className="text-right font-mono" value={l.discount}
onChange={(e) => updateLine(i, { discount: Number(e.target.value) })} />
<MoneyInput disabled={isPosted}
value={l.discount}
onChange={(n) => updateLine(i, { discount: n ?? 0 })}
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code}
currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol} />
</td>
<td className="py-2 px-3 text-right font-mono font-semibold">
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}

View file

@ -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'
@ -312,16 +312,16 @@ export function SupplyEditPage() {
</td>
<td className="py-2 px-3 text-slate-500">{l.unitName}</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.001" disabled={isPosted}
className="text-right font-mono"
<NumberInput disabled={isPosted}
value={l.quantity}
onChange={(e) => updateLine(i, { quantity: Number(e.target.value) })} />
onChange={(n) => updateLine(i, { quantity: n ?? 0 })} />
</td>
<td className="py-2 px-3">
<TextInput type="number" step="0.01" disabled={isPosted}
className="text-right font-mono"
<MoneyInput disabled={isPosted}
value={l.unitPrice}
onChange={(e) => updateLine(i, { unitPrice: Number(e.target.value) })} />
onChange={(n) => updateLine(i, { unitPrice: n ?? 0 })}
currencyCode={currencies.data?.find((c) => c.id === form.currencyId)?.code}
currencySymbol={currencies.data?.find((c) => c.id === form.currencyId)?.symbol} />
</td>
<td className="py-2 px-3 text-right font-mono font-semibold">
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}