This commit is contained in:
Veronika Smirnova
2025-08-01 11:31:20 +03:00
9 changed files with 852 additions and 389 deletions

View File

@ -236,7 +236,13 @@ model Product {
article String
description String?
price Decimal @db.Decimal(12, 2)
pricePerSet Decimal? @db.Decimal(12, 2)
quantity Int @default(0)
setQuantity Int?
ordered Int?
inTransit Int?
stock Int?
sold Int?
type ProductType @default(PRODUCT)
categoryId String?
brand String?

View File

@ -18,7 +18,13 @@ interface Product {
article: string
description: string
price: number
pricePerSet?: number
quantity: number
setQuantity?: number
ordered?: number
inTransit?: number
stock?: number
sold?: number
type: 'PRODUCT' | 'CONSUMABLE'
category: { id: string; name: string } | null
brand: string
@ -66,19 +72,25 @@ export function ProductCard({ product, onEdit, onDeleted }: ProductCardProps) {
}
const getStatusColor = () => {
const stock = product.stock || product.quantity || 0;
if (!product.isActive) return 'bg-gray-500/20 text-gray-300 border-gray-400/30'
if (product.quantity === 0) return 'bg-red-500/20 text-red-300 border-red-400/30'
if (product.quantity < 10) return 'bg-yellow-500/20 text-yellow-300 border-yellow-400/30'
if (stock === 0) return 'bg-red-500/20 text-red-300 border-red-400/30'
if (stock < 10) return 'bg-yellow-500/20 text-yellow-300 border-yellow-400/30'
return 'bg-green-500/20 text-green-300 border-green-400/30'
}
const getStatusText = () => {
const stock = product.stock || product.quantity || 0;
if (!product.isActive) return 'Неактивен'
if (product.quantity === 0) return 'Нет в наличии'
if (product.quantity < 10) return 'Мало на складе'
if (stock === 0) return 'Нет в наличии'
if (stock < 10) return 'Мало на складе'
return 'В наличии'
}
const getStockQuantity = () => {
return product.stock || product.quantity || 0;
}
return (
<Card className="glass-card group relative overflow-hidden transition-all duration-300 hover:scale-[1.02] hover:shadow-xl hover:shadow-purple-500/20">
{/* Изображение товара */}
@ -192,12 +204,44 @@ export function ProductCard({ product, onEdit, onDeleted }: ProductCardProps) {
</div>
{/* Цена и количество */}
<div className="flex items-center justify-between">
<div className="text-white font-semibold">
{formatPrice(product.price)}
<div className="space-y-2">
<div className="flex items-center justify-between">
<div>
<div className="text-white font-semibold">
{formatPrice(product.price)}
</div>
{product.pricePerSet && (
<div className="text-white/60 text-xs">
Комплект: {formatPrice(product.pricePerSet)}
</div>
)}
</div>
<div className="text-right">
<div className="text-white/70 text-sm">
{getStockQuantity()} шт.
</div>
{product.setQuantity && (
<div className="text-white/60 text-xs">
{product.setQuantity} компл.
</div>
)}
</div>
</div>
<div className="text-white/70 text-sm">
{product.quantity} шт.
{/* Учет движения */}
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center bg-yellow-500/10 rounded p-1">
<div className="text-yellow-300 font-medium">{product.ordered || 0}</div>
<div className="text-yellow-300/70">Заказано</div>
</div>
<div className="text-center bg-purple-500/10 rounded p-1">
<div className="text-purple-300 font-medium">{product.inTransit || 0}</div>
<div className="text-purple-300/70">В пути</div>
</div>
<div className="text-center bg-green-500/10 rounded p-1">
<div className="text-green-300 font-medium">{product.sold || 0}</div>
<div className="text-green-300/70">Продано</div>
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useRef } from "react";
import { useState, useRef, useEffect } from "react";
import Image from "next/image";
import { useMutation, useQuery } from "@apollo/client";
import { Button } from "@/components/ui/button";
@ -16,7 +16,7 @@ import {
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 { X, Star, Upload, RefreshCw } from "lucide-react";
import { toast } from "sonner";
interface Product {
@ -25,7 +25,13 @@ interface Product {
article: string;
description: string;
price: number;
pricePerSet?: number;
quantity: number;
setQuantity?: number;
ordered?: number;
inTransit?: number;
stock?: number;
sold?: number;
type: "PRODUCT" | "CONSUMABLE";
category: { id: string; name: string } | null;
brand: string;
@ -49,9 +55,16 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
const [formData, setFormData] = useState({
name: product?.name || "",
article: product?.article || "",
autoGenerateArticle: !product?.article, // Автогенерация только для новых товаров
description: product?.description || "",
price: product?.price || 0,
quantity: product?.quantity || 0,
setQuantity: product?.setQuantity || 0, // Количество комплектов
pricePerSet: product?.pricePerSet || 0, // Цена за комплект
ordered: product?.ordered || 0, // Заказано
inTransit: product?.inTransit || 0, // В пути
stock: product?.stock || 0, // Остаток
sold: product?.sold || 0, // Продано
type: product?.type || ("PRODUCT" as "PRODUCT" | "CONSUMABLE"),
categoryId: product?.category?.id || "none",
brand: product?.brand || "",
@ -79,6 +92,24 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
const loading = creating || updating;
// Генерация артикула СФ
const generateArticle = () => {
const prefix = formData.type === 'PRODUCT' ? 'SF-T' : 'SF-C'; // T=Товар, C=Расходник
const timestamp = Date.now().toString().slice(-6); // Последние 6 цифр timestamp
const random = Math.floor(Math.random() * 1000).toString().padStart(3, '0');
return `${prefix}-${timestamp}-${random}`;
};
// Автогенерация артикула при смене типа
useEffect(() => {
if (formData.autoGenerateArticle && formData.type) {
setFormData(prev => ({
...prev,
article: generateArticle()
}));
}
}, [formData.type, formData.autoGenerateArticle]);
const handleInputChange = (
field: string,
value: string | number | boolean
@ -89,6 +120,13 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
}));
};
const handleGenerateNewArticle = () => {
setFormData(prev => ({
...prev,
article: generateArticle()
}));
};
const handleImageUpload = async (files: FileList) => {
const newUploadingIndexes = new Set<number>();
const startIndex = formData.images.length;
@ -194,13 +232,21 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
return;
}
console.log("📝 ФОРМА ДАННЫЕ ПЕРЕД ОТПРАВКОЙ:", formData);
try {
const input = {
name: formData.name,
article: formData.article,
description: formData.description || undefined,
price: formData.price,
pricePerSet: formData.pricePerSet || undefined,
quantity: formData.quantity,
setQuantity: formData.setQuantity || undefined,
ordered: formData.ordered || undefined,
inTransit: formData.inTransit || undefined,
stock: formData.stock || undefined,
sold: formData.sold || undefined,
type: formData.type,
categoryId:
formData.categoryId && formData.categoryId !== "none"
@ -218,10 +264,12 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
};
if (product) {
await updateProduct({
console.log("📝 ОБНОВЛЕНИЕ ТОВАРА - ОТПРАВКА ЗАПРОСА:", input);
const result = await updateProduct({
variables: { id: product.id, input },
refetchQueries: ["GetMyProducts"],
});
console.log("📝 РЕЗУЛЬТАТ ОБНОВЛЕНИЯ ТОВАРА:", result);
toast.success("Товар успешно обновлен");
} else {
console.log("📝 СОЗДАНИЕ ТОВАРА - ОТПРАВКА ЗАПРОСА:", input);
@ -241,322 +289,341 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
};
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>
<form onSubmit={handleSubmit} className="space-y-3">
{/* Верхняя часть - 2 колонки */}
<div className="grid grid-cols-2 gap-4">
{/* Левая колонка */}
<div className="space-y-3">
{/* Основная информация */}
<Card className="bg-white/5 backdrop-blur border-white/10 p-3">
<h3 className="text-white font-medium mb-3 text-sm">Основная информация</h3>
<div className="space-y-3">
<div>
<Label className="text-white/80 text-xs mb-1 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-8 text-sm"
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"
<div>
<Label className="text-white/80 text-xs mb-1 block">
Артикул СФ <span className="text-red-400">*</span>
</Label>
<div className="flex gap-2">
<Input
value={formData.article}
onChange={(e) => {
handleInputChange("article", e.target.value);
handleInputChange("autoGenerateArticle", false);
}}
placeholder="SF-T-123456-001"
className="glass-input text-white placeholder:text-white/40 h-8 text-sm flex-1"
required
readOnly={formData.autoGenerateArticle}
/>
{!product && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerateNewArticle}
className="glass-secondary text-white hover:text-white px-2 h-8"
title="Генерировать новый артикул"
>
{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>
<RefreshCw className="h-3 w-3" />
</Button>
)}
</div>
{formData.autoGenerateArticle && (
<p className="text-white/60 text-xs mt-1">Автогенерация</p>
)}
</div>
))}
</div>
)}
</Card>
<div className="flex gap-2 relative">
<div className="flex-1">
<Label className="text-white/80 text-xs mb-1 block">
Тип <span className="text-red-400">*</span>
</Label>
<div className="flex border border-white/10 rounded-lg overflow-hidden h-8">
<Button
type="button"
onClick={() => handleInputChange("type", "PRODUCT")}
className={`flex-1 h-full rounded-none text-xs border-0 ${
formData.type === 'PRODUCT'
? 'bg-blue-500/20 text-blue-300 hover:bg-blue-500/30'
: 'bg-transparent text-white/70 hover:text-white hover:bg-white/5'
}`}
>
Товар
</Button>
<Button
type="button"
onClick={() => handleInputChange("type", "CONSUMABLE")}
className={`flex-1 h-full rounded-none border-l border-white/10 text-xs border-t-0 border-r-0 border-b-0 ${
formData.type === 'CONSUMABLE'
? 'bg-orange-500/20 text-orange-300 hover:bg-orange-500/30'
: 'bg-transparent text-white/70 hover:text-white hover:bg-white/5'
}`}
>
Расходник
</Button>
</div>
</div>
<div className="flex-1">
<Label className="text-white/80 text-xs mb-1 block">Категория</Label>
<Select
value={formData.categoryId}
onValueChange={(value) => handleInputChange("categoryId", value)}
>
<SelectTrigger className="glass-input text-white h-8 text-sm">
<SelectValue placeholder="Категория" />
</SelectTrigger>
<SelectContent className="glass-card" side="bottom" align="end">
<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>
<div>
<Label className="text-white/80 text-xs mb-1 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 text-sm h-16"
style={{
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '0.5rem',
padding: '0.5rem'
}}
/>
</div>
</div>
</Card>
{/* Ценообразование */}
<Card className="bg-white/5 backdrop-blur border-white/10 p-3">
<h3 className="text-white font-medium mb-3 text-sm">Ценообразование</h3>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-white/80 text-xs mb-1 block">Кол-во предметов</Label>
<Input
type="number"
value={formData.quantity || ""}
onChange={(e) => handleInputChange("quantity", parseInt(e.target.value) || 0)}
className="glass-input text-white h-8 text-sm"
/>
</div>
<div>
<Label className="text-white/80 text-xs mb-1 block">
Цена за ед. () <span className="text-red-400">*</span>
</Label>
<Input
type="number"
step="0.01"
value={formData.price || ""}
onChange={(e) => handleInputChange("price", parseFloat(e.target.value) || 0)}
className="glass-input text-white h-8 text-sm"
required
/>
</div>
<div>
<Label className="text-white/80 text-xs mb-1 block">Кол-во комплектов</Label>
<Input
type="number"
value={formData.setQuantity || ""}
onChange={(e) => handleInputChange("setQuantity", parseInt(e.target.value) || 0)}
className="glass-input text-white h-8 text-sm"
/>
</div>
<div>
<Label className="text-white/80 text-xs mb-1 block">Цена за компл. ()</Label>
<Input
type="number"
step="0.01"
value={formData.pricePerSet || ""}
onChange={(e) => handleInputChange("pricePerSet", parseFloat(e.target.value) || 0)}
className="glass-input text-white h-8 text-sm"
/>
</div>
</div>
</Card>
</div>
{/* Правая колонка */}
<div className="space-y-3">
{/* Характеристики */}
<Card className="bg-white/5 backdrop-blur border-white/10 p-3">
<h3 className="text-white font-medium mb-3 text-sm">Характеристики</h3>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-white/80 text-xs mb-1 block">Бренд</Label>
<Input
value={formData.brand}
onChange={(e) => handleInputChange("brand", e.target.value)}
className="glass-input text-white h-8 text-sm"
/>
</div>
<div>
<Label className="text-white/80 text-xs mb-1 block">Цвет</Label>
<Input
value={formData.color}
onChange={(e) => handleInputChange("color", e.target.value)}
className="glass-input text-white h-8 text-sm"
/>
</div>
<div>
<Label className="text-white/80 text-xs mb-1 block">Размер</Label>
<Input
value={formData.size}
onChange={(e) => handleInputChange("size", e.target.value)}
className="glass-input text-white h-8 text-sm"
/>
</div>
<div>
<Label className="text-white/80 text-xs mb-1 block">Вес (кг)</Label>
<Input
type="number"
step="0.001"
value={formData.weight || ""}
onChange={(e) => handleInputChange("weight", parseFloat(e.target.value) || 0)}
className="glass-input text-white h-8 text-sm"
/>
</div>
<div className="col-span-2">
<Label className="text-white/80 text-xs mb-1 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 h-8 text-sm"
/>
</div>
<div className="col-span-2">
<Label className="text-white/80 text-xs mb-1 block">Материал</Label>
<Input
value={formData.material}
onChange={(e) => handleInputChange("material", e.target.value)}
placeholder="Титан, стекло"
className="glass-input text-white h-8 text-sm"
/>
</div>
</div>
</Card>
{/* Изображения */}
<Card className="bg-white/5 backdrop-blur border-white/10 p-3">
<div className="flex items-center justify-between mb-3">
<h3 className="text-white font-medium text-sm">Изображения</h3>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => e.target.files && handleImageUpload(e.target.files)}
className="hidden"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="glass-secondary text-white hover:text-white h-7 px-2 text-xs"
>
<Upload className="h-3 w-3 mr-1" />
Загрузить
</Button>
</div>
{formData.images.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{formData.images.slice(0, 8).map((imageUrl, index) => (
<div key={index} className="relative group">
{uploadingImages.has(index) ? (
<div className="aspect-square bg-white/10 rounded flex items-center justify-center">
<div className="animate-spin rounded-full h-4 w-4 border border-white border-t-transparent"></div>
</div>
) : imageUrl ? (
<>
<Image
src={imageUrl}
alt={`${index + 1}`}
width={60}
height={60}
className="w-full aspect-square object-contain rounded bg-white/5"
/>
{formData.mainImage === imageUrl && (
<Star className="absolute top-1 left-1 h-3 w-3 text-yellow-400 fill-current" />
)}
<div className="absolute top-1 right-1 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-0 h-4 w-4 bg-white/20 border-white/30 hover:bg-white/30"
>
<Star className="h-2 w-2 text-white" />
</Button>
)}
<Button
type="button"
size="sm"
variant="outline"
onClick={() => handleRemoveImage(index)}
className="p-0 h-4 w-4 bg-red-500/20 border-red-400/30 hover:bg-red-500/30"
>
<X className="h-2 w-2 text-white" />
</Button>
</div>
</>
) : (
<div className="aspect-square bg-white/10 rounded flex items-center justify-center">
<div className="text-white/40 text-xs">Нет</div>
</div>
)}
</div>
))}
</div>
)}
</Card>
</div>
</div>
{/* Кнопки управления */}
<div className="flex gap-3 pt-4">
<div className="flex gap-3 pt-2">
<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"
className="flex-1 border-purple-400/30 text-purple-200 hover:bg-purple-500/10 hover:border-purple-300 transition-all duration-300 h-9"
>
Отмена
</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"
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 h-9"
>
{loading
? "Сохранение..."
: product
? "Сохранить изменения"
: "Создать товар"}
{loading ? "Сохранение..." : product ? "Сохранить изменения" : "Создать товар"}
</Button>
</div>
</form>

