revert(date-field): drop custom react-day-picker — use native input type=date

Кастомный DateField на react-day-picker оказался хрупким (стрелки
навигации не реагировали, dropdown лет вылазил за popover). В проекте
нет shadcn/ui-обёртки над day-picker, а пилить её с нуля под одно
поле — overkill.

Откатил на нативный <input type=\"date\"> с max-w-[180px], чтобы
поле не растягивалось на всю колонку. Браузер сам подтягивает
локаль из ОС/настроек — у пользователя с RU-локалью календарь
будет на русском, формат DD.MM.YYYY (как в его референс-скриншоте).

- Удалён components/DateField.tsx.
- В SupplyEditPage возвращён <TextInput type=\"date\"> с
  className=\"max-w-[180px]\".
- Сняты зависимости react-day-picker и date-fns.

Если когда-нибудь вернёмся к кастомному picker'у — будем ставить
shadcn/ui calendar+popover целиком, не вручную.

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

View file

@ -16,10 +16,8 @@
"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-day-picker": "^9.14.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,18 +26,12 @@ 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-day-picker:
specifier: ^9.14.0
version: 9.14.0(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)
@ -178,9 +172,6 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@date-fns/tz@1.4.1':
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
'@emnapi/core@1.9.2': '@emnapi/core@1.9.2':
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
@ -396,10 +387,6 @@ packages:
'@standard-schema/utils@0.3.0': '@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@tabby_ai/hijri-converter@1.0.5':
resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==}
engines: {node: '>=16.0.0'}
'@tailwindcss/node@4.2.3': '@tailwindcss/node@4.2.3':
resolution: {integrity: sha512-dhXFXkW2dGvX4r/fi24gyXM0t1mFMrpykQjqrdA4SuavaMagm4SY1u5G2SCJwu1/0x/5RlZJ2VPjP3mKYQfCkA==} resolution: {integrity: sha512-dhXFXkW2dGvX4r/fi24gyXM0t1mFMrpykQjqrdA4SuavaMagm4SY1u5G2SCJwu1/0x/5RlZJ2VPjP3mKYQfCkA==}
@ -784,12 +771,6 @@ packages:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'} engines: {node: '>=12'}
date-fns-jalali@4.1.0-0:
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
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'}
@ -1270,12 +1251,6 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
react-day-picker@9.14.0:
resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==}
engines: {node: '>=18'}
peerDependencies:
react: '>=16.8.0'
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:
@ -1623,8 +1598,6 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@date-fns/tz@1.4.1': {}
'@emnapi/core@1.9.2': '@emnapi/core@1.9.2':
dependencies: dependencies:
'@emnapi/wasi-threads': 1.2.1 '@emnapi/wasi-threads': 1.2.1
@ -1805,8 +1778,6 @@ snapshots:
'@standard-schema/utils@0.3.0': {} '@standard-schema/utils@0.3.0': {}
'@tabby_ai/hijri-converter@1.0.5': {}
'@tailwindcss/node@4.2.3': '@tailwindcss/node@4.2.3':
dependencies: dependencies:
'@jridgewell/remapping': 2.3.5 '@jridgewell/remapping': 2.3.5
@ -2176,10 +2147,6 @@ snapshots:
d3-timer@3.0.1: {} d3-timer@3.0.1: {}
date-fns-jalali@4.1.0-0: {}
date-fns@4.1.0: {}
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@ -2592,14 +2559,6 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
react-day-picker@9.14.0(react@19.2.5):
dependencies:
'@date-fns/tz': 1.4.1
'@tabby_ai/hijri-converter': 1.0.5
date-fns: 4.1.0
date-fns-jalali: 4.1.0-0
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

View file

