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 React from 'react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { Label } from './Typography'; import { Label } from './Typography';
import { persianToEnglish } from '../../utils/numberUtils';
interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> { interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> {
label?: string; label?: string;
@ -8,10 +9,11 @@ interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, '
helperText?: string; helperText?: string;
inputSize?: 'sm' | 'md' | 'lg'; inputSize?: 'sm' | 'md' | 'lg';
icon?: React.ComponentType<{ className?: string }>; icon?: React.ComponentType<{ className?: string }>;
numeric?: boolean;
} }
export const Input = React.forwardRef<HTMLInputElement, InputProps>( 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 = { const sizeClasses = {
sm: 'px-3 py-2 text-sm', sm: 'px-3 py-2 text-sm',
md: 'px-3 py-3 text-base', md: 'px-3 py-3 text-base',
@ -29,15 +31,35 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
className 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 ( return (
<div className="space-y-1"> <div className="space-y-1">
{label && <Label htmlFor={id}>{label}</Label>} {label && <Label htmlFor={id}>{label}</Label>}
<input <input {...inputProps} />
ref={ref}
id={id}
className={inputClasses}
{...props}
/>
{helperText && !error && ( {helperText && !error && (
<p className="text-xs text-gray-500 dark:text-gray-400">{helperText}</p> <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 => ( selectedOptions.map(option => (
<span <span
key={option.id} 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} {option.title}
<button <button
@ -107,7 +107,7 @@ export const MultiSelectAutocomplete: React.FC<MultiSelectAutocompleteProps> = (
e.stopPropagation(); e.stopPropagation();
handleRemoveOption(option.id); 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} disabled={disabled}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />

View File

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

View File

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

View File

@ -1,9 +1,10 @@
import { useParams, useNavigate } from 'react-router-dom'; 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 { Button } from '../../../components/ui/Button';
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner'; import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
import { useProduct } from '../core/_hooks'; import { useProduct } from '../core/_hooks';
import { PRODUCT_TYPE_LABELS } from '../core/_models'; import { PRODUCT_TYPE_LABELS } from '../core/_models';
import { PageContainer, PageTitle, SectionTitle } from '../../../components/ui/Typography';
const ProductDetailPage = () => { const ProductDetailPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -19,11 +20,14 @@ const ProductDetailPage = () => {
return new Intl.NumberFormat('fa-IR').format(price) + ' تومان'; return new Intl.NumberFormat('fa-IR').format(price) + ' تومان';
}; };
const formatNumber = (num: number) => {
return new Intl.NumberFormat('fa-IR').format(num);
};
return ( return (
<div className="p-6"> <PageContainer>
<div className="mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center gap-3">
<div className="flex items-center gap-4">
<Button <Button
variant="secondary" variant="secondary"
onClick={() => navigate('/products')} onClick={() => navigate('/products')}
@ -32,9 +36,10 @@ const ProductDetailPage = () => {
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
بازگشت بازگشت
</Button> </Button>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100"> <div>
جزئیات محصول <PageTitle>جزئیات محصول</PageTitle>
</h1> <p className="text-gray-600 dark:text-gray-400">{product.name}</p>
</div>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@ -48,17 +53,19 @@ const ProductDetailPage = () => {
</Button> </Button>
</div> </div>
</div> </div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* اطلاعات اصلی */} {/* اطلاعات اصلی */}
<div className="lg:col-span-2"> <div className="lg:col-span-2 space-y-6">
<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="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 className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
نام محصول نام محصول
@ -70,6 +77,20 @@ const ProductDetailPage = () => {
</div> </div>
</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 && ( {product.description && (
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@ -96,7 +117,7 @@ const ProductDetailPage = () => {
</div> </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> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
نوع محصول نوع محصول
@ -121,16 +142,54 @@ const ProductDetailPage = () => {
</span> </span>
</div> </div>
</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> </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 && ( {product.images && product.images.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> <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"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{product.images.map((image, index) => ( {product.images.map((image, index) => (
<div key={image.id || index} className="relative group"> <div key={image.id || index} className="relative group">
@ -148,44 +207,137 @@ const ProductDetailPage = () => {
</div> </div>
)} )}
{/* محصول متغیر */} {/* نسخه‌های محصول */}
{product.variants && product.variants.length > 0 && ( {product.variants && product.variants.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> <SectionTitle className="flex items-center gap-2 mb-4">
<Layers className="h-5 w-5" />
نسخههای محصول نسخههای محصول
</h3> </SectionTitle>
<div className="space-y-4"> <div className="space-y-6">
{product.variants.map((variant, index) => ( {product.variants.map((variant, index) => (
<div key={variant.id || index} className="p-4 border border-gray-200 dark:border-gray-600 rounded-lg"> <div key={variant.id || index} className="border border-gray-200 dark:border-gray-600 rounded-lg p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="flex items-center justify-between mb-4">
<div> <h4 className="font-medium text-gray-900 dark:text-gray-100">
<span className="text-sm text-gray-600 dark:text-gray-400">وضعیت:</span> نسخه {index + 1}
<span className={`ml-2 px-2 py-1 text-xs rounded-full ${variant.enabled </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-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' : 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100'
}`}> }`}>
{variant.enabled ? 'فعال' : 'غیرفعال'} {variant.enabled ? 'فعال' : 'غیرفعال'}
</span> </span>
</div> </div>
<div>
<span className="text-sm text-gray-600 dark:text-gray-400">موجودی:</span> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<span className="ml-2 font-medium text-gray-900 dark:text-gray-100"> <div className="p-3 bg-gray-50 dark:bg-gray-700 rounded">
{variant.stock_number} <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> </span>
</div> </div>
<div> <div className="p-3 bg-gray-50 dark:bg-gray-700 rounded">
<span className="text-sm text-gray-600 dark:text-gray-400">وزن:</span> <span className="text-xs text-gray-600 dark:text-gray-400 block">درصد سود</span>
<span className="ml-2 font-medium text-gray-900 dark:text-gray-100"> <span className="font-medium text-gray-900 dark:text-gray-100">
{variant.weight} گرم {formatNumber(variant.profit_percentage)}%
</span> </span>
</div> </div>
<div> <div className="p-3 bg-gray-50 dark:bg-gray-700 rounded">
<span className="text-sm text-gray-600 dark:text-gray-400">درصد سود:</span> <span className="text-xs text-gray-600 dark:text-gray-400 block">وزن</span>
<span className="ml-2 font-medium text-gray-900 dark:text-gray-100"> <span className="font-medium text-gray-900 dark:text-gray-100">
{variant.profit_percentage}% {formatNumber(variant.weight)} گرم
</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="font-medium text-gray-900 dark:text-gray-100">
{formatNumber(variant.stock_number)}
</span> </span>
</div> </div>
</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>
))} ))}
</div> </div>
@ -196,11 +348,24 @@ const ProductDetailPage = () => {
{/* اطلاعات جانبی */} {/* اطلاعات جانبی */}
<div className="space-y-6"> <div className="space-y-6">
{/* آمار */} {/* آمار */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> <SectionTitle className="flex items-center gap-2 mb-4">
آمار <DollarSign className="h-5 w-5" />
</h3> آمار محصول
</SectionTitle>
<div className="space-y-4"> <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 justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DollarSign className="h-4 w-4 text-green-500" /> <DollarSign className="h-4 w-4 text-green-500" />
@ -209,20 +374,20 @@ const ProductDetailPage = () => {
</span> </span>
</div> </div>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{product.total_sold || 0} {formatNumber(product.total_sold || 0)}
</span> </span>
</div> </div>
{product.variants && ( {product.variants && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <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 className="text-sm text-gray-600 dark:text-gray-400">
تعداد نسخهها تعداد نسخهها
</span> </span>
</div> </div>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <span className="font-semibold text-gray-900 dark:text-gray-100">
{product.variants.length} {formatNumber(product.variants.length)}
</span> </span>
</div> </div>
)} )}
@ -236,7 +401,21 @@ const ProductDetailPage = () => {
</span> </span>
</div> </div>
<span className="font-semibold text-gray-900 dark:text-gray-100"> <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> </span>
</div> </div>
)} )}
@ -245,20 +424,26 @@ const ProductDetailPage = () => {
{/* دسته‌بندی‌ها */} {/* دسته‌بندی‌ها */}
{product.categories && product.categories.length > 0 && ( {product.categories && product.categories.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> <SectionTitle className="flex items-center gap-2 mb-4">
<Tag className="h-5 w-5" />
دستهبندیها دستهبندیها
</h3> </SectionTitle>
<div className="space-y-2"> <div className="space-y-2">
{product.categories.map((category) => ( {product.categories.map((category) => (
<div <div
key={category.id} 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" /> <Tag className="h-4 w-4 text-blue-500" />
<span className="text-blue-900 dark:text-blue-100 font-medium"> <span className="text-blue-900 dark:text-blue-100 font-medium">
{category.name} {category.name}
</span> </span>
{category.description && (
<span className="text-xs text-blue-700 dark:text-blue-300">
- {category.description}
</span>
)}
</div> </div>
))} ))}
</div> </div>
@ -267,13 +452,14 @@ const ProductDetailPage = () => {
{/* گزینه محصول */} {/* گزینه محصول */}
{product.product_option && ( {product.product_option && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> <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"> <div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-gray-900 dark:text-gray-100 font-medium"> <p className="text-gray-900 dark:text-gray-100 font-medium">
{product.product_option.name} {product.product_option.title}
</p> </p>
{product.product_option.description && ( {product.product_option.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> <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"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4"> <SectionTitle className="flex items-center gap-2 mb-4">
<Calendar className="h-5 w-5" />
اطلاعات زمانی اطلاعات زمانی
</h3> </SectionTitle>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@ -317,7 +504,7 @@ const ProductDetailPage = () => {
</div> </div>
</div> </div>
</div> </div>
</div> </PageContainer>
); );
}; };

View File

@ -16,16 +16,20 @@ import { FileUploader } from "@/components/ui/FileUploader";
import { VariantManager } from "@/components/ui/VariantManager"; import { VariantManager } from "@/components/ui/VariantManager";
import { ArrowRight, Package, X, Plus, Trash2 } from "lucide-react"; import { ArrowRight, Package, X, Plus, Trash2 } from "lucide-react";
import { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography'; import { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography';
import { createNumberTransform, createOptionalNumberTransform, convertPersianNumbersInObject } from '../../../utils/numberUtils';
const productSchema = yup.object({ const productSchema = yup.object({
name: yup.string().required('نام محصول الزامی است').min(2, 'نام محصول باید حداقل 2 کاراکتر باشد'), name: yup.string().required('نام محصول الزامی است').min(2, 'نام محصول باید حداقل 2 کاراکتر باشد'),
description: yup.string().default(''), description: yup.string(),
design_style: yup.string().default(''), design_style: yup.string(),
enabled: yup.boolean().default(true), enabled: yup.boolean().default(true),
total_sold: yup.number().min(0).default(0), total_sold: yup.number()
type: yup.number().oneOf([0, 1, 2, 3]).default(0), .transform(createNumberTransform())
.min(0, 'تعداد فروخته شده نمی‌تواند منفی باشد')
.optional(),
type: yup.number().oneOf([0, 1, 2, 3]).default(1),
category_ids: yup.array().of(yup.number()).default([]), 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({}), attributes: yup.object().default({}),
images: yup.array().of(yup.object()).default([]), images: yup.array().of(yup.object()).default([]),
variants: yup.array().default([]), variants: yup.array().default([]),
@ -67,8 +71,8 @@ const ProductFormPage = () => {
description: '', description: '',
design_style: '', design_style: '',
enabled: true, enabled: true,
total_sold: 0, total_sold: undefined,
type: 1, // هارد کد شده به VARIABLE type: 1,
category_ids: [], category_ids: [],
product_option_id: undefined, product_option_id: undefined,
attributes: {}, attributes: {},
@ -81,8 +85,23 @@ const ProductFormPage = () => {
useEffect(() => { useEffect(() => {
if (isEdit && product) { if (isEdit && product) {
// تبدیل variants از ProductVariant به ProductVariantFormData console.log('Product data in edit mode:', product);
const formVariants = product.variants?.map(variant => ({
// 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, id: variant.id,
enabled: variant.enabled, enabled: variant.enabled,
fee_percentage: variant.fee_percentage, fee_percentage: variant.fee_percentage,
@ -91,10 +110,13 @@ const ProductFormPage = () => {
stock_managed: variant.stock_managed, stock_managed: variant.stock_managed,
stock_number: variant.stock_number, stock_number: variant.stock_number,
weight: variant.weight, weight: variant.weight,
product_option_id: variant.product_option_id || undefined,
attributes: variant.attributes || {}, attributes: variant.attributes || {},
meta: variant.meta || {}, meta: variant.meta || {},
images: variant.images || [] images: variant.images || []
})) || []; }));
console.log('✅ Successfully processed variants:', formVariants.length);
reset({ reset({
name: product.name, name: product.name,
@ -102,8 +124,8 @@ const ProductFormPage = () => {
design_style: product.design_style || '', design_style: product.design_style || '',
enabled: product.enabled, enabled: product.enabled,
total_sold: product.total_sold || 0, total_sold: product.total_sold || 0,
type: 1, // هارد کد شده به VARIABLE type: 1,
category_ids: product.category_ids || [], category_ids: categoryIds,
product_option_id: product.product_option_id || undefined, product_option_id: product.product_option_id || undefined,
attributes: product.attributes || {}, attributes: product.attributes || {},
images: product.images || [], images: product.images || [],
@ -162,25 +184,36 @@ const ProductFormPage = () => {
setValue('attributes', updatedAttributes, { shouldValidate: true, shouldDirty: true }); 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 = { const baseSubmitData = {
name: data.name, name: convertedData.name,
description: data.description, description: convertedData.description || '',
design_style: data.design_style, design_style: convertedData.design_style || '',
enabled: data.enabled, enabled: convertedData.enabled,
total_sold: data.total_sold, total_sold: convertedData.total_sold || 0,
type: 1, // هارد کد شده به VARIABLE type: 1,
attributes, attributes: convertPersianNumbersInObject(attributes),
category_ids: data.category_ids.length > 0 ? data.category_ids : [], category_ids: convertedData.category_ids.length > 0 ? convertedData.category_ids : [],
product_option_id: data.product_option_id || undefined, product_option_id: convertedData.product_option_id || null,
images: uploadedImages.map(img => parseInt(img.id)) // فقط ID های تصاویر به صورت عدد ارسال می‌شود images: validImageIds
}; };
console.log('Submitting product data:', baseSubmitData); console.log('Submitting product data:', baseSubmitData);
console.log('Original data:', data);
console.log('Converted data:', convertedData);
if (isEdit && id) { if (isEdit && id) {
// برای update، variants باید شامل ID باشه // برای update، variants باید شامل ID باشه
const updateVariants = data.variants?.map(variant => ({ const updateVariants = data.variants?.map((variant: any) => ({
id: variant.id || 0, // اگر ID نداره، 0 بذار (برای variant جدید) id: variant.id || 0, // اگر ID نداره، 0 بذار (برای variant جدید)
enabled: variant.enabled, enabled: variant.enabled,
fee_percentage: variant.fee_percentage, fee_percentage: variant.fee_percentage,
@ -189,6 +222,7 @@ const ProductFormPage = () => {
stock_managed: variant.stock_managed, stock_managed: variant.stock_managed,
stock_number: variant.stock_number, stock_number: variant.stock_number,
weight: variant.weight, weight: variant.weight,
product_option_id: variant.product_option_id || null,
attributes: variant.attributes && Object.keys(variant.attributes).length > 0 ? variant.attributes : {}, attributes: variant.attributes && Object.keys(variant.attributes).length > 0 ? variant.attributes : {},
meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {} meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {}
})) || []; })) || [];
@ -204,7 +238,7 @@ const ProductFormPage = () => {
}); });
} else { } else {
// برای create، variants نباید ID داشته باشه // برای create، variants نباید ID داشته باشه
const createVariants = data.variants?.map(variant => ({ const createVariants = data.variants?.map((variant: any) => ({
enabled: variant.enabled, enabled: variant.enabled,
fee_percentage: variant.fee_percentage, fee_percentage: variant.fee_percentage,
profit_percentage: variant.profit_percentage, profit_percentage: variant.profit_percentage,
@ -212,6 +246,7 @@ const ProductFormPage = () => {
stock_managed: variant.stock_managed, stock_managed: variant.stock_managed,
stock_number: variant.stock_number, stock_number: variant.stock_number,
weight: variant.weight, weight: variant.weight,
product_option_id: variant.product_option_id || null,
attributes: variant.attributes && Object.keys(variant.attributes).length > 0 ? variant.attributes : {}, attributes: variant.attributes && Object.keys(variant.attributes).length > 0 ? variant.attributes : {},
meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {} meta: variant.meta && Object.keys(variant.meta).length > 0 ? variant.meta : {}
})) || []; })) || [];
@ -251,6 +286,8 @@ const ProductFormPage = () => {
description: `تعداد گزینه‌ها: ${(option.options || []).length}` description: `تعداد گزینه‌ها: ${(option.options || []).length}`
})); }));
const backButton = ( const backButton = (
<Button <Button
variant="secondary" variant="secondary"
@ -303,11 +340,10 @@ const ProductFormPage = () => {
<Input <Input
label="تعداد فروخته شده" label="تعداد فروخته شده"
type="number" numeric
{...register('total_sold')} {...register('total_sold')}
error={errors.total_sold?.message} error={errors.total_sold?.message}
placeholder="0" placeholder="مثال: ۱۰۰"
min="0"
/> />
<Input <Input
@ -353,17 +389,26 @@ const ProductFormPage = () => {
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
گزینه محصول گزینه محصول
</label> </label>
{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 <select
{...register('product_option_id')} {...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" 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> <option value="">بدون گزینه</option>
{(productOptions || []).map((option) => ( {productOptionOptions.map((option) => (
<option key={option.id} value={option.id}> <option key={option.id} value={option.id}>
{option.title} ({(option.options || []).length} گزینه) {option.title} - {option.description}
</option> </option>
))} ))}
</select> </select>
)}
{productOptionOptions.length === 0 && !isLoadingProductOptions && (
<p className="text-amber-600 text-sm mt-1">هیچ گزینه محصولی یافت نشد</p>
)}
{errors.product_option_id && ( {errors.product_option_id && (
<p className="text-red-500 text-sm mt-1">{errors.product_option_id.message}</p> <p className="text-red-500 text-sm mt-1">{errors.product_option_id.message}</p>
)} )}
@ -424,6 +469,7 @@ const ProductFormPage = () => {
<VariantManager <VariantManager
variants={watch('variants') || []} variants={watch('variants') || []}
onChange={(variants) => setValue('variants', variants, { shouldValidate: true, shouldDirty: true })} onChange={(variants) => setValue('variants', variants, { shouldValidate: true, shouldDirty: true })}
productOptions={productOptionOptions}
/> />
</div> </div>

View File

@ -7,6 +7,7 @@ import { Button } from "@/components/ui/Button";
import { Trash2, Edit3, Plus, Package, Eye, Image } from "lucide-react"; import { Trash2, Edit3, Plus, Package, Eye, Image } from "lucide-react";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { persianToEnglish } from '../../../utils/numberUtils';
const ProductsTableSkeleton = () => ( const ProductsTableSkeleton = () => (
<div className="bg-white dark:bg-gray-800 shadow-sm border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden"> <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> </label>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="number" type="text"
placeholder="حداقل" inputMode="numeric"
placeholder="حداقل (مثال: ۱۰۰۰۰)"
value={filters.min_price} 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" 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 <input
type="number" type="text"
placeholder="حداکثر" inputMode="numeric"
placeholder="حداکثر (مثال: ۵۰۰۰۰۰)"
value={filters.max_price} 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" 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> </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;
};
};