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:
parent
e00eb4a160
commit
4f4ef51ccc
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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="حذف"
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
|
|
|
||||||
|
|
@ -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,55 +20,75 @@ const ProductDetailPage = () => {
|
||||||
return new Intl.NumberFormat('fa-IR').format(price) + ' تومان';
|
return new Intl.NumberFormat('fa-IR').format(price) + ' تومان';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const formatNumber = (num: number) => {
|
||||||
<div className="p-6">
|
return new Intl.NumberFormat('fa-IR').format(num);
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
return (
|
||||||
<Button
|
<PageContainer>
|
||||||
variant="secondary"
|
<div className="flex items-center justify-between mb-6">
|
||||||
onClick={() => navigate(`/products/${id}/edit`)}
|
<div className="flex items-center gap-3">
|
||||||
className="flex items-center gap-2"
|
<Button
|
||||||
>
|
variant="secondary"
|
||||||
<Edit className="h-4 w-4" />
|
onClick={() => navigate('/products')}
|
||||||
ویرایش
|
className="flex items-center gap-2"
|
||||||
</Button>
|
>
|
||||||
|
<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>
|
</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>
|
||||||
|
|
||||||
<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>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<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">
|
</label>
|
||||||
<p className="text-gray-900 dark:text-gray-100 font-medium">
|
<div className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
{product.name}
|
<p className="text-gray-900 dark:text-gray-100 font-medium">
|
||||||
</p>
|
{product.name}
|
||||||
|
</p>
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
{product.description && (
|
{product.description && (
|
||||||
|
|
@ -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>
|
||||||
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
|
<span className={`px-2 py-1 text-xs rounded-full ${variant.enabled
|
||||||
: 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-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'
|
||||||
{variant.enabled ? 'فعال' : 'غیرفعال'}
|
}`}>
|
||||||
|
{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>
|
</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.stock_number}
|
{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.weight} گرم
|
{formatNumber(variant.weight)} گرم
|
||||||
</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.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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<select
|
{isLoadingProductOptions ? (
|
||||||
{...register('product_option_id')}
|
<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">
|
||||||
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>
|
||||||
<option value="">بدون گزینه</option>
|
) : (
|
||||||
{(productOptions || []).map((option) => (
|
<select
|
||||||
<option key={option.id} value={option.id}>
|
{...register('product_option_id')}
|
||||||
{option.title} ({(option.options || []).length} گزینه)
|
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>
|
>
|
||||||
))}
|
<option value="">بدون گزینه</option>
|
||||||
</select>
|
{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 && (
|
{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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue