Merge branch 'main' of https://gittea.biveki.ru/Sfera/sfera
This commit is contained in:
@ -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?
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
191
src/components/warehouse/warehouse-statistics.tsx
Normal file
191
src/components/warehouse/warehouse-statistics.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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
|
||||
|
@ -134,7 +134,13 @@ export const GET_MY_PRODUCTS = gql`
|
||||
article
|
||||
description
|
||||
price
|
||||
pricePerSet
|
||||
quantity
|
||||
setQuantity
|
||||
ordered
|
||||
inTransit
|
||||
stock
|
||||
sold
|
||||
type
|
||||
category {
|
||||
id
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user