@ -1,223 +0,0 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Calendar as CalendarIcon } from 'lucide-react'
import { DayPicker } from 'react-day-picker'
import { ru } from 'date-fns/locale'
import 'react-day-picker/dist/style.css'
import { cn } from '@/lib/utils'
interface Props {
/** Значение в формате ISO `YYYY-MM-DD` (как приходит/уходит к API). Пустая строка = нет даты. */
value: string
onChange: (iso: string) => void
disabled?: boolean
required?: boolean
className?: string
/** Ширина контейнера. По умолчанию `w-40` (160px) — достаточно для DD.MM.YYYY. */
widthClass?: string
}
const inputClass = 'w-full h-10 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 pr-9 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'
function isoToDisplay(iso: string): string {
if (!iso) return ''
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso)
return m ? `${m[3]}.${m[2]}.${m[1]}` : ''
}
function displayToIso(s: string): string | null {
const m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(s.trim())
if (!m) return null
const [_, dd, mm, yyyy] = m
const d = Number(dd), mo = Number(mm), y = Number(yyyy)
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null
const dt = new Date(y, mo - 1, d)
if (dt.getFullYear() !== y || dt.getMonth() !== mo - 1 || dt.getDate() !== d) return null
const iso = `${yyyy}-${mm}-${dd}`
void _
return iso
}
function isoToDate(iso: string): Date | undefined {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso)
if (!m) return undefined
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
}
function dateToIso(d: Date): string {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const dd = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${dd}`
}
/** Дата в едином RU-формате (DD.MM.YYYY) с попапом-календарём (локаль ru, capitalized).
* Хранится и отдаётся в ISO `YYYY-MM-DD`. Ширина по умолчанию ~w-40 нет смысла
* растягивать на всю колонку, в дате 10 символов. */
export function DateField({ value, onChange, disabled, required, className, widthClass = 'w-40' }: Props) {
const [draft, setDraft] = useState<string>(isoToDisplay(value))
const [open, setOpen] = useState(false)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const popRef = useRef<HTMLDivElement>(null)
useEffect(() => { setDraft(isoToDisplay(value)) }, [value])
useLayoutEffect(() => {
if (!open) return
const recompute = () => {
const el = wrapRef.current
if (!el) return
const r = el.getBoundingClientRect()
setPos({ top: r.bottom + 4, left: r.left })
}
recompute()
const onScroll = () => recompute()
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
return () => {
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
}
}, [open])
useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => {
const t = e.target as Node
if (wrapRef.current?.contains(t)) return
if (popRef.current?.contains(t)) return
setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [open])
const commitDraft = (s: string) => {
if (s.trim() === '') { onChange(''); return }
const iso = displayToIso(s)
if (iso) onChange(iso)
else setDraft(isoToDisplay(value))
}
return (
<div ref={wrapRef} className={cn('relative', widthClass, className)}>
<input
ref={inputRef}
type="text"
inputMode="numeric"
placeholder="ДД.ММ.ГГГГ"
value={draft}
disabled={disabled}
required={required}
onChange={(e) => {
// Ограничиваем ввод цифрами и точками; авто-вставляем точки после dd и mm
let v = e.target.value.replace(/[^\d.]/g, '')
// Авто-точки: после 2-й и 5-й цифры
const digits = v.replace(/\./g, '')
if (digits.length >= 5) v = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4, 8)}`
else if (digits.length >= 3) v = `${digits.slice(0, 2)}.${digits.slice(2, 4)}`
else v = digits
setDraft(v)
}}
onBlur={() => commitDraft(draft)}
onFocus={(e) => e.currentTarget.select()}
className={inputClass}
/>
<button
type="button"
tabIndex={-1}
disabled={disabled}
onClick={() => !disabled && setOpen((o) => !o)}
className="absolute right-1 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 disabled:opacity-50"
title="Открыть календарь"
>
<CalendarIcon className="w-4 h-4" />
</button>
{open && pos && createPortal(
<div
ref={popRef}
style={{
position: 'fixed', top: pos.top, left: pos.left,
// Тонкие настройки react-day-picker v9 через CSS-переменные.
// Сегодня = синяя заливка (как у нативного date-picker macOS).
...({ '--rdp-day-height': '2.25rem', '--rdp-day-width': '2.25rem',
'--rdp-weekday-padding': '0', '--rdp-nav-height': '1.75rem',
'--rdp-day_button-height': '2.25rem', '--rdp-day_button-width': '2.25rem',
'--rdp-day_button-border-radius': '0.375rem',
'--rdp-accent-color': 'var(--color-brand)',
'--rdp-accent-background-color': 'transparent',
'--rdp-selected-border': 'none',
'--rdp-today-color': 'inherit' } as Record<string, string>),
}}
className="z-[100] w-[340px] rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-md p-3 text-sm"
>
<DayPicker
mode="single"
locale={ru}
weekStartsOn={1}
ISOWeek
captionLayout="dropdown"
startMonth={new Date(2020, 0)}
endMonth={new Date(2035, 11)}
selected={isoToDate(value)}
defaultMonth={isoToDate(value) ?? new Date()}
onSelect={(d) => {
if (!d) return
const iso = dateToIso(d)
onChange(iso)
setDraft(isoToDisplay(iso))
setOpen(false)
}}
footer={
<div className="flex items-center justify-between pt-2 mt-2 border-t border-slate-100 dark:border-slate-800">
<button
type="button"
onClick={() => { onChange(''); setDraft(''); setOpen(false) }}
className="text-sm text-[var(--color-brand)] hover:underline"
>
Очистить
</button>
<button
type="button"
onClick={() => {
const iso = dateToIso(new Date())
onChange(iso); setDraft(isoToDisplay(iso)); setOpen(false)
}}
className="text-sm text-[var(--color-brand)] hover:underline"
>
Сегодня
</button>
</div>
}
classNames={{
months: 'flex flex-col',
month: 'relative space-y-2',
month_caption: 'flex items-center pt-0.5 pl-1 h-8 pr-16',
dropdowns: 'flex items-center gap-1.5 text-sm font-semibold',
dropdown_root: 'relative inline-flex items-center',
dropdown: 'appearance-none bg-transparent capitalize cursor-pointer focus:outline-none rounded hover:bg-slate-100 dark:hover:bg-slate-800 px-1.5 py-0.5 pr-5',
caption_label: 'capitalize pointer-events-none absolute opacity-0',
nav: 'absolute right-1 top-1 flex items-center gap-0.5',
button_previous: 'h-7 w-7 inline-flex items-center justify-center rounded-md text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800',
button_next: 'h-7 w-7 inline-flex items-center justify-center rounded-md text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800',
chevron: 'w-4 h-4 fill-current',
weekdays: 'flex',
weekday: 'w-9 text-center text-[0.75rem] font-normal text-slate-500 capitalize',
weeks: 'mt-1',
week: 'flex w-full mt-1',
day: 'w-9 h-9 p-0 text-center align-middle',
day_button: 'w-9 h-9 inline-flex items-center justify-center rounded-md text-sm font-normal hover:bg-slate-100 dark:hover:bg-slate-800 disabled:hover:bg-transparent',
today: '[&_button]:bg-[var(--color-brand)] [&_button]:text-white [&_button]:font-medium [&_button]:hover:bg-[var(--color-brand)]',
selected: '[&_button]:ring-2 [&_button]:ring-[var(--color-brand)] [&_button]:ring-offset-1',
outside: 'text-slate-300 dark:text-slate-600 opacity-60',
disabled: 'text-slate-300 opacity-50',
}}
/>
</div>,
document.body,
)}
</div>
)
}

View file

@ -4,8 +4,7 @@ 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, TextArea, Select, Checkbox, MoneyInput, NumberInput } from '@/components/Field' import { Field, TextInput, 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'
@ -300,8 +299,9 @@ 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="Дата *">
<DateField required value={form.date} disabled={isPosted} <TextInput type="date" required value={form.date} disabled={isPosted}
onChange={(iso) => setForm({ ...form, date: iso })} /> onChange={(e) => setForm({ ...form, date: e.target.value })}
className="max-w-[180px]" />
</Field> </Field>
<Field label="Поставщик *"> <Field label="Поставщик *">
<Select <Select