fix(date-fields): cap width + ru locale + DD.MM.YYYY format
Some checks are pending
Some checks are pending
Новый компонент <DateField/>: - Ширина зафиксирована (по умолчанию w-40 = 160px) — раньше нативный <input type="date"> растягивался на всю колонку, хотя содержимое всегда 10 символов. - Ввод в формате DD.MM.YYYY с авто-вставкой точек после dd и mm, inputMode=numeric для мобилы. Хранит/отдаёт ISO YYYY-MM-DD — API-контракт не меняется. - Иконка календаря справа открывает попап (через portal в body, position fixed) с react-day-picker: locale=ru, weekStartsOn=1, ISOWeek; caption_label/weekday с capitalize CSS — «Апр 2026», «Пн Вт Ср …». Outside-click закрывает. Подключено в SupplyEditPage (поле «Дата»). Ставка на единый компонент DateField — все будущие даты в системе через него. Зависимости: + react-day-picker ^9, + date-fns ^4. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
290a95c54c
commit
e731626390
|
|
@ -16,8 +16,10 @@
|
|||
"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,12 +26,18 @@ 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)
|
||||
|
|
@ -172,6 +178,9 @@ 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==}
|
||||
|
||||
|
|
@ -387,6 +396,10 @@ 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==}
|
||||
|
||||
|
|
@ -771,6 +784,12 @@ 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'}
|
||||
|
|
@ -1251,6 +1270,12 @@ 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:
|
||||
|
|
@ -1598,6 +1623,8 @@ 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
|
||||
|
|
@ -1778,6 +1805,8 @@ 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
|
||||
|
|
@ -2147,6 +2176,10 @@ 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
|
||||
|
|
@ -2559,6 +2592,14 @@ 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
|
||||
|
|
|
|||
168
src/food-market.web/src/components/DateField.tsx
Normal file
168
src/food-market.web/src/components/DateField.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
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 }}
|
||||
className="z-[100] rounded-md border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 shadow-lg p-2"
|
||||
>
|
||||
<DayPicker
|
||||
mode="single"
|
||||
locale={ru}
|
||||
weekStartsOn={1}
|
||||
ISOWeek
|
||||
selected={isoToDate(value)}
|
||||
defaultMonth={isoToDate(value) ?? new Date()}
|
||||
onSelect={(d) => {
|
||||
if (!d) return
|
||||
const iso = dateToIso(d)
|
||||
onChange(iso)
|
||||
setDraft(isoToDisplay(iso))
|
||||
setOpen(false)
|
||||
}}
|
||||
classNames={{
|
||||
caption_label: 'capitalize',
|
||||
weekday: 'capitalize',
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,7 +4,8 @@ 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, 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 { SupplyLineQuickAdd, type AddedProduct } from '@/components/SupplyLineQuickAdd'
|
||||
import { useStores, useCurrencies, useSuppliers, usePriceTypes } from '@/lib/useLookups'
|
||||
|
|
@ -299,8 +300,8 @@ export function SupplyEditPage() {
|
|||
<Section title="Реквизиты документа">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
|
||||
<Field label="Дата *">
|
||||
<TextInput type="date" required value={form.date} disabled={isPosted}
|
||||
onChange={(e) => setForm({ ...form, date: e.target.value })} />
|
||||
<DateField required value={form.date} disabled={isPosted}
|
||||
onChange={(iso) => setForm({ ...form, date: iso })} />
|
||||
</Field>
|
||||
<Field label="Поставщик *">
|
||||
<Select
|
||||
|
|
|
|||
Loading…
Reference in a new issue