This commit is contained in:
Veronika Smirnova
2025-07-24 19:25:48 +03:00
7 changed files with 912 additions and 338 deletions

View File

@ -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 ||

View File

@ -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">

View File

@ -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">

View 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>
)
}

View 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>
)
}

View File

@ -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>

View 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>
)
}