admin/src/components/ui/VariantManager.tsx

636 lines
29 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import { Plus, Trash2, Edit3, Package, X, Edit, Image as ImageIcon } from 'lucide-react';
import { ProductVariantFormData, ProductImage } from '../../pages/products/core/_models';
import { Button } from './Button';
import { FileUploader } from './FileUploader';
import { useFileUpload, useFileDelete } from '../../hooks/useFileUpload';
import { persianToEnglish, convertPersianNumbersInObject } from '../../utils/numberUtils';
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
const toPublicUrl = (img: any): ProductImage => {
const rawUrl: string = img?.url || '';
const serveKey: string | undefined = (img && img.serve_key) || undefined;
const url = serveKey
? `${API_GATE_WAY}/${API_ROUTES.DOWNLOAD_FILE(serveKey)}`
: rawUrl?.startsWith('http')
? rawUrl
: rawUrl
? `${API_GATE_WAY}${rawUrl.startsWith('/') ? '' : '/'}${rawUrl}`
: '';
return {
id: (img?.id ?? img).toString(),
url,
alt: img?.alt || '',
order: img?.order ?? 0,
};
};
interface ProductOption {
id: number;
title: string;
description?: string;
}
interface VariantManagerProps {
variants: ProductVariantFormData[];
onChange: (variants: ProductVariantFormData[]) => void;
disabled?: boolean;
productOptions?: ProductOption[];
variantAttributeName?: string;
}
interface VariantFormProps {
variant?: ProductVariantFormData;
onSave: (variant: ProductVariantFormData) => void;
onCancel: () => void;
isEdit?: boolean;
productOptions?: ProductOption[];
variantAttributeName?: string;
}
const VariantForm: React.FC<VariantFormProps> = ({ variant, onSave, onCancel, isEdit = false, productOptions = [], variantAttributeName }) => {
const [formData, setFormData] = useState<ProductVariantFormData>(
variant || {
enabled: true,
fee_percentage: 0,
profit_percentage: 0,
stock_limit: 0,
stock_managed: true,
stock_number: 0,
weight: 0,
attributes: {},
meta: {},
file_ids: []
}
);
const [uploadedImages, setUploadedImages] = useState<ProductImage[]>(
Array.isArray(variant?.file_ids) && variant.file_ids.length > 0 && typeof variant.file_ids[0] === 'object'
? variant.file_ids.map(toPublicUrl)
: []
);
const [variantAttributeValue, setVariantAttributeValue] = useState('');
const [meta, setMeta] = useState<Record<string, any>>(variant?.meta || {});
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();
// Sync formData.file_ids with uploadedImages
useEffect(() => {
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);
}
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleFileUpload = async (file: File) => {
try {
const result = await uploadFile(file);
// 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) {
console.error('Upload error:', error);
throw error;
}
};
const handleFileRemove = (fileId: string) => {
const updatedImages = uploadedImages.filter(img => img.id !== fileId);
setUploadedImages(updatedImages);
deleteFile(fileId);
};
const handleAddMeta = () => {
if (newMetaKey.trim() && newMetaValue.trim()) {
const updatedMeta = {
...meta,
[newMetaKey.trim()]: newMetaValue.trim()
};
setMeta(updatedMeta);
setNewMetaKey('');
setNewMetaValue('');
}
};
const handleRemoveMeta = (key: string) => {
const updatedMeta = { ...meta };
delete updatedMeta[key];
setMeta(updatedMeta);
};
const handleSave = () => {
// 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: fileObjects
});
onSave(convertedData);
};
return (
<div className="space-y-6 bg-gray-50 dark:bg-gray-700 p-6 rounded-lg border">
<div>
<h4 className="text-lg font-medium text-gray-900 dark:text-gray-100">
{isEdit ? 'ویرایش Variant' : 'افزودن Variant جدید'}
</h4>
</div>
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
درصد کارمزد
</label>
<input
type="text"
inputMode="decimal"
value={feePercentageDisplay}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
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="مثال: ۵.۲"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
درصد سود
</label>
<input
type="text"
inputMode="decimal"
value={profitPercentageDisplay}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
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="مثال: ۱۰.۵"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
وزن (گرم)
</label>
<input
type="text"
inputMode="decimal"
value={weightDisplay}
onChange={(e) => {
const converted = persianToEnglish(e.target.value);
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="مثال: ۱۲۰۰.۵"
/>
</div>
</div>
{/* Stock Management */}
<div>
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
مدیریت موجودی
</h5>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex items-center space-x-3 space-x-reverse">
<input
type="checkbox"
checked={formData.stock_managed}
onChange={(e) => handleInputChange('stock_managed', e.target.checked)}
className="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500"
/>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
مدیریت موجودی فعال باشد
</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
تعداد موجودی
</label>
<input
type="text"
inputMode="numeric"
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"
placeholder="مثال: ۱۰۰"
disabled={!formData.stock_managed}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
حد موجودی
</label>
<input
type="text"
inputMode="numeric"
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"
placeholder="مثال: ۱۰"
disabled={!formData.stock_managed}
/>
</div>
</div>
</div>
{/* Images */}
<div>
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
تصاویر Variant
</h5>
<FileUploader
onUpload={handleFileUpload}
onRemove={handleFileRemove}
acceptedTypes={['image/*']}
maxFileSize={5 * 1024 * 1024}
maxFiles={5}
label=""
description="تصاویر مخصوص این variant را آپلود کنید"
/>
{uploadedImages.length > 0 && (
<div className="mt-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{uploadedImages.map((image, index) => (
<div key={image.id} className="relative group">
<img
src={image.url}
alt={image.alt || `تصویر ${index + 1}`}
className="w-full h-20 object-cover rounded-lg border"
/>
<button
type="button"
onClick={() => handleFileRemove(image.id)}
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</div>
))}
</div>
</div>
)}
</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>
)}
{/* Meta Data */}
<div>
<h5 className="text-md font-medium text-gray-900 dark:text-gray-100 mb-3">
Meta Data
</h5>
<div className="flex gap-3 mb-3">
<input
type="text"
value={newMetaKey}
onChange={(e) => setNewMetaKey(e.target.value)}
placeholder="کلید Meta"
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={newMetaValue}
onChange={(e) => setNewMetaValue(e.target.value)}
placeholder="مقدار Meta"
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={handleAddMeta}
className="flex items-center gap-2"
>
<Plus className="h-4 w-4" />
افزودن
</Button>
</div>
{Object.keys(meta).length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{Object.entries(meta).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={() => handleRemoveMeta(key)}
className="text-red-500 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
{/* Status */}
<div className="flex items-center space-x-3 space-x-reverse">
<input
type="checkbox"
checked={formData.enabled}
onChange={(e) => handleInputChange('enabled', e.target.checked)}
className="w-4 h-4 text-primary-600 bg-gray-100 border-gray-300 rounded focus:ring-primary-500"
/>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
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 = [], variantAttributeName }) => {
const [showForm, setShowForm] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const handleAddVariant = () => {
setEditingIndex(null);
setShowForm(true);
};
const handleEditVariant = (index: number) => {
setEditingIndex(index);
setShowForm(true);
};
const handleDeleteVariant = (index: number) => {
const updatedVariants = variants.filter((_, i) => i !== index);
onChange(updatedVariants);
};
const handleSaveVariant = (variant: ProductVariantFormData) => {
if (editingIndex !== null) {
// Edit existing variant
const updatedVariants = [...variants];
updatedVariants[editingIndex] = variant;
onChange(updatedVariants);
} else {
// Add new variant
onChange([...variants, variant]);
}
setShowForm(false);
setEditingIndex(null);
};
const handleCancelForm = () => {
setShowForm(false);
setEditingIndex(null);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
Variants محصول ({variants.length})
</h3>
{!disabled && !showForm && (
<Button onClick={handleAddVariant} className="flex items-center gap-2">
<Plus className="h-4 w-4" />
افزودن Variant
</Button>
)}
</div>
{/* Show Form */}
{showForm && (
<VariantForm
variant={editingIndex !== null ? variants[editingIndex] : undefined}
onSave={handleSaveVariant}
onCancel={handleCancelForm}
isEdit={editingIndex !== null}
productOptions={productOptions}
variantAttributeName={variantAttributeName}
/>
)}
{/* Variants List */}
{variants.length > 0 && (
<div className="space-y-3">
{variants.map((variant, index) => (
<div key={index} className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-4 mb-2">
<h4 className="font-medium text-gray-900 dark:text-gray-100">
Variant {index + 1}
</h4>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${variant.enabled ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{variant.enabled ? 'فعال' : 'غیرفعال'}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm text-gray-600 dark:text-gray-400">
<div>
<strong>درصد کارمزد:</strong> {variant.fee_percentage}%
</div>
<div>
<strong>درصد سود:</strong> {variant.profit_percentage}%
</div>
<div>
<strong>موجودی:</strong> {variant.stock_managed ? `${variant.stock_number} عدد` : 'بدون محدودیت'}
</div>
<div>
<strong>وزن:</strong> {parseFloat(variant.weight.toString()).toLocaleString('fa-IR')} گرم
</div>
</div>
{variant.file_ids && variant.file_ids.length > 0 && (
<div className="flex gap-2 mt-3">
{variant.file_ids.slice(0, 3).map((image, imgIndex) => (
<img
key={image.id}
src={image.url}
alt={image.alt || `تصویر ${imgIndex + 1}`}
className="w-12 h-12 object-cover rounded border"
/>
))}
{variant.file_ids.length > 3 && (
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-600 rounded border flex items-center justify-center text-xs">
+{variant.file_ids.length - 3}
</div>
)}
</div>
)}
{/* Show Attributes if any */}
{Object.keys(variant.attributes).length > 0 && (
<div className="mt-2">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">ویژگیها:</div>
<div className="flex flex-wrap gap-1">
{Object.entries(variant.attributes).map(([key, value]) => (
<span key={key} className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-blue-100 text-blue-800">
{key}: {String(value)}
</span>
))}
</div>
</div>
)}
</div>
{!disabled && (
<div className="flex gap-2">
<button
type="button"
onClick={() => handleEditVariant(index)}
className="p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md"
title="ویرایش"
>
<Edit3 className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleDeleteVariant(index)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md"
title="حذف"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
{variants.length === 0 && !showForm && (
<div className="text-center py-8 bg-gray-50 dark:bg-gray-700 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
<Package className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500 dark:text-gray-400 mb-4">
هنوز هیچ Variant ای اضافه نشده
</p>
{!disabled && (
<Button onClick={handleAddVariant} className="flex items-center gap-2 mx-auto">
<Plus className="h-4 w-4" />
افزودن اولین Variant
</Button>
)}
</div>
)}
</div>
);
};