diff --git a/src/components/ui/FileUploader.tsx b/src/components/ui/FileUploader.tsx index fea07e2..6912961 100644 --- a/src/components/ui/FileUploader.tsx +++ b/src/components/ui/FileUploader.tsx @@ -25,6 +25,9 @@ interface FileUploaderProps { error?: string; disabled?: boolean; className?: string; + mode?: 'single' | 'multi'; + onUploadStart?: () => void; + onUploadComplete?: () => void; } export const FileUploader: React.FC = ({ @@ -38,6 +41,9 @@ export const FileUploader: React.FC = ({ error, disabled = false, className = "", + mode = 'multi', + onUploadStart, + onUploadComplete, }) => { const [files, setFiles] = useState([]); const [isDragOver, setIsDragOver] = useState(false); @@ -100,10 +106,12 @@ export const FileUploader: React.FC = ({ 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 = ({ 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 = ({ ? { ...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 = ({ onRemove?.(fileId); }; + const hasUploadedFiles = files.some(f => f.status === 'completed'); + const showUploadArea = mode === 'multi' || (mode === 'single' && !hasUploadedFiles); + return (
{label && ( @@ -188,41 +202,43 @@ export const FileUploader: React.FC = ({ )} - {/* Upload Area */} -
- e.target.files && handleFileSelect(e.target.files)} - disabled={disabled} - /> + {/* Upload Area - only show in multi mode or single mode without uploaded files */} + {showUploadArea && ( +
+ e.target.files && handleFileSelect(e.target.files)} + disabled={disabled} + /> -
- -
-

- {description} -

-

- حداکثر {formatFileSize(maxFileSize)} • {acceptedTypes.join(', ')} -

+
+ +
+

+ {description} +

+

+ حداکثر {formatFileSize(maxFileSize)} • {acceptedTypes.join(', ')} +

+
-
+ )} {error && (

diff --git a/src/components/ui/VariantManager.tsx b/src/components/ui/VariantManager.tsx index 68a0511..86126f1 100644 --- a/src/components/ui/VariantManager.tsx +++ b/src/components/ui/VariantManager.tsx @@ -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 = ({ variant, onSave, onCancel, isEdit = false, productOptions = [] }) => { +const VariantForm: React.FC = ({ variant, onSave, onCancel, isEdit = false, productOptions = [], variantAttributeName }) => { const [formData, setFormData] = useState( variant || { enabled: true, @@ -37,7 +39,6 @@ const VariantForm: React.FC = ({ 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 = ({ variant, onSave, onCancel, is ? variant.file_ids : [] ); - const [attributes, setAttributes] = useState>(variant?.attributes || {}); + const [variantAttributeValue, setVariantAttributeValue] = useState(''); const [meta, setMeta] = useState>(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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ variant, onSave, onCancel, is return (

-
+

{isEdit ? 'ویرایش Variant' : 'افزودن Variant جدید'}

-
- - -
{/* Basic Info */} @@ -171,10 +184,15 @@ const VariantForm: React.FC = ({ variant, onSave, onCancel, is { 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 = ({ variant, onSave, onCancel, is { 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 = ({ variant, onSave, onCancel, is { 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="مثال: ۱۲۰۰.۵" />
- {/* Product Option Selection */} - {productOptions && productOptions.length > 0 && ( -
-
- گزینه محصول -
-
- - -
-
- )} + {/* Stock Management */}
@@ -338,57 +341,29 @@ const VariantForm: React.FC = ({ variant, onSave, onCancel, is )}
- {/* Attributes */} -
-
- ویژگی‌های Variant -
- -
- 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" - /> - 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" - /> - -
- - {Object.keys(attributes).length > 0 && ( -
- {Object.entries(attributes).map(([key, value]) => ( -
- - {key}: {String(value)} - - -
- ))} + {/* Variant Attribute */} + {variantAttributeName && ( +
+
+ ویژگی Variant +
+
+ + 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 && ( +

{attributeError}

+ )}
- )} -
+
+ )} {/* Meta Data */}
@@ -454,11 +429,21 @@ const VariantForm: React.FC = ({ variant, onSave, onCancel, is Variant فعال باشد
+ + {/* Action Buttons */} +
+ + +
); }; -export const VariantManager: React.FC = ({ variants, onChange, disabled = false, productOptions = [] }) => { +export const VariantManager: React.FC = ({ variants, onChange, disabled = false, productOptions = [], variantAttributeName }) => { const [showForm, setShowForm] = useState(false); const [editingIndex, setEditingIndex] = useState(null); @@ -518,6 +503,7 @@ export const VariantManager: React.FC = ({ variants, onChan onCancel={handleCancelForm} isEdit={editingIndex !== null} productOptions={productOptions} + variantAttributeName={variantAttributeName} /> )} @@ -549,13 +535,8 @@ export const VariantManager: React.FC = ({ variants, onChan موجودی: {variant.stock_managed ? `${variant.stock_number} عدد` : 'بدون محدودیت'}
- وزن: {variant.weight} گرم + وزن: {parseFloat(variant.weight.toString()).toLocaleString('fa-IR')} گرم
- {variant.product_option_id && ( -
- گزینه محصول: {productOptions.find(opt => opt.id === variant.product_option_id)?.title || `شناسه ${variant.product_option_id}`} -
- )}
{variant.file_ids && variant.file_ids.length > 0 && ( diff --git a/src/pages/categories/category-form/CategoryFormPage.tsx b/src/pages/categories/category-form/CategoryFormPage.tsx index 911c9ab..5623e6d 100644 --- a/src/pages/categories/category-form/CategoryFormPage.tsx +++ b/src/pages/categories/category-form/CategoryFormPage.tsx @@ -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 = () => { />
+
+ setIsUploading(true)} + onUploadComplete={() => setIsUploading(false)} + /> +
+ {/* Actions */}