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.Common;
using foodmarket.Application.Inventory; using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory; using foodmarket.Domain.Inventory;
@ -46,7 +47,10 @@ public record SupplyDto(
decimal Total, DateTime? PostedAt, decimal Total, DateTime? PostedAt,
IReadOnlyList<SupplyLineDto> Lines); 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( public record SupplyInput(
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId, DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate, string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate,

View file

@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Application.Common; using foodmarket.Application.Common;
using foodmarket.Application.Inventory; using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory; using foodmarket.Domain.Inventory;
@ -47,10 +48,17 @@ public record RetailSaleDto(
string? Notes, DateTime? PostedAt, string? Notes, DateTime? PostedAt,
IReadOnlyList<RetailSaleLineDto> Lines); 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( public record RetailSaleInput(
DateTime Date, Guid StoreId, Guid? RetailPointId, Guid? CustomerId, Guid CurrencyId, 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, string? Notes,
IReadOnlyList<RetailSaleLineInput> Lines); IReadOnlyList<RetailSaleLineInput> Lines);

View file

@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using foodmarket.Domain.Catalog; using foodmarket.Domain.Catalog;
namespace foodmarket.Application.Catalog; namespace foodmarket.Application.Catalog;
@ -73,14 +74,14 @@ public record CounterpartyInput(
string? Address, string? Phone, string? Email, string? Address, string? Phone, string? Email,
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive = true); 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 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( public record ProductInput(
string Name, string? Article, string? Description, 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, Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false, bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false,
decimal? MinStock = null, decimal? MaxStock = null, [Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null,
decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null, [Range(0, 1e10)] decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
string? ImageUrl = null, bool IsActive = true, string? ImageUrl = null, bool IsActive = true,
IReadOnlyList<ProductPriceInput>? Prices = null, IReadOnlyList<ProductPriceInput>? Prices = null,
IReadOnlyList<ProductBarcodeInput>? Barcodes = 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)} /> 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({ export function Checkbox({
label, label,
checked, checked,

View file

@ -1,5 +1,3 @@
import { useEffect, useState } from 'react'
interface PaginationProps { interface PaginationProps {
page: number page: number
pageSize: 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 totalPages = Math.max(1, Math.ceil(total / pageSize))
const from = (page - 1) * pageSize + 1 const from = (page - 1) * pageSize + 1
const to = Math.min(page * pageSize, total) const to = Math.min(page * pageSize, total)
const [jumpValue, setJumpValue] = useState<string>(String(page))
useEffect(() => { setJumpValue(String(page)) }, [page])
if (total === 0) return null 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 ( 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> <span>{from}{to} из {total}</span>
<div className="flex items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<button <button
disabled={page <= 1} disabled={page <= 1}
onClick={() => onPageChange(page - 1)} onClick={() => onPageChange(page - 1)}
@ -38,16 +25,15 @@ export function Pagination({ page, pageSize, total, onPageChange }: PaginationPr
</button> </button>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span>Страница</span> <span>Страница</span>
<input <select
type="number" value={page}
min={1} onChange={(e) => onPageChange(Number(e.target.value))}
max={totalPages} className="px-1.5 py-0.5 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 tabular-nums"
value={jumpValue} >
onChange={(e) => setJumpValue(e.target.value)} {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
onBlur={commitJump} <option key={p} value={p}>{p}</option>
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>
/>
<span>из {totalPages}</span> <span>из {totalPages}</span>
</span> </span>
<button <button

View file

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

View file

@ -4,7 +4,7 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react' import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Button } from '@/components/Button' 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 { import {
useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers, useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
} from '@/lib/useLookups' } from '@/lib/useLookups'
@ -278,10 +278,9 @@ export function ProductEditPage() {
{org.data?.showVatEnabledOnProduct && form.vatEnabled && ( {org.data?.showVatEnabledOnProduct && form.vatEnabled && (
<Grid cols={3}> <Grid cols={3}>
<Field label="Ставка НДС, %"> <Field label="Ставка НДС, %">
<TextInput <NumberInput
type="number" step="0.01" min="0"
value={form.vat} value={form.vat}
onChange={(e) => setForm({ ...form, vat: Number(e.target.value) })} onChange={(n) => setForm({ ...form, vat: n ?? 0 })}
/> />
</Field> </Field>
</Grid> </Grid>
@ -303,7 +302,12 @@ export function ProductEditPage() {
<Section title="Закупка"> <Section title="Закупка">
<Grid cols={4}> <Grid cols={4}>
<Field label="Закупочная цена"> <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> </Field>
{org.data?.multiCurrencyEnabled && ( {org.data?.multiCurrencyEnabled && (
<Field label="Валюта закупки"> <Field label="Валюта закупки">
@ -320,10 +324,18 @@ export function ProductEditPage() {
<AdvancedSection> <AdvancedSection>
<Grid cols={4}> <Grid cols={4}>
<Field label="Минимальный остаток (для уведомления)"> <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>
<Field label="Максимальный остаток (для автозаказа)"> <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> </Field>
</Grid> </Grid>
</AdvancedSection> </AdvancedSection>
@ -344,11 +356,13 @@ export function ProductEditPage() {
{priceTypes.data?.map((pt) => <option key={pt.id} value={pt.id}>{pt.name}</option>)} {priceTypes.data?.map((pt) => <option key={pt.id} value={pt.id}>{pt.name}</option>)}
</Select> </Select>
</div> </div>
<div className="col-span-3 flex items-center gap-2"> <div className="col-span-3">
<TextInput type="number" step="0.01" value={p.amount} onChange={(e) => updatePrice(i, { amount: Number(e.target.value) })} /> <MoneyInput
{!org.data?.multiCurrencyEnabled && ( value={p.amount}
<span className="text-sm text-slate-500">{org.data?.defaultCurrencySymbol ?? ''}</span> 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> </div>
{org.data?.multiCurrencyEnabled && ( {org.data?.multiCurrencyEnabled && (
<div className="col-span-2"> <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 { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Button } from '@/components/Button' 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 { ProductPicker } from '@/components/ProductPicker'
import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups' import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
@ -285,12 +285,16 @@ export function RetailSaleEditPage() {
</Select> </Select>
</Field> </Field>
<Field label="Получено наличными"> <Field label="Получено наличными">
<TextInput type="number" step="0.01" value={form.paidCash} disabled={isPosted} <MoneyInput value={form.paidCash} disabled={isPosted}
onChange={(e) => setForm({ ...form, paidCash: Number(e.target.value) })} /> 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>
<Field label="Получено картой"> <Field label="Получено картой">
<TextInput type="number" step="0.01" value={form.paidCard} disabled={isPosted} <MoneyInput value={form.paidCard} disabled={isPosted}
onChange={(e) => setForm({ ...form, paidCard: Number(e.target.value) })} /> 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>
<Field label="Примечание" className="md:col-span-3"> <Field label="Примечание" className="md:col-span-3">
<TextArea rows={2} value={form.notes} disabled={isPosted} <TextArea rows={2} value={form.notes} disabled={isPosted}
@ -332,19 +336,23 @@ export function RetailSaleEditPage() {
</td> </td>
<td className="py-2 px-3 text-slate-500">{l.unitName}</td> <td className="py-2 px-3 text-slate-500">{l.unitName}</td>
<td className="py-2 px-3"> <td className="py-2 px-3">
<TextInput type="number" step="0.001" disabled={isPosted} <NumberInput disabled={isPosted}
className="text-right font-mono" value={l.quantity} value={l.quantity}
onChange={(e) => updateLine(i, { quantity: Number(e.target.value) })} /> onChange={(n) => updateLine(i, { quantity: n ?? 0 })} />
</td> </td>
<td className="py-2 px-3"> <td className="py-2 px-3">
<TextInput type="number" step="0.01" disabled={isPosted} <MoneyInput disabled={isPosted}
className="text-right font-mono" value={l.unitPrice} 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>
<td className="py-2 px-3"> <td className="py-2 px-3">
<TextInput type="number" step="0.01" disabled={isPosted} <MoneyInput disabled={isPosted}
className="text-right font-mono" value={l.discount} value={l.discount}
onChange={(e) => updateLine(i, { discount: Number(e.target.value) })} /> 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>
<td className="py-2 px-3 text-right font-mono font-semibold"> <td className="py-2 px-3 text-right font-mono font-semibold">
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })} {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 { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { Button } from '@/components/Button' 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 { ProductPicker } from '@/components/ProductPicker'
import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups' import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
@ -312,16 +312,16 @@ export function SupplyEditPage() {
</td> </td>
<td className="py-2 px-3 text-slate-500">{l.unitName}</td> <td className="py-2 px-3 text-slate-500">{l.unitName}</td>
<td className="py-2 px-3"> <td className="py-2 px-3">
<TextInput type="number" step="0.001" disabled={isPosted} <NumberInput disabled={isPosted}
className="text-right font-mono"
value={l.quantity} value={l.quantity}
onChange={(e) => updateLine(i, { quantity: Number(e.target.value) })} /> onChange={(n) => updateLine(i, { quantity: n ?? 0 })} />
</td> </td>
<td className="py-2 px-3"> <td className="py-2 px-3">
<TextInput type="number" step="0.01" disabled={isPosted} <MoneyInput disabled={isPosted}
className="text-right font-mono"
value={l.unitPrice} 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>
<td className="py-2 px-3 text-right font-mono font-semibold"> <td className="py-2 px-3 text-right font-mono font-semibold">
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })} {lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}