Compare commits
2 Commits
9986797e27
...
d45cdde42d
Author | SHA1 | Date | |
---|---|---|---|
d45cdde42d | |||
0a3a2dae7b |
@ -18,7 +18,13 @@ interface Product {
|
|||||||
article: string
|
article: string
|
||||||
description: string
|
description: string
|
||||||
price: number
|
price: number
|
||||||
|
pricePerSet?: number
|
||||||
quantity: number
|
quantity: number
|
||||||
|
setQuantity?: number
|
||||||
|
ordered?: number
|
||||||
|
inTransit?: number
|
||||||
|
stock?: number
|
||||||
|
sold?: number
|
||||||
type: 'PRODUCT' | 'CONSUMABLE'
|
type: 'PRODUCT' | 'CONSUMABLE'
|
||||||
category: { id: string; name: string } | null
|
category: { id: string; name: string } | null
|
||||||
brand: string
|
brand: string
|
||||||
@ -66,19 +72,25 @@ export function ProductCard({ product, onEdit, onDeleted }: ProductCardProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getStatusColor = () => {
|
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.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 (stock === 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 < 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'
|
return 'bg-green-500/20 text-green-300 border-green-400/30'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusText = () => {
|
const getStatusText = () => {
|
||||||
|
const stock = product.stock || product.quantity || 0;
|
||||||
if (!product.isActive) return 'Неактивен'
|
if (!product.isActive) return 'Неактивен'
|
||||||
if (product.quantity === 0) return 'Нет в наличии'
|
if (stock === 0) return 'Нет в наличии'
|
||||||
if (product.quantity < 10) return 'Мало на складе'
|
if (stock < 10) return 'Мало на складе'
|
||||||
return 'В наличии'
|
return 'В наличии'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStockQuantity = () => {
|
||||||
|
return product.stock || product.quantity || 0;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<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>
|
||||||
|
|
||||||
{/* Цена и количество */}
|
{/* Цена и количество */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-2">
|
||||||
<div className="text-white font-semibold">
|
<div className="flex items-center justify-between">
|
||||||
{formatPrice(product.price)}
|
<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>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useMutation, useQuery } from "@apollo/client";
|
import { useMutation, useQuery } from "@apollo/client";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@ -16,7 +16,7 @@ import {
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { CREATE_PRODUCT, UPDATE_PRODUCT } from "@/graphql/mutations";
|
import { CREATE_PRODUCT, UPDATE_PRODUCT } from "@/graphql/mutations";
|
||||||
import { GET_CATEGORIES } from "@/graphql/queries";
|
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";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
@ -49,9 +49,16 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
|
|||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: product?.name || "",
|
name: product?.name || "",
|
||||||
article: product?.article || "",
|
article: product?.article || "",
|
||||||
|
autoGenerateArticle: !product?.article, // Автогенерация только для новых товаров
|
||||||
description: product?.description || "",
|
description: product?.description || "",
|
||||||
price: product?.price || 0,
|
price: product?.price || 0,
|
||||||
quantity: product?.quantity || 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"),
|
type: product?.type || ("PRODUCT" as "PRODUCT" | "CONSUMABLE"),
|
||||||
categoryId: product?.category?.id || "none",
|
categoryId: product?.category?.id || "none",
|
||||||
brand: product?.brand || "",
|
brand: product?.brand || "",
|
||||||
@ -79,6 +86,24 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
|
|||||||
|
|
||||||
const loading = creating || updating;
|
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 = (
|
const handleInputChange = (
|
||||||
field: string,
|
field: string,
|
||||||
value: string | number | boolean
|
value: string | number | boolean
|
||||||
@ -89,6 +114,13 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGenerateNewArticle = () => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
article: generateArticle()
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleImageUpload = async (files: FileList) => {
|
const handleImageUpload = async (files: FileList) => {
|
||||||
const newUploadingIndexes = new Set<number>();
|
const newUploadingIndexes = new Set<number>();
|
||||||
const startIndex = formData.images.length;
|
const startIndex = formData.images.length;
|
||||||
@ -200,7 +232,13 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
|
|||||||
article: formData.article,
|
article: formData.article,
|
||||||
description: formData.description || undefined,
|
description: formData.description || undefined,
|
||||||
price: formData.price,
|
price: formData.price,
|
||||||
|
pricePerSet: formData.pricePerSet || undefined,
|
||||||
quantity: formData.quantity,
|
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,
|
type: formData.type,
|
||||||
categoryId:
|
categoryId:
|
||||||
formData.categoryId && formData.categoryId !== "none"
|
formData.categoryId && formData.categoryId !== "none"
|
||||||
@ -241,7 +279,7 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-4 pb-4">
|
||||||
{/* Основная информация */}
|
{/* Основная информация */}
|
||||||
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
|
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
|
||||||
<h3 className="text-white font-medium mb-4">Основная информация</h3>
|
<h3 className="text-white font-medium mb-4">Основная информация</h3>
|
||||||
@ -261,15 +299,38 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
Артикул <span className="text-red-400">*</span>
|
Артикул СФ <span className="text-red-400">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<div className="flex gap-2">
|
||||||
value={formData.article}
|
<Input
|
||||||
onChange={(e) => handleInputChange("article", e.target.value)}
|
value={formData.article}
|
||||||
placeholder="IP15PM-256-BLU"
|
onChange={(e) => {
|
||||||
className="glass-input text-white placeholder:text-white/40 h-10"
|
handleInputChange("article", e.target.value);
|
||||||
required
|
handleInputChange("autoGenerateArticle", false); // Отключаем автогенерацию при ручном вводе
|
||||||
/>
|
}}
|
||||||
|
placeholder="SF-T-123456-001"
|
||||||
|
className="glass-input text-white placeholder:text-white/40 h-10 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-3 h-10"
|
||||||
|
title="Генерировать новый артикул"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{formData.autoGenerateArticle && (
|
||||||
|
<p className="text-white/60 text-xs mt-1">
|
||||||
|
Артикул генерируется автоматически
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -309,7 +370,7 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
|
|||||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
Цена (₽) <span className="text-red-400">*</span>
|
Цена за единицу (₽) <span className="text-red-400">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@ -327,7 +388,26 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-sm mb-2 block">
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
Количество
|
Цена за комплект (₽)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={formData.pricePerSet || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("pricePerSet", parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
placeholder="299999.99"
|
||||||
|
className="glass-input text-white placeholder:text-white/40 h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
|
Количество предметов
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
@ -340,6 +420,22 @@ export function ProductForm({ product, onSave, onCancel }: ProductFormProps) {
|
|||||||
className="glass-input text-white placeholder:text-white/40 h-10"
|
className="glass-input text-white placeholder:text-white/40 h-10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-white/80 text-sm mb-2 block">
|
||||||
|
Количество комплектов
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.setQuantity || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange("setQuantity", parseInt(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
placeholder="10"
|
||||||
|
className="glass-input text-white placeholder:text-white/40 h-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
@ -15,8 +15,9 @@ import { Sidebar } from "@/components/dashboard/sidebar";
|
|||||||
import { useSidebar } from "@/hooks/useSidebar";
|
import { useSidebar } from "@/hooks/useSidebar";
|
||||||
import { ProductForm } from "./product-form";
|
import { ProductForm } from "./product-form";
|
||||||
import { ProductCard } from "./product-card";
|
import { ProductCard } from "./product-card";
|
||||||
|
import { WarehouseStatistics } from "./warehouse-statistics";
|
||||||
import { GET_MY_PRODUCTS } from "@/graphql/queries";
|
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";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
@ -25,7 +26,13 @@ interface Product {
|
|||||||
article: string;
|
article: string;
|
||||||
description: string;
|
description: string;
|
||||||
price: number;
|
price: number;
|
||||||
|
pricePerSet?: number;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
setQuantity?: number;
|
||||||
|
ordered?: number;
|
||||||
|
inTransit?: number;
|
||||||
|
stock?: number;
|
||||||
|
sold?: number;
|
||||||
type: "PRODUCT" | "CONSUMABLE";
|
type: "PRODUCT" | "CONSUMABLE";
|
||||||
category: { id: string; name: string } | null;
|
category: { id: string; name: string } | null;
|
||||||
brand: string;
|
brand: string;
|
||||||
@ -46,6 +53,7 @@ export function WarehouseDashboard() {
|
|||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [viewMode, setViewMode] = useState<'cards' | 'table'>('cards');
|
||||||
|
|
||||||
const { data, loading, error, refetch } = useQuery(GET_MY_PRODUCTS, {
|
const { data, loading, error, refetch } = useQuery(GET_MY_PRODUCTS, {
|
||||||
errorPolicy: "all",
|
errorPolicy: "all",
|
||||||
@ -53,24 +61,6 @@ export function WarehouseDashboard() {
|
|||||||
|
|
||||||
const products: Product[] = data?.myProducts || [];
|
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(
|
const filteredProducts = products.filter(
|
||||||
(product) =>
|
(product) =>
|
||||||
@ -140,61 +130,85 @@ export function WarehouseDashboard() {
|
|||||||
className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}
|
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="h-full w-full flex flex-col">
|
||||||
{/* Заголовок и поиск */}
|
{/* Поиск и управление */}
|
||||||
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||||
<div>
|
<div className="flex gap-4 items-center flex-1">
|
||||||
<h1 className="text-xl font-bold text-white mb-1">Мой склад</h1>
|
<div className="relative max-w-md">
|
||||||
<p className="text-white/70 text-sm">
|
<Input
|
||||||
Управление товарами и расходниками
|
type="text"
|
||||||
</p>
|
placeholder="Поиск по названию, артикулу, категории..."
|
||||||
</div>
|
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}>
|
<div className="flex border border-white/10 rounded-lg overflow-hidden">
|
||||||
<DialogTrigger asChild>
|
<Button
|
||||||
<Button
|
onClick={() => setViewMode('cards')}
|
||||||
onClick={handleCreateProduct}
|
variant="ghost"
|
||||||
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"
|
size="sm"
|
||||||
>
|
className={`px-3 h-10 rounded-none ${
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
viewMode === 'cards'
|
||||||
Добавить товар/расходник
|
? 'bg-purple-500/20 text-white border-purple-400/30'
|
||||||
</Button>
|
: 'text-white/70 hover:text-white hover:bg-white/5'
|
||||||
</DialogTrigger>
|
}`}
|
||||||
<DialogContent className="glass-card max-w-4xl max-h-[90vh] overflow-y-auto">
|
>
|
||||||
<DialogHeader>
|
<Grid3X3 className="w-4 h-4" />
|
||||||
<DialogTitle className="text-white">
|
</Button>
|
||||||
{editingProduct
|
<Button
|
||||||
? "Редактировать товар/расходник"
|
onClick={() => setViewMode('table')}
|
||||||
: "Добавить товар/расходник"}
|
variant="ghost"
|
||||||
</DialogTitle>
|
size="sm"
|
||||||
</DialogHeader>
|
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 max-w-4xl h-[90vh] flex flex-col">
|
||||||
|
<DialogHeader className="flex-shrink-0">
|
||||||
|
<DialogTitle className="text-white">
|
||||||
|
{editingProduct
|
||||||
|
? "Редактировать товар/расходник"
|
||||||
|
: "Добавить товар/расходник"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 overflow-y-auto pr-2">
|
||||||
<ProductForm
|
<ProductForm
|
||||||
product={editingProduct}
|
product={editingProduct}
|
||||||
onSave={handleProductSaved}
|
onSave={handleProductSaved}
|
||||||
onCancel={() => setIsDialogOpen(false)}
|
onCancel={() => setIsDialogOpen(false)}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</div>
|
||||||
</Dialog>
|
</DialogContent>
|
||||||
</div>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Поиск */}
|
{/* Блок статистики */}
|
||||||
<div className="mb-4 flex-shrink-0">
|
<Card className="bg-white/5 backdrop-blur border-white/10 p-4 mb-4">
|
||||||
<div className="relative max-w-md">
|
<WarehouseStatistics products={filteredProducts} />
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/50" />
|
</Card>
|
||||||
<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="flex-1 bg-white/5 backdrop-blur border-white/10 p-6 overflow-hidden">
|
<Card className="flex-1 bg-white/5 backdrop-blur border-white/10 p-6 overflow-y-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center">
|
<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>
|
<div className="animate-spin rounded-full h-16 w-16 border-4 border-white border-t-transparent mx-auto mb-4"></div>
|
||||||
@ -225,17 +239,106 @@ export function WarehouseDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
{viewMode === 'cards' ? (
|
||||||
{filteredProducts.map((product) => (
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
<ProductCard
|
{filteredProducts.map((product) => (
|
||||||
key={product.id}
|
<ProductCard
|
||||||
product={product}
|
key={product.id}
|
||||||
onEdit={handleEditProduct}
|
product={product}
|
||||||
onDeleted={handleProductDeleted}
|
onEdit={handleEditProduct}
|
||||||
/>
|
onDelete={handleProductDeleted}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</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
|
article
|
||||||
description
|
description
|
||||||
price
|
price
|
||||||
|
pricePerSet
|
||||||
quantity
|
quantity
|
||||||
|
setQuantity
|
||||||
|
ordered
|
||||||
|
inTransit
|
||||||
|
stock
|
||||||
|
sold
|
||||||
type
|
type
|
||||||
category {
|
category {
|
||||||
id
|
id
|
||||||
@ -836,7 +842,13 @@ export const UPDATE_PRODUCT = gql`
|
|||||||
article
|
article
|
||||||
description
|
description
|
||||||
price
|
price
|
||||||
|
pricePerSet
|
||||||
quantity
|
quantity
|
||||||
|
setQuantity
|
||||||
|
ordered
|
||||||
|
inTransit
|
||||||
|
stock
|
||||||
|
sold
|
||||||
type
|
type
|
||||||
category {
|
category {
|
||||||
id
|
id
|
||||||
|
@ -134,7 +134,13 @@ export const GET_MY_PRODUCTS = gql`
|
|||||||
article
|
article
|
||||||
description
|
description
|
||||||
price
|
price
|
||||||
|
pricePerSet
|
||||||
quantity
|
quantity
|
||||||
|
setQuantity
|
||||||
|
ordered
|
||||||
|
inTransit
|
||||||
|
stock
|
||||||
|
sold
|
||||||
type
|
type
|
||||||
category {
|
category {
|
||||||
id
|
id
|
||||||
|
@ -696,7 +696,13 @@ export const typeDefs = gql`
|
|||||||
article: String!
|
article: String!
|
||||||
description: String
|
description: String
|
||||||
price: Float!
|
price: Float!
|
||||||
|
pricePerSet: Float
|
||||||
quantity: Int!
|
quantity: Int!
|
||||||
|
setQuantity: Int
|
||||||
|
ordered: Int
|
||||||
|
inTransit: Int
|
||||||
|
stock: Int
|
||||||
|
sold: Int
|
||||||
type: ProductType
|
type: ProductType
|
||||||
category: Category
|
category: Category
|
||||||
brand: String
|
brand: String
|
||||||
@ -718,7 +724,13 @@ export const typeDefs = gql`
|
|||||||
article: String!
|
article: String!
|
||||||
description: String
|
description: String
|
||||||
price: Float!
|
price: Float!
|
||||||
|
pricePerSet: Float
|
||||||
quantity: Int!
|
quantity: Int!
|
||||||
|
setQuantity: Int
|
||||||
|
ordered: Int
|
||||||
|
inTransit: Int
|
||||||
|
stock: Int
|
||||||
|
sold: Int
|
||||||
type: ProductType
|
type: ProductType
|
||||||
categoryId: ID
|
categoryId: ID
|
||||||
brand: String
|
brand: String
|
||||||
|
Reference in New Issue
Block a user