565 lines
20 KiB
TypeScript
565 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef } from "react";
|
||
import Image from "next/image";
|
||
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 { X, Star, Upload } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
|
||
interface Product {
|
||
id: string;
|
||
name: string;
|
||
article: string;
|
||
description: string;
|
||
price: number;
|
||
quantity: number;
|
||
type: "PRODUCT" | "CONSUMABLE";
|
||
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,
|
||
type: product?.type || ("PRODUCT" as "PRODUCT" | "CONSUMABLE"),
|
||
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] = 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,
|
||
type: formData.type,
|
||
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 },
|
||
refetchQueries: ["GetMyProducts"],
|
||
});
|
||
toast.success("Товар успешно обновлен");
|
||
} else {
|
||
console.log("📝 СОЗДАНИЕ ТОВАРА - ОТПРАВКА ЗАПРОСА:", input);
|
||
const result = await createProduct({
|
||
variables: { input },
|
||
refetchQueries: ["GetMyProducts"],
|
||
});
|
||
console.log("📝 РЕЗУЛЬТАТ СОЗДАНИЯ ТОВАРА:", result);
|
||
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="mt-4">
|
||
<Label className="text-white/80 text-sm mb-2 block">
|
||
Тип <span className="text-red-400">*</span>
|
||
</Label>
|
||
<Select
|
||
value={formData.type}
|
||
onValueChange={(value) => handleInputChange("type", value)}
|
||
>
|
||
<SelectTrigger className="glass-input text-white h-10">
|
||
<SelectValue placeholder="Выберите тип" />
|
||
</SelectTrigger>
|
||
<SelectContent className="glass-card">
|
||
<SelectItem value="PRODUCT" className="text-white">
|
||
Товар
|
||
</SelectItem>
|
||
<SelectItem value="CONSUMABLE" className="text-white">
|
||
Расходник
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</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>
|
||
) : imageUrl ? (
|
||
<>
|
||
<Image
|
||
src={imageUrl}
|
||
alt={`Товар ${index + 1}`}
|
||
width={200}
|
||
height={150}
|
||
className="w-full aspect-square object-contain rounded-lg bg-white/5"
|
||
/>
|
||
|
||
{/* Индикатор главного изображения */}
|
||
{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 className="aspect-square bg-white/10 rounded-lg flex items-center justify-center">
|
||
<div className="text-white/40 text-sm">Нет изображения</div>
|
||
</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>
|
||
);
|
||
}
|