revert(date-field): drop custom react-day-picker — use native input type=date
Some checks are pending
Some checks are pending
Кастомный 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:
parent
88e382d9d7
commit
33e1572c3a
|
|
@ -16,10 +16,8 @@
|
|||
"axios": "^1.15.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hook-form": "^7.73.1",
|
||||
"react-router-dom": "^7.14.1",
|
||||
|
|
|
|||
|
|
@ -26,18 +26,12 @@ importers:
|
|||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
lucide-react:
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0(react@19.2.5)
|
||||
react:
|
||||
specifier: ^19.2.5
|
||||
version: 19.2.5
|
||||
react-day-picker:
|
||||
specifier: ^9.14.0
|
||||
version: 9.14.0(react@19.2.5)
|
||||
react-dom:
|
||||
specifier: ^19.2.5
|
||||
version: 19.2.5(react@19.2.5)
|
||||
|
|
@ -178,9 +172,6 @@ packages:
|
|||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@date-fns/tz@1.4.1':
|
||||
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
|
||||
|
||||
'@emnapi/core@1.9.2':
|
||||
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
||||
|
||||
|
|
@ -396,10 +387,6 @@ packages:
|
|||
'@standard-schema/utils@0.3.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-dhXFXkW2dGvX4r/fi24gyXM0t1mFMrpykQjqrdA4SuavaMagm4SY1u5G2SCJwu1/0x/5RlZJ2VPjP3mKYQfCkA==}
|
||||
|
||||
|
|
@ -784,12 +771,6 @@ packages:
|
|||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
|
|
@ -1270,12 +1251,6 @@ packages:
|
|||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
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:
|
||||
resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==}
|
||||
peerDependencies:
|
||||
|
|
@ -1623,8 +1598,6 @@ snapshots:
|
|||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@date-fns/tz@1.4.1': {}
|
||||
|
||||
'@emnapi/core@1.9.2':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
|
|
@ -1805,8 +1778,6 @@ snapshots:
|
|||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@tabby_ai/hijri-converter@1.0.5': {}
|
||||
|
||||
'@tailwindcss/node@4.2.3':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
|
|
@ -2176,10 +2147,6 @@ snapshots:
|
|||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
date-fns-jalali@4.1.0-0: {}
|
||||
|
||||
date-fns@4.1.0: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
|
@ -2592,14 +2559,6 @@ snapshots:
|
|||
|
||||
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):
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,8 +4,7 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
|||
import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { Button } from '@/components/Button'
|
||||
import { Field, TextArea, Select, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
|
||||
import { DateField } from '@/components/DateField'
|
||||
import { Field, TextInput, TextArea, Select, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
|
||||
import { ProductPicker } from '@/components/ProductPicker'
|
||||
import { SupplyLineQuickAdd, type AddedProduct } from '@/components/SupplyLineQuickAdd'
|
||||
import { useStores, useCurrencies, useSuppliers, usePriceTypes } from '@/lib/useLookups'
|
||||
|
|
@ -300,8 +299,9 @@ export function SupplyEditPage() {
|
|||
<Section title="Реквизиты документа">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
|
||||
<Field label="Дата *">
|
||||
<DateField required value={form.date} disabled={isPosted}
|
||||
onChange={(iso) => setForm({ ...form, date: iso })} />
|
||||
<TextInput type="date" required value={form.date} disabled={isPosted}
|
||||
onChange={(e) => setForm({ ...form, date: e.target.value })}
|
||||
className="max-w-[180px]" />
|
||||
</Field>
|
||||
<Field label="Поставщик *">
|
||||
<Select
|
||||
|
|
|
|||
Loading…
Reference in a new issue