From 4f4ef51ccc7740e4e579f9f717df5a9a0bf5e2ac Mon Sep 17 00:00:00 2001 From: hosseintaromi Date: Wed, 30 Jul 2025 08:29:19 +0330 Subject: [PATCH] feat: enhance input handling and product management features - Added numeric input handling in Input component with Persian to English conversion - Updated MultiSelectAutocomplete styles for dark mode compatibility - Enhanced VariantManager to support product options and improved variant data handling - Updated ProductDetailPage and ProductFormPage to display and manage product options - Improved ProductsListPage to handle numeric filters with Persian number conversion --- src/components/ui/Input.tsx | 36 +- src/components/ui/MultiSelectAutocomplete.tsx | 4 +- src/components/ui/VariantManager.tsx | 138 +++++-- src/pages/products/core/_models.ts | 4 + .../product-detail/ProductDetailPage.tsx | 365 +++++++++++++----- .../products/product-form/ProductFormPage.tsx | 124 ++++-- .../products-list/ProductsListPage.tsx | 21 +- src/utils/numberUtils.ts | 85 ++++ 8 files changed, 594 insertions(+), 183 deletions(-) create mode 100644 src/utils/numberUtils.ts diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx index 2674e40..3dd10ba 100644 --- a/src/components/ui/Input.tsx +++ b/src/components/ui/Input.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { clsx } from 'clsx'; import { Label } from './Typography'; +import { persianToEnglish } from '../../utils/numberUtils'; interface InputProps extends Omit, 'size'> { label?: string; @@ -8,10 +9,11 @@ interface InputProps extends Omit, ' helperText?: string; inputSize?: 'sm' | 'md' | 'lg'; icon?: React.ComponentType<{ className?: string }>; + numeric?: boolean; } export const Input = React.forwardRef( - ({ label, error, helperText, inputSize = 'md', className, id, ...props }, ref) => { + ({ label, error, helperText, inputSize = 'md', className, id, onChange, type, numeric, ...props }, ref) => { const sizeClasses = { sm: 'px-3 py-2 text-sm', md: 'px-3 py-3 text-base', @@ -29,15 +31,35 @@ export const Input = React.forwardRef( className ); + const handleChange = (e: React.ChangeEvent) => { + if ((type === 'number' || numeric) && e.target.value) { + const convertedValue = persianToEnglish(e.target.value); + e.target.value = convertedValue; + } + onChange?.(e); + }; + + const getInputMode = (): "numeric" | "decimal" | undefined => { + if (numeric) { + return type === 'number' ? 'decimal' : 'numeric'; + } + return undefined; + }; + + const inputProps = { + ref, + id, + type: numeric ? 'text' : type, + inputMode: getInputMode(), + className: inputClasses, + onChange: handleChange, + ...props + }; + return (
{label && } - + {helperText && !error && (

{helperText}

)} diff --git a/src/components/ui/MultiSelectAutocomplete.tsx b/src/components/ui/MultiSelectAutocomplete.tsx index e157836..6e83e33 100644 --- a/src/components/ui/MultiSelectAutocomplete.tsx +++ b/src/components/ui/MultiSelectAutocomplete.tsx @@ -98,7 +98,7 @@ export const MultiSelectAutocomplete: React.FC = ( selectedOptions.map(option => ( {option.title}
@@ -161,14 +175,15 @@ const VariantForm: React.FC = ({ variant, onSave, onCancel, is درصد سود handleInputChange('profit_percentage', parseFloat(e.target.value) || 0)} + type="text" + inputMode="decimal" + value={formData.profit_percentage || ''} + onChange={(e) => { + const converted = persianToEnglish(e.target.value); + handleInputChange('profit_percentage', parseFloat(converted) || 0); + }} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" - placeholder="0" - min="0" - max="100" - step="0.1" + placeholder="مثال: ۱۰.۵" /> @@ -177,17 +192,46 @@ const VariantForm: React.FC = ({ variant, onSave, onCancel, is وزن (گرم) handleInputChange('weight', parseFloat(e.target.value) || 0)} + type="text" + inputMode="decimal" + value={formData.weight || ''} + onChange={(e) => { + const converted = persianToEnglish(e.target.value); + handleInputChange('weight', parseFloat(converted) || 0); + }} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" - placeholder="0" - min="0" - step="0.1" + placeholder="مثال: ۱۲۰۰" /> + {/* Product Option Selection */} + {productOptions && productOptions.length > 0 && ( +
+
+ گزینه محصول +
+
+ + +
+
+ )} + {/* Stock Management */}
@@ -211,27 +255,33 @@ const VariantForm: React.FC = ({ variant, onSave, onCancel, is تعداد موجودی handleInputChange('stock_number', parseInt(e.target.value) || 0)} + type="text" + inputMode="numeric" + value={formData.stock_number || ''} + onChange={(e) => { + const converted = persianToEnglish(e.target.value); + handleInputChange('stock_number', parseInt(converted) || 0); + }} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" - placeholder="0" - min="0" + placeholder="مثال: ۱۰۰" disabled={!formData.stock_managed} />
handleInputChange('stock_limit', parseInt(e.target.value) || 0)} + type="text" + inputMode="numeric" + value={formData.stock_limit || ''} + onChange={(e) => { + const converted = persianToEnglish(e.target.value); + handleInputChange('stock_limit', parseInt(converted) || 0); + }} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" - placeholder="0" - min="0" + placeholder="مثال: ۱۰" disabled={!formData.stock_managed} />
@@ -397,7 +447,7 @@ const VariantForm: React.FC = ({ variant, onSave, onCancel, is ); }; -export const VariantManager: React.FC = ({ variants, onChange, disabled = false }) => { +export const VariantManager: React.FC = ({ variants, onChange, disabled = false, productOptions = [] }) => { const [showForm, setShowForm] = useState(false); const [editingIndex, setEditingIndex] = useState(null); @@ -456,6 +506,7 @@ export const VariantManager: React.FC = ({ variants, onChan onSave={handleSaveVariant} onCancel={handleCancelForm} isEdit={editingIndex !== null} + productOptions={productOptions} /> )} @@ -489,6 +540,11 @@ export const VariantManager: React.FC = ({ variants, onChan
وزن: {variant.weight} گرم
+ {variant.product_option_id && ( +
+ گزینه محصول: {productOptions.find(opt => opt.id === variant.product_option_id)?.title || `شناسه ${variant.product_option_id}`} +
+ )} {variant.images && variant.images.length > 0 && ( @@ -527,6 +583,7 @@ export const VariantManager: React.FC = ({ variants, onChan {!disabled && (
-

- جزئیات محصول -

-
+ const formatNumber = (num: number) => { + return new Intl.NumberFormat('fa-IR').format(num); + }; -
- + return ( + +
+
+ +
+ جزئیات محصول +

{product.name}

+ +
+ +
{/* اطلاعات اصلی */} -
-
-

+
+ {/* اطلاعات پایه */} +
+ + اطلاعات محصول -

+
-
- -
-

- {product.name} -

+
+
+ +
+

+ {product.name} +

+
+ + {product.sku && ( +
+ +
+

+ {product.sku} +

+
+
+ )}
{product.description && ( @@ -96,7 +117,7 @@ const ProductDetailPage = () => {
)} -
+
+ + {product.price && ( +
+ +
+

+ {formatPrice(product.price)} +

+
+
+ )}
+ {/* ویژگی‌های محصول */} + {product.attributes && Object.keys(product.attributes).length > 0 && ( +
+ + + ویژگی‌های محصول + +
+ {Object.entries(product.attributes).map(([key, value]) => ( +
+
+ + {key} + + + {typeof value === 'object' ? JSON.stringify(value) : value} + +
+
+ ))} +
+
+ )} + {/* تصاویر محصول */} {product.images && product.images.length > 0 && ( -
-

+
+ + تصاویر محصول -

+
{product.images.map((image, index) => (
@@ -148,44 +207,137 @@ const ProductDetailPage = () => {
)} - {/* محصول متغیر */} + {/* نسخه‌های محصول */} {product.variants && product.variants.length > 0 && ( -
-

+
+ + نسخه‌های محصول -

-
+ +
{product.variants.map((variant, index) => ( -
-
-
- وضعیت: - - {variant.enabled ? 'فعال' : 'غیرفعال'} +
+
+

+ نسخه {index + 1} +

+ + {variant.enabled ? 'فعال' : 'غیرفعال'} + +
+ +
+
+ درصد کارمزد + + {formatNumber(variant.fee_percentage)}%
-
- موجودی: - - {variant.stock_number} +
+ درصد سود + + {formatNumber(variant.profit_percentage)}%
-
- وزن: - - {variant.weight} گرم +
+ وزن + + {formatNumber(variant.weight)} گرم
-
- درصد سود: - - {variant.profit_percentage}% +
+ موجودی + + {formatNumber(variant.stock_number)}
+ +
+
+ حد موجودی + + {formatNumber(variant.stock_limit)} + +
+
+ مدیریت موجودی + + {variant.stock_managed ? 'فعال' : 'غیرفعال'} + +
+ {variant.product_option_id && ( +
+ گزینه محصول + + شناسه: {variant.product_option_id} + +
+ )} +
+ + {/* ویژگی‌های نسخه */} + {variant.attributes && Object.keys(variant.attributes).length > 0 && ( +
+
+ ویژگی‌های نسخه +
+
+ {Object.entries(variant.attributes).map(([key, value]) => ( +
+ {key}: + + {typeof value === 'object' ? JSON.stringify(value) : value} + +
+ ))} +
+
+ )} + + {/* متاداده نسخه */} + {variant.meta && Object.keys(variant.meta).length > 0 && ( +
+
+ اطلاعات تکمیلی +
+
+ {Object.entries(variant.meta).map(([key, value]) => ( +
+ {key}: + + {typeof value === 'object' ? JSON.stringify(value) : value} + +
+ ))} +
+
+ )} + + {/* تصاویر نسخه */} + {variant.images && variant.images.length > 0 && ( +
+
+ تصاویر نسخه +
+
+ {variant.images.map((image, imgIndex) => ( + {image.alt + ))} +
+
+ )}
))}
@@ -196,11 +348,24 @@ const ProductDetailPage = () => { {/* اطلاعات جانبی */}
{/* آمار */} -
-

- آمار -

+
+ + + آمار محصول +
+
+
+ + + شناسه محصول + +
+ + {product.id} + +
+
@@ -209,20 +374,20 @@ const ProductDetailPage = () => {
- {product.total_sold || 0} + {formatNumber(product.total_sold || 0)}
{product.variants && (
- + تعداد نسخه‌ها
- {product.variants.length} + {formatNumber(product.variants.length)}
)} @@ -236,7 +401,21 @@ const ProductDetailPage = () => {
- {product.images.length} + {formatNumber(product.images.length)} + +
+ )} + + {product.categories && product.categories.length > 0 && ( +
+
+ + + تعداد دسته‌بندی‌ها + +
+ + {formatNumber(product.categories.length)}
)} @@ -245,20 +424,26 @@ const ProductDetailPage = () => { {/* دسته‌بندی‌ها */} {product.categories && product.categories.length > 0 && ( -
-

+
+ + دسته‌بندی‌ها -

+
{product.categories.map((category) => (
{category.name} + {category.description && ( + + - {category.description} + + )}
))}
@@ -267,13 +452,14 @@ const ProductDetailPage = () => { {/* گزینه محصول */} {product.product_option && ( -
-

+
+ + گزینه محصول -

+

- {product.product_option.name} + {product.product_option.title}

{product.product_option.description && (

@@ -285,10 +471,11 @@ const ProductDetailPage = () => { )} {/* اطلاعات زمانی */} -

-

+
+ + اطلاعات زمانی -

+
@@ -317,7 +504,7 @@ const ProductDetailPage = () => {
-
+ ); }; diff --git a/src/pages/products/product-form/ProductFormPage.tsx b/src/pages/products/product-form/ProductFormPage.tsx index af85763..6c8e024 100644 --- a/src/pages/products/product-form/ProductFormPage.tsx +++ b/src/pages/products/product-form/ProductFormPage.tsx @@ -16,16 +16,20 @@ import { FileUploader } from "@/components/ui/FileUploader"; import { VariantManager } from "@/components/ui/VariantManager"; import { ArrowRight, Package, X, Plus, Trash2 } from "lucide-react"; import { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography'; +import { createNumberTransform, createOptionalNumberTransform, convertPersianNumbersInObject } from '../../../utils/numberUtils'; const productSchema = yup.object({ name: yup.string().required('نام محصول الزامی است').min(2, 'نام محصول باید حداقل 2 کاراکتر باشد'), - description: yup.string().default(''), - design_style: yup.string().default(''), + description: yup.string(), + design_style: yup.string(), enabled: yup.boolean().default(true), - total_sold: yup.number().min(0).default(0), - type: yup.number().oneOf([0, 1, 2, 3]).default(0), + total_sold: yup.number() + .transform(createNumberTransform()) + .min(0, 'تعداد فروخته شده نمی‌تواند منفی باشد') + .optional(), + type: yup.number().oneOf([0, 1, 2, 3]).default(1), category_ids: yup.array().of(yup.number()).default([]), - product_option_id: yup.number().optional().nullable(), + product_option_id: yup.number().transform(createOptionalNumberTransform()).nullable(), attributes: yup.object().default({}), images: yup.array().of(yup.object()).default([]), variants: yup.array().default([]), @@ -67,8 +71,8 @@ const ProductFormPage = () => { description: '', design_style: '', enabled: true, - total_sold: 0, - type: 1, // هارد کد شده به VARIABLE + total_sold: undefined, + type: 1, category_ids: [], product_option_id: undefined, attributes: {}, @@ -81,8 +85,23 @@ const ProductFormPage = () => { useEffect(() => { if (isEdit && product) { - // تبدیل variants از ProductVariant به ProductVariantFormData - const formVariants = product.variants?.map(variant => ({ + console.log('Product data in edit mode:', product); + + // Extract category IDs - handle different API response formats + let categoryIds: number[] = []; + if (product.category_ids && product.category_ids.length > 0) { + categoryIds = product.category_ids; + } else if (product.categories && product.categories.length > 0) { + categoryIds = product.categories.map(cat => cat.id); + } else if (product.product_categories && product.product_categories.length > 0) { + categoryIds = product.product_categories.map(cat => cat.id); + } + + console.log('✅ Successfully extracted category IDs:', categoryIds); + + // تبدیل variants از ProductVariant به ProductVariantFormData - handle both variants and product_variants + const variants = product.variants || product.product_variants || []; + const formVariants = variants.map(variant => ({ id: variant.id, enabled: variant.enabled, fee_percentage: variant.fee_percentage, @@ -91,10 +110,13 @@ const ProductFormPage = () => { stock_managed: variant.stock_managed, stock_number: variant.stock_number, weight: variant.weight, + product_option_id: variant.product_option_id || undefined, attributes: variant.attributes || {}, meta: variant.meta || {}, images: variant.images || [] - })) || []; + })); + + console.log('✅ Successfully processed variants:', formVariants.length); reset({ name: product.name, @@ -102,8 +124,8 @@ const ProductFormPage = () => { design_style: product.design_style || '', enabled: product.enabled, total_sold: product.total_sold || 0, - type: 1, // هارد کد شده به VARIABLE - category_ids: product.category_ids || [], + type: 1, + category_ids: categoryIds, product_option_id: product.product_option_id || undefined, attributes: product.attributes || {}, images: product.images || [], @@ -162,25 +184,36 @@ const ProductFormPage = () => { setValue('attributes', updatedAttributes, { shouldValidate: true, shouldDirty: true }); }; - const onSubmit = (data: ProductFormData) => { + const onSubmit = (data: any) => { + const convertedData = convertPersianNumbersInObject(data); + + const validImageIds = uploadedImages + .map(img => { + const numericId = Number(img.id); + return isNaN(numericId) ? null : numericId; + }) + .filter(id => id !== null); + const baseSubmitData = { - name: data.name, - description: data.description, - design_style: data.design_style, - enabled: data.enabled, - total_sold: data.total_sold, - type: 1, // هارد کد شده به VARIABLE - attributes, - category_ids: data.category_ids.length > 0 ? data.category_ids : [], - product_option_id: data.product_option_id || undefined, - images: uploadedImages.map(img => parseInt(img.id)) // فقط ID های تصاویر به صورت عدد ارسال می‌شود + name: convertedData.name, + description: convertedData.description || '', + design_style: convertedData.design_style || '', + enabled: convertedData.enabled, + total_sold: convertedData.total_sold || 0, + type: 1, + attributes: convertPersianNumbersInObject(attributes), + category_ids: convertedData.category_ids.length > 0 ? convertedData.category_ids : [], + product_option_id: convertedData.product_option_id || null, + images: validImageIds }; console.log('Submitting product data:', baseSubmitData); + console.log('Original data:', data); + console.log('Converted data:', convertedData); if (isEdit && id) { // برای update، variants باید شامل ID باشه - const updateVariants = data.variants?.map(variant => ({ + const updateVariants = data.variants?.map((variant: any) => ({ id: variant.id || 0, // اگر ID نداره، 0 بذار (برای variant جدید) enabled: variant.enabled, fee_percentage: variant.fee_percentage, @@ -189,6 +222,7 @@ const ProductFormPage = () => { stock_managed: variant.stock_managed, stock_number: variant.stock_number, weight: variant.weight, + product_option_id: variant.product_option_id || null, attributes: variant.attributes && Object.keys(variant.attributes).length > 0 ? variant.attributes : {}, meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {} })) || []; @@ -204,7 +238,7 @@ const ProductFormPage = () => { }); } else { // برای create، variants نباید ID داشته باشه - const createVariants = data.variants?.map(variant => ({ + const createVariants = data.variants?.map((variant: any) => ({ enabled: variant.enabled, fee_percentage: variant.fee_percentage, profit_percentage: variant.profit_percentage, @@ -212,6 +246,7 @@ const ProductFormPage = () => { stock_managed: variant.stock_managed, stock_number: variant.stock_number, weight: variant.weight, + product_option_id: variant.product_option_id || null, attributes: variant.attributes && Object.keys(variant.attributes).length > 0 ? variant.attributes : {}, meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {} })) || []; @@ -251,6 +286,8 @@ const ProductFormPage = () => { description: `تعداد گزینه‌ها: ${(option.options || []).length}` })); + + const backButton = (
diff --git a/src/pages/products/products-list/ProductsListPage.tsx b/src/pages/products/products-list/ProductsListPage.tsx index c273ca5..b65550e 100644 --- a/src/pages/products/products-list/ProductsListPage.tsx +++ b/src/pages/products/products-list/ProductsListPage.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/Button"; import { Trash2, Edit3, Plus, Package, Eye, Image } from "lucide-react"; import { Modal } from "@/components/ui/Modal"; +import { persianToEnglish } from '../../../utils/numberUtils'; const ProductsTableSkeleton = () => (
@@ -220,17 +221,25 @@ const ProductsListPage = () => {
setFilters(prev => ({ ...prev, min_price: e.target.value }))} + onChange={(e) => { + const converted = persianToEnglish(e.target.value); + setFilters(prev => ({ ...prev, min_price: converted })); + }} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" /> setFilters(prev => ({ ...prev, max_price: e.target.value }))} + onChange={(e) => { + const converted = persianToEnglish(e.target.value); + setFilters(prev => ({ ...prev, max_price: converted })); + }} className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100" />
diff --git a/src/utils/numberUtils.ts b/src/utils/numberUtils.ts new file mode 100644 index 0000000..292497a --- /dev/null +++ b/src/utils/numberUtils.ts @@ -0,0 +1,85 @@ +export const persianToEnglish = (str: string | number): string => { + if (typeof str === "number") return str.toString(); + if (!str) return ""; + + const persianNumbers = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"]; + const arabicNumbers = ["٠", "١", "٢", "٣", "٤", "٥", "٦", "٧", "٨", "٩"]; + const englishNumbers = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]; + + let result = str.toString(); + + for (let i = 0; i < 10; i++) { + result = result.replace( + new RegExp(persianNumbers[i], "g"), + englishNumbers[i] + ); + result = result.replace( + new RegExp(arabicNumbers[i], "g"), + englishNumbers[i] + ); + } + + return result; +}; + +export const convertPersianNumbersInObject = (obj: any): any => { + if (obj === null || obj === undefined) return obj; + + if (typeof obj === "string") { + return persianToEnglish(obj); + } + + if (typeof obj === "number") { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item) => convertPersianNumbersInObject(item)); + } + + if (typeof obj === "object") { + const converted: any = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + converted[key] = convertPersianNumbersInObject(obj[key]); + } + } + return converted; + } + + return obj; +}; + +export const createNumberTransform = () => { + return (value: any, originalValue: any) => { + if ( + originalValue === "" || + originalValue === null || + originalValue === undefined + ) { + return undefined; + } + + const converted = persianToEnglish(originalValue); + const num = Number(converted); + + return isNaN(num) ? undefined : num; + }; +}; + +export const createOptionalNumberTransform = () => { + return (value: any, originalValue: any) => { + if ( + originalValue === "" || + originalValue === null || + originalValue === undefined + ) { + return null; + } + + const converted = persianToEnglish(originalValue); + const num = Number(converted); + + return isNaN(num) ? null : num; + }; +};