feat(date-field): replace native input with react-datepicker — polished UX

Нативный <input type=\"date\"> рендерил американский MM/DD/YYYY и
тонкий браузерный popup, выглядел криво рядом с другими полями.

Используем готовый react-datepicker (10M downloads/week) — никакой
кастомизации, всё из коробки:
- dateFormat=\"dd.MM.yyyy\" + locale=\"ru\" → «25.04.2026», русские
  Январь/Понедельник
- showMonthDropdown + showYearDropdown + dropdownMode=\"select\" →
  быстрый прыжок на любой месяц/год
- todayButton=\"Сегодня\" → кнопка под календарём
- isClearable → крестик в input для очистки
- popperClassName z-[100] чтобы попап не резался z-стеком

ISO YYYY-MM-DD внутрь/наружу собираем вручную из локальных Y/M/D —
не toISOString(), чтобы вечерние даты в часовом поясе KZ (UTC+5)
не сдвигались на день назад.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 03:39:51 +05:00
parent dc162a6c06
commit 39da12edec
4 changed files with 140 additions and 4 deletions

View file

@ -16,8 +16,10 @@
"axios": "^1.15.1", "axios": "^1.15.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^1.8.0", "lucide-react": "^1.8.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-datepicker": "^9.1.0",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-hook-form": "^7.73.1", "react-hook-form": "^7.73.1",
"react-router-dom": "^7.14.1", "react-router-dom": "^7.14.1",

View file

@ -26,12 +26,18 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
date-fns:
specifier: ^4.1.0
version: 4.1.0
lucide-react: lucide-react:
specifier: ^1.8.0 specifier: ^1.8.0
version: 1.8.0(react@19.2.5) version: 1.8.0(react@19.2.5)
react: react:
specifier: ^19.2.5 specifier: ^19.2.5
version: 19.2.5 version: 19.2.5
react-datepicker:
specifier: ^9.1.0
version: 9.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react-dom: react-dom:
specifier: ^19.2.5 specifier: ^19.2.5
version: 19.2.5(react@19.2.5) version: 19.2.5(react@19.2.5)
@ -219,6 +225,27 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
'@floating-ui/dom@1.7.6':
resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==}
'@floating-ui/react-dom@2.1.8':
resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/react@0.27.19':
resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==}
peerDependencies:
react: '>=17.0.0'
react-dom: '>=17.0.0'
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@hookform/resolvers@5.2.2': '@hookform/resolvers@5.2.2':
resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==}
peerDependencies: peerDependencies:
@ -771,6 +798,9 @@ packages:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'} engines: {node: '>=12'}
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@ -1251,6 +1281,16 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
react-datepicker@9.1.0:
resolution: {integrity: sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==}
peerDependencies:
date-fns-tz: ^3.0.0
react: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
peerDependenciesMeta:
date-fns-tz:
optional: true
react-dom@19.2.5: react-dom@19.2.5:
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
peerDependencies: peerDependencies:
@ -1361,6 +1401,9 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'} engines: {node: '>=8'}
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
tailwind-merge@3.5.0: tailwind-merge@3.5.0:
resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==}
@ -1660,6 +1703,31 @@ snapshots:
'@eslint/core': 0.17.0 '@eslint/core': 0.17.0
levn: 0.4.1 levn: 0.4.1
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.11
'@floating-ui/dom@1.7.6':
dependencies:
'@floating-ui/core': 1.7.5
'@floating-ui/utils': 0.2.11
'@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@floating-ui/dom': 1.7.6
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
'@floating-ui/react@0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@floating-ui/utils': 0.2.11
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
tabbable: 6.4.0
'@floating-ui/utils@0.2.11': {}
'@hookform/resolvers@5.2.2(react-hook-form@7.73.1(react@19.2.5))': '@hookform/resolvers@5.2.2(react-hook-form@7.73.1(react@19.2.5))':
dependencies: dependencies:
'@standard-schema/utils': 0.3.0 '@standard-schema/utils': 0.3.0
@ -2147,6 +2215,8 @@ snapshots:
d3-timer@3.0.1: {} d3-timer@3.0.1: {}
date-fns@4.1.0: {}
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@ -2559,6 +2629,14 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
react-datepicker@9.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@floating-ui/react': 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
clsx: 2.1.1
date-fns: 4.1.0
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-dom@19.2.5(react@19.2.5): react-dom@19.2.5(react@19.2.5):
dependencies: dependencies:
react: 19.2.5 react: 19.2.5
@ -2668,6 +2746,8 @@ snapshots:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
tabbable@6.4.0: {}
tailwind-merge@3.5.0: {} tailwind-merge@3.5.0: {}
tailwindcss-animate@1.0.7(tailwindcss@4.2.3): tailwindcss-animate@1.0.7(tailwindcss@4.2.3):

View file

@ -0,0 +1,54 @@
import DatePicker, { registerLocale } from 'react-datepicker'
import 'react-datepicker/dist/react-datepicker.css'
import { ru } from 'date-fns/locale/ru'
import { cn } from '@/lib/utils'
registerLocale('ru', ru)
interface Props {
/** ISO YYYY-MM-DD или пустая строка/null. */
value: string | null
onChange: (iso: string | null) => void
placeholder?: string
required?: boolean
disabled?: boolean
className?: string
}
const inputClass = 'h-10 w-full max-w-[180px] rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 text-sm leading-none focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] disabled:opacity-60 disabled:bg-slate-50 dark:disabled:bg-slate-800/60'
/** Дата с готовым попапом react-datepicker: RU-локаль, формат DD.MM.YYYY,
* dropdowns для месяца и года, кнопка «Сегодня», крестик «очистить».
* Хранит/отдаёт ISO YYYY-MM-DD API-контракт без изменений. */
export function DateField({ value, onChange, placeholder = 'дд.мм.гггг', required, disabled, className }: Props) {
const dateValue = value ? new Date(value) : null
return (
<DatePicker
selected={dateValue}
onChange={(d: Date | null) => {
if (!d) { onChange(null); return }
// Локальный YYYY-MM-DD без UTC-смещения, чтобы вечерние даты
// не сдвигались на день назад в часовом поясе KZ (UTC+5).
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
onChange(`${y}-${m}-${dd}`)
}}
dateFormat="dd.MM.yyyy"
locale="ru"
placeholderText={placeholder}
todayButton="Сегодня"
isClearable
showMonthDropdown
showYearDropdown
dropdownMode="select"
required={required}
disabled={disabled}
autoComplete="off"
className={cn(inputClass, className)}
wrapperClassName="inline-block"
calendarClassName="!font-sans"
popperClassName="z-[100]"
/>
)
}

View file

@ -4,7 +4,8 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react' import { ArrowLeft, Plus, Trash2, Save, CheckCircle } 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, MoneyInput, NumberInput } from '@/components/Field' import { Field, TextArea, Select, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { ProductPicker } from '@/components/ProductPicker' import { ProductPicker } from '@/components/ProductPicker'
import { SupplyLineQuickAdd, type AddedProduct } from '@/components/SupplyLineQuickAdd' import { SupplyLineQuickAdd, type AddedProduct } from '@/components/SupplyLineQuickAdd'
import { useStores, useCurrencies, useSuppliers, usePriceTypes } from '@/lib/useLookups' import { useStores, useCurrencies, useSuppliers, usePriceTypes } from '@/lib/useLookups'
@ -299,9 +300,8 @@ export function SupplyEditPage() {
<Section title="Реквизиты документа"> <Section title="Реквизиты документа">
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3"> <div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
<Field label="Дата *"> <Field label="Дата *">
<TextInput type="date" required value={form.date} disabled={isPosted} <DateField required value={form.date || null} disabled={isPosted}
onChange={(e) => setForm({ ...form, date: e.target.value })} onChange={(iso) => setForm({ ...form, date: iso ?? '' })} />
className="max-w-[180px]" />
</Field> </Field>
<Field label="Поставщик *"> <Field label="Поставщик *">
<Select <Select