fix: نمایش صحیح تصاویر هنگام ویرایش variant

- اصلاح منطق بارگذاری تصاویر variant در حالت ویرایش
- افزودن fallback به فیلد files در صورت خالی بودن file_ids
- رفع مشکل نمایش فقط یک تصویر از چندین تصویر آپلود شده
This commit is contained in:
hosseintaromi 2025-08-01 14:38:04 +03:30
parent afab715b56
commit 25429f9745
6 changed files with 351 additions and 216 deletions

View File

@ -25,6 +25,9 @@ interface FileUploaderProps {
error?: string; error?: string;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
mode?: 'single' | 'multi';
onUploadStart?: () => void;
onUploadComplete?: () => void;
} }
export const FileUploader: React.FC<FileUploaderProps> = ({ export const FileUploader: React.FC<FileUploaderProps> = ({
@ -38,6 +41,9 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
error, error,
disabled = false, disabled = false,
className = "", className = "",
mode = 'multi',
onUploadStart,
onUploadComplete,
}) => { }) => {
const [files, setFiles] = useState<UploadedFile[]>([]); const [files, setFiles] = useState<UploadedFile[]>([]);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@ -100,10 +106,12 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
status: 'error', status: 'error',
error: validationError, error: validationError,
}; };
setFiles(prev => [...prev, errorFile]); setFiles(prev => mode === 'single' ? [errorFile] : [...prev, errorFile]);
return; return;
} }
onUploadStart?.();
const fileId = Math.random().toString(36).substr(2, 9); const fileId = Math.random().toString(36).substr(2, 9);
const preview = await createFilePreview(file); const preview = await createFilePreview(file);
@ -117,7 +125,7 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
status: 'uploading', status: 'uploading',
}; };
setFiles(prev => [...prev, newFile]); setFiles(prev => mode === 'single' ? [newFile] : [...prev, newFile]);
try { try {
const progressInterval = setInterval(() => { 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, progress: 100, status: 'completed', url: result.url, id: result.id }
: f : f
)); ));
onUploadComplete?.();
} catch (error: any) { } catch (error: any) {
setFiles(prev => prev.map(f => setFiles(prev => prev.map(f =>
f.id === fileId f.id === fileId
? { ...f, status: 'error', error: error.message || 'خطا در آپلود فایل' } ? { ...f, status: 'error', error: error.message || 'خطا در آپلود فایل' }
: f : f
)); ));
onUploadComplete?.();
} }
}, [onUpload, maxFiles, maxFileSize, acceptedTypes]); }, [onUpload, maxFiles, maxFileSize, acceptedTypes, mode, onUploadStart, onUploadComplete]);
const handleFileSelect = useCallback((selectedFiles: FileList) => { const handleFileSelect = useCallback((selectedFiles: FileList) => {
Array.from(selectedFiles).forEach(file => { Array.from(selectedFiles).forEach(file => {
@ -180,6 +191,9 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
onRemove?.(fileId); onRemove?.(fileId);
}; };
const hasUploadedFiles = files.some(f => f.status === 'completed');
const showUploadArea = mode === 'multi' || (mode === 'single' && !hasUploadedFiles);
return ( return (
<div className={`space-y-4 ${className}`}> <div className={`space-y-4 ${className}`}>
{label && ( {label && (
@ -188,41 +202,43 @@ export const FileUploader: React.FC<FileUploaderProps> = ({
</label> </label>
)} )}
{/* Upload Area */} {/* Upload Area - only show in multi mode or single mode without uploaded files */}
<div {showUploadArea && (
className={` <div
relative border-2 border-dashed rounded-lg p-6 transition-colors cursor-pointer className={`
${isDragOver ? 'border-primary-400 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-300 dark:border-gray-600'} relative border-2 border-dashed rounded-lg p-6 transition-colors cursor-pointer
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700'} ${isDragOver ? 'border-primary-400 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-300 dark:border-gray-600'}
${error ? 'border-red-300 bg-red-50 dark:bg-red-900/20' : ''} ${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} onDrop={handleDrop}
onDragLeave={handleDragLeave} onDragOver={handleDragOver}
onClick={handleClick} onDragLeave={handleDragLeave}
> onClick={handleClick}
<input >
ref={fileInputRef} <input
type="file" ref={fileInputRef}
multiple type="file"
accept={acceptedTypes.join(',')} multiple={mode === 'multi'}
className="hidden" accept={acceptedTypes.join(',')}
onChange={(e) => e.target.files && handleFileSelect(e.target.files)} className="hidden"
disabled={disabled} onChange={(e) => e.target.files && handleFileSelect(e.target.files)}
/> disabled={disabled}
/>
<div className="text-center"> <div className="text-center">
<Upload className="mx-auto h-12 w-12 text-gray-400" /> <Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4"> <div className="mt-4">
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
{description} {description}
</p> </p>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1"> <p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
حداکثر {formatFileSize(maxFileSize)} {acceptedTypes.join(', ')} حداکثر {formatFileSize(maxFileSize)} {acceptedTypes.join(', ')}
</p> </p>
</div>
</div> </div>
</div> </div>
</div> )}
{error && ( {error && (
<p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-1"> <p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-1">

View File

@ -17,6 +17,7 @@ interface VariantManagerProps {
onChange: (variants: ProductVariantFormData[]) => void; onChange: (variants: ProductVariantFormData[]) => void;
disabled?: boolean; disabled?: boolean;
productOptions?: ProductOption[]; productOptions?: ProductOption[];
variantAttributeName?: string;
} }
interface VariantFormProps { interface VariantFormProps {
@ -25,9 +26,10 @@ interface VariantFormProps {
onCancel: () => void; onCancel: () => void;
isEdit?: boolean; isEdit?: boolean;
productOptions?: ProductOption[]; 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>( const [formData, setFormData] = useState<ProductVariantFormData>(
variant || { variant || {
enabled: true, enabled: true,
@ -37,7 +39,6 @@ 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: {},
file_ids: [] file_ids: []
@ -49,12 +50,14 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
? variant.file_ids ? 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 [meta, setMeta] = useState<Record<string, any>>(variant?.meta || {});
const [newAttributeKey, setNewAttributeKey] = useState('');
const [newAttributeValue, setNewAttributeValue] = useState('');
const [newMetaKey, setNewMetaKey] = useState(''); const [newMetaKey, setNewMetaKey] = useState('');
const [newMetaValue, setNewMetaValue] = 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 { mutateAsync: uploadFile } = useFileUpload();
const { mutate: deleteFile } = useFileDelete(); const { mutate: deleteFile } = useFileDelete();
@ -64,6 +67,23 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
setFormData(prev => ({ ...prev, file_ids: uploadedImages })); setFormData(prev => ({ ...prev, file_ids: uploadedImages }));
}, [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) => { const handleInputChange = (field: keyof ProductVariantFormData, value: any) => {
if (typeof value === 'string') { if (typeof value === 'string') {
value = persianToEnglish(value); value = persianToEnglish(value);
@ -74,15 +94,17 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
const handleFileUpload = async (file: File) => { const handleFileUpload = async (file: File) => {
try { try {
const result = await uploadFile(file); const result = await uploadFile(file);
const newImage: ProductImage = {
id: result.id,
url: result.url,
alt: file.name,
order: uploadedImages.length
};
const updatedImages = [...uploadedImages, newImage]; // Use functional update to avoid stale state when multiple files upload concurrently
setUploadedImages(updatedImages); setUploadedImages(prev => {
const newImage: ProductImage = {
id: result.id,
url: result.url,
alt: file.name,
order: prev.length
};
return [...prev, newImage];
});
return result; return result;
} catch (error) { } catch (error) {
@ -97,23 +119,7 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
deleteFile(fileId); 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 = () => { const handleAddMeta = () => {
if (newMetaKey.trim() && newMetaValue.trim()) { if (newMetaKey.trim() && newMetaValue.trim()) {
@ -134,13 +140,28 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
}; };
const handleSave = () => { 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({ const convertedData = convertPersianNumbersInObject({
...formData, ...formData,
attributes, attributes,
meta, meta,
file_ids: fileIds file_ids: fileObjects
}); });
onSave(convertedData); onSave(convertedData);
@ -148,18 +169,10 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
return ( return (
<div className="space-y-6 bg-gray-50 dark:bg-gray-700 p-6 rounded-lg border"> <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"> <h4 className="text-lg font-medium text-gray-900 dark:text-gray-100">
{isEdit ? 'ویرایش Variant' : 'افزودن Variant جدید'} {isEdit ? 'ویرایش Variant' : 'افزودن Variant جدید'}
</h4> </h4>
<div className="flex gap-2">
<Button variant="secondary" onClick={onCancel}>
انصراف
</Button>
<Button onClick={handleSave}>
{isEdit ? 'به‌روزرسانی' : 'افزودن'}
</Button>
</div>
</div> </div>
{/* Basic Info */} {/* Basic Info */}
@ -171,10 +184,15 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
<input <input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
value={formData.fee_percentage || ''} value={feePercentageDisplay}
onChange={(e) => { onChange={(e) => {
const converted = persianToEnglish(e.target.value); 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" 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="مثال: ۵.۲"
@ -188,10 +206,15 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
<input <input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
value={formData.profit_percentage || ''} value={profitPercentageDisplay}
onChange={(e) => { onChange={(e) => {
const converted = persianToEnglish(e.target.value); 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" 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="مثال: ۱۰.۵"
@ -205,43 +228,23 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
<input <input
type="text" type="text"
inputMode="decimal" inputMode="decimal"
value={formData.weight || ''} value={weightDisplay}
onChange={(e) => { onChange={(e) => {
const converted = persianToEnglish(e.target.value); 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" 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>
</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>
@ -338,57 +341,29 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
)} )}
</div> </div>
{/* Attributes */} {/* Variant Attribute */}
<div> {variantAttributeName && (
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3"> <div>
ویژگیهای Variant <h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
</h5> ویژگی Variant
</h5>
<div className="flex gap-3 mb-3"> <div>
<input <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
type="text" {variantAttributeName}
value={newAttributeKey} </label>
onChange={(e) => setNewAttributeKey(e.target.value)} <input
placeholder="نام ویژگی (مثل: رنگ، سایز)" type="text"
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" value={variantAttributeValue}
/> onChange={(e) => setVariantAttributeValue(e.target.value)}
<input placeholder={`مقدار ${variantAttributeName} را وارد کنید`}
type="text" 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"
value={newAttributeValue} />
onChange={(e) => setNewAttributeValue(e.target.value)} {attributeError && (
placeholder="مقدار (مثل: قرمز، بزرگ)" <p className="text-red-500 text-xs mt-1">{attributeError}</p>
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>
))}
</div> </div>
)} </div>
</div> )}
{/* Meta Data */} {/* Meta Data */}
<div> <div>
@ -454,11 +429,21 @@ const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, is
Variant فعال باشد Variant فعال باشد
</label> </label>
</div> </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> </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 [showForm, setShowForm] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null); const [editingIndex, setEditingIndex] = useState<number | null>(null);
@ -518,6 +503,7 @@ export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChan
onCancel={handleCancelForm} onCancel={handleCancelForm}
isEdit={editingIndex !== null} isEdit={editingIndex !== null}
productOptions={productOptions} productOptions={productOptions}
variantAttributeName={variantAttributeName}
/> />
)} )}
@ -549,13 +535,8 @@ export const VariantManager: React.FC<VariantManagerProps> = ({ variants, onChan
<strong>موجودی:</strong> {variant.stock_managed ? `${variant.stock_number} عدد` : 'بدون محدودیت'} <strong>موجودی:</strong> {variant.stock_managed ? `${variant.stock_number} عدد` : 'بدون محدودیت'}
</div> </div>
<div> <div>
<strong>وزن:</strong> {variant.weight} گرم <strong>وزن:</strong> {parseFloat(variant.weight.toString()).toLocaleString('fa-IR')} گرم
</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.file_ids && variant.file_ids.length > 0 && ( {variant.file_ids && variant.file_ids.length > 0 && (

View File

@ -4,6 +4,8 @@ import { ArrowRight, FolderOpen } from 'lucide-react';
import { Button } from '../../../components/ui/Button'; import { Button } from '../../../components/ui/Button';
import { Input } from '../../../components/ui/Input'; import { Input } from '../../../components/ui/Input';
import { LoadingSpinner } from '../../../components/ui/LoadingSpinner'; import { LoadingSpinner } from '../../../components/ui/LoadingSpinner';
import { FileUploader } from '../../../components/ui/FileUploader';
import { useFileUpload, useFileDelete } from '../../../hooks/useFileUpload';
import { useToast } from '../../../contexts/ToastContext'; import { useToast } from '../../../contexts/ToastContext';
import { useCategory, useCreateCategory, useUpdateCategory } from '../core/_hooks'; import { useCategory, useCreateCategory, useUpdateCategory } from '../core/_hooks';
import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography'; import { FormHeader, PageContainer, Label } from '../../../components/ui/Typography';
@ -18,8 +20,12 @@ const CategoryFormPage = () => {
name: '', name: '',
description: '', description: '',
parent_id: null as number | null, 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( const { data: category, isLoading: isLoadingCategory } = useCategory(
id || '0', id || '0',
isEdit isEdit
@ -27,6 +33,8 @@ const CategoryFormPage = () => {
const createMutation = useCreateCategory(); const createMutation = useCreateCategory();
const updateMutation = useUpdateCategory(); const updateMutation = useUpdateCategory();
const { mutateAsync: uploadFile } = useFileUpload();
const { mutate: deleteFile } = useFileDelete();
useEffect(() => { useEffect(() => {
if (category && isEdit) { if (category && isEdit) {
@ -34,7 +42,16 @@ const CategoryFormPage = () => {
name: category.name || '', name: category.name || '',
description: category.description || '', description: category.description || '',
parent_id: category.parent_id || null, 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]); }, [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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -120,6 +165,21 @@ const CategoryFormPage = () => {
/> />
</div> </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 */} {/* 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"> <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 <Button
@ -133,6 +193,7 @@ const CategoryFormPage = () => {
<Button <Button
type="submit" type="submit"
loading={createMutation.isPending || updateMutation.isPending} loading={createMutation.isPending || updateMutation.isPending}
disabled={isUploading}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
{isEdit ? 'ویرایش' : 'ایجاد'} {isEdit ? 'ویرایش' : 'ایجاد'}

View File

@ -3,6 +3,7 @@ export interface Category {
name: string; name: string;
description?: string; description?: string;
parent_id?: number; parent_id?: number;
file_id?: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@ -10,6 +11,7 @@ export interface Category {
export interface CategoryFormData { export interface CategoryFormData {
name: string; name: string;
description: string; description: string;
file_id?: number;
} }
export interface CategoryFilters { export interface CategoryFilters {
@ -21,12 +23,14 @@ export interface CategoryFilters {
export interface CreateCategoryRequest { export interface CreateCategoryRequest {
name: string; name: string;
description?: string; description?: string;
file_id?: number;
} }
export interface UpdateCategoryRequest { export interface UpdateCategoryRequest {
id: number; id: number;
name: string; name: string;
description?: string; description?: string;
file_id?: number;
} }
export interface CategoriesResponse { export interface CategoriesResponse {

View File

@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useForm, useFieldArray } from 'react-hook-form'; import { useForm, useFieldArray } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup'; import { yupResolver } from '@hookform/resolvers/yup';
@ -8,6 +8,8 @@ import { ProductOptionFormData, Maintenance, Option } from '../core/_models';
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { LoadingSpinner } from "@/components/ui/LoadingSpinner"; 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 { ArrowRight, Settings, Plus, Trash2 } from "lucide-react";
import { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography'; import { FormHeader, PageContainer, SectionTitle, Label } from '../../../components/ui/Typography';
@ -39,6 +41,11 @@ const ProductOptionFormPage = () => {
const { data: productOption, isLoading: isLoadingOption } = useProductOption(id || '', isEdit); const { data: productOption, isLoading: isLoadingOption } = useProductOption(id || '', isEdit);
const { mutate: createOption, isPending: isCreating } = useCreateProductOption(); const { mutate: createOption, isPending: isCreating } = useCreateProductOption();
const { mutate: updateOption, isPending: isUpdating } = useUpdateProductOption(); 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; const isLoading = isCreating || isUpdating;
@ -78,6 +85,14 @@ const ProductOptionFormPage = () => {
setValue('description', productOption.description, { shouldValidate: true }); setValue('description', productOption.description, { shouldValidate: true });
setValue('maintenance', productOption.maintenance, { shouldValidate: true }); setValue('maintenance', productOption.maintenance, { shouldValidate: true });
setValue('options', productOption.options, { 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]); }, [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) { if (isLoadingOption) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-screen">
@ -151,42 +188,65 @@ const ProductOptionFormPage = () => {
/> />
</div> </div>
<div> <div>
<Input <Label htmlFor="description">توضیحات</Label>
label="توضیحات" <textarea
id="description"
{...register('description')} {...register('description')}
error={errors.description?.message}
placeholder="توضیحات گزینه محصول را وارد کنید" 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> </div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"> <div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<SectionTitle className="mb-4">اطلاعات نگهداری</SectionTitle> <SectionTitle className="mb-4">اطلاعات نگهداری</SectionTitle>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="space-y-4">
<Input <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
label="عنوان نگهداری" <Input
{...register('maintenance.title')} label="عنوان نگهداری"
error={errors.maintenance?.title?.message} {...register('maintenance.title')}
placeholder="عنوان نگهداری را وارد کنید" error={errors.maintenance?.title?.message}
/> placeholder="عنوان نگهداری را وارد کنید"
<Input />
label="توضیحات نگهداری" <Input
{...register('maintenance.description')} label="توضیحات نگهداری"
error={errors.maintenance?.description?.message} {...register('maintenance.description')}
placeholder="توضیحات نگهداری را وارد کنید" error={errors.maintenance?.description?.message}
/> placeholder="توضیحات نگهداری را وارد کنید"
<Input />
label="محتوای نگهداری" </div>
{...register('maintenance.content')} <div>
error={errors.maintenance?.content?.message} <Label htmlFor="maintenance-content">محتوای نگهداری</Label>
placeholder="محتوای نگهداری را وارد کنید" <textarea
/> id="maintenance-content"
<Input {...register('maintenance.content')}
label="تصویر نگهداری" placeholder="محتوای نگهداری را وارد کنید"
{...register('maintenance.image')} rows={4}
error={errors.maintenance?.image?.message} className="input resize-none"
placeholder="آدرس تصویر نگهداری را وارد کنید" />
/> {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>
</div> </div>
@ -218,25 +278,34 @@ const ProductOptionFormPage = () => {
حذف حذف
</Button> </Button>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="space-y-4">
<Input <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
label="عنوان" <Input
{...register(`options.${index}.title`)} label="عنوان"
error={errors.options?.[index]?.title?.message} {...register(`options.${index}.title`)}
placeholder="عنوان گزینه را وارد کنید" error={errors.options?.[index]?.title?.message}
/> placeholder="عنوان گزینه را وارد کنید"
<Input />
label="توضیحات" <Input
{...register(`options.${index}.description`)} label="متا تایتل"
error={errors.options?.[index]?.description?.message} {...register(`options.${index}.meta_title`)}
placeholder="توضیحات گزینه را وارد کنید" error={errors.options?.[index]?.meta_title?.message}
/> placeholder="متا تایتل را وارد کنید"
<Input />
label="متا تایتل" </div>
{...register(`options.${index}.meta_title`)} <div>
error={errors.options?.[index]?.meta_title?.message} <Label htmlFor={`option-${index}-description`}>توضیحات</Label>
placeholder="متا تایتل را وارد کنید" <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>
</div> </div>
))} ))}
@ -260,7 +329,7 @@ const ProductOptionFormPage = () => {
<Button <Button
type="submit" type="submit"
variant="primary" variant="primary"
disabled={!isValid || isLoading} disabled={!isValid || isLoading || isUploading}
loading={isLoading} loading={isLoading}
> >
{isEdit ? 'ویرایش گزینه محصول' : 'ایجاد گزینه محصول'} {isEdit ? 'ویرایش گزینه محصول' : 'ایجاد گزینه محصول'}

View File

@ -44,6 +44,7 @@ const ProductFormPage = () => {
const [attributes, setAttributes] = useState<Record<string, any>>({}); const [attributes, setAttributes] = useState<Record<string, any>>({});
const [newAttributeKey, setNewAttributeKey] = useState(''); const [newAttributeKey, setNewAttributeKey] = useState('');
const [newAttributeValue, setNewAttributeValue] = useState(''); const [newAttributeValue, setNewAttributeValue] = useState('');
const [isUploading, setIsUploading] = useState(false);
const { data: product, isLoading: isLoadingProduct } = useProduct(id || '', isEdit); const { data: product, isLoading: isLoadingProduct } = useProduct(id || '', isEdit);
const { data: categories, isLoading: isLoadingCategories } = useCategories(); const { data: categories, isLoading: isLoadingCategories } = useCategories();
@ -391,7 +392,7 @@ const ProductFormPage = () => {
<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>
{isLoadingProductOptions ? ( {isLoadingProductOptions ? (
<div className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-gray-50 dark:bg-gray-700 text-gray-500 dark:text-gray-400"> <div 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/*']} acceptedTypes={['image/*']}
maxFileSize={5 * 1024 * 1024} maxFileSize={5 * 1024 * 1024}
maxFiles={10} maxFiles={10}
mode="multi"
label="" label=""
description="تصاویر محصول را اینجا بکشید یا کلیک کنید" description="تصاویر محصول را اینجا بکشید یا کلیک کنید"
onUploadStart={() => setIsUploading(true)}
onUploadComplete={() => setIsUploading(false)}
/> />
{uploadedImages.length > 0 && ( {uploadedImages.length > 0 && (
@ -613,7 +617,7 @@ const ProductFormPage = () => {
<Button <Button
type="submit" type="submit"
loading={isLoading} loading={isLoading}
disabled={!isValid || isLoading} disabled={!isValid || isLoading || isUploading}
> >
{isEdit ? 'به‌روزرسانی' : 'ایجاد محصول'} {isEdit ? 'به‌روزرسانی' : 'ایجاد محصول'}
</Button> </Button>