Files
sfera/src/components/warehouse/product-form.tsx

487 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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.

"use client"
import { useState, useRef } from 'react'
import { useMutation, useQuery } from '@apollo/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Card } from '@/components/ui/card'
import { CREATE_PRODUCT, UPDATE_PRODUCT } from '@/graphql/mutations'
import { GET_CATEGORIES } from '@/graphql/queries'
import { Upload, X, Star, Plus, Image as ImageIcon } from 'lucide-react'
import { toast } from 'sonner'
interface Product {
id: string
name: string
article: string
description: string
price: number
quantity: number
category: { id: string; name: string } | null
brand: string
color: string
size: string
weight: number
dimensions: string
material: string
images: string[]
mainImage: string
isActive: boolean
}
interface ProductFormProps {
product?: Product | null
onSave: () => void
onCancel: () => void
}
export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
const [formData, setFormData] = useState({
name: product?.name || '',
article: product?.article || '',
description: product?.description || '',
price: product?.price || 0,
quantity: product?.quantity || 0,
categoryId: product?.category?.id || 'none',
brand: product?.brand || '',
color: product?.color || '',
size: product?.size || '',
weight: product?.weight || 0,
dimensions: product?.dimensions || '',
material: product?.material || '',
images: product?.images || [],
mainImage: product?.mainImage || '',
isActive: product?.isActive ?? true
})
const [isUploading, setIsUploading] = useState(false)
const [uploadingImages, setUploadingImages] = useState<Set<number>>(new Set())
const fileInputRef = useRef<HTMLInputElement>(null)
const [createProduct, { loading: creating }] = useMutation(CREATE_PRODUCT)
const [updateProduct, { loading: updating }] = useMutation(UPDATE_PRODUCT)
// Загружаем категории
const { data: categoriesData } = useQuery(GET_CATEGORIES)
const loading = creating || updating
const handleInputChange = (field: string, value: string | number | boolean) => {
setFormData(prev => ({
...prev,
[field]: value
}))
}
const handleImageUpload = async (files: FileList) => {
const newUploadingIndexes = new Set<number>()
const startIndex = formData.images.length
// Добавляем плейсхолдеры для загружаемых изображений
const placeholders = Array.from(files).map((_, index) => {
newUploadingIndexes.add(startIndex + index)
return '' // Пустой URL как плейсхолдер
})
setUploadingImages(prev => new Set([...prev, ...newUploadingIndexes]))
setFormData(prev => ({
...prev,
images: [...prev.images, ...placeholders]
}))
try {
// Загружаем каждое изображение
const uploadPromises = Array.from(files).map(async (file, index) => {
const actualIndex = startIndex + index
const formData = new FormData()
formData.append('file', file)
formData.append('type', 'product')
const response = await fetch('/api/upload-file', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error('Ошибка загрузки изображения')
}
const result = await response.json()
return { index: actualIndex, url: result.fileUrl }
})
const results = await Promise.all(uploadPromises)
// Обновляем URLs загруженных изображений
setFormData(prev => {
const newImages = [...prev.images]
results.forEach(({ index, url }) => {
newImages[index] = url
})
return {
...prev,
images: newImages,
mainImage: prev.mainImage || results[0]?.url || '' // Устанавливаем первое изображение как главное
}
})
toast.success('Изображения успешно загружены')
} catch (error) {
console.error('Error uploading images:', error)
toast.error('Ошибка загрузки изображений')
// Удаляем неудачные плейсхолдеры
setFormData(prev => ({
...prev,
images: prev.images.slice(0, startIndex)
}))
} finally {
// Убираем индикаторы загрузки
setUploadingImages(prev => {
const updated = new Set(prev)
newUploadingIndexes.forEach(index => updated.delete(index))
return updated
})
}
}
const handleRemoveImage = (indexToRemove: number) => {
setFormData(prev => {
const newImages = prev.images.filter((_, index) => index !== indexToRemove)
const removedImageUrl = prev.images[indexToRemove]
return {
...prev,
images: newImages,
mainImage: prev.mainImage === removedImageUrl ? (newImages[0] || '') : prev.mainImage
}
})
}
const handleSetMainImage = (imageUrl: string) => {
setFormData(prev => ({
...prev,
mainImage: imageUrl
}))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!formData.name || !formData.article || formData.price <= 0) {
toast.error('Пожалуйста, заполните все обязательные поля')
return
}
try {
const input = {
name: formData.name,
article: formData.article,
description: formData.description || undefined,
price: formData.price,
quantity: formData.quantity,
categoryId: formData.categoryId && formData.categoryId !== 'none' ? formData.categoryId : undefined,
brand: formData.brand || undefined,
color: formData.color || undefined,
size: formData.size || undefined,
weight: formData.weight || undefined,
dimensions: formData.dimensions || undefined,
material: formData.material || undefined,
images: formData.images.filter(img => img), // Убираем пустые строки
mainImage: formData.mainImage || undefined,
isActive: formData.isActive
}
if (product) {
await updateProduct({
variables: { id: product.id, input }
})
toast.success('Товар успешно обновлен')
} else {
await createProduct({
variables: { input }
})
toast.success('Товар успешно создан')
}
onSave()
} catch (error: unknown) {
console.error('Error saving product:', error)
toast.error((error as Error).message || 'Ошибка при сохранении товара')
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Основная информация */}
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
<h3 className="text-white font-medium mb-4">Основная информация</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Название товара <span className="text-red-400">*</span>
</Label>
<Input
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
placeholder="iPhone 15 Pro Max"
className="glass-input text-white placeholder:text-white/40 h-10"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">
Артикул <span className="text-red-400">*</span>
</Label>
<Input
value={formData.article}
onChange={(e) => handleInputChange('article', e.target.value)}
placeholder="IP15PM-256-BLU"
className="glass-input text-white placeholder:text-white/40 h-10"
required
/>
</div>
</div>
<div className="mt-4">
<Label className="text-white/80 text-sm mb-2 block">Описание</Label>
<textarea
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Подробное описание товара..."
className="glass-input text-white placeholder:text-white/40 w-full resize-none"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">
Цена () <span className="text-red-400">*</span>
</Label>
<Input
type="number"
step="0.01"
min="0"
value={formData.price || ''}
onChange={(e) => handleInputChange('price', parseFloat(e.target.value) || 0)}
placeholder="99999.99"
className="glass-input text-white placeholder:text-white/40 h-10"
required
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Количество</Label>
<Input
type="number"
min="0"
value={formData.quantity || ''}
onChange={(e) => handleInputChange('quantity', parseInt(e.target.value) || 0)}
placeholder="100"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
</div>
</Card>
{/* Категоризация */}
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
<h3 className="text-white font-medium mb-4">Категоризация</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">Категория</Label>
<Select
value={formData.categoryId}
onValueChange={(value) => handleInputChange('categoryId', value)}
>
<SelectTrigger className="glass-input text-white h-10">
<SelectValue placeholder="Выберите категорию" />
</SelectTrigger>
<SelectContent className="glass-card">
<SelectItem value="none">Без категории</SelectItem>
{categoriesData?.categories?.map((category: { id: string; name: string }) => (
<SelectItem key={category.id} value={category.id} className="text-white">
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Бренд</Label>
<Input
value={formData.brand}
onChange={(e) => handleInputChange('brand', e.target.value)}
placeholder="Apple"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
</div>
</Card>
{/* Характеристики */}
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
<h3 className="text-white font-medium mb-4">Характеристики</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-white/80 text-sm mb-2 block">Цвет</Label>
<Input
value={formData.color}
onChange={(e) => handleInputChange('color', e.target.value)}
placeholder="Синий"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Размер</Label>
<Input
value={formData.size}
onChange={(e) => handleInputChange('size', e.target.value)}
placeholder="L, XL, 42"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Вес (кг)</Label>
<Input
type="number"
step="0.001"
min="0"
value={formData.weight || ''}
onChange={(e) => handleInputChange('weight', parseFloat(e.target.value) || 0)}
placeholder="0.221"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
<div>
<Label className="text-white/80 text-sm mb-2 block">Габариты</Label>
<Input
value={formData.dimensions}
onChange={(e) => handleInputChange('dimensions', e.target.value)}
placeholder="159.9 × 76.7 × 8.25 мм"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
</div>
<div className="mt-4">
<Label className="text-white/80 text-sm mb-2 block">Материал</Label>
<Input
value={formData.material}
onChange={(e) => handleInputChange('material', e.target.value)}
placeholder="Титан, стекло"
className="glass-input text-white placeholder:text-white/40 h-10"
/>
</div>
</Card>
{/* Изображения */}
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
<h3 className="text-white font-medium mb-4">Изображения товара</h3>
{/* Кнопка загрузки */}
<div className="mb-4">
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => e.target.files && handleImageUpload(e.target.files)}
className="hidden"
/>
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="glass-secondary text-white hover:text-white cursor-pointer"
>
<Upload className="h-4 w-4 mr-2" />
{isUploading ? 'Загрузка...' : 'Добавить изображения'}
</Button>
</div>
{/* Галерея изображений */}
{formData.images.length > 0 && (
<div className="grid grid-cols-3 gap-4">
{formData.images.map((imageUrl, index) => (
<div key={index} className="relative group">
{uploadingImages.has(index) ? (
<div className="aspect-square bg-white/10 rounded-lg flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-white border-t-transparent"></div>
</div>
) : (
<>
<img
src={imageUrl}
alt={`Товар ${index + 1}`}
className="w-full aspect-square object-cover rounded-lg"
/>
{/* Индикатор главного изображения */}
{formData.mainImage === imageUrl && (
<div className="absolute top-2 left-2">
<Star className="h-5 w-5 text-yellow-400 fill-current" />
</div>
)}
{/* Кнопки управления */}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
{formData.mainImage !== imageUrl && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => handleSetMainImage(imageUrl)}
className="p-1 h-7 w-7 bg-white/20 border-white/30 hover:bg-white/30"
>
<Star className="h-3 w-3 text-white" />
</Button>
)}
<Button
type="button"
size="sm"
variant="outline"
onClick={() => handleRemoveImage(index)}
className="p-1 h-7 w-7 bg-red-500/20 border-red-400/30 hover:bg-red-500/30"
>
<X className="h-3 w-3 text-white" />
</Button>
</div>
</>
)}
</div>
))}
</div>
)}
</Card>
{/* Кнопки управления */}
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1 border-purple-400/30 text-purple-200 hover:bg-purple-500/10 hover:border-purple-300 transition-all duration-300"
>
Отмена
</Button>
<Button
type="submit"
disabled={loading || isUploading}
className="flex-1 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white border-0 shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40 transition-all duration-300"
>
{loading ? 'Сохранение...' : (product ? 'Сохранить изменения' : 'Создать товар')}
</Button>
</div>
</form>
)
}