View File

@ -15,8 +15,9 @@ import { Sidebar } from "@/components/dashboard/sidebar";
import { useSidebar } from "@/hooks/useSidebar";
import { ProductForm } from "./product-form";
import { ProductCard } from "./product-card";
import { WarehouseStatistics } from "./warehouse-statistics";
import { GET_MY_PRODUCTS } from "@/graphql/queries";
import { Plus, Search, Package } from "lucide-react";
import { Plus, Package, Grid3X3, List, Edit3, Trash2 } from "lucide-react";
import { Input } from "@/components/ui/input";
interface Product {
@ -25,7 +26,13 @@ interface Product {
article: string;
description: string;
price: number;
pricePerSet?: number;
quantity: number;
setQuantity?: number;
ordered?: number;
inTransit?: number;
stock?: number;
sold?: number;
type: "PRODUCT" | "CONSUMABLE";
category: { id: string; name: string } | null;
brand: string;
@ -46,6 +53,7 @@ export function WarehouseDashboard() {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [viewMode, setViewMode] = useState<'cards' | 'table'>('cards');
const { data, loading, error, refetch } = useQuery(GET_MY_PRODUCTS, {
errorPolicy: "all",
@ -53,24 +61,6 @@ export function WarehouseDashboard() {
const products: Product[] = data?.myProducts || [];
// Отладочное логирование
React.useEffect(() => {
console.log("🏪 WAREHOUSE DASHBOARD DEBUG:", {
loading,
error: error?.message,
dataReceived: !!data,
productsCount: products.length,
products: products.map((p) => ({
id: p.id,
name: p.name,
article: p.article,
type: p.type,
isActive: p.isActive,
createdAt: p.createdAt,
})),
});
}, [data, loading, error, products]);
// Фильтрация товаров по поисковому запросу
const filteredProducts = products.filter(
(product) =>
@ -140,61 +130,83 @@ export function WarehouseDashboard() {
className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}
>
<div className="h-full w-full flex flex-col">
{/* Заголовок и поиск */}
{/* Поиск и управление */}
<div className="flex items-center justify-between mb-4 flex-shrink-0">
<div>
<h1 className="text-xl font-bold text-white mb-1">Мой склад</h1>
<p className="text-white/70 text-sm">
Управление товарами и расходниками
</p>
</div>
<div className="flex gap-4 items-center flex-1">
<div className="relative max-w-md">
<Input
type="text"
placeholder="Поиск по названию, артикулу, категории..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="glass-input text-white placeholder:text-white/50 h-10"
/>
</div>
<div className="flex gap-2">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={handleCreateProduct}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 transition-all duration-300"
>
<Plus className="w-4 h-4 mr-2" />
Добавить товар/расходник
</Button>
</DialogTrigger>
<DialogContent className="glass-card max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white">
{editingProduct
? "Редактировать товар/расходник"
: "Добавить товар/расходник"}
</DialogTitle>
</DialogHeader>
<ProductForm
product={editingProduct}
onSave={handleProductSaved}
onCancel={() => setIsDialogOpen(false)}
/>
</DialogContent>
</Dialog>
{/* Переключатель режимов отображения */}
<div className="flex border border-white/10 rounded-lg overflow-hidden">
<Button
onClick={() => setViewMode('cards')}
variant="ghost"
size="sm"
className={`px-3 h-10 rounded-none ${
viewMode === 'cards'
? 'bg-purple-500/20 text-white border-purple-400/30'
: 'text-white/70 hover:text-white hover:bg-white/5'
}`}
>
<Grid3X3 className="w-4 h-4" />
</Button>
<Button
onClick={() => setViewMode('table')}
variant="ghost"
size="sm"
className={`px-3 h-10 rounded-none border-l border-white/10 ${
viewMode === 'table'
? 'bg-purple-500/20 text-white border-purple-400/30'
: 'text-white/70 hover:text-white hover:bg-white/5'
}`}
>
<List className="w-4 h-4" />
</Button>
</div>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={handleCreateProduct}
className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white border-0 shadow-lg shadow-purple-500/25 transition-all duration-300"
>
<Plus className="w-4 h-4 mr-2" />
Добавить товар/расходник
</Button>
</DialogTrigger>
<DialogContent className="glass-card !w-[90vw] !max-w-[90vw] max-h-[95vh]" style={{ width: '90vw', maxWidth: '90vw' }}>
<DialogHeader>
<DialogTitle className="text-white">
{editingProduct
? "Редактировать товар/расходник"
: "Добавить товар/расходник"}
</DialogTitle>
</DialogHeader>
<ProductForm
product={editingProduct}
onSave={handleProductSaved}
onCancel={() => setIsDialogOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
{/* Поиск */}
<div className="mb-4 flex-shrink-0">
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/50" />
<Input
type="text"
placeholder="Поиск по названию, артикулу, категории..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="glass-input text-white placeholder:text-white/50 pl-10 h-10"
/>
</div>
</div>
{/* Блок статистики */}
<Card className="bg-white/5 backdrop-blur border-white/10 p-4 mb-4">
<WarehouseStatistics products={filteredProducts} />
</Card>
{/* Основной контент */}
<Card className="flex-1 bg-white/5 backdrop-blur border-white/10 p-6 overflow-hidden">
{loading ? (
<Card className="flex-1 bg-white/5 backdrop-blur border-white/10 p-6 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-white border-t-transparent mx-auto mb-4"></div>
@ -225,17 +237,106 @@ export function WarehouseDashboard() {
</div>
</div>
) : (
<div className="h-full overflow-y-auto">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
onEdit={handleEditProduct}
onDeleted={handleProductDeleted}
/>
))}
</div>
<div className="space-y-4">
{viewMode === 'cards' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{filteredProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
onEdit={handleEditProduct}
onDelete={handleProductDeleted}
/>
))}
</div>
) : (
<div className="space-y-2">
<div className="grid grid-cols-12 gap-4 p-4 text-white/60 text-sm font-medium border-b border-white/10">
<div className="col-span-1">Фото</div>
<div className="col-span-2">Название</div>
<div className="col-span-1">Артикул</div>
<div className="col-span-1">Тип</div>
<div className="col-span-1">Категория</div>
<div className="col-span-1">Цена</div>
<div className="col-span-1">Остаток</div>
<div className="col-span-1">Заказано</div>
<div className="col-span-1">В пути</div>
<div className="col-span-1">Продано</div>
<div className="col-span-1">Действия</div>
</div>
{filteredProducts.map((product) => (
<div key={product.id} className="grid grid-cols-12 gap-4 p-4 hover:bg-white/5 rounded-lg transition-colors">
<div className="col-span-1">
{product.mainImage || product.images[0] ? (
<img
src={product.mainImage || product.images[0]}
alt={product.name}
className="w-12 h-12 object-contain rounded bg-white/10"
/>
) : (
<div className="w-12 h-12 bg-white/10 rounded flex items-center justify-center">
<Package className="h-6 w-6 text-white/40" />
</div>
)}
</div>
<div className="col-span-2 text-white text-sm">
<div className="font-medium truncate">{product.name}</div>
<div className="text-white/60 text-xs">{product.brand}</div>
</div>
<div className="col-span-1 text-white/70 text-sm font-mono">{product.article}</div>
<div className="col-span-1">
<span className={`inline-block px-2 py-1 rounded text-xs ${
product.type === 'PRODUCT'
? 'bg-blue-500/20 text-blue-300 border border-blue-400/30'
: 'bg-orange-500/20 text-orange-300 border border-orange-400/30'
}`}>
{product.type === 'PRODUCT' ? 'Товар' : 'Расходник'}
</span>
</div>
<div className="col-span-1 text-white/70 text-sm">
{product.category?.name || 'Нет'}
</div>
<div className="col-span-1 text-white text-sm font-medium">
{new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0
}).format(product.price)}
</div>
<div className="col-span-1 text-white text-sm">
<span className={`${
(product.stock || product.quantity) === 0 ? 'text-red-400' :
(product.stock || product.quantity) < 10 ? 'text-yellow-400' : 'text-green-400'
}`}>
{product.stock || product.quantity || 0}
</span>
</div>
<div className="col-span-1 text-white/70 text-sm">{product.ordered || 0}</div>
<div className="col-span-1 text-white/70 text-sm">{product.inTransit || 0}</div>
<div className="col-span-1 text-white/70 text-sm">{product.sold || 0}</div>
<div className="col-span-1">
<div className="flex gap-1">
<Button
size="sm"
variant="outline"
onClick={() => handleEditProduct(product)}
className="p-1 h-7 w-7 bg-white/10 border-white/20 hover:bg-white/20"
>
<Edit3 className="h-3 w-3 text-white" />
</Button>
<Button
size="sm"
variant="outline"
className="p-1 h-7 w-7 bg-red-500/20 border-red-400/30 hover:bg-red-500/30"
>
<Trash2 className="h-3 w-3 text-white" />
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</Card>

View File

@ -0,0 +1,191 @@
"use client";
import React from "react";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Package,
ShoppingCart,
Truck,
CheckCircle,
AlertTriangle,
TrendingUp,
TrendingDown
} from "lucide-react";
interface Product {
id: string;
name: string;
article: string;
type: "PRODUCT" | "CONSUMABLE";
quantity: number;
ordered?: number;
inTransit?: number;
stock?: number;
sold?: number;
isActive: boolean;
}
interface WarehouseStatisticsProps {
products: Product[];
}
export function WarehouseStatistics({ products }: WarehouseStatisticsProps) {
console.log('📊 STATISTICS DEBUG:', { productsCount: products.length, products });
// Разделение товаров по типам
const goods = products.filter(p => p.type === 'PRODUCT');
const consumables = products.filter(p => p.type === 'CONSUMABLE');
// Общая статистика
const totalProducts = products.length;
const activeProducts = products.filter(p => p.isActive).length;
const totalStock = products.reduce((sum, p) => sum + (p.stock || p.quantity || 0), 0);
const totalOrdered = products.reduce((sum, p) => sum + (p.ordered || 0), 0);
const totalInTransit = products.reduce((sum, p) => sum + (p.inTransit || 0), 0);
const totalSold = products.reduce((sum, p) => sum + (p.sold || 0), 0);
// Статистика по товарам
const goodsStock = goods.reduce((sum, p) => sum + (p.stock || p.quantity || 0), 0);
const goodsOrdered = goods.reduce((sum, p) => sum + (p.ordered || 0), 0);
const goodsInTransit = goods.reduce((sum, p) => sum + (p.inTransit || 0), 0);
const goodsSold = goods.reduce((sum, p) => sum + (p.sold || 0), 0);
// Статистика по расходникам
const consumablesStock = consumables.reduce((sum, p) => sum + (p.stock || p.quantity || 0), 0);
const consumablesOrdered = consumables.reduce((sum, p) => sum + (p.ordered || 0), 0);
const consumablesInTransit = consumables.reduce((sum, p) => sum + (p.inTransit || 0), 0);
const consumablesSold = consumables.reduce((sum, p) => sum + (p.sold || 0), 0);
// Товары с низкими остатками
const lowStockProducts = products.filter(p => {
const stock = p.stock || p.quantity || 0;
return stock > 0 && stock < 10;
});
const outOfStockProducts = products.filter(p => {
const stock = p.stock || p.quantity || 0;
return stock === 0;
});
const StatCard = ({
icon: Icon,
title,
value,
subtitle,
trend,
color = "text-white"
}: {
icon: any;
title: string;
value: number;
subtitle?: string;
trend?: 'up' | 'down';
color?: string;
}) => (
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<Icon className={`h-4 w-4 ${color}`} />
<span className="text-white/70 text-sm">{title}</span>
</div>
<div className="text-2xl font-bold text-white">{value.toLocaleString()}</div>
{subtitle && (
<div className="text-white/60 text-xs mt-1">{subtitle}</div>
)}
</div>
{trend && (
<div className="text-right">
{trend === 'up' ? (
<TrendingUp className="h-4 w-4 text-green-400" />
) : (
<TrendingDown className="h-4 w-4 text-red-400" />
)}
</div>
)}
</div>
</Card>
);
return (
<div className="space-y-4">
{/* Общая статистика */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard
icon={Package}
title="Всего позиций"
value={totalProducts}
subtitle={`Активных: ${activeProducts}`}
color="text-blue-400"
/>
<StatCard
icon={CheckCircle}
title="Остаток"
value={totalStock}
subtitle="штук на складе"
color="text-green-400"
/>
<StatCard
icon={ShoppingCart}
title="Заказано"
value={totalOrdered}
subtitle="штук в заказах"
color="text-yellow-400"
/>
<StatCard
icon={Truck}
title="В пути"
value={totalInTransit}
subtitle="штук доставляется"
color="text-purple-400"
/>
</div>
{/* Статистика по типам */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Товары */}
<div>
<div className="flex items-center gap-2 mb-2">
<Package className="h-4 w-4 text-blue-400" />
<h3 className="text-sm font-semibold text-white">Товары</h3>
<Badge className="bg-blue-500/20 text-blue-300 border-blue-400/30 text-xs">
{goods.length}
</Badge>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="bg-white/5 backdrop-blur border-white/10 p-2 rounded">
<div className="text-xs text-white/70">Остаток</div>
<div className="text-sm font-bold text-green-400">{goodsStock}</div>
</div>
<div className="bg-white/5 backdrop-blur border-white/10 p-2 rounded">
<div className="text-xs text-white/70">Продано</div>
<div className="text-sm font-bold text-emerald-400">{goodsSold}</div>
</div>
</div>
</div>
{/* Расходники */}
<div>
<div className="flex items-center gap-2 mb-2">
<Package className="h-4 w-4 text-orange-400" />
<h3 className="text-sm font-semibold text-white">Расходники</h3>
<Badge className="bg-orange-500/20 text-orange-300 border-orange-400/30 text-xs">
{consumables.length}
</Badge>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="bg-white/5 backdrop-blur border-white/10 p-2 rounded">
<div className="text-xs text-white/70">Остаток</div>
<div className="text-sm font-bold text-green-400">{consumablesStock}</div>
</div>
<div className="bg-white/5 backdrop-blur border-white/10 p-2 rounded">
<div className="text-xs text-white/70">Продано</div>
<div className="text-sm font-bold text-emerald-400">{consumablesSold}</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -803,7 +803,13 @@ export const CREATE_PRODUCT = gql`
article
description
price
pricePerSet
quantity
setQuantity
ordered
inTransit
stock
sold
type
category {
id
@ -836,7 +842,13 @@ export const UPDATE_PRODUCT = gql`
article
description
price
pricePerSet
quantity
setQuantity
ordered
inTransit
stock
sold
type
category {
id

View File

@ -134,7 +134,13 @@ export const GET_MY_PRODUCTS = gql`
article
description
price
pricePerSet
quantity
setQuantity
ordered
inTransit
stock
sold
type
category {
id

View File

@ -4134,7 +4134,13 @@ export const resolvers = {
article: string;
description?: string;
price: number;
pricePerSet?: number;
quantity: number;
setQuantity?: number;
ordered?: number;
inTransit?: number;
stock?: number;
sold?: number;
type?: "PRODUCT" | "CONSUMABLE";
categoryId?: string;
brand?: string;
@ -4211,7 +4217,13 @@ export const resolvers = {
article: args.input.article,
description: args.input.description,
price: args.input.price,
pricePerSet: args.input.pricePerSet,
quantity: args.input.quantity,
setQuantity: args.input.setQuantity,
ordered: args.input.ordered,
inTransit: args.input.inTransit,
stock: args.input.stock,
sold: args.input.sold,
type: args.input.type || "PRODUCT",
categoryId: args.input.categoryId,
brand: args.input.brand,
@ -4265,7 +4277,13 @@ export const resolvers = {
article: string;
description?: string;
price: number;
pricePerSet?: number;
quantity: number;
setQuantity?: number;
ordered?: number;
inTransit?: number;
stock?: number;
sold?: number;
type?: "PRODUCT" | "CONSUMABLE";
categoryId?: string;
brand?: string;
@ -4334,7 +4352,13 @@ export const resolvers = {
article: args.input.article,
description: args.input.description,
price: args.input.price,
pricePerSet: args.input.pricePerSet,
quantity: args.input.quantity,
setQuantity: args.input.setQuantity,
ordered: args.input.ordered,
inTransit: args.input.inTransit,
stock: args.input.stock,
sold: args.input.sold,
...(args.input.type && { type: args.input.type }),
categoryId: args.input.categoryId,
brand: args.input.brand,

View File

@ -696,7 +696,13 @@ export const typeDefs = gql`
article: String!
description: String
price: Float!
pricePerSet: Float
quantity: Int!
setQuantity: Int
ordered: Int
inTransit: Int
stock: Int
sold: Int
type: ProductType
category: Category
brand: String
@ -718,7 +724,13 @@ export const typeDefs = gql`
article: String!
description: String
price: Float!
pricePerSet: Float
quantity: Int!
setQuantity: Int
ordered: Int
inTransit: Int
stock: Int
sold: Int
type: ProductType
categoryId: ID
brand: String