Merge branch 'main' of https://gittea.biveki.ru/Sfera/sfera
This commit is contained in:
@ -244,8 +244,8 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
|||||||
setProductSearchQuery("");
|
setProductSearchQuery("");
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
|
|
||||||
// Перенаправляем на страницу поставок фулфилмента
|
// Перенаправляем на страницу поставок фулфилмента с активной вкладкой "Наши расходники"
|
||||||
router.push("/fulfillment-supplies");
|
router.push("/fulfillment-supplies?tab=detailed-supplies");
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
result.data?.createSupplyOrder?.message ||
|
result.data?.createSupplyOrder?.message ||
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Package, Wrench, RotateCcw, Building2 } from "lucide-react";
|
import { Package, Wrench, RotateCcw, Building2 } from "lucide-react";
|
||||||
|
|
||||||
@ -13,13 +14,31 @@ import { FulfillmentConsumablesOrdersTab } from "./fulfillment-consumables-order
|
|||||||
import { FulfillmentDetailedSuppliesTab } from "./fulfillment-detailed-supplies-tab";
|
import { FulfillmentDetailedSuppliesTab } from "./fulfillment-detailed-supplies-tab";
|
||||||
|
|
||||||
export function FulfillmentSuppliesTab() {
|
export function FulfillmentSuppliesTab() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [activeTab, setActiveTab] = useState("goods");
|
const [activeTab, setActiveTab] = useState("goods");
|
||||||
|
|
||||||
|
// Проверяем URL параметр при загрузке
|
||||||
|
useEffect(() => {
|
||||||
|
const tabParam = searchParams.get("tab");
|
||||||
|
if (tabParam && ["goods", "detailed-supplies", "consumables", "returns"].includes(tabParam)) {
|
||||||
|
setActiveTab(tabParam);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Обновляем URL при смене вкладки
|
||||||
|
const handleTabChange = (newTab: string) => {
|
||||||
|
setActiveTab(newTab);
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
const newUrl = `${currentPath}?tab=${newTab}`;
|
||||||
|
router.replace(newUrl);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={setActiveTab}
|
onValueChange={handleTabChange}
|
||||||
className="h-full flex flex-col"
|
className="h-full flex flex-col"
|
||||||
>
|
>
|
||||||
<TabsList className="grid w-full grid-cols-4 bg-white/10 backdrop-blur border-white/10 flex-shrink-0 h-10 mb-3 mx-4 mt-4">
|
<TabsList className="grid w-full grid-cols-4 bg-white/10 backdrop-blur border-white/10 flex-shrink-0 h-10 mb-3 mx-4 mt-4">
|
||||||
|
@ -672,7 +672,7 @@ export function DirectSupplyCreation({
|
|||||||
{/* НОВЫЙ БЛОК СОЗДАНИЯ ПОСТАВКИ */}
|
{/* НОВЫЙ БЛОК СОЗДАНИЯ ПОСТАВКИ */}
|
||||||
<Card className="bg-white/10 backdrop-blur-xl border border-white/20 p-3">
|
<Card className="bg-white/10 backdrop-blur-xl border border-white/20 p-3">
|
||||||
{/* Первая строка */}
|
{/* Первая строка */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 items-end mb-2">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 items-end mb-0.5">
|
||||||
{/* 1. Модуль выбора даты */}
|
{/* 1. Модуль выбора даты */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-xs mb-1 block flex items-center gap-1">
|
<Label className="text-white/80 text-xs mb-1 block flex items-center gap-1">
|
||||||
@ -700,7 +700,7 @@ export function DirectSupplyCreation({
|
|||||||
value={selectedFulfillment}
|
value={selectedFulfillment}
|
||||||
onValueChange={setSelectedFulfillment}
|
onValueChange={setSelectedFulfillment}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 bg-white/20 border-0 text-white focus:bg-white/30 focus:ring-1 focus:ring-white/20 text-xs">
|
<SelectTrigger className="w-full h-8 py-0 px-2 bg-white/20 border-0 text-white focus:bg-white/30 focus:ring-1 focus:ring-white/20 text-xs">
|
||||||
<SelectValue placeholder="ФУЛФИЛМЕНТ ИВАНОВО" />
|
<SelectValue placeholder="ФУЛФИЛМЕНТ ИВАНОВО" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -745,7 +745,7 @@ export function DirectSupplyCreation({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Вторая строка */}
|
{/* Вторая строка */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 items-end">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 items-end">
|
||||||
{/* 5. Цена товаров */}
|
{/* 5. Цена товаров */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-white/80 text-xs mb-1 block">
|
<Label className="text-white/80 text-xs mb-1 block">
|
||||||
|
292
src/components/wb-warehouse/fulfillment-warehouse-tab.tsx
Normal file
292
src/components/wb-warehouse/fulfillment-warehouse-tab.tsx
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Truck, Package, Clock, AlertCircle, Search, Plus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface FulfillmentOrder {
|
||||||
|
id: string
|
||||||
|
orderId: string
|
||||||
|
customerName: string
|
||||||
|
items: Array<{
|
||||||
|
name: string
|
||||||
|
quantity: number
|
||||||
|
sku: string
|
||||||
|
}>
|
||||||
|
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled'
|
||||||
|
priority: 'low' | 'medium' | 'high'
|
||||||
|
createdAt: string
|
||||||
|
shippingAddress: string
|
||||||
|
totalValue: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FulfillmentStats {
|
||||||
|
totalOrders: number
|
||||||
|
pendingOrders: number
|
||||||
|
processingOrders: number
|
||||||
|
shippedOrders: number
|
||||||
|
averageProcessingTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FulfillmentWarehouseTab() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<string>('all')
|
||||||
|
|
||||||
|
const [orders, setOrders] = useState<FulfillmentOrder[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
orderId: 'FL-2024-001',
|
||||||
|
customerName: 'Иван Петров',
|
||||||
|
items: [
|
||||||
|
{ name: 'Товар A', quantity: 2, sku: 'SKU-001' },
|
||||||
|
{ name: 'Товар B', quantity: 1, sku: 'SKU-002' }
|
||||||
|
],
|
||||||
|
status: 'pending',
|
||||||
|
priority: 'high',
|
||||||
|
createdAt: '2024-01-15T10:30:00',
|
||||||
|
shippingAddress: 'Москва, ул. Ленина, 10',
|
||||||
|
totalValue: 3500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
orderId: 'FL-2024-002',
|
||||||
|
customerName: 'Анна Сидорова',
|
||||||
|
items: [
|
||||||
|
{ name: 'Товар C', quantity: 1, sku: 'SKU-003' }
|
||||||
|
],
|
||||||
|
status: 'processing',
|
||||||
|
priority: 'medium',
|
||||||
|
createdAt: '2024-01-14T15:20:00',
|
||||||
|
shippingAddress: 'СПб, пр. Невский, 25',
|
||||||
|
totalValue: 1200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
orderId: 'FL-2024-003',
|
||||||
|
customerName: 'Олег Козлов',
|
||||||
|
items: [
|
||||||
|
{ name: 'Товар D', quantity: 3, sku: 'SKU-004' },
|
||||||
|
{ name: 'Товар E', quantity: 2, sku: 'SKU-005' }
|
||||||
|
],
|
||||||
|
status: 'shipped',
|
||||||
|
priority: 'low',
|
||||||
|
createdAt: '2024-01-13T09:15:00',
|
||||||
|
shippingAddress: 'Екатеринбург, ул. Мира, 45',
|
||||||
|
totalValue: 5600
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const stats: FulfillmentStats = {
|
||||||
|
totalOrders: orders.length,
|
||||||
|
pendingOrders: orders.filter(o => o.status === 'pending').length,
|
||||||
|
processingOrders: orders.filter(o => o.status === 'processing').length,
|
||||||
|
shippedOrders: orders.filter(o => o.status === 'shipped').length,
|
||||||
|
averageProcessingTime: 2.5
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredOrders = orders.filter(order => {
|
||||||
|
const matchesSearch = !searchTerm ||
|
||||||
|
order.orderId.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
order.customerName.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
|
||||||
|
const matchesStatus = selectedStatus === 'all' || order.status === selectedStatus
|
||||||
|
|
||||||
|
return matchesSearch && matchesStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30'
|
||||||
|
case 'processing': return 'bg-blue-500/20 text-blue-400 border-blue-500/30'
|
||||||
|
case 'shipped': return 'bg-green-500/20 text-green-400 border-green-500/30'
|
||||||
|
case 'delivered': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30'
|
||||||
|
case 'cancelled': return 'bg-red-500/20 text-red-400 border-red-500/30'
|
||||||
|
default: return 'bg-gray-500/20 text-gray-400 border-gray-500/30'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending': return 'Ожидает'
|
||||||
|
case 'processing': return 'Обрабатывается'
|
||||||
|
case 'shipped': return 'Отправлен'
|
||||||
|
case 'delivered': return 'Доставлен'
|
||||||
|
case 'cancelled': return 'Отменён'
|
||||||
|
default: return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high': return 'text-red-400'
|
||||||
|
case 'medium': return 'text-yellow-400'
|
||||||
|
case 'low': return 'text-green-400'
|
||||||
|
default: return 'text-white/60'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPriorityText = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high': return 'Высокий'
|
||||||
|
case 'medium': return 'Средний'
|
||||||
|
case 'low': return 'Низкий'
|
||||||
|
default: return priority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col space-y-6">
|
||||||
|
{/* Статистика */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card className="glass-card border-white/10 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">Всего заказов</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{stats.totalOrders}</p>
|
||||||
|
</div>
|
||||||
|
<Package className="h-8 w-8 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card border-white/10 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">Ожидают обработки</p>
|
||||||
|
<p className="text-2xl font-bold text-yellow-400">{stats.pendingOrders}</p>
|
||||||
|
</div>
|
||||||
|
<Clock className="h-8 w-8 text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card border-white/10 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">В обработке</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-400">{stats.processingOrders}</p>
|
||||||
|
</div>
|
||||||
|
<AlertCircle className="h-8 w-8 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card border-white/10 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">Отправлено</p>
|
||||||
|
<p className="text-2xl font-bold text-green-400">{stats.shippedOrders}</p>
|
||||||
|
</div>
|
||||||
|
<Truck className="h-8 w-8 text-green-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Панель управления */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||||
|
<div className="flex gap-4 flex-1">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40" />
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск по номеру заказа или клиенту..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10 bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedStatus}
|
||||||
|
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||||
|
className="px-3 py-2 bg-white/5 border border-white/10 rounded-md text-white text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Все статусы</option>
|
||||||
|
<option value="pending">Ожидает</option>
|
||||||
|
<option value="processing">Обрабатывается</option>
|
||||||
|
<option value="shipped">Отправлен</option>
|
||||||
|
<option value="delivered">Доставлен</option>
|
||||||
|
<option value="cancelled">Отменён</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Новый заказ
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список заказов */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{filteredOrders.length === 0 ? (
|
||||||
|
<Card className="glass-card border-white/10 p-8 text-center">
|
||||||
|
<Package className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">
|
||||||
|
{searchTerm ? 'Заказы не найдены' : 'Нет заказов'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-white/60 mb-4">
|
||||||
|
{searchTerm ? 'Попробуйте изменить параметры поиска' : 'Здесь будут отображаться заказы фулфилмент'}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-y-auto pr-2 max-h-full space-y-3">
|
||||||
|
{filteredOrders.map((order) => (
|
||||||
|
<Card key={order.id} className="glass-card border-white/10 p-4 hover:bg-white/5 transition-colors">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="text-white font-semibold">{order.orderId}</h3>
|
||||||
|
<Badge className={getStatusColor(order.status)}>
|
||||||
|
{getStatusText(order.status)}
|
||||||
|
</Badge>
|
||||||
|
<span className={`text-xs font-medium ${getPriorityColor(order.priority)}`}>
|
||||||
|
{getPriorityText(order.priority)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-white font-bold">{order.totalValue.toLocaleString()} ₽</p>
|
||||||
|
<p className="text-white/60 text-xs">
|
||||||
|
{new Date(order.createdAt).toLocaleDateString('ru-RU')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-xs mb-1">Клиент</p>
|
||||||
|
<p className="text-white text-sm">{order.customerName}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-xs mb-1">Товары</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{order.items.map((item, index) => (
|
||||||
|
<p key={index} className="text-white text-xs">
|
||||||
|
{item.name} × {item.quantity}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-xs mb-1">Адрес доставки</p>
|
||||||
|
<p className="text-white text-xs">{order.shippingAddress}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
|
<Button size="sm" variant="outline" className="border-white/10 text-white/60 hover:bg-white/5">
|
||||||
|
Подробнее
|
||||||
|
</Button>
|
||||||
|
{order.status === 'pending' && (
|
||||||
|
<Button size="sm" className="bg-blue-600 hover:bg-blue-700">
|
||||||
|
Начать обработку
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
210
src/components/wb-warehouse/my-warehouse-tab.tsx
Normal file
210
src/components/wb-warehouse/my-warehouse-tab.tsx
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Package, Plus, Search, Warehouse } from 'lucide-react'
|
||||||
|
|
||||||
|
interface MyWarehouseItem {
|
||||||
|
id: string
|
||||||
|
sku: string
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
quantity: number
|
||||||
|
price: number
|
||||||
|
location: string
|
||||||
|
status: 'in_stock' | 'low_stock' | 'out_of_stock'
|
||||||
|
lastUpdated: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MyWarehouseTab() {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [items, setItems] = useState<MyWarehouseItem[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
sku: 'SKU-001',
|
||||||
|
name: 'Товар 1',
|
||||||
|
category: 'Электроника',
|
||||||
|
quantity: 25,
|
||||||
|
price: 1500,
|
||||||
|
location: 'A-01-15',
|
||||||
|
status: 'in_stock',
|
||||||
|
lastUpdated: '2024-01-15'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
sku: 'SKU-002',
|
||||||
|
name: 'Товар 2',
|
||||||
|
category: 'Одежда',
|
||||||
|
quantity: 5,
|
||||||
|
price: 800,
|
||||||
|
location: 'B-02-08',
|
||||||
|
status: 'low_stock',
|
||||||
|
lastUpdated: '2024-01-14'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
sku: 'SKU-003',
|
||||||
|
name: 'Товар 3',
|
||||||
|
category: 'Дом и сад',
|
||||||
|
quantity: 0,
|
||||||
|
price: 650,
|
||||||
|
location: 'C-01-22',
|
||||||
|
status: 'out_of_stock',
|
||||||
|
lastUpdated: '2024-01-13'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const filteredItems = items.filter(item => {
|
||||||
|
if (!searchTerm) return true
|
||||||
|
const search = searchTerm.toLowerCase()
|
||||||
|
return (
|
||||||
|
item.name.toLowerCase().includes(search) ||
|
||||||
|
item.sku.toLowerCase().includes(search) ||
|
||||||
|
item.category.toLowerCase().includes(search)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'in_stock': return 'text-green-400'
|
||||||
|
case 'low_stock': return 'text-yellow-400'
|
||||||
|
case 'out_of_stock': return 'text-red-400'
|
||||||
|
default: return 'text-white/60'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'in_stock': return 'В наличии'
|
||||||
|
case 'low_stock': return 'Мало'
|
||||||
|
case 'out_of_stock': return 'Нет в наличии'
|
||||||
|
default: return 'Неизвестно'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalItems = items.length
|
||||||
|
const totalQuantity = items.reduce((sum, item) => sum + item.quantity, 0)
|
||||||
|
const totalValue = items.reduce((sum, item) => sum + (item.quantity * item.price), 0)
|
||||||
|
const lowStockItems = items.filter(item => item.status === 'low_stock' || item.status === 'out_of_stock').length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col space-y-6">
|
||||||
|
{/* Статистика */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card className="glass-card border-white/10 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">Общее кол-во товаров</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{totalItems}</p>
|
||||||
|
</div>
|
||||||
|
<Package className="h-8 w-8 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card border-white/10 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">Общее количество</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{totalQuantity}</p>
|
||||||
|
</div>
|
||||||
|
<Warehouse className="h-8 w-8 text-green-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card border-white/10 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">Общая стоимость</p>
|
||||||
|
<p className="text-2xl font-bold text-white">{totalValue.toLocaleString()} ₽</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-8 w-8 bg-purple-500 rounded-lg flex items-center justify-center text-white font-bold">₽</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="glass-card border-white/10 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white/60 text-sm">Требует внимания</p>
|
||||||
|
<p className="text-2xl font-bold text-yellow-400">{lowStockItems}</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-8 w-8 bg-yellow-500 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-white text-lg">⚠</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Панель управления */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40" />
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск товаров..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10 bg-white/5 border-white/10 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Добавить товар
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список товаров */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<Card className="glass-card border-white/10 p-8 text-center">
|
||||||
|
<Package className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">
|
||||||
|
{searchTerm ? 'Товары не найдены' : 'Ваш склад пуст'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-white/60 mb-4">
|
||||||
|
{searchTerm ? 'Попробуйте изменить параметры поиска' : 'Добавьте первый товар на склад'}
|
||||||
|
</p>
|
||||||
|
{!searchTerm && (
|
||||||
|
<Button className="bg-blue-600 hover:bg-blue-700">
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Добавить товар
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-y-auto pr-2 max-h-full">
|
||||||
|
{/* Заголовок таблицы */}
|
||||||
|
<div className="grid grid-cols-7 gap-4 p-4 border-b border-white/10 text-white/60 text-sm font-medium">
|
||||||
|
<div>SKU</div>
|
||||||
|
<div>Название</div>
|
||||||
|
<div>Категория</div>
|
||||||
|
<div>Количество</div>
|
||||||
|
<div>Цена</div>
|
||||||
|
<div>Локация</div>
|
||||||
|
<div>Статус</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Строки товаров */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredItems.map((item) => (
|
||||||
|
<Card key={item.id} className="glass-card border-white/10 p-4 hover:bg-white/5 transition-colors">
|
||||||
|
<div className="grid grid-cols-7 gap-4 items-center">
|
||||||
|
<div className="text-white font-mono text-sm">{item.sku}</div>
|
||||||
|
<div className="text-white font-medium">{item.name}</div>
|
||||||
|
<div className="text-white/60 text-sm">{item.category}</div>
|
||||||
|
<div className="text-white font-bold">{item.quantity}</div>
|
||||||
|
<div className="text-white">{item.price.toLocaleString()} ₽</div>
|
||||||
|
<div className="text-white/60 text-sm font-mono">{item.location}</div>
|
||||||
|
<div className={`text-sm font-medium ${getStatusColor(item.status)}`}>
|
||||||
|
{getStatusText(item.status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,352 +1,63 @@
|
|||||||
"use client"
|
"use client"
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { WildberriesService } from '@/services/wildberries-service'
|
import { WildberriesWarehouseTab } from './wildberries-warehouse-tab'
|
||||||
import { toast } from 'sonner'
|
import { MyWarehouseTab } from './my-warehouse-tab'
|
||||||
import { StatsCards } from './stats-cards'
|
import { FulfillmentWarehouseTab } from './fulfillment-warehouse-tab'
|
||||||
import { SearchBar } from './search-bar'
|
|
||||||
import { TableHeader } from './table-header'
|
|
||||||
import { LoadingSkeleton } from './loading-skeleton'
|
|
||||||
import { StockTableRow } from './stock-table-row'
|
|
||||||
import { TrendingUp, Package } from 'lucide-react'
|
|
||||||
|
|
||||||
interface WBStock {
|
|
||||||
nmId: number
|
|
||||||
vendorCode: string
|
|
||||||
title: string
|
|
||||||
brand: string
|
|
||||||
price: number
|
|
||||||
stocks: Array<{
|
|
||||||
warehouseId: number
|
|
||||||
warehouseName: string
|
|
||||||
quantity: number
|
|
||||||
quantityFull: number
|
|
||||||
inWayToClient: number
|
|
||||||
inWayFromClient: number
|
|
||||||
}>
|
|
||||||
totalQuantity: number
|
|
||||||
totalReserved: number
|
|
||||||
photos: any[]
|
|
||||||
mediaFiles: any[]
|
|
||||||
characteristics: any[]
|
|
||||||
subjectName: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WBWarehouse {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
cargoType: number
|
|
||||||
deliveryType: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WBWarehouseDashboard() {
|
export function WBWarehouseDashboard() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { isCollapsed, getSidebarMargin } = useSidebar()
|
const { isCollapsed, getSidebarMargin } = useSidebar()
|
||||||
|
const [activeTab, setActiveTab] = useState('fulfillment')
|
||||||
const [stocks, setStocks] = useState<WBStock[]>([])
|
|
||||||
const [warehouses, setWarehouses] = useState<WBWarehouse[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
|
||||||
|
|
||||||
// Статистика
|
|
||||||
const [totalProducts, setTotalProducts] = useState(0)
|
|
||||||
const [totalStocks, setTotalStocks] = useState(0)
|
|
||||||
const [totalReserved, setTotalReserved] = useState(0)
|
|
||||||
const [totalFromClient, setTotalFromClient] = useState(0)
|
|
||||||
const [activeWarehouses, setActiveWarehouses] = useState(0)
|
|
||||||
|
|
||||||
// Analytics data
|
|
||||||
const [analyticsData, setAnalyticsData] = useState<any[]>([])
|
|
||||||
|
|
||||||
// Проверяем настройку API ключа
|
|
||||||
const hasWBApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')?.isActive
|
|
||||||
|
|
||||||
// Комбинирование карточек с индивидуальными данными аналитики
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const combineCardsWithIndividualAnalytics = (cards: any[], analyticsResults: any[]): WBStock[] => {
|
|
||||||
const stocksMap = new Map<number, WBStock>()
|
|
||||||
|
|
||||||
// Создаем карту аналитических данных для быстрого поиска
|
|
||||||
const analyticsMap = new Map() // Map nmId to its analytics data
|
|
||||||
analyticsResults.forEach(result => {
|
|
||||||
analyticsMap.set(result.nmId, result.data)
|
|
||||||
})
|
|
||||||
|
|
||||||
cards.forEach(card => {
|
|
||||||
const stock: WBStock = {
|
|
||||||
nmId: card.nmID,
|
|
||||||
vendorCode: String(card.vendorCode || card.supplierVendorCode || ''),
|
|
||||||
title: String(card.title || card.object || `Товар ${card.nmID}`),
|
|
||||||
brand: String(card.brand || ''),
|
|
||||||
price: 0,
|
|
||||||
stocks: [],
|
|
||||||
totalQuantity: 0,
|
|
||||||
totalReserved: 0,
|
|
||||||
photos: Array.isArray(card.photos) ? card.photos : [],
|
|
||||||
mediaFiles: Array.isArray(card.mediaFiles) ? card.mediaFiles : [],
|
|
||||||
characteristics: Array.isArray(card.characteristics) ? card.characteristics : [],
|
|
||||||
subjectName: String(card.subjectName || card.object || ''),
|
|
||||||
description: String(card.description || '')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (card.sizes && card.sizes.length > 0) {
|
|
||||||
stock.price = Number(card.sizes[0].price || card.sizes[0].discountedPrice) || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const analyticsData = analyticsMap.get(card.nmID)
|
|
||||||
if (analyticsData?.data?.regions) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
analyticsData.data.regions.forEach((region: any) => {
|
|
||||||
if (region.offices && region.offices.length > 0) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
region.offices.forEach((office: any) => {
|
|
||||||
stock.stocks.push({
|
|
||||||
warehouseId: office.officeID,
|
|
||||||
warehouseName: office.officeName,
|
|
||||||
quantity: office.metrics?.stockCount || 0,
|
|
||||||
quantityFull: office.metrics?.stockCount || 0,
|
|
||||||
inWayToClient: office.metrics?.toClientCount || 0,
|
|
||||||
inWayFromClient: office.metrics?.fromClientCount || 0
|
|
||||||
})
|
|
||||||
|
|
||||||
stock.totalQuantity += office.metrics?.stockCount || 0
|
|
||||||
stock.totalReserved += office.metrics?.toClientCount || 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
stocksMap.set(card.nmID, stock)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Array.from(stocksMap.values()).sort((a, b) => b.totalQuantity - a.totalQuantity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Извлечение информации о складах из данных
|
|
||||||
const extractWarehousesFromStocks = (stocksData: WBStock[]): WBWarehouse[] => {
|
|
||||||
const warehousesMap = new Map<number, WBWarehouse>()
|
|
||||||
|
|
||||||
stocksData.forEach(stock => {
|
|
||||||
stock.stocks.forEach(stockInfo => {
|
|
||||||
if (!warehousesMap.has(stockInfo.warehouseId)) {
|
|
||||||
warehousesMap.set(stockInfo.warehouseId, {
|
|
||||||
id: stockInfo.warehouseId,
|
|
||||||
name: stockInfo.warehouseName,
|
|
||||||
cargoType: 1,
|
|
||||||
deliveryType: 1
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return Array.from(warehousesMap.values())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновление статистики
|
|
||||||
const updateStatistics = (stocksData: WBStock[], warehousesData: WBWarehouse[]) => {
|
|
||||||
setTotalProducts(stocksData.length)
|
|
||||||
setTotalStocks(stocksData.reduce((sum, item) => sum + item.totalQuantity, 0))
|
|
||||||
setTotalReserved(stocksData.reduce((sum, item) => sum + item.totalReserved, 0))
|
|
||||||
|
|
||||||
const totalFromClientCount = stocksData.reduce((sum, item) =>
|
|
||||||
sum + item.stocks.reduce((stockSum, stock) => stockSum + stock.inWayFromClient, 0), 0
|
|
||||||
)
|
|
||||||
setTotalFromClient(totalFromClientCount)
|
|
||||||
|
|
||||||
const warehousesWithStock = new Set(
|
|
||||||
stocksData.flatMap(item => item.stocks.map(s => s.warehouseId))
|
|
||||||
)
|
|
||||||
setActiveWarehouses(warehousesWithStock.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загрузка данных склада
|
|
||||||
const loadWarehouseData = async () => {
|
|
||||||
if (!hasWBApiKey) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')
|
|
||||||
|
|
||||||
if (!wbApiKey?.isActive) {
|
|
||||||
toast.error('API ключ Wildberries не настроен')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const validationData = wbApiKey.validationData as Record<string, string>
|
|
||||||
const apiToken = validationData?.token ||
|
|
||||||
validationData?.apiKey ||
|
|
||||||
validationData?.key ||
|
|
||||||
(wbApiKey as { apiKey?: string }).apiKey
|
|
||||||
|
|
||||||
if (!apiToken) {
|
|
||||||
toast.error('Токен API не найден')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const wbService = new WildberriesService(apiToken)
|
|
||||||
|
|
||||||
// 1. Получаем карточки товаров
|
|
||||||
const cards = await WildberriesService.getAllCards(apiToken).catch(() => [])
|
|
||||||
console.log('WB Warehouse: Loaded cards:', cards.length)
|
|
||||||
|
|
||||||
if (cards.length === 0) {
|
|
||||||
toast.error('Нет карточек товаров в WB')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nmIds = cards.map(card => card.nmID).filter(id => id > 0)
|
|
||||||
console.log('WB Warehouse: NM IDs to process:', nmIds.length)
|
|
||||||
|
|
||||||
// 2. Получаем аналитику для каждого товара индивидуально
|
|
||||||
const analyticsResults = []
|
|
||||||
for (const nmId of nmIds) {
|
|
||||||
try {
|
|
||||||
console.log(`WB Warehouse: Fetching analytics for nmId ${nmId}`)
|
|
||||||
const result = await wbService.getStocksReportByOffices({
|
|
||||||
nmIds: [nmId],
|
|
||||||
stockType: ''
|
|
||||||
})
|
|
||||||
analyticsResults.push({ nmId, data: result })
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`WB Warehouse: Error fetching analytics for nmId ${nmId}:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('WB Warehouse: Analytics results:', analyticsResults.length)
|
|
||||||
|
|
||||||
// 3. Комбинируем данные
|
|
||||||
const combinedStocks = combineCardsWithIndividualAnalytics(cards, analyticsResults)
|
|
||||||
console.log('WB Warehouse: Combined stocks:', combinedStocks.length)
|
|
||||||
|
|
||||||
// 4. Извлекаем склады и обновляем статистику
|
|
||||||
const extractedWarehouses = extractWarehousesFromStocks(combinedStocks)
|
|
||||||
|
|
||||||
setStocks(combinedStocks)
|
|
||||||
setWarehouses(extractedWarehouses)
|
|
||||||
updateStatistics(combinedStocks, extractedWarehouses)
|
|
||||||
|
|
||||||
toast.success(`Загружено товаров: ${combinedStocks.length}`)
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('WB Warehouse: Error loading data:', error)
|
|
||||||
toast.error('Ошибка загрузки данных: ' + (error.message || 'Неизвестная ошибка'))
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasWBApiKey) {
|
|
||||||
loadWarehouseData()
|
|
||||||
}
|
|
||||||
}, [hasWBApiKey])
|
|
||||||
|
|
||||||
// Фильтрация товаров
|
|
||||||
const filteredStocks = stocks.filter(item => {
|
|
||||||
if (!searchTerm) return true
|
|
||||||
const search = searchTerm.toLowerCase()
|
|
||||||
return (
|
|
||||||
item.title.toLowerCase().includes(search) ||
|
|
||||||
String(item.nmId).includes(search) ||
|
|
||||||
item.brand.toLowerCase().includes(search) ||
|
|
||||||
item.vendorCode.toLowerCase().includes(search)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex overflow-hidden">
|
<div className="h-screen flex overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
|
<main 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">
|
||||||
|
{/* Табы */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col">
|
||||||
|
<TabsList className="grid grid-cols-3 w-full max-w-md mb-6 bg-white/5 border border-white/10">
|
||||||
|
<TabsTrigger
|
||||||
|
value="fulfillment"
|
||||||
|
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white text-white/60"
|
||||||
|
>
|
||||||
|
Склад фулфилмент
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="wildberries"
|
||||||
|
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white text-white/60"
|
||||||
|
>
|
||||||
|
Склад Wildberries
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="my-warehouse"
|
||||||
|
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white text-white/60"
|
||||||
|
>
|
||||||
|
Мой склад
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
{/* Результирующие вкладки */}
|
<div className="flex-1 overflow-hidden">
|
||||||
<StatsCards
|
<TabsContent value="fulfillment" className="h-full mt-0">
|
||||||
totalProducts={totalProducts}
|
<FulfillmentWarehouseTab />
|
||||||
totalStocks={totalStocks}
|
</TabsContent>
|
||||||
totalReserved={totalReserved}
|
|
||||||
totalFromClient={totalFromClient}
|
<TabsContent value="wildberries" className="h-full mt-0">
|
||||||
activeWarehouses={activeWarehouses}
|
<WildberriesWarehouseTab />
|
||||||
loading={loading}
|
</TabsContent>
|
||||||
/>
|
|
||||||
|
<TabsContent value="my-warehouse" className="h-full mt-0">
|
||||||
{/* Аналитика по складам WB */}
|
<MyWarehouseTab />
|
||||||
{analyticsData.length > 0 && (
|
</TabsContent>
|
||||||
<Card className="glass-card border-white/10 p-4 mb-6">
|
</div>
|
||||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
</Tabs>
|
||||||
<TrendingUp className="h-5 w-5 mr-2 text-blue-400" />
|
|
||||||
Движение товаров по складам WB
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{analyticsData.map((warehouse) => (
|
|
||||||
<Card key={warehouse.warehouseId} className="bg-white/5 border-white/10 p-3">
|
|
||||||
<div className="text-sm font-medium text-white mb-2">{warehouse.warehouseName}</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between text-xs">
|
|
||||||
<span className="text-white/60">К клиенту:</span>
|
|
||||||
<span className="text-green-400 font-medium">{warehouse.toClient}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between text-xs">
|
|
||||||
<span className="text-white/60">От клиента:</span>
|
|
||||||
<span className="text-orange-400 font-medium">{warehouse.fromClient}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Поиск */}
|
|
||||||
<SearchBar
|
|
||||||
searchTerm={searchTerm}
|
|
||||||
onSearchChange={setSearchTerm}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Список товаров */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
{loading ? (
|
|
||||||
<div className="overflow-y-auto pr-2 max-h-full">
|
|
||||||
<TableHeader />
|
|
||||||
<LoadingSkeleton />
|
|
||||||
</div>
|
|
||||||
) : !hasWBApiKey ? (
|
|
||||||
<Card className="glass-card border-white/10 p-8 text-center">
|
|
||||||
<Package className="h-12 w-12 text-blue-400 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-white mb-2">Настройте API Wildberries</h3>
|
|
||||||
<p className="text-white/60 mb-4">Для просмотра остатков добавьте API ключ Wildberries в настройках</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => window.location.href = '/settings'}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Перейти в настройки
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
) : filteredStocks.length === 0 ? (
|
|
||||||
<Card className="glass-card border-white/10 p-8 text-center">
|
|
||||||
<Package className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-white mb-2">Товары не найдены</h3>
|
|
||||||
<p className="text-white/60">Попробуйте изменить параметры поиска</p>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-y-auto pr-2 max-h-full">
|
|
||||||
<TableHeader />
|
|
||||||
|
|
||||||
{/* Таблица товаров */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{filteredStocks.map((item, index) => (
|
|
||||||
<StockTableRow key={`${item.nmId}-${index}`} item={item} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
342
src/components/wb-warehouse/wildberries-warehouse-tab.tsx
Normal file
342
src/components/wb-warehouse/wildberries-warehouse-tab.tsx
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
"use client"
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { WildberriesService } from '@/services/wildberries-service'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { StatsCards } from './stats-cards'
|
||||||
|
import { SearchBar } from './search-bar'
|
||||||
|
import { TableHeader } from './table-header'
|
||||||
|
import { LoadingSkeleton } from './loading-skeleton'
|
||||||
|
import { StockTableRow } from './stock-table-row'
|
||||||
|
import { TrendingUp, Package } from 'lucide-react'
|
||||||
|
|
||||||
|
interface WBStock {
|
||||||
|
nmId: number
|
||||||
|
vendorCode: string
|
||||||
|
title: string
|
||||||
|
brand: string
|
||||||
|
price: number
|
||||||
|
stocks: Array<{
|
||||||
|
warehouseId: number
|
||||||
|
warehouseName: string
|
||||||
|
quantity: number
|
||||||
|
quantityFull: number
|
||||||
|
inWayToClient: number
|
||||||
|
inWayFromClient: number
|
||||||
|
}>
|
||||||
|
totalQuantity: number
|
||||||
|
totalReserved: number
|
||||||
|
photos: any[]
|
||||||
|
mediaFiles: any[]
|
||||||
|
characteristics: any[]
|
||||||
|
subjectName: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WBWarehouse {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
cargoType: number
|
||||||
|
deliveryType: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WildberriesWarehouseTab() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
const [stocks, setStocks] = useState<WBStock[]>([])
|
||||||
|
const [warehouses, setWarehouses] = useState<WBWarehouse[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
|
||||||
|
// Статистика
|
||||||
|
const [totalProducts, setTotalProducts] = useState(0)
|
||||||
|
const [totalStocks, setTotalStocks] = useState(0)
|
||||||
|
const [totalReserved, setTotalReserved] = useState(0)
|
||||||
|
const [totalFromClient, setTotalFromClient] = useState(0)
|
||||||
|
const [activeWarehouses, setActiveWarehouses] = useState(0)
|
||||||
|
|
||||||
|
// Analytics data
|
||||||
|
const [analyticsData, setAnalyticsData] = useState<any[]>([])
|
||||||
|
|
||||||
|
// Проверяем настройку API ключа
|
||||||
|
const hasWBApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')?.isActive
|
||||||
|
|
||||||
|
// Комбинирование карточек с индивидуальными данными аналитики
|
||||||
|
const combineCardsWithIndividualAnalytics = (cards: any[], analyticsResults: any[]): WBStock[] => {
|
||||||
|
const stocksMap = new Map<number, WBStock>()
|
||||||
|
|
||||||
|
// Создаем карту аналитических данных для быстрого поиска
|
||||||
|
const analyticsMap = new Map() // Map nmId to its analytics data
|
||||||
|
analyticsResults.forEach(result => {
|
||||||
|
analyticsMap.set(result.nmId, result.data)
|
||||||
|
})
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
const stock: WBStock = {
|
||||||
|
nmId: card.nmID,
|
||||||
|
vendorCode: String(card.vendorCode || card.supplierVendorCode || ''),
|
||||||
|
title: String(card.title || card.object || `Товар ${card.nmID}`),
|
||||||
|
brand: String(card.brand || ''),
|
||||||
|
price: 0,
|
||||||
|
stocks: [],
|
||||||
|
totalQuantity: 0,
|
||||||
|
totalReserved: 0,
|
||||||
|
photos: Array.isArray(card.photos) ? card.photos : [],
|
||||||
|
mediaFiles: Array.isArray(card.mediaFiles) ? card.mediaFiles : [],
|
||||||
|
characteristics: Array.isArray(card.characteristics) ? card.characteristics : [],
|
||||||
|
subjectName: String(card.subjectName || card.object || ''),
|
||||||
|
description: String(card.description || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (card.sizes && card.sizes.length > 0) {
|
||||||
|
stock.price = Number(card.sizes[0].price || card.sizes[0].discountedPrice) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyticsData = analyticsMap.get(card.nmID)
|
||||||
|
if (analyticsData?.data?.regions) {
|
||||||
|
analyticsData.data.regions.forEach((region: any) => {
|
||||||
|
if (region.offices && region.offices.length > 0) {
|
||||||
|
region.offices.forEach((office: any) => {
|
||||||
|
stock.stocks.push({
|
||||||
|
warehouseId: office.officeID,
|
||||||
|
warehouseName: office.officeName,
|
||||||
|
quantity: office.metrics?.stockCount || 0,
|
||||||
|
quantityFull: office.metrics?.stockCount || 0,
|
||||||
|
inWayToClient: office.metrics?.toClientCount || 0,
|
||||||
|
inWayFromClient: office.metrics?.fromClientCount || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
stock.totalQuantity += office.metrics?.stockCount || 0
|
||||||
|
stock.totalReserved += office.metrics?.toClientCount || 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
stocksMap.set(card.nmID, stock)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(stocksMap.values()).sort((a, b) => b.totalQuantity - a.totalQuantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлечение информации о складах из данных
|
||||||
|
const extractWarehousesFromStocks = (stocksData: WBStock[]): WBWarehouse[] => {
|
||||||
|
const warehousesMap = new Map<number, WBWarehouse>()
|
||||||
|
|
||||||
|
stocksData.forEach(stock => {
|
||||||
|
stock.stocks.forEach(stockInfo => {
|
||||||
|
if (!warehousesMap.has(stockInfo.warehouseId)) {
|
||||||
|
warehousesMap.set(stockInfo.warehouseId, {
|
||||||
|
id: stockInfo.warehouseId,
|
||||||
|
name: stockInfo.warehouseName,
|
||||||
|
cargoType: 1,
|
||||||
|
deliveryType: 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(warehousesMap.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление статистики
|
||||||
|
const updateStatistics = (stocksData: WBStock[], warehousesData: WBWarehouse[]) => {
|
||||||
|
setTotalProducts(stocksData.length)
|
||||||
|
setTotalStocks(stocksData.reduce((sum, item) => sum + item.totalQuantity, 0))
|
||||||
|
setTotalReserved(stocksData.reduce((sum, item) => sum + item.totalReserved, 0))
|
||||||
|
|
||||||
|
const totalFromClientCount = stocksData.reduce((sum, item) =>
|
||||||
|
sum + item.stocks.reduce((stockSum, stock) => stockSum + stock.inWayFromClient, 0), 0
|
||||||
|
)
|
||||||
|
setTotalFromClient(totalFromClientCount)
|
||||||
|
|
||||||
|
const warehousesWithStock = new Set(
|
||||||
|
stocksData.flatMap(item => item.stocks.map(s => s.warehouseId))
|
||||||
|
)
|
||||||
|
setActiveWarehouses(warehousesWithStock.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка данных склада
|
||||||
|
const loadWarehouseData = async () => {
|
||||||
|
if (!hasWBApiKey) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')
|
||||||
|
|
||||||
|
if (!wbApiKey?.isActive) {
|
||||||
|
toast.error('API ключ Wildberries не настроен')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationData = wbApiKey.validationData as Record<string, string>
|
||||||
|
const apiToken = validationData?.token ||
|
||||||
|
validationData?.apiKey ||
|
||||||
|
validationData?.key ||
|
||||||
|
(wbApiKey as { apiKey?: string }).apiKey
|
||||||
|
|
||||||
|
if (!apiToken) {
|
||||||
|
toast.error('Токен API не найден')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const wbService = new WildberriesService(apiToken)
|
||||||
|
|
||||||
|
// 1. Получаем карточки товаров
|
||||||
|
const cards = await WildberriesService.getAllCards(apiToken).catch(() => [])
|
||||||
|
console.log('WB Warehouse: Loaded cards:', cards.length)
|
||||||
|
|
||||||
|
if (cards.length === 0) {
|
||||||
|
toast.error('Нет карточек товаров в WB')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nmIds = cards.map(card => card.nmID).filter(id => id > 0)
|
||||||
|
console.log('WB Warehouse: NM IDs to process:', nmIds.length)
|
||||||
|
|
||||||
|
// 2. Получаем аналитику для каждого товара индивидуально
|
||||||
|
const analyticsResults = []
|
||||||
|
for (const nmId of nmIds) {
|
||||||
|
try {
|
||||||
|
console.log(`WB Warehouse: Fetching analytics for nmId ${nmId}`)
|
||||||
|
const result = await wbService.getStocksReportByOffices({
|
||||||
|
nmIds: [nmId],
|
||||||
|
stockType: ''
|
||||||
|
})
|
||||||
|
analyticsResults.push({ nmId, data: result })
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`WB Warehouse: Error fetching analytics for nmId ${nmId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('WB Warehouse: Analytics results:', analyticsResults.length)
|
||||||
|
|
||||||
|
// 3. Комбинируем данные
|
||||||
|
const combinedStocks = combineCardsWithIndividualAnalytics(cards, analyticsResults)
|
||||||
|
console.log('WB Warehouse: Combined stocks:', combinedStocks.length)
|
||||||
|
|
||||||
|
// 4. Извлекаем склады и обновляем статистику
|
||||||
|
const extractedWarehouses = extractWarehousesFromStocks(combinedStocks)
|
||||||
|
|
||||||
|
setStocks(combinedStocks)
|
||||||
|
setWarehouses(extractedWarehouses)
|
||||||
|
updateStatistics(combinedStocks, extractedWarehouses)
|
||||||
|
|
||||||
|
toast.success(`Загружено товаров: ${combinedStocks.length}`)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('WB Warehouse: Error loading data:', error)
|
||||||
|
toast.error('Ошибка загрузки данных: ' + (error.message || 'Неизвестная ошибка'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasWBApiKey) {
|
||||||
|
loadWarehouseData()
|
||||||
|
}
|
||||||
|
}, [hasWBApiKey])
|
||||||
|
|
||||||
|
// Фильтрация товаров
|
||||||
|
const filteredStocks = stocks.filter(item => {
|
||||||
|
if (!searchTerm) return true
|
||||||
|
const search = searchTerm.toLowerCase()
|
||||||
|
return (
|
||||||
|
item.title.toLowerCase().includes(search) ||
|
||||||
|
String(item.nmId).includes(search) ||
|
||||||
|
item.brand.toLowerCase().includes(search) ||
|
||||||
|
item.vendorCode.toLowerCase().includes(search)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
{/* Статистика */}
|
||||||
|
<StatsCards
|
||||||
|
totalProducts={totalProducts}
|
||||||
|
totalStocks={totalStocks}
|
||||||
|
totalReserved={totalReserved}
|
||||||
|
totalFromClient={totalFromClient}
|
||||||
|
activeWarehouses={activeWarehouses}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Аналитика по складам WB */}
|
||||||
|
{analyticsData.length > 0 && (
|
||||||
|
<Card className="glass-card border-white/10 p-4 mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
||||||
|
<TrendingUp className="h-5 w-5 mr-2 text-blue-400" />
|
||||||
|
Движение товаров по складам WB
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{analyticsData.map((warehouse) => (
|
||||||
|
<Card key={warehouse.warehouseId} className="bg-white/5 border-white/10 p-3">
|
||||||
|
<div className="text-sm font-medium text-white mb-2">{warehouse.warehouseName}</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-white/60">К клиенту:</span>
|
||||||
|
<span className="text-green-400 font-medium">{warehouse.toClient}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-white/60">От клиента:</span>
|
||||||
|
<span className="text-orange-400 font-medium">{warehouse.fromClient}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Поиск */}
|
||||||
|
<SearchBar
|
||||||
|
searchTerm={searchTerm}
|
||||||
|
onSearchChange={setSearchTerm}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Список товаров */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{loading ? (
|
||||||
|
<div className="overflow-y-auto pr-2 max-h-full">
|
||||||
|
<TableHeader />
|
||||||
|
<LoadingSkeleton />
|
||||||
|
</div>
|
||||||
|
) : !hasWBApiKey ? (
|
||||||
|
<Card className="glass-card border-white/10 p-8 text-center">
|
||||||
|
<Package className="h-12 w-12 text-blue-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">Настройте API Wildberries</h3>
|
||||||
|
<p className="text-white/60 mb-4">Для просмотра остатков добавьте API ключ Wildberries в настройках</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => window.location.href = '/settings'}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Перейти в настройки
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : filteredStocks.length === 0 ? (
|
||||||
|
<Card className="glass-card border-white/10 p-8 text-center">
|
||||||
|
<Package className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-white mb-2">Товары не найдены</h3>
|
||||||
|
<p className="text-white/60">Попробуйте изменить параметры поиска</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-y-auto pr-2 max-h-full">
|
||||||
|
<TableHeader />
|
||||||
|
|
||||||
|
{/* Таблица товаров */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{filteredStocks.map((item, index) => (
|
||||||
|
<StockTableRow key={`${item.nmId}-${index}`} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
Reference in New Issue
Block a user