From d28c6e703a67849de23dd0ae2296c23577704e23 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sun, 26 Apr 2026 03:39:51 +0500 Subject: [PATCH] =?UTF-8?q?feat(date-field):=20replace=20native=20input=20?= =?UTF-8?q?with=20react-datepicker=20=E2=80=94=20polished=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Нативный рендерил американский 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) --- src/food-market.web/package.json | 2 + src/food-market.web/pnpm-lock.yaml | 80 +++++++++++++++++++ .../src/components/DateField.tsx | 54 +++++++++++++ .../src/pages/SupplyEditPage.tsx | 8 +- 4 files changed, 140 insertions(+), 4 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..8f9bd8f 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-datepicker": "^9.1.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..84082f5 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-datepicker: + specifier: ^9.1.0 + version: 9.1.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react-dom: specifier: ^19.2.5 version: 19.2.5(react@19.2.5) @@ -219,6 +225,27 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} 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': resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} peerDependencies: @@ -771,6 +798,9 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + 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 +1281,16 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} 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: resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: @@ -1361,6 +1401,9 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} @@ -1660,6 +1703,31 @@ snapshots: '@eslint/core': 0.17.0 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))': dependencies: '@standard-schema/utils': 0.3.0 @@ -2147,6 +2215,8 @@ snapshots: d3-timer@3.0.1: {} + date-fns@4.1.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2559,6 +2629,14 @@ snapshots: 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): dependencies: react: 19.2.5 @@ -2668,6 +2746,8 @@ snapshots: dependencies: has-flag: 4.0.0 + tabbable@6.4.0: {} + tailwind-merge@3.5.0: {} tailwindcss-animate@1.0.7(tailwindcss@4.2.3): 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..197f145 --- /dev/null +++ b/src/food-market.web/src/components/DateField.tsx @@ -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 ( + { + 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]" + /> + ) +} diff --git a/src/food-market.web/src/pages/SupplyEditPage.tsx b/src/food-market.web/src/pages/SupplyEditPage.tsx index e8d6e74..886d5c7 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,9 +300,8 @@ export function SupplyEditPage() { - setForm({ ...form, date: e.target.value })} - className="max-w-[180px]" /> + setForm({ ...form, date: iso ?? '' })} />