From c31611d6c4746c9e5a8a93b1f959adf8b17eea8c Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sun, 26 Apr 2026 03:13:35 +0500 Subject: [PATCH] fix(date-fields): cap width + ru locale + DD.MM.YYYY format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый компонент : - Ширина зафиксирована (по умолчанию w-40 = 160px) — раньше нативный растягивался на всю колонку, хотя содержимое всегда 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) --- src/food-market.web/package.json | 2 + src/food-market.web/pnpm-lock.yaml | 41 +++++ .../src/components/DateField.tsx | 168 ++++++++++++++++++ .../src/pages/SupplyEditPage.tsx | 7 +- 4 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 src/food-market.web/src/components/DateField.tsx diff --git a/src/food-market.web/package.json b/src/food-market.web/package.json index 70ea679..9711412 100644 --- a/src/food-market.web/package.json +++ b/src/food-market.web/package.json @@ -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", diff --git a/src/food-market.web/pnpm-lock.yaml b/src/food-market.web/pnpm-lock.yaml index 4b073ca..f4d138e 100644 --- a/src/food-market.web/pnpm-lock.yaml +++ b/src/food-market.web/pnpm-lock.yaml @@ -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 diff --git a/src/food-market.web/src/components/DateField.tsx b/src/food-market.web/src/components/DateField.tsx new file mode 100644 index 0000000..3f9de38 --- /dev/null +++ b/src/food-market.web/src/components/DateField.tsx @@ -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(isoToDisplay(value)) + const [open, setOpen] = useState(false) + const [pos, setPos] = useState<{ top: number; left: number } | null>(null) + const wrapRef = useRef(null) + const inputRef = useRef(null) + const popRef = useRef(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 ( +
+ { + // Ограничиваем ввод цифрами и точками; авто-вставляем точки после 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} + /> + + {open && pos && createPortal( +
+ { + if (!d) return + const iso = dateToIso(d) + onChange(iso) + setDraft(isoToDisplay(iso)) + setOpen(false) + }} + classNames={{ + caption_label: 'capitalize', + weekday: 'capitalize', + }} + /> +
, + document.body, + )} +
+ ) +} diff --git a/src/food-market.web/src/pages/SupplyEditPage.tsx b/src/food-market.web/src/pages/SupplyEditPage.tsx index ad8ae2f..ee20c23 100644 --- a/src/food-market.web/src/pages/SupplyEditPage.tsx +++ b/src/food-market.web/src/pages/SupplyEditPage.tsx @@ -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() {
- setForm({ ...form, date: e.target.value })} /> + setForm({ ...form, date: iso })} />