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
This commit is contained in:
hosseintaromi 2025-07-30 08:29:19 +03:30
parent e00eb4a160
commit 4f4ef51ccc
8 changed files with 594 additions and 183 deletions

View File

@ -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<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
label?: string;
@ -8,10 +9,11 @@ interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, '
helperText?: string;
inputSize?: 'sm' | 'md' | 'lg';
icon?: React.ComponentType<{ className?: string }>;
numeric?: boolean;
}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ 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<HTMLInputElement, InputProps>(
className
);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="space-y-1">
{label && <Label htmlFor={id}>{label}</Label>}
<input
ref={ref}
id={id}
className={inputClasses}
{...props}
/>
<input {...inputProps} />
{helperText && !error && (
<p className="text-xs text-gray-500 dark:text-gray-400">{helperText}</p>
)}

View File

@ -98,7 +98,7 @@ export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = (
selectedOptions.map(option => (
<span
key={option.id}
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 text-primary-800 text-xs rounded-md"
className="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 dark:bg-primary-800 text-primary-800 dark:text-primary-100 text-xs rounded-md"
>
{option.title}
<button
@ -107,7 +107,7 @@ export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = (
e.stopPropagation();
handleRemoveOption(option.id);
}}
className="hover:bg-primary-200 rounded-full p-0.5"
className="hover:bg-primary-200 dark:hover:bg-primary-700 rounded-full p-0.5 transition-colors"
disabled={disabled}
>
<X className="h-3 w-3" />

View File

@ -1,15 +1,22 @@
import React, { useState } from 'react';
import { Plus, Trash2, Edit3, Package } from 'lucide-react';
import { Plus, Trash2, Edit3, Package, X, Edit, Image as ImageIcon } from 'lucide-react';
import { ProductVariantFormData, ProductImage } from '../../pages/products/core/_models';
import { Button } from './Button';
import { FileUploader } from './FileUploader';
import { useFileUpload, useFileDelete } from '../../hooks/useFileUpload';
import { persianToEnglish, convertPersianNumbersInObject } from '../../utils/numberUtils';
interface ProductOption {
id: number;
title: string;
description?: string;
}
interface VariantManagerProps {
variants: ProductVariantFormData[];
onChange: (variants: ProductVariantFormData[]) => void;
disabled?: boolean;
productOptions?: ProductOption[];
}
interface VariantFormProps {
@ -17,9 +24,10 @@ interface VariantFormProps {
onSave: (variant: ProductVariantFormData) => void;
onCancel: () => void;
isEdit?: boolean;
productOptions?: ProductOption[];
}
const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, isEdit = false }) => {
const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, isEdit = false, productOptions = [] }) => {
const [formData, setFormData] = useState<ProductVariantFormData>(
variant || {
enabled: true,
@ -29,6 +37,7 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
stock_managed: true,
stock_number: 0,
weight: 0,
product_option_id: undefined,
attributes: {},
meta: {},
images: []
@ -47,6 +56,9 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
const { mutate: deleteFile } = useFileDelete();
const handleInputChange = (field: keyof ProductVariantFormData, value: any) => {
if (typeof value === 'string') {
value = persianToEnglish(value);
}
setFormData(prev => ({ ...prev, [field]: value }));
};
@ -113,13 +125,14 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
};
const handleSave = () => {
const variantToSave: ProductVariantFormData = {
const convertedData = convertPersianNumbersInObject({
...formData,
images: uploadedImages,
attributes,
meta
};
onSave(variantToSave);
meta,
images: uploadedImages
});
onSave(convertedData);
};
return (
@ -145,14 +158,15 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
درصد کارمزد
</label>
<input
type="number"
value={formData.fee_percentage}
onChange={(e) => handleInputChange('fee_percentage', parseFloat(e.target.value) || 0)}
type="text"
inputMode="decimal"
value={formData.fee_percentage || ''}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
handleInputChange('fee_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="مثال: ۵.۲"
/>
</div>
@ -161,14 +175,15 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
درصد سود
</label>
<input
type="number"
value={formData.profit_percentage}
onChange={(e) => 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="مثال: ۱۰.۵"
/>
</div>
@ -177,17 +192,46 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
وزن (گرم)
</label>
<input
type="number"
value={formData.weight}
onChange={(e) => 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="مثال: ۱۲۰۰"
/>
</div>
</div>
{/* Product Option Selection */}
{productOptions && productOptions.length > 0 && (
<div>
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
گزینه محصول
</h5>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
انتخاب گزینه محصول
</label>
<select
value={formData.product_option_id || ''}
onChange={(e) => handleInputChange('product_option_id', e.target.value ? parseInt(e.target.value) : undefined)}
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"
>
<option value="">انتخاب کنید...</option>
{productOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.title}
{option.description && ` - ${option.description}`}
</option>
))}
</select>
</div>
</div>
)}
{/* Stock Management */}
<div>
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
@ -211,27 +255,33 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
تعداد موجودی
</label>
<input
type="number"
value={formData.stock_number}
onChange={(e) => 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}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حد کمینه موجودی
حد موجودی
</label>
<input
type="number"
value={formData.stock_limit}
onChange={(e) => 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}
/>
</div>
@ -397,7 +447,7 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
);
};
export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChange, disabled = false }) => {
export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChange, disabled = false, productOptions = [] }) => {
const [showForm, setShowForm] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
@ -456,6 +506,7 @@ export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChan
onSave={handleSaveVariant}
onCancel={handleCancelForm}
isEdit={editingIndex !== null}
productOptions={productOptions}
/>
)}
@ -489,6 +540,11 @@ export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChan
<div>
<strong>وزن:</strong> {variant.weight} گرم
</div>
{variant.product_option_id && (
<div className="md:col-span-2">
<strong>گزینه محصول:</strong> {productOptions.find(opt => opt.id === variant.product_option_id)?.title || `شناسه ${variant.product_option_id}`}
</div>
)}
</div>
{variant.images && variant.images.length > 0 && (
@ -527,6 +583,7 @@ export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChan
{!disabled && (
<div className="flex gap-2">
<button
type="button"
onClick={() => handleEditVariant(index)}
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md"
title="ویرایش"
@ -534,6 +591,7 @@ export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChan
<Edit3 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleDeleteVariant(index)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md"
title="حذف"

View File

@ -18,6 +18,7 @@ export interface ProductVariant {
stock_managed: boolean;
stock_number: number;
weight: number;
product_option_id?: number;
attributes?: Record<string, any>;
meta?: Record<string, any>;
images?: ProductImage[];
@ -33,6 +34,7 @@ export interface Product {
enabled: boolean;
category_ids?: number[];
categories?: Category[];
product_categories?: Category[];
category?: Category;
product_option_id?: number;
product_option?: ProductOption;
@ -44,6 +46,7 @@ export interface Product {
attributes?: Record<string, any>;
images: ProductImage[];
variants?: ProductVariant[];
product_variants?: ProductVariant[];
created_at: string;
updated_at: string;
}
@ -71,6 +74,7 @@ export interface ProductVariantFormData {
stock_managed: boolean;
stock_number: number;
weight: number;
product_option_id?: number;
attributes: Record<string, any>;
meta: Record<string, any>;
images: ProductImage[];

View File

@ -1,9 +1,10 @@
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowRight, Edit, Package, Tag, Image, Calendar, FileText, Eye, DollarSign } from 'lucide-react';
import { ArrowRight, Edit, Package, Tag, Image, Calendar, FileText, Eye, DollarSign, Hash, Layers, Settings } from 'lucide-react';
import { Button } from '../../../components/ui/Button';
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
import { useProduct } from '../core/_hooks';
import { PRODUCT_TYPE_LABELS } from '../core/_models';
import { PageContainer, PageTitle, SectionTitle } from '../../../components/ui/Typography';
const ProductDetailPage = () => {
const navigate = useNavigate();
@ -19,55 +20,75 @@ const ProductDetailPage = () => {
return new Intl.NumberFormat('fa-IR').format(price) + ' تومان';
};
return (
<div className="p-6">
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<Button
variant="secondary"
onClick={() => navigate('/products')}
className="flex items-center gap-2"
>
<ArrowRight className="h-4 w-4" />
بازگشت
</Button>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
جزئیات محصول
</h1>
</div>
const formatNumber = (num: number) => {
return new Intl.NumberFormat('fa-IR').format(num);
};
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => navigate(`/products/${id}/edit`)}
className="flex items-center gap-2"
>
<Edit className="h-4 w-4" />
ویرایش
</Button>
return (
<PageContainer>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Button
variant="secondary"
onClick={() => navigate('/products')}
className="flex items-center gap-2"
>
<ArrowRight className="h-4 w-4" />
بازگشت
</Button>
<div>
<PageTitle>جزئیات محصول</PageTitle>
<p className="text-gray-600 dark:text-gray-400">{product.name}</p>
</div>
</div>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => navigate(`/products/${id}/edit`)}
className="flex items-center gap-2"
>
<Edit className="h-4 w-4" />
ویرایش
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* اطلاعات اصلی */}
<div className="lg:col-span-2">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-6">
<div className="lg:col-span-2 space-y-6">
{/* اطلاعات پایه */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<SectionTitle className="flex items-center gap-2 mb-6">
<Package className="h-5 w-5" />
اطلاعات محصول
</h2>
</SectionTitle>
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
نام محصول
</label>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-gray-100 font-medium">
{product.name}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
نام محصول
</label>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-gray-100 font-medium">
{product.name}
</p>
</div>
</div>
{product.sku && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
کد محصول (SKU)
</label>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-gray-100 font-mono">
{product.sku}
</p>
</div>
</div>
)}
</div>
{product.description && (
@ -96,7 +117,7 @@ const ProductDetailPage = () => {
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
نوع محصول
@ -121,16 +142,54 @@ const ProductDetailPage = () => {
</span>
</div>
</div>
{product.price && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
قیمت
</label>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-gray-100 font-medium">
{formatPrice(product.price)}
</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* ویژگی‌های محصول */}
{product.attributes && Object.keys(product.attributes).length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<SectionTitle className="flex items-center gap-2 mb-4">
<Settings className="h-5 w-5" />
ویژگیهای محصول
</SectionTitle>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(product.attributes).map(([key, value]) => (
<div key={key} className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{key}
</span>
<span className="text-sm text-gray-900 dark:text-gray-100">
{typeof value === 'object' ? JSON.stringify(value) : value}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* تصاویر محصول */}
{product.images && product.images.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<SectionTitle className="flex items-center gap-2 mb-4">
<Image className="h-5 w-5" />
تصاویر محصول
</h3>
</SectionTitle>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{product.images.map((image, index) => (
<div key={image.id || index} className="relative group">
@ -148,44 +207,137 @@ const ProductDetailPage = () => {
</div>
)}
{/* محصول متغیر */}
{/* نسخه‌های محصول */}
{product.variants && product.variants.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<SectionTitle className="flex items-center gap-2 mb-4">
<Layers className="h-5 w-5" />
نسخههای محصول
</h3>
<div className="space-y-4">
</SectionTitle>
<div className="space-y-6">
{product.variants.map((variant, index) => (
<div key={variant.id || index} className="p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">وضعیت:</span>
<span className={`ml-2 px-2 py-1 text-xs rounded-full ${variant.enabled
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
}`}>
{variant.enabled ? 'فعال' : 'غیرفعال'}
<div key={variant.id || index} className="border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h4 className="font-medium text-gray-900 dark:text-gray-100">
نسخه {index + 1}
</h4>
<span className={`px-2 py-1 text-xs rounded-full ${variant.enabled
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
}`}>
{variant.enabled ? 'فعال' : 'غیرفعال'}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded">
<span className="text-xs text-gray-600 dark:text-gray-400 block">درصد کارمزد</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{formatNumber(variant.fee_percentage)}%
</span>
</div>
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">موجودی:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-gray-100">
{variant.stock_number}
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded">
<span className="text-xs text-gray-600 dark:text-gray-400 block">درصد سود</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{formatNumber(variant.profit_percentage)}%
</span>
</div>
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">وزن:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-gray-100">
{variant.weight} گرم
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded">
<span className="text-xs text-gray-600 dark:text-gray-400 block">وزن</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{formatNumber(variant.weight)} گرم
</span>
</div>
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">درصد سود:</span>
<span className="ml-2 font-medium text-gray-900 dark:text-gray-100">
{variant.profit_percentage}%
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded">
<span className="text-xs text-gray-600 dark:text-gray-400 block">موجودی</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{formatNumber(variant.stock_number)}
</span>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded">
<span className="text-xs text-gray-600 dark:text-gray-400 block">حد موجودی</span>
<span className="font-medium text-gray-900 dark:text-gray-100">
{formatNumber(variant.stock_limit)}
</span>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded">
<span className="text-xs text-gray-600 dark:text-gray-400 block">مدیریت موجودی</span>
<span className={`px-2 py-1 text-xs rounded-full ${variant.stock_managed
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
}`}>
{variant.stock_managed ? 'فعال' : 'غیرفعال'}
</span>
</div>
{variant.product_option_id && (
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded">
<span className="text-xs text-gray-600 dark:text-gray-400 block">گزینه محصول</span>
<span className="font-medium text-blue-900 dark:text-blue-100">
شناسه: {variant.product_option_id}
</span>
</div>
)}
</div>
{/* ویژگی‌های نسخه */}
{variant.attributes && Object.keys(variant.attributes).length > 0 && (
<div className="mb-4">
<h5 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
ویژگیهای نسخه
</h5>
<div className="grid grid-cols-2 gap-2">
{Object.entries(variant.attributes).map(([key, value]) => (
<div key={key} className="p-2 bg-blue-50 dark:bg-blue-900/20 rounded text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">{key}:</span>
<span className="ml-1 text-gray-900 dark:text-gray-100">
{typeof value === 'object' ? JSON.stringify(value) : value}
</span>
</div>
))}
</div>
</div>
)}
{/* متاداده نسخه */}
{variant.meta && Object.keys(variant.meta).length > 0 && (
<div className="mb-4">
<h5 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
اطلاعات تکمیلی
</h5>
<div className="grid grid-cols-2 gap-2">
{Object.entries(variant.meta).map(([key, value]) => (
<div key={key} className="p-2 bg-gray-50 dark:bg-gray-700 rounded text-sm">
<span className="font-medium text-gray-700 dark:text-gray-300">{key}:</span>
<span className="ml-1 text-gray-900 dark:text-gray-100">
{typeof value === 'object' ? JSON.stringify(value) : value}
</span>
</div>
))}
</div>
</div>
)}
{/* تصاویر نسخه */}
{variant.images && variant.images.length > 0 && (
<div>
<h5 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
تصاویر نسخه
</h5>
<div className="grid grid-cols-4 gap-2">
{variant.images.map((image, imgIndex) => (
<img
key={image.id || imgIndex}
src={image.url}
alt={image.alt || `تصویر نسخه ${imgIndex + 1}`}
className="w-full h-16 object-cover rounded border border-gray-200 dark:border-gray-600"
/>
))}
</div>
</div>
)}
</div>
))}
</div>
@ -196,11 +348,24 @@ const ProductDetailPage = () => {
{/* اطلاعات جانبی */}
<div className="space-y-6">
{/* آمار */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
آمار
</h3>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<SectionTitle className="flex items-center gap-2 mb-4">
<DollarSign className="h-5 w-5" />
آمار محصول
</SectionTitle>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Hash className="h-4 w-4 text-blue-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">
شناسه محصول
</span>
</div>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{product.id}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-green-500" />
@ -209,20 +374,20 @@ const ProductDetailPage = () => {
</span>
</div>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{product.total_sold || 0}
{formatNumber(product.total_sold || 0)}
</span>
</div>
{product.variants && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-blue-500" />
<Layers className="h-4 w-4 text-blue-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">
تعداد نسخهها
</span>
</div>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{product.variants.length}
{formatNumber(product.variants.length)}
</span>
</div>
)}
@ -236,7 +401,21 @@ const ProductDetailPage = () => {
</span>
</div>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{product.images.length}
{formatNumber(product.images.length)}
</span>
</div>
)}
{product.categories && product.categories.length > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Tag className="h-4 w-4 text-orange-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">
تعداد دستهبندیها
</span>
</div>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{formatNumber(product.categories.length)}
</span>
</div>
)}
@ -245,20 +424,26 @@ const ProductDetailPage = () => {
{/* دسته‌بندی‌ها */}
{product.categories && product.categories.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<SectionTitle className="flex items-center gap-2 mb-4">
<Tag className="h-5 w-5" />
دستهبندیها
</h3>
</SectionTitle>
<div className="space-y-2">
{product.categories.map((category) => (
<div
key={category.id}
className="flex items-center gap-2 p-2 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"
className="flex items-center gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg"
>
<Tag className="h-4 w-4 text-blue-500" />
<span className="text-blue-900 dark:text-blue-100 font-medium">
{category.name}
</span>
{category.description && (
<span className="text-xs text-blue-700 dark:text-blue-300">
- {category.description}
</span>
)}
</div>
))}
</div>
@ -267,13 +452,14 @@ const ProductDetailPage = () => {
{/* گزینه محصول */}
{product.product_option && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<SectionTitle className="flex items-center gap-2 mb-4">
<Settings className="h-5 w-5" />
گزینه محصول
</h3>
</SectionTitle>
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-gray-100 font-medium">
{product.product_option.name}
{product.product_option.title}
</p>
{product.product_option.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
@ -285,10 +471,11 @@ const ProductDetailPage = () => {
)}
{/* اطلاعات زمانی */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<SectionTitle className="flex items-center gap-2 mb-4">
<Calendar className="h-5 w-5" />
اطلاعات زمانی
</h3>
</SectionTitle>
<div className="space-y-4">
<div>
<div className="flex items-center gap-2 mb-1">
@ -317,7 +504,7 @@ const ProductDetailPage = () => {
</div>
</div>
</div>
</div>
</PageContainer>
);
};

View File

@ -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 = (
<Button
variant="secondary"
@ -303,11 +340,10 @@ const ProductFormPage = () => {
<Input
label="تعداد فروخته شده"
type="number"
numeric
{...register('total_sold')}
error={errors.total_sold?.message}
placeholder="0"
min="0"
placeholder="مثال: ۱۰۰"
/>
<Input
@ -353,17 +389,26 @@ const ProductFormPage = () => {
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
گزینه محصول
</label>
<select
{...register('product_option_id')}
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"
>
<option value="">بدون گزینه</option>
{(productOptions || []).map((option) => (
<option key={option.id} value={option.id}>
{option.title} ({(option.options || []).length} گزینه)
</option>
))}
</select>
{isLoadingProductOptions ? (
<div className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
در حال بارگذاری گزینهها...
</div>
) : (
<select
{...register('product_option_id')}
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"
>
<option value="">بدون گزینه</option>
{productOptionOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.title} - {option.description}
</option>
))}
</select>
)}
{productOptionOptions.length === 0 && !isLoadingProductOptions && (
<p className="text-amber-600 text-sm mt-1">هیچ گزینه محصولی یافت نشد</p>
)}
{errors.product_option_id && (
<p className="text-red-500 text-sm mt-1">{errors.product_option_id.message}</p>
)}
@ -424,6 +469,7 @@ const ProductFormPage = () => {
<VariantManager
variants={watch('variants') || []}
onChange={(variants) => setValue('variants', variants, { shouldValidate: true, shouldDirty: true })}
productOptions={productOptionOptions}
/>
</div>

View File

@ -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 = () => (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
@ -220,17 +221,25 @@ const ProductsListPage = () => {
</label>
<div className="flex gap-2">
<input
type="number"
placeholder="حداقل"
type="text"
inputMode="numeric"
placeholder="حداقل (مثال: ۱۰۰۰۰)"
value={filters.min_price}
onChange={(e) => 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"
/>
<input
type="number"
placeholder="حداکثر"
type="text"
inputMode="numeric"
placeholder="حداکثر (مثال: ۵۰۰۰۰۰)"
value={filters.max_price}
onChange={(e) => 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"
/>
</div>

85
src/utils/numberUtils.ts Normal file
View File

@ -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;
};
};