From fbffc716ba677a2d864955db8ee02c9e1e8facdb Mon Sep 17 00:00:00 2001 From: hosseintaromi Date: Wed, 1 Oct 2025 16:43:38 +0800 Subject: [PATCH] feat(discount-codes): update DiscountCodeFormPage with date picker and single user selection --- package-lock.json | 32 +++ package.json | 2 + src/components/ui/JalaliDateTimePicker.tsx | 75 +++++++ .../ui/SingleSelectAutocomplete.tsx | 207 ++++++++++++++++++ .../DiscountCodeFormPage.tsx | 98 +++++---- 5 files changed, 369 insertions(+), 45 deletions(-) create mode 100644 src/components/ui/JalaliDateTimePicker.tsx create mode 100644 src/components/ui/SingleSelectAutocomplete.tsx diff --git a/package-lock.json b/package-lock.json index 5a120bc..a23bbdb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,9 +18,11 @@ "js-cookie": "^3.0.5", "lucide-react": "^0.263.1", "react": "^18.2.0", + "react-date-object": "2.1.9", "react-dom": "^18.2.0", "react-hook-form": "^7.57.0", "react-hot-toast": "^2.5.2", + "react-multi-date-picker": "4.5.2", "react-router-dom": "^6.15.0", "recharts": "^2.8.0", "yup": "^1.6.1", @@ -5796,6 +5798,12 @@ "node": ">=0.10.0" } }, + "node_modules/react-date-object": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz", + "integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==", + "license": "MIT" + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -5809,6 +5817,16 @@ "react": "^18.3.1" } }, + "node_modules/react-element-popper": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-element-popper/-/react-element-popper-2.1.7.tgz", + "integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-hook-form": { "version": "7.57.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.57.0.tgz", @@ -5848,6 +5866,20 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-multi-date-picker": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz", + "integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==", + "license": "MIT", + "dependencies": { + "react-date-object": "^2.1.8", + "react-element-popper": "^2.1.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/package.json b/package.json index dbaeadc..0831215 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,11 @@ "js-cookie": "^3.0.5", "lucide-react": "^0.263.1", "react": "^18.2.0", + "react-date-object": "2.1.9", "react-dom": "^18.2.0", "react-hook-form": "^7.57.0", "react-hot-toast": "^2.5.2", + "react-multi-date-picker": "4.5.2", "react-router-dom": "^6.15.0", "recharts": "^2.8.0", "yup": "^1.6.1", diff --git a/src/components/ui/JalaliDateTimePicker.tsx b/src/components/ui/JalaliDateTimePicker.tsx new file mode 100644 index 0000000..afe8729 --- /dev/null +++ b/src/components/ui/JalaliDateTimePicker.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import DatePicker from 'react-multi-date-picker'; +import TimePicker from 'react-multi-date-picker/plugins/time_picker'; +import persian from 'react-date-object/calendars/persian'; +import persian_fa from 'react-date-object/locales/persian_fa'; +import DateObject from 'react-date-object'; +import { Label } from './Typography'; + +interface JalaliDateTimePickerProps { + label?: string; + value?: string | null; + onChange: (value: string | undefined) => void; + error?: string; + placeholder?: string; +} + +const toIsoLike = (date?: DateObject | null): string | undefined => { + if (!date) return undefined; + try { + const g = date.convert(); + const yyyy = g.year.toString().padStart(4, '0'); + const mm = g.month.toString().padStart(2, '0'); + const dd = g.day.toString().padStart(2, '0'); + const hh = g.hour.toString().padStart(2, '0'); + const mi = g.minute.toString().padStart(2, '0'); + return `${yyyy}-${mm}-${dd}T${hh}:${mi}:00Z`; + } catch { + return undefined; + } +}; + +const fromIsoToDateObject = (value?: string | null): DateObject | undefined => { + if (!value) return undefined; + try { + const d = new Date(value); + if (isNaN(d.getTime())) return undefined; + return new DateObject(d).convert(persian, persian_fa); + } catch { + return undefined; + } +}; + +export const JalaliDateTimePicker: React.FC = ({ label, value, onChange, error, placeholder }) => { + const selected = fromIsoToDateObject(value); + + return ( +
+ {label && } + onChange(toIsoLike(val as DateObject | null))} + format="YYYY/MM/DD HH:mm" + calendar={persian} + locale={persian_fa} + calendarPosition="bottom-center" + disableDayPicker={false} + inputClass={`w-full border rounded-lg px-3 py-3 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 ${error ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 dark:border-gray-600 focus:border-primary-500 focus:ring-primary-500'}`} + containerClassName="w-full" + placeholder={placeholder || 'تاریخ و ساعت'} + editable={false} + plugins={[]} + disableMonthPicker={false} + disableYearPicker={false} + showOtherDays + /> + {error && ( +

{error}

+ )} +
+ ); +}; + +export default JalaliDateTimePicker; + + diff --git a/src/components/ui/SingleSelectAutocomplete.tsx b/src/components/ui/SingleSelectAutocomplete.tsx new file mode 100644 index 0000000..3e1feec --- /dev/null +++ b/src/components/ui/SingleSelectAutocomplete.tsx @@ -0,0 +1,207 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { ChevronDown, X } from 'lucide-react'; + +export interface Option { + id: number; + title: string; + description?: string; +} + +interface SingleSelectAutocompleteProps { + options: Option[]; + selectedValue?: number; + onChange: (value?: number) => void; + placeholder?: string; + label?: string; + error?: string; + isLoading?: boolean; + disabled?: boolean; + onSearchChange?: (query: string) => void; + onLoadMore?: () => void; + hasMore?: boolean; + loadingMore?: boolean; +} + +export const SingleSelectAutocomplete: React.FC = ({ + options, + selectedValue, + onChange, + placeholder = "انتخاب کنید...", + label, + error, + isLoading = false, + disabled = false, + onSearchChange, + onLoadMore, + hasMore = false, + loadingMore = false, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const dropdownRef = useRef(null); + const inputRef = useRef(null); + const listRef = useRef(null); + + const filteredOptions = options.filter(option => + option.title.toLowerCase().includes(searchTerm.toLowerCase()) || + (option.description && option.description.toLowerCase().includes(searchTerm.toLowerCase())) + ); + + const displayedOptions = onSearchChange ? options : filteredOptions; + const selectedOption = options.find(option => option.id === selectedValue); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + setSearchTerm(''); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleSelectOption = (optionId: number) => { + onChange(optionId); + setIsOpen(false); + setSearchTerm(''); + }; + + const handleClearSelection = (e: React.MouseEvent) => { + e.stopPropagation(); + onChange(undefined); + }; + + const handleToggleDropdown = () => { + if (disabled) return; + setIsOpen(!isOpen); + if (!isOpen) { + setTimeout(() => inputRef.current?.focus(), 100); + } + }; + + const handleSearchChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setSearchTerm(value); + if (onSearchChange) { + onSearchChange(value); + } + }; + + const handleScroll = (e: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + if (scrollHeight - scrollTop <= clientHeight + 5 && hasMore && !loadingMore && onLoadMore) { + onLoadMore(); + } + }; + + return ( +
+ {label && ( + + )} + +
+
+
+ {selectedOption ? ( +
+
+ {selectedOption.title} + {selectedOption.description && ( + + {selectedOption.description} + + )} +
+ +
+ ) : ( + {placeholder} + )} +
+ +
+
+ + {isOpen && ( +
+
+ +
+ +
+ {isLoading && displayedOptions.length === 0 ? ( +
+ در حال بارگذاری... +
+ ) : displayedOptions.length === 0 ? ( +
+ موردی یافت نشد +
+ ) : ( + <> + {displayedOptions.map((option) => ( +
handleSelectOption(option.id)} + > +
{option.title}
+ {option.description && ( +
+ {option.description} +
+ )} +
+ ))} + {loadingMore && ( +
+ در حال بارگذاری بیشتر... +
+ )} + + )} +
+
+ )} + + {error && ( +

{error}

+ )} +
+ ); +}; diff --git a/src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx b/src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx index 6a7477e..9f4b44f 100644 --- a/src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx +++ b/src/pages/discount-codes/discount-code-form/DiscountCodeFormPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { useForm } from 'react-hook-form'; +import { useForm, Controller } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; import * as yup from 'yup'; import { parseFormattedNumber } from '@/utils/numberUtils'; @@ -10,6 +10,8 @@ import { Button } from "@/components/ui/Button"; import { Input } from "@/components/ui/Input"; import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; import { MultiSelectAutocomplete, Option } from "@/components/ui/MultiSelectAutocomplete"; +import { SingleSelectAutocomplete } from "@/components/ui/SingleSelectAutocomplete"; +import { JalaliDateTimePicker } from "@/components/ui/JalaliDateTimePicker"; import { FormHeader, PageContainer, Label, SectionTitle } from '../../../components/ui/Typography'; import { ArrowRight, BadgePercent, Calendar, Settings, Users, Tag, Info } from 'lucide-react'; import { useUsers, useSearchUsers } from '../../users-admin/core/_hooks'; @@ -29,7 +31,7 @@ const schema = yup.object({ .min(0.01, 'مقدار باید بیشتر از صفر باشد') .max(999999, 'مقدار نباید بیشتر از ۹۹۹,۹۹۹ باشد'), status: yup.mixed<'active' | 'inactive'>().oneOf(['active', 'inactive']).required('وضعیت الزامی است'), - application_level: yup.array().of(yup.mixed<'invoice' | 'category' | 'product' | 'shipping' | 'product_fee'>().oneOf(['invoice', 'category', 'product', 'shipping', 'product_fee'])).min(1, 'حداقل یک سطح اعمال انتخاب کنید').required('سطح اعمال الزامی است'), + application_level: yup.mixed<'invoice' | 'category' | 'product' | 'shipping' | 'product_fee'>().oneOf(['invoice', 'category', 'product', 'shipping', 'product_fee']).required('سطح اعمال الزامی است'), min_purchase_amount: yup .number() .transform((val, original) => parseFormattedNumber(original) as any) @@ -77,10 +79,10 @@ const DiscountCodeFormPage = () => { const { id } = useParams(); const isEdit = !!id; - const [selectedUserIds, setSelectedUserIds] = useState([]); const [selectedProductIds, setSelectedProductIds] = useState([]); const [selectedCategoryIds, setSelectedCategoryIds] = useState([]); - const [selectedApplicationLevels, setSelectedApplicationLevels] = useState(['invoice']); + const [selectedApplicationLevel, setSelectedApplicationLevel] = useState('invoice'); + const [selectedUserId, setSelectedUserId] = useState(); const { data: dc, isLoading: dcLoading } = useDiscountCode(id || ''); const { mutate: create, isPending: creating } = useCreateDiscountCode(); @@ -177,10 +179,10 @@ const DiscountCodeFormPage = () => { description: category.description || `دسته‌بندی #${category.id}` })); - const { register, handleSubmit, formState: { errors, isValid }, reset, watch } = useForm({ + const { register, handleSubmit, control, formState: { errors, isValid }, reset, watch } = useForm({ resolver: yupResolver(schema as any), mode: 'onChange', - defaultValues: { status: 'active', type: 'percentage', application_level: ['invoice'], single_use: false } + defaultValues: { status: 'active', type: 'percentage', application_level: 'invoice', single_use: false } }); const applicationLevel = watch('application_level'); @@ -206,17 +208,17 @@ const DiscountCodeFormPage = () => { meta: dc.meta, }); - // Set selected user IDs - if (dc.user_restrictions?.user_ids) { - setSelectedUserIds(dc.user_restrictions.user_ids); + // Set selected user ID (first one if multiple) + if (dc.user_restrictions?.user_ids && dc.user_restrictions.user_ids.length > 0) { + setSelectedUserId(dc.user_restrictions.user_ids[0]); } - // Set selected application levels + // Set selected application level if (dc.application_level) { - setSelectedApplicationLevels( + setSelectedApplicationLevel( Array.isArray(dc.application_level) - ? dc.application_level - : [dc.application_level] + ? dc.application_level[0] + : dc.application_level ); } @@ -238,14 +240,12 @@ const DiscountCodeFormPage = () => { const formData: CreateDiscountCodeRequest = { ...data, - application_level: selectedApplicationLevels.length === 1 - ? selectedApplicationLevels[0] as any - : selectedApplicationLevels as any, + application_level: selectedApplicationLevel as any, valid_from: toApiDateTime(data.valid_from), valid_to: toApiDateTime(data.valid_to), - user_restrictions: selectedUserIds.length > 0 ? { + user_restrictions: selectedUserId ? { ...cleanRestrictions, - user_ids: selectedUserIds, + user_ids: [selectedUserId], } : cleanRestrictions.user_group ? cleanRestrictions : undefined, product_ids: selectedProductIds.length > 0 ? selectedProductIds : undefined, category_ids: selectedCategoryIds.length > 0 ? selectedCategoryIds : undefined, @@ -414,7 +414,7 @@ const DiscountCodeFormPage = () => { color: 'red' } ].map(option => { - const isSelected = selectedApplicationLevels.includes(option.value); + const isSelected = selectedApplicationLevel === option.value; const getColorClasses = (color: string, selected: boolean) => { const baseClasses = 'relative flex flex-col p-4 border-2 rounded-xl cursor-pointer transition-all duration-200 hover:shadow-lg transform hover:-translate-y-1'; @@ -468,15 +468,11 @@ const DiscountCodeFormPage = () => { className={getColorClasses(option.color, isSelected)} > { - if (e.target.checked) { - setSelectedApplicationLevels(prev => [...prev, option.value]); - } else { - setSelectedApplicationLevels(prev => prev.filter(level => level !== option.value)); - } - }} + onChange={() => setSelectedApplicationLevel(option.value)} className="sr-only" /> @@ -511,7 +507,7 @@ const DiscountCodeFormPage = () => { {/* Conditional Product Selection */} - {selectedApplicationLevels.includes('product') && ( + {selectedApplicationLevel === 'product' && (
@@ -553,7 +549,7 @@ const DiscountCodeFormPage = () => { )} {/* Conditional Category Selection */} - {selectedApplicationLevels.includes('category') && ( + {selectedApplicationLevel === 'category' && (
@@ -656,17 +652,29 @@ const DiscountCodeFormPage = () => {
- ( + + )} /> - ( + + )} />
@@ -700,12 +708,12 @@ const DiscountCodeFormPage = () => { {/* User Selection */}
- { setUserSearch(q); setUserOffset(0); }} @@ -718,7 +726,7 @@ const DiscountCodeFormPage = () => { loadingMore={usersLoading && userOffset > 0} />

- در صورت انتخاب کاربران، کد تخفیف فقط برای آن‌ها قابل استفاده خواهد بود. + در صورت انتخاب کاربر، کد تخفیف فقط برای آن کاربر قابل استفاده خواهد بود.