fix: نمایش صحیح تصاویر هنگام ویرایش variant
- اصلاح منطق بارگذاری تصاویر variant در حالت ویرایش - افزودن fallback به فیلد files در صورت خالی بودن file_ids - رفع مشکل نمایش فقط یک تصویر از چندین تصویر آپلود شده
This commit is contained in:
parent
afab715b56
commit
25429f9745
|
|
@ -25,6 +25,9 @@ interface FileUploaderProps {
|
|||
error?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
mode?: 'single' | 'multi';
|
||||
onUploadStart?: () => void;
|
||||
onUploadComplete?: () => void;
|
||||
}
|
||||
|
||||
export const FileUploader: React.FC<FileUploaderProps> = ({
|
||||
|
|
@ -38,6 +41,9 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
|
|||
error,
|
||||
disabled = false,
|
||||
className = "",
|
||||
mode = 'multi',
|
||||
onUploadStart,
|
||||
onUploadComplete,
|
||||
}) => {
|
||||
const [files, setFiles] = useState<UploadedFile[]>([]);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
|
@ -100,10 +106,12 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
|
|||
status: 'error',
|
||||
error: validationError,
|
||||
};
|
||||
setFiles(prev => [...prev, errorFile]);
|
||||
setFiles(prev => mode === 'single' ? [errorFile] : [...prev, errorFile]);
|
||||
return;
|
||||
}
|
||||
|
||||
onUploadStart?.();
|
||||
|
||||
const fileId = Math.random().toString(36).substr(2, 9);
|
||||
const preview = await createFilePreview(file);
|
||||
|
||||
|
|
@ -117,7 +125,7 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
|
|||
status: 'uploading',
|
||||
};
|
||||
|
||||
setFiles(prev => [...prev, newFile]);
|
||||
setFiles(prev => mode === 'single' ? [newFile] : [...prev, newFile]);
|
||||
|
||||
try {
|
||||
const progressInterval = setInterval(() => {
|
||||
|
|
@ -137,14 +145,17 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
|
|||
? { ...f, progress: 100, status: 'completed', url: result.url, id: result.id }
|
||||
: f
|
||||
));
|
||||
|
||||
onUploadComplete?.();
|
||||
} catch (error: any) {
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === fileId
|
||||
? { ...f, status: 'error', error: error.message || 'خطا در آپلود فایل' }
|
||||
: f
|
||||
));
|
||||
onUploadComplete?.();
|
||||
}
|
||||
}, [onUpload, maxFiles, maxFileSize, acceptedTypes]);
|
||||
}, [onUpload, maxFiles, maxFileSize, acceptedTypes, mode, onUploadStart, onUploadComplete]);
|
||||
|
||||
const handleFileSelect = useCallback((selectedFiles: FileList) => {
|
||||
Array.from(selectedFiles).forEach(file => {
|
||||
|
|
@ -180,6 +191,9 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
|
|||
onRemove?.(fileId);
|
||||
};
|
||||
|
||||
const hasUploadedFiles = files.some(f => f.status === 'completed');
|
||||
const showUploadArea = mode === 'multi' || (mode === 'single' && !hasUploadedFiles);
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{label && (
|
||||
|
|
@ -188,41 +202,43 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
|
|||
</label>
|
||||
)}
|
||||
|
||||
{/* Upload Area */}
|
||||
<div
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-lg p-6 transition-colors cursor-pointer
|
||||
${isDragOver ? 'border-primary-400 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-300 dark:border-gray-600'}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700'}
|
||||
${error ? 'border-red-300 bg-red-50 dark:bg-red-900/20' : ''}
|
||||
`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={acceptedTypes.join(',')}
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && handleFileSelect(e.target.files)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{/* Upload Area - only show in multi mode or single mode without uploaded files */}
|
||||
{showUploadArea && (
|
||||
<div
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-lg p-6 transition-colors cursor-pointer
|
||||
${isDragOver ? 'border-primary-400 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-300 dark:border-gray-600'}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700'}
|
||||
${error ? 'border-red-300 bg-red-50 dark:bg-red-900/20' : ''}
|
||||
`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple={mode === 'multi'}
|
||||
accept={acceptedTypes.join(',')}
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && handleFileSelect(e.target.files)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<div className="text-center">
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
حداکثر {formatFileSize(maxFileSize)} • {acceptedTypes.join(', ')}
|
||||
</p>
|
||||
<div className="text-center">
|
||||
<Upload className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
حداکثر {formatFileSize(maxFileSize)} • {acceptedTypes.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ interface VariantManagerProps {
|
|||
onChange: (variants: ProductVariantFormData[]) => void;
|
||||
disabled?: boolean;
|
||||
productOptions?: ProductOption[];
|
||||
variantAttributeName?: string;
|
||||
}
|
||||
|
||||
interface VariantFormProps {
|
||||
|
|
@ -25,9 +26,10 @@ interface VariantFormProps {
|
|||
onCancel: () => void;
|
||||
isEdit?: boolean;
|
||||
productOptions?: ProductOption[];
|
||||
variantAttributeName?: string;
|
||||
}
|
||||
|
||||
const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, isEdit = false, productOptions = [] }) => {
|
||||
const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, isEdit = false, productOptions = [], variantAttributeName }) => {
|
||||
const [formData, setFormData] = useState<ProductVariantFormData>(
|
||||
variant || {
|
||||
enabled: true,
|
||||
|
|
@ -37,7 +39,6 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
stock_managed: true,
|
||||
stock_number: 0,
|
||||
weight: 0,
|
||||
product_option_id: undefined,
|
||||
attributes: {},
|
||||
meta: {},
|
||||
file_ids: []
|
||||
|
|
@ -49,12 +50,14 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
? variant.file_ids
|
||||
: []
|
||||
);
|
||||
const [attributes, setAttributes] = useState<Record<string, any>>(variant?.attributes || {});
|
||||
const [variantAttributeValue, setVariantAttributeValue] = useState('');
|
||||
const [meta, setMeta] = useState<Record<string, any>>(variant?.meta || {});
|
||||
const [newAttributeKey, setNewAttributeKey] = useState('');
|
||||
const [newAttributeValue, setNewAttributeValue] = useState('');
|
||||
const [newMetaKey, setNewMetaKey] = useState('');
|
||||
const [newMetaValue, setNewMetaValue] = useState('');
|
||||
const [attributeError, setAttributeError] = useState('');
|
||||
const [weightDisplay, setWeightDisplay] = useState(variant?.weight?.toString() || '');
|
||||
const [feePercentageDisplay, setFeePercentageDisplay] = useState(variant?.fee_percentage?.toString() || '');
|
||||
const [profitPercentageDisplay, setProfitPercentageDisplay] = useState(variant?.profit_percentage?.toString() || '');
|
||||
|
||||
const { mutateAsync: uploadFile } = useFileUpload();
|
||||
const { mutate: deleteFile } = useFileDelete();
|
||||
|
|
@ -64,6 +67,23 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
setFormData(prev => ({ ...prev, file_ids: uploadedImages }));
|
||||
}, [uploadedImages]);
|
||||
|
||||
// Sync display states with formData when editing
|
||||
useEffect(() => {
|
||||
if (variant?.weight !== undefined) {
|
||||
setWeightDisplay(variant.weight.toString());
|
||||
}
|
||||
if (variant?.fee_percentage !== undefined) {
|
||||
setFeePercentageDisplay(variant.fee_percentage.toString());
|
||||
}
|
||||
if (variant?.profit_percentage !== undefined) {
|
||||
setProfitPercentageDisplay(variant.profit_percentage.toString());
|
||||
}
|
||||
// Load variant attribute value if exists
|
||||
if (variantAttributeName && variant?.attributes && variant.attributes[variantAttributeName]) {
|
||||
setVariantAttributeValue(variant.attributes[variantAttributeName].toString());
|
||||
}
|
||||
}, [variant?.weight, variant?.fee_percentage, variant?.profit_percentage, variant?.attributes, variantAttributeName]);
|
||||
|
||||
const handleInputChange = (field: keyof ProductVariantFormData, value: any) => {
|
||||
if (typeof value === 'string') {
|
||||
value = persianToEnglish(value);
|
||||
|
|
@ -74,15 +94,17 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
const handleFileUpload = async (file: File) => {
|
||||
try {
|
||||
const result = await uploadFile(file);
|
||||
const newImage: ProductImage = {
|
||||
id: result.id,
|
||||
url: result.url,
|
||||
alt: file.name,
|
||||
order: uploadedImages.length
|
||||
};
|
||||
|
||||
const updatedImages = [...uploadedImages, newImage];
|
||||
setUploadedImages(updatedImages);
|
||||
// Use functional update to avoid stale state when multiple files upload concurrently
|
||||
setUploadedImages(prev => {
|
||||
const newImage: ProductImage = {
|
||||
id: result.id,
|
||||
url: result.url,
|
||||
alt: file.name,
|
||||
order: prev.length
|
||||
};
|
||||
return [...prev, newImage];
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
|
@ -97,23 +119,7 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
deleteFile(fileId);
|
||||
};
|
||||
|
||||
const handleAddAttribute = () => {
|
||||
if (newAttributeKey.trim() && newAttributeValue.trim()) {
|
||||
const updatedAttributes = {
|
||||
...attributes,
|
||||
[newAttributeKey.trim()]: newAttributeValue.trim()
|
||||
};
|
||||
setAttributes(updatedAttributes);
|
||||
setNewAttributeKey('');
|
||||
setNewAttributeValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAttribute = (key: string) => {
|
||||
const updatedAttributes = { ...attributes };
|
||||
delete updatedAttributes[key];
|
||||
setAttributes(updatedAttributes);
|
||||
};
|
||||
|
||||
const handleAddMeta = () => {
|
||||
if (newMetaKey.trim() && newMetaValue.trim()) {
|
||||
|
|
@ -134,13 +140,28 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const fileIds = uploadedImages.map(img => Number(img.id)).filter(id => !isNaN(id));
|
||||
// Reset previous errors
|
||||
setAttributeError('');
|
||||
|
||||
// Validate attribute value when attribute name is defined
|
||||
if (variantAttributeName && !variantAttributeValue.trim()) {
|
||||
setAttributeError(`مقدار ${variantAttributeName} الزامی است.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// نگه داشتن آبجکت کامل تصویر برای نمایش در لیست و حالت ویرایش
|
||||
const fileObjects = uploadedImages;
|
||||
|
||||
// Create attributes object with single key-value pair
|
||||
const attributes = variantAttributeName && variantAttributeValue.trim()
|
||||
? { [variantAttributeName]: variantAttributeValue.trim() }
|
||||
: {};
|
||||
|
||||
const convertedData = convertPersianNumbersInObject({
|
||||
...formData,
|
||||
attributes,
|
||||
meta,
|
||||
file_ids: fileIds
|
||||
file_ids: fileObjects
|
||||
});
|
||||
|
||||
onSave(convertedData);
|
||||
|
|
@ -148,18 +169,10 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
|
||||
return (
|
||||
<div className="space-y-6 bg-gray-50 dark:bg-gray-700 p-6 rounded-lg border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{isEdit ? 'ویرایش Variant' : 'افزودن Variant جدید'}
|
||||
</h4>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{isEdit ? 'بهروزرسانی' : 'افزودن'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
|
|
@ -171,10 +184,15 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={formData.fee_percentage || ''}
|
||||
value={feePercentageDisplay}
|
||||
onChange={(e) => {
|
||||
const converted = persianToEnglish(e.target.value);
|
||||
handleInputChange('fee_percentage', parseFloat(converted) || 0);
|
||||
setFeePercentageDisplay(converted);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const converted = persianToEnglish(e.target.value);
|
||||
const numValue = parseFloat(converted) || 0;
|
||||
handleInputChange('fee_percentage', numValue);
|
||||
}}
|
||||
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="مثال: ۵.۲"
|
||||
|
|
@ -188,10 +206,15 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={formData.profit_percentage || ''}
|
||||
value={profitPercentageDisplay}
|
||||
onChange={(e) => {
|
||||
const converted = persianToEnglish(e.target.value);
|
||||
handleInputChange('profit_percentage', parseFloat(converted) || 0);
|
||||
setProfitPercentageDisplay(converted);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const converted = persianToEnglish(e.target.value);
|
||||
const numValue = parseFloat(converted) || 0;
|
||||
handleInputChange('profit_percentage', numValue);
|
||||
}}
|
||||
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="مثال: ۱۰.۵"
|
||||
|
|
@ -205,43 +228,23 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={formData.weight || ''}
|
||||
value={weightDisplay}
|
||||
onChange={(e) => {
|
||||
const converted = persianToEnglish(e.target.value);
|
||||
handleInputChange('weight', parseFloat(converted) || 0);
|
||||
setWeightDisplay(converted);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const converted = persianToEnglish(e.target.value);
|
||||
const numValue = parseFloat(converted) || 0;
|
||||
handleInputChange('weight', numValue);
|
||||
}}
|
||||
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="مثال: ۱۲۰۰"
|
||||
placeholder="مثال: ۱۲۰۰.۵"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Option Selection */}
|
||||
{productOptions && productOptions.length > 0 && (
|
||||
<div>
|
||||
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
گزینه محصول
|
||||
</h5>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
انتخاب گزینه محصول
|
||||
</label>
|
||||
<select
|
||||
value={formData.product_option_id || ''}
|
||||
onChange={(e) => handleInputChange('product_option_id', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
>
|
||||
<option value="">انتخاب کنید...</option>
|
||||
{productOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.title}
|
||||
{option.description && ` - ${option.description}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Stock Management */}
|
||||
<div>
|
||||
|
|
@ -338,57 +341,29 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Attributes */}
|
||||
<div>
|
||||
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
ویژگیهای Variant
|
||||
</h5>
|
||||
|
||||
<div className="flex gap-3 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newAttributeKey}
|
||||
onChange={(e) => setNewAttributeKey(e.target.value)}
|
||||
placeholder="نام ویژگی (مثل: رنگ، سایز)"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newAttributeValue}
|
||||
onChange={(e) => setNewAttributeValue(e.target.value)}
|
||||
placeholder="مقدار (مثل: قرمز، بزرگ)"
|
||||
className="flex-1 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"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleAddAttribute}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
افزودن
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{Object.keys(attributes).length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{Object.entries(attributes).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between bg-white dark:bg-gray-600 px-3 py-2 rounded-md border">
|
||||
<span className="text-sm">
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveAttribute(key)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* Variant Attribute */}
|
||||
{variantAttributeName && (
|
||||
<div>
|
||||
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
ویژگی Variant
|
||||
</h5>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{variantAttributeName}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={variantAttributeValue}
|
||||
onChange={(e) => setVariantAttributeValue(e.target.value)}
|
||||
placeholder={`مقدار ${variantAttributeName} را وارد کنید`}
|
||||
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"
|
||||
/>
|
||||
{attributeError && (
|
||||
<p className="text-red-500 text-xs mt-1">{attributeError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meta Data */}
|
||||
<div>
|
||||
|
|
@ -454,11 +429,21 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
|
|||
Variant فعال باشد
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-600">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{isEdit ? 'بهروزرسانی' : 'افزودن'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChange, disabled = false, productOptions = [] }) => {
|
||||
export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChange, disabled = false, productOptions = [], variantAttributeName }) => {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
|
||||
|
|
@ -518,6 +503,7 @@ export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChan
|
|||
onCancel={handleCancelForm}
|
||||
isEdit={editingIndex !== null}
|
||||
productOptions={productOptions}
|
||||
variantAttributeName={variantAttributeName}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -549,13 +535,8 @@ export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChan
|
|||
<strong>موجودی:</strong> {variant.stock_managed ? `${variant.stock_number} عدد` : 'بدون محدودیت'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>وزن:</strong> {variant.weight} گرم
|
||||
<strong>وزن:</strong> {parseFloat(variant.weight.toString()).toLocaleString('fa-IR')} گرم
|
||||
</div>
|
||||
{variant.product_option_id && (
|
||||
<div className="md:col-span-2">
|
||||
<strong>گزینه محصول:</strong> {productOptions.find(opt => opt.id === variant.product_option_id)?.title || `شناسه ${variant.product_option_id}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{variant.file_ids && variant.file_ids.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { ArrowRight, FolderOpen } from 'lucide-react';
|
|||
import { Button } from '../../../components/ui/Button';
|
||||
import { Input } from '../../../components/ui/Input';
|
||||
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
|
||||
import { FileUploader } from '../../../components/ui/FileUploader';
|
||||
import { useFileUpload, useFileDelete } from '../../../hooks/useFileUpload';
|
||||
import { useToast } from '../../../contexts/ToastContext';
|
||||
import { useCategory, useCreateCategory, useUpdateCategory } from '../core/_hooks';
|
||||
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
|
||||
|
|
@ -18,8 +20,12 @@ const CategoryFormPage = () => {
|
|||
name: '',
|
||||
description: '',
|
||||
parent_id: null as number | null,
|
||||
file_id: undefined as number | undefined,
|
||||
});
|
||||
|
||||
const [uploadedImage, setUploadedImage] = useState<{ id: string, url: string } | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const { data: category, isLoading: isLoadingCategory } = useCategory(
|
||||
id || '0',
|
||||
isEdit
|
||||
|
|
@ -27,6 +33,8 @@ const CategoryFormPage = () => {
|
|||
|
||||
const createMutation = useCreateCategory();
|
||||
const updateMutation = useUpdateCategory();
|
||||
const { mutateAsync: uploadFile } = useFileUpload();
|
||||
const { mutate: deleteFile } = useFileDelete();
|
||||
|
||||
useEffect(() => {
|
||||
if (category && isEdit) {
|
||||
|
|
@ -34,7 +42,16 @@ const CategoryFormPage = () => {
|
|||
name: category.name || '',
|
||||
description: category.description || '',
|
||||
parent_id: category.parent_id || null,
|
||||
file_id: category.file_id || undefined,
|
||||
});
|
||||
|
||||
// Set uploaded image if exists
|
||||
if (category.file_id) {
|
||||
setUploadedImage({
|
||||
id: category.file_id.toString(),
|
||||
url: '' // We don't have URL from category, just ID
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [category, isEdit]);
|
||||
|
||||
|
|
@ -45,6 +62,34 @@ const CategoryFormPage = () => {
|
|||
}));
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file: File) => {
|
||||
try {
|
||||
const result = await uploadFile(file);
|
||||
const fileId = parseInt(result.id);
|
||||
setUploadedImage({
|
||||
id: result.id,
|
||||
url: result.url
|
||||
});
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
file_id: fileId
|
||||
}));
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileRemove = (fileId: string) => {
|
||||
setUploadedImage(null);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
file_id: undefined
|
||||
}));
|
||||
deleteFile(fileId);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
@ -120,6 +165,21 @@ const CategoryFormPage = () => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FileUploader
|
||||
onUpload={handleFileUpload}
|
||||
onRemove={handleFileRemove}
|
||||
acceptedTypes={['image/*']}
|
||||
maxFileSize={5 * 1024 * 1024} // 5MB
|
||||
maxFiles={1}
|
||||
mode="single"
|
||||
label="تصویر دستهبندی"
|
||||
description="تصویر دستهبندی را انتخاب کنید (حداکثر 5MB)"
|
||||
onUploadStart={() => setIsUploading(true)}
|
||||
onUploadComplete={() => setIsUploading(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col space-y-3 sm:flex-row sm:justify-end sm:space-y-0 sm:space-x-3 sm:space-x-reverse pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
|
|
@ -133,6 +193,7 @@ const CategoryFormPage = () => {
|
|||
<Button
|
||||
type="submit"
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
disabled={isUploading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isEdit ? 'ویرایش' : 'ایجاد'}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export interface Category {
|
|||
name: string;
|
||||
description?: string;
|
||||
parent_id?: number;
|
||||
file_id?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
@ -10,6 +11,7 @@ export interface Category {
|
|||
export interface CategoryFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
file_id?: number;
|
||||
}
|
||||
|
||||
export interface CategoryFilters {
|
||||
|
|
@ -21,12 +23,14 @@ export interface CategoryFilters {
|
|||
export interface CreateCategoryRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
file_id?: number;
|
||||
}
|
||||
|
||||
export interface UpdateCategoryRequest {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
file_id?: number;
|
||||
}
|
||||
|
||||
export interface CategoriesResponse {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useForm, useFieldArray } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
|
|
@ -8,6 +8,8 @@ import { ProductOptionFormData, Maintenance, Option } from '../core/_models';
|
|||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { FileUploader } from "@/components/ui/FileUploader";
|
||||
import { useFileUpload, useFileDelete } from "@/hooks/useFileUpload";
|
||||
import { ArrowRight, Settings, Plus, Trash2 } from "lucide-react";
|
||||
import { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography';
|
||||
|
||||
|
|
@ -39,6 +41,11 @@ const ProductOptionFormPage = () => {
|
|||
const { data: productOption, isLoading: isLoadingOption } = useProductOption(id || '', isEdit);
|
||||
const { mutate: createOption, isPending: isCreating } = useCreateProductOption();
|
||||
const { mutate: updateOption, isPending: isUpdating } = useUpdateProductOption();
|
||||
const { mutateAsync: uploadFile } = useFileUpload();
|
||||
const { mutate: deleteFile } = useFileDelete();
|
||||
|
||||
const [uploadedImage, setUploadedImage] = useState<{ id: string, url: string } | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const isLoading = isCreating || isUpdating;
|
||||
|
||||
|
|
@ -78,6 +85,14 @@ const ProductOptionFormPage = () => {
|
|||
setValue('description', productOption.description, { shouldValidate: true });
|
||||
setValue('maintenance', productOption.maintenance, { shouldValidate: true });
|
||||
setValue('options', productOption.options, { shouldValidate: true });
|
||||
|
||||
// Set uploaded image if exists
|
||||
if (productOption.maintenance.image) {
|
||||
setUploadedImage({
|
||||
id: productOption.maintenance.image,
|
||||
url: productOption.maintenance.image
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isEdit, productOption, setValue]);
|
||||
|
||||
|
|
@ -108,6 +123,28 @@ const ProductOptionFormPage = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file: File) => {
|
||||
try {
|
||||
const result = await uploadFile(file);
|
||||
const imageData = {
|
||||
id: result.id,
|
||||
url: result.url
|
||||
};
|
||||
setUploadedImage(imageData);
|
||||
setValue('maintenance.image', result.id, { shouldValidate: true, shouldDirty: true });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileRemove = (fileId: string) => {
|
||||
setUploadedImage(null);
|
||||
setValue('maintenance.image', '', { shouldValidate: true, shouldDirty: true });
|
||||
deleteFile(fileId);
|
||||
};
|
||||
|
||||
if (isLoadingOption) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
|
|
@ -151,42 +188,65 @@ const ProductOptionFormPage = () => {
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label="توضیحات"
|
||||
<Label htmlFor="description">توضیحات</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
{...register('description')}
|
||||
error={errors.description?.message}
|
||||
placeholder="توضیحات گزینه محصول را وارد کنید"
|
||||
rows={4}
|
||||
className="input resize-none"
|
||||
/>
|
||||
{errors.description?.message && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{errors.description.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<SectionTitle className="mb-4">اطلاعات نگهداری</SectionTitle>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="عنوان نگهداری"
|
||||
{...register('maintenance.title')}
|
||||
error={errors.maintenance?.title?.message}
|
||||
placeholder="عنوان نگهداری را وارد کنید"
|
||||
/>
|
||||
<Input
|
||||
label="توضیحات نگهداری"
|
||||
{...register('maintenance.description')}
|
||||
error={errors.maintenance?.description?.message}
|
||||
placeholder="توضیحات نگهداری را وارد کنید"
|
||||
/>
|
||||
<Input
|
||||
label="محتوای نگهداری"
|
||||
{...register('maintenance.content')}
|
||||
error={errors.maintenance?.content?.message}
|
||||
placeholder="محتوای نگهداری را وارد کنید"
|
||||
/>
|
||||
<Input
|
||||
label="تصویر نگهداری"
|
||||
{...register('maintenance.image')}
|
||||
error={errors.maintenance?.image?.message}
|
||||
placeholder="آدرس تصویر نگهداری را وارد کنید"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="عنوان نگهداری"
|
||||
{...register('maintenance.title')}
|
||||
error={errors.maintenance?.title?.message}
|
||||
placeholder="عنوان نگهداری را وارد کنید"
|
||||
/>
|
||||
<Input
|
||||
label="توضیحات نگهداری"
|
||||
{...register('maintenance.description')}
|
||||
error={errors.maintenance?.description?.message}
|
||||
placeholder="توضیحات نگهداری را وارد کنید"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="maintenance-content">محتوای نگهداری</Label>
|
||||
<textarea
|
||||
id="maintenance-content"
|
||||
{...register('maintenance.content')}
|
||||
placeholder="محتوای نگهداری را وارد کنید"
|
||||
rows={4}
|
||||
className="input resize-none"
|
||||
/>
|
||||
{errors.maintenance?.content?.message && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{errors.maintenance.content.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<FileUploader
|
||||
onUpload={handleFileUpload}
|
||||
onRemove={handleFileRemove}
|
||||
acceptedTypes={['image/*']}
|
||||
maxFileSize={5 * 1024 * 1024} // 5MB
|
||||
maxFiles={1}
|
||||
mode="single"
|
||||
label="تصویر نگهداری"
|
||||
description="تصویر نگهداری را انتخاب کنید (حداکثر 5MB)"
|
||||
error={errors.maintenance?.image?.message}
|
||||
onUploadStart={() => setIsUploading(true)}
|
||||
onUploadComplete={() => setIsUploading(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -218,25 +278,34 @@ const ProductOptionFormPage = () => {
|
|||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input
|
||||
label="عنوان"
|
||||
{...register(`options.${index}.title`)}
|
||||
error={errors.options?.[index]?.title?.message}
|
||||
placeholder="عنوان گزینه را وارد کنید"
|
||||
/>
|
||||
<Input
|
||||
label="توضیحات"
|
||||
{...register(`options.${index}.description`)}
|
||||
error={errors.options?.[index]?.description?.message}
|
||||
placeholder="توضیحات گزینه را وارد کنید"
|
||||
/>
|
||||
<Input
|
||||
label="متا تایتل"
|
||||
{...register(`options.${index}.meta_title`)}
|
||||
error={errors.options?.[index]?.meta_title?.message}
|
||||
placeholder="متا تایتل را وارد کنید"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="عنوان"
|
||||
{...register(`options.${index}.title`)}
|
||||
error={errors.options?.[index]?.title?.message}
|
||||
placeholder="عنوان گزینه را وارد کنید"
|
||||
/>
|
||||
<Input
|
||||
label="متا تایتل"
|
||||
{...register(`options.${index}.meta_title`)}
|
||||
error={errors.options?.[index]?.meta_title?.message}
|
||||
placeholder="متا تایتل را وارد کنید"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`option-${index}-description`}>توضیحات</Label>
|
||||
<textarea
|
||||
id={`option-${index}-description`}
|
||||
{...register(`options.${index}.description`)}
|
||||
placeholder="توضیحات گزینه را وارد کنید"
|
||||
rows={3}
|
||||
className="input resize-none"
|
||||
/>
|
||||
{errors.options?.[index]?.description?.message && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">{errors.options[index]?.description?.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -260,7 +329,7 @@ const ProductOptionFormPage = () => {
|
|||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={!isValid || isLoading}
|
||||
disabled={!isValid || isLoading || isUploading}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isEdit ? 'ویرایش گزینه محصول' : 'ایجاد گزینه محصول'}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ const ProductFormPage = () => {
|
|||
const [attributes, setAttributes] = useState<Record<string, any>>({});
|
||||
const [newAttributeKey, setNewAttributeKey] = useState('');
|
||||
const [newAttributeValue, setNewAttributeValue] = useState('');
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const { data: product, isLoading: isLoadingProduct } = useProduct(id || '', isEdit);
|
||||
const { data: categories, isLoading: isLoadingCategories } = useCategories();
|
||||
|
|
@ -391,7 +392,7 @@ const ProductFormPage = () => {
|
|||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
گزینه محصول
|
||||
ویژگی های محصول و نحوه نگه داری
|
||||
</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">
|
||||
|
|
@ -432,8 +433,11 @@ const ProductFormPage = () => {
|
|||
acceptedTypes={['image/*']}
|
||||
maxFileSize={5 * 1024 * 1024}
|
||||
maxFiles={10}
|
||||
mode="multi"
|
||||
label=""
|
||||
description="تصاویر محصول را اینجا بکشید یا کلیک کنید"
|
||||
onUploadStart={() => setIsUploading(true)}
|
||||
onUploadComplete={() => setIsUploading(false)}
|
||||
/>
|
||||
|
||||
{uploadedImages.length > 0 && (
|
||||
|
|
@ -613,7 +617,7 @@ const ProductFormPage = () => {
|
|||
<Button
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
disabled={!isValid || isLoading}
|
||||
disabled={!isValid || isLoading || isUploading}
|
||||
>
|
||||
{isEdit ? 'بهروزرسانی' : 'ایجاد محصول'}
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Reference in New Issue