Merge branch 'main' of https://gittea.biveki.ru/Sfera/sfera
This commit is contained in:
@ -18,6 +18,7 @@ import { BusinessDemo } from "./ui-kit/business-demo";
|
||||
import { TimesheetDemo } from "./ui-kit/timesheet-demo";
|
||||
import { FulfillmentWarehouseDemo } from "./ui-kit/fulfillment-warehouse-demo";
|
||||
import { SuppliesDemo } from "./ui-kit/supplies-demo";
|
||||
import { WBWarehouseDemo } from "./ui-kit/wb-warehouse-demo";
|
||||
import { SuppliesNavigationDemo } from "./ui-kit/supplies-navigation-demo";
|
||||
|
||||
export function UIKitSection() {
|
||||
@ -134,6 +135,12 @@ export function UIKitSection() {
|
||||
>
|
||||
Поставки
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="wb-warehouse"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 text-xs px-3 py-2"
|
||||
>
|
||||
Склад WB
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="supplies-navigation"
|
||||
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/70 text-xs px-3 py-2"
|
||||
@ -210,6 +217,10 @@ export function UIKitSection() {
|
||||
<SuppliesDemo />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="wb-warehouse" className="space-y-6">
|
||||
<WBWarehouseDemo />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="supplies-navigation" className="space-y-6">
|
||||
<SuppliesNavigationDemo />
|
||||
</TabsContent>
|
||||
|
315
src/components/admin/ui-kit/wb-warehouse-demo.tsx
Normal file
315
src/components/admin/ui-kit/wb-warehouse-demo.tsx
Normal file
@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { StatsCards } from '@/components/wb-warehouse/stats-cards';
|
||||
import { SearchBar } from '@/components/wb-warehouse/search-bar';
|
||||
import { TableHeader } from '@/components/wb-warehouse/table-header';
|
||||
import { LoadingSkeleton } from '@/components/wb-warehouse/loading-skeleton';
|
||||
import { StockTableRow } from '@/components/wb-warehouse/stock-table-row';
|
||||
|
||||
export function WBWarehouseDemo() {
|
||||
// Мок данные для демонстрации
|
||||
const mockStatsData = {
|
||||
totalProducts: 156,
|
||||
totalStocks: 12847,
|
||||
totalReserved: 342,
|
||||
totalFromClient: 28,
|
||||
activeWarehouses: 12,
|
||||
loading: false
|
||||
};
|
||||
|
||||
const mockStockItem = {
|
||||
nmId: 444711032,
|
||||
vendorCode: "V326",
|
||||
title: "Электробритва для бороды с 3D головками триммер беспроводной",
|
||||
brand: "ANNRennel",
|
||||
price: 2990,
|
||||
stocks: [
|
||||
{
|
||||
warehouseId: 120762,
|
||||
warehouseName: "Электросталь",
|
||||
quantity: 188,
|
||||
quantityFull: 188,
|
||||
inWayToClient: 2,
|
||||
inWayFromClient: 1
|
||||
},
|
||||
{
|
||||
warehouseId: 507,
|
||||
warehouseName: "Коледино",
|
||||
quantity: 0,
|
||||
quantityFull: 0,
|
||||
inWayToClient: 3,
|
||||
inWayFromClient: 1
|
||||
},
|
||||
{
|
||||
warehouseId: 208277,
|
||||
warehouseName: "Невинномысск",
|
||||
quantity: 56,
|
||||
quantityFull: 56,
|
||||
inWayToClient: 0,
|
||||
inWayFromClient: 0
|
||||
},
|
||||
{
|
||||
warehouseId: 130744,
|
||||
warehouseName: "Краснодар",
|
||||
quantity: 1,
|
||||
quantityFull: 1,
|
||||
inWayToClient: 0,
|
||||
inWayFromClient: 0
|
||||
}
|
||||
],
|
||||
totalQuantity: 245,
|
||||
totalReserved: 5,
|
||||
photos: [
|
||||
{
|
||||
big: "https://basket-04.wbbasket.ru/vol444/part44471/444711032/images/big/1.webp",
|
||||
c246x328: "https://basket-04.wbbasket.ru/vol444/part44471/444711032/images/c246x328/1.webp"
|
||||
}
|
||||
],
|
||||
mediaFiles: [],
|
||||
characteristics: [
|
||||
{ name: "Способ бритья", value: "сухое" },
|
||||
{ name: "Модель", value: "V326" },
|
||||
{ name: "Время работы от аккумулятора (мин)", value: "60" },
|
||||
{ name: "Гарантийный срок", value: "1 год" },
|
||||
{ name: "Цвет", value: ["синий", "черный"] }
|
||||
],
|
||||
subjectName: "Триммеры",
|
||||
description: "Триммер для бороды - незаменимый помощник для каждого парня и мужчины."
|
||||
};
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Заголовок секции */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-2">WB Warehouse Components</h2>
|
||||
<p className="text-white/70">
|
||||
Компоненты для страницы склада Wildberries
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">📊 StatsCards - Карточки статистики</h3>
|
||||
<div className="p-4 rounded-lg bg-white/5 border border-white/10">
|
||||
<StatsCards {...mockStatsData} />
|
||||
|
||||
<div className="mt-4 p-3 rounded bg-black/20 border border-white/5">
|
||||
<p className="text-white/60 text-sm mb-2">📝 Код использования:</p>
|
||||
<pre className="text-green-400 text-xs overflow-x-auto">
|
||||
{`<StatsCards
|
||||
totalProducts={156}
|
||||
totalStocks={12847}
|
||||
totalReserved={342}
|
||||
totalFromClient={28}
|
||||
activeWarehouses={12}
|
||||
loading={false}
|
||||
/>`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">🔍 SearchBar - Поиск товаров</h3>
|
||||
<div className="p-4 rounded-lg bg-white/5 border border-white/10">
|
||||
<SearchBar
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
|
||||
<div className="mt-4 p-3 rounded bg-black/20 border border-white/5">
|
||||
<p className="text-white/60 text-sm mb-2">📝 Код использования:</p>
|
||||
<pre className="text-green-400 text-xs overflow-x-auto">
|
||||
{`<SearchBar
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Header */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">📋 TableHeader - Шапка таблицы</h3>
|
||||
<div className="p-4 rounded-lg bg-white/5 border border-white/10">
|
||||
<TableHeader />
|
||||
|
||||
<div className="mt-4 p-3 rounded bg-black/20 border border-white/5">
|
||||
<p className="text-white/60 text-sm mb-2">📝 Код использования:</p>
|
||||
<pre className="text-green-400 text-xs overflow-x-auto">
|
||||
{`<TableHeader />`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">⏳ LoadingSkeleton - Скелетоны загрузки</h3>
|
||||
<div className="p-4 rounded-lg bg-white/5 border border-white/10">
|
||||
<div className="max-h-96 overflow-hidden">
|
||||
<LoadingSkeleton />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 rounded bg-black/20 border border-white/5">
|
||||
<p className="text-white/60 text-sm mb-2">📝 Код использования:</p>
|
||||
<pre className="text-green-400 text-xs overflow-x-auto">
|
||||
{`<LoadingSkeleton />`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stock Table Row */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">📦 StockTableRow - Строка товара</h3>
|
||||
<div className="p-4 rounded-lg bg-white/5 border border-white/10">
|
||||
<StockTableRow item={mockStockItem} />
|
||||
|
||||
<div className="mt-4 p-3 rounded bg-black/20 border border-white/5">
|
||||
<p className="text-white/60 text-sm mb-2">📝 Код использования:</p>
|
||||
<pre className="text-green-400 text-xs overflow-x-auto">
|
||||
{`<StockTableRow item={stockItem} />
|
||||
|
||||
// где stockItem содержит:
|
||||
// - nmId, vendorCode, title, brand
|
||||
// - stocks[] - данные по складам
|
||||
// - totalQuantity, totalReserved
|
||||
// - photos[], characteristics[]
|
||||
// - subjectName, description`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* States Demo */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">🎭 States - Состояния компонентов</h3>
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* Loading State */}
|
||||
<div className="p-4 rounded-lg bg-white/5 border border-white/10">
|
||||
<h4 className="text-white/80 font-medium mb-3">⏳ Loading State</h4>
|
||||
<StatsCards {...mockStatsData} loading={true} />
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
<div className="p-4 rounded-lg bg-white/5 border border-white/10">
|
||||
<h4 className="text-white/80 font-medium mb-3">🔍 Search State</h4>
|
||||
<SearchBar
|
||||
searchTerm="электробритва"
|
||||
onSearchChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Multiple Stock Rows */}
|
||||
<div className="p-4 rounded-lg bg-white/5 border border-white/10">
|
||||
<h4 className="text-white/80 font-medium mb-3">📋 Table with Multiple Items</h4>
|
||||
<TableHeader />
|
||||
<div className="space-y-1 mt-3">
|
||||
<StockTableRow item={mockStockItem} />
|
||||
<StockTableRow item={{
|
||||
...mockStockItem,
|
||||
nmId: 444830802,
|
||||
vendorCode: "V319",
|
||||
title: "Электробритва для бороды влагозащитная с дисплеем",
|
||||
totalQuantity: 133,
|
||||
stocks: [
|
||||
{
|
||||
warehouseId: 120762,
|
||||
warehouseName: "Электросталь",
|
||||
quantity: 88,
|
||||
quantityFull: 88,
|
||||
inWayToClient: 1,
|
||||
inWayFromClient: 0
|
||||
},
|
||||
{
|
||||
warehouseId: 208277,
|
||||
warehouseName: "Невинномысск",
|
||||
quantity: 45,
|
||||
quantityFull: 45,
|
||||
inWayToClient: 2,
|
||||
inWayFromClient: 1
|
||||
}
|
||||
]
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Variants */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">🎨 Color Variants - Цветовые варианты</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
|
||||
{/* Товары - синий */}
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-blue-500/20 to-blue-600/10 border border-blue-500/20">
|
||||
<div className="text-blue-400 text-xl font-bold">156</div>
|
||||
<div className="text-blue-300 text-xs">Товаров</div>
|
||||
</div>
|
||||
|
||||
{/* Остаток - зеленый */}
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-green-500/20 to-green-600/10 border border-green-500/20">
|
||||
<div className="text-green-400 text-xl font-bold">12,847</div>
|
||||
<div className="text-green-300 text-xs">Остаток</div>
|
||||
</div>
|
||||
|
||||
{/* К клиенту - оранжевый */}
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-orange-500/20 to-orange-600/10 border border-orange-500/20">
|
||||
<div className="text-orange-400 text-xl font-bold">342</div>
|
||||
<div className="text-orange-300 text-xs">К клиенту</div>
|
||||
</div>
|
||||
|
||||
{/* От клиента - красный */}
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-red-500/20 to-red-600/10 border border-red-500/20">
|
||||
<div className="text-red-400 text-xl font-bold">28</div>
|
||||
<div className="text-red-300 text-xs">От клиента</div>
|
||||
</div>
|
||||
|
||||
{/* Складов - фиолетовый */}
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-purple-500/20 to-purple-600/10 border border-purple-500/20">
|
||||
<div className="text-purple-400 text-xl font-bold">12</div>
|
||||
<div className="text-purple-300 text-xs">Складов</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Guidelines */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">📚 Usage Guidelines - Рекомендации</h3>
|
||||
<div className="p-4 rounded-lg bg-white/5 border border-white/10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
<div>
|
||||
<h4 className="text-white font-medium mb-2">✅ Правильно</h4>
|
||||
<ul className="text-green-400 text-sm space-y-1">
|
||||
<li>• Используй StatsCards для отображения ключевых метрик</li>
|
||||
<li>• SearchBar всегда размещай перед таблицей</li>
|
||||
<li>• TableHeader обязателен для понимания структуры</li>
|
||||
<li>• LoadingSkeleton соответствует структуре данных</li>
|
||||
<li>• StockTableRow содержит основную и дополнительную информацию</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-white font-medium mb-2">❌ Неправильно</h4>
|
||||
<ul className="text-red-400 text-sm space-y-1">
|
||||
<li>• Не используй StatsCards без данных</li>
|
||||
<li>• Не размещай SearchBar после таблицы</li>
|
||||
<li>• Не показывай данные без TableHeader</li>
|
||||
<li>• Не используй LoadingSkeleton с готовыми данными</li>
|
||||
<li>• Не модифицируй внутреннюю структуру StockTableRow</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
51
src/components/wb-warehouse/loading-skeleton.tsx
Normal file
51
src/components/wb-warehouse/loading-skeleton.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="grid grid-cols-12 gap-4 p-4 rounded-xl bg-white/5 border border-white/10">
|
||||
<div className="col-span-3 flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-white/10 animate-pulse" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 bg-white/10 rounded animate-pulse" style={{ width: '70%' }} />
|
||||
<div className="h-2 bg-white/10 rounded animate-pulse" style={{ width: '50%' }} />
|
||||
<div className="h-2 bg-white/10 rounded animate-pulse" style={{ width: '40%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-1">
|
||||
<div className="h-5 w-8 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
<div className="h-3 w-12 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-1">
|
||||
<div className="h-5 w-6 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
<div className="h-3 w-8 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-1">
|
||||
<div className="h-5 w-6 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
<div className="h-3 w-10 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-1">
|
||||
<div className="h-5 w-4 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
<div className="h-3 w-12 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-5 space-y-1">
|
||||
<div className="h-2 bg-white/10 rounded animate-pulse" style={{ width: '90%' }} />
|
||||
<div className="h-2 bg-white/10 rounded animate-pulse" style={{ width: '75%' }} />
|
||||
<div className="h-2 bg-white/10 rounded animate-pulse" style={{ width: '65%' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
26
src/components/wb-warehouse/search-bar.tsx
Normal file
26
src/components/wb-warehouse/search-bar.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import React from 'react'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
interface SearchBarProps {
|
||||
searchTerm: string
|
||||
onSearchChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function SearchBar({ searchTerm, onSearchChange }: SearchBarProps) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="relative group">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40 group-focus-within:text-blue-400 transition-colors duration-200" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск товаров..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="w-full h-12 pl-12 pr-4 rounded-xl bg-white/5 border border-white/10 text-white placeholder:text-white/40 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/30 transition-all duration-200 hover:bg-white/10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
106
src/components/wb-warehouse/stats-cards.tsx
Normal file
106
src/components/wb-warehouse/stats-cards.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
"use client"
|
||||
|
||||
import React from 'react'
|
||||
import { Package, Warehouse, TrendingUp, TrendingDown, MapPin } from 'lucide-react'
|
||||
|
||||
interface StatsCardsProps {
|
||||
totalProducts: number
|
||||
totalStocks: number
|
||||
totalReserved: number
|
||||
totalFromClient: number
|
||||
activeWarehouses: number
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
export function StatsCards({
|
||||
totalProducts,
|
||||
totalStocks,
|
||||
totalReserved,
|
||||
totalFromClient,
|
||||
activeWarehouses,
|
||||
loading
|
||||
}: StatsCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-4">
|
||||
{/* Товаров */}
|
||||
<div className="group relative overflow-hidden rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 border border-blue-500/20 p-3 hover:border-blue-400/40 transition-all duration-300 hover:scale-105">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Package className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-blue-300 text-xs font-medium uppercase tracking-wide">Товаров</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">
|
||||
{loading ? <div className="h-5 w-8 bg-white/10 rounded animate-pulse" /> : totalProducts.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-500/0 via-blue-500/5 to-blue-500/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</div>
|
||||
|
||||
{/* Общий остаток */}
|
||||
<div className="group relative overflow-hidden rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 border border-green-500/20 p-3 hover:border-green-400/40 transition-all duration-300 hover:scale-105">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Warehouse className="h-4 w-4 text-green-400" />
|
||||
<span className="text-green-300 text-xs font-medium uppercase tracking-wide">Остаток</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">
|
||||
{loading ? <div className="h-5 w-8 bg-white/10 rounded animate-pulse" /> : totalStocks.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-green-500/0 via-green-500/5 to-green-500/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</div>
|
||||
|
||||
{/* К клиенту */}
|
||||
<div className="group relative overflow-hidden rounded-xl bg-gradient-to-br from-orange-500/20 to-orange-600/10 border border-orange-500/20 p-3 hover:border-orange-400/40 transition-all duration-300 hover:scale-105">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrendingUp className="h-4 w-4 text-orange-400" />
|
||||
<span className="text-orange-300 text-xs font-medium uppercase tracking-wide">К клиенту</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">
|
||||
{loading ? <div className="h-5 w-8 bg-white/10 rounded animate-pulse" /> : totalReserved.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-orange-500/0 via-orange-500/5 to-orange-500/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</div>
|
||||
|
||||
{/* От клиента */}
|
||||
<div className="group relative overflow-hidden rounded-xl bg-gradient-to-br from-red-500/20 to-red-600/10 border border-red-500/20 p-3 hover:border-red-400/40 transition-all duration-300 hover:scale-105">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrendingDown className="h-4 w-4 text-red-400" />
|
||||
<span className="text-red-300 text-xs font-medium uppercase tracking-wide">От клиента</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">
|
||||
{loading ? <div className="h-5 w-8 bg-white/10 rounded animate-pulse" /> : totalFromClient.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-red-500/0 via-red-500/5 to-red-500/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</div>
|
||||
|
||||
{/* Складов */}
|
||||
<div className="group relative overflow-hidden rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-600/10 border border-purple-500/20 p-3 hover:border-purple-400/40 transition-all duration-300 hover:scale-105">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<MapPin className="h-4 w-4 text-purple-400" />
|
||||
<span className="text-purple-300 text-xs font-medium uppercase tracking-wide">Складов</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">
|
||||
{loading ? <div className="h-5 w-8 bg-white/10 rounded animate-pulse" /> : activeWarehouses}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-purple-500/0 via-purple-500/5 to-purple-500/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
231
src/components/wb-warehouse/stock-table-row.tsx
Normal file
231
src/components/wb-warehouse/stock-table-row.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
"use client"
|
||||
|
||||
import React from 'react'
|
||||
import { Package } from 'lucide-react'
|
||||
|
||||
// Интерфейсы (можно будет вынести в отдельный файл types.ts)
|
||||
interface WBStockInfo {
|
||||
warehouseId: number
|
||||
warehouseName: string
|
||||
quantity: number
|
||||
quantityFull: number
|
||||
inWayToClient: number
|
||||
inWayFromClient: number
|
||||
}
|
||||
|
||||
interface WBStock {
|
||||
nmId: number
|
||||
vendorCode: string
|
||||
title: string
|
||||
brand: string
|
||||
price: number
|
||||
stocks: WBStockInfo[]
|
||||
totalQuantity: number
|
||||
totalReserved: number
|
||||
photos: any[]
|
||||
mediaFiles: any[]
|
||||
characteristics: any[]
|
||||
subjectName: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface StockTableRowProps {
|
||||
item: WBStock
|
||||
}
|
||||
|
||||
export function StockTableRow({ item }: StockTableRowProps) {
|
||||
// Функция для получения изображений карточки
|
||||
const getCardImages = (item: WBStock) => {
|
||||
const fallbackUrl = `https://basket-${String(item.nmId).slice(0, 2)}.wbbasket.ru/vol${String(item.nmId).slice(0, -5)}/part${String(item.nmId).slice(0, -3)}/${item.nmId}/images/big/1.webp`
|
||||
|
||||
// Проверяем photos
|
||||
if (item.photos && item.photos.length > 0) {
|
||||
return item.photos.map(photo => photo.big || photo.c516x688 || photo.c246x328 || photo.square || photo.tm || fallbackUrl)
|
||||
}
|
||||
|
||||
// Проверяем mediaFiles
|
||||
if (item.mediaFiles && item.mediaFiles.length > 0) {
|
||||
return item.mediaFiles.map(media => media.big || media.c516x688 || media.c246x328 || media.square || media.tm || fallbackUrl)
|
||||
}
|
||||
|
||||
return [fallbackUrl]
|
||||
}
|
||||
|
||||
const getStockStatus = (quantity: number) => {
|
||||
if (quantity === 0) return {
|
||||
color: 'text-red-400',
|
||||
bgColor: 'bg-red-500/10',
|
||||
label: 'Нет в наличии'
|
||||
}
|
||||
if (quantity < 10) return {
|
||||
color: 'text-orange-400',
|
||||
bgColor: 'bg-orange-500/10',
|
||||
label: 'Мало'
|
||||
}
|
||||
return {
|
||||
color: 'text-green-400',
|
||||
bgColor: 'bg-green-500/10',
|
||||
label: 'В наличии'
|
||||
}
|
||||
}
|
||||
|
||||
const stockStatus = getStockStatus(item.totalQuantity)
|
||||
const images = getCardImages(item)
|
||||
const mainImage = images[0] || null
|
||||
|
||||
// Отбираем ключевые характеристики для отображения в таблице
|
||||
const keyCharacteristics = item.characteristics?.slice(0, 3) || []
|
||||
|
||||
return (
|
||||
<div className="group">
|
||||
{/* Основная строка товара */}
|
||||
<div
|
||||
className="grid grid-cols-12 gap-4 p-4 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/20 transition-all duration-300"
|
||||
>
|
||||
{/* Товар (3 колонки) */}
|
||||
<div className="col-span-3 flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg overflow-hidden bg-white/10 flex-shrink-0">
|
||||
{mainImage ? (
|
||||
<img
|
||||
src={mainImage}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.src = `https://basket-${String(item.nmId).slice(0, 2)}.wbbasket.ru/vol${String(item.nmId).slice(0, -5)}/part${String(item.nmId).slice(0, -3)}/${item.nmId}/images/big/1.webp`
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Package className="h-5 w-5 text-white/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-blue-300 bg-blue-500/20 px-2 py-0.5 rounded">
|
||||
{item.brand || 'Без бренда'}
|
||||
</span>
|
||||
<span className="text-white/40 text-xs">#{item.nmId}</span>
|
||||
</div>
|
||||
<h3 className="text-white text-sm font-medium line-clamp-1 mb-1">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="text-white/60 text-xs">
|
||||
{item.vendorCode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Остаток */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-bold ${stockStatus.color}`}>
|
||||
{item.totalQuantity.toLocaleString()}
|
||||
</div>
|
||||
<div className={`text-xs px-2 py-0.5 rounded ${stockStatus.bgColor} ${stockStatus.color}`}>
|
||||
{stockStatus.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* К клиенту */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-orange-400">
|
||||
{item.stocks.reduce((sum, s) => sum + s.inWayToClient, 0)}
|
||||
</div>
|
||||
<div className="text-xs text-white/60">в пути</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* От клиента */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-red-400">
|
||||
{item.stocks.reduce((sum, s) => sum + s.inWayFromClient, 0)}
|
||||
</div>
|
||||
<div className="text-xs text-white/60">возвраты</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Складов */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-purple-400">
|
||||
{item.stocks.length}
|
||||
</div>
|
||||
<div className="text-xs text-white/60">активных</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Характеристики (5 колонок) */}
|
||||
<div className="col-span-5 flex items-center">
|
||||
<div className="space-y-1 w-full">
|
||||
{keyCharacteristics.map((char, index) => (
|
||||
<div key={index} className="flex justify-between text-xs">
|
||||
<span className="text-white/60 truncate w-1/2">{char.name}:</span>
|
||||
<span className="text-white truncate w-1/2 text-right">
|
||||
{Array.isArray(char.value) ? char.value.join(', ') : String(char.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{item.subjectName && (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-white/60">Категория:</span>
|
||||
<span className="text-blue-300 truncate text-right">{item.subjectName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Города в модулях */}
|
||||
<div className="grid grid-cols-12 gap-4 p-3 bg-white/[0.02] border-l-2 border-blue-400/30">
|
||||
<div className="col-span-12 flex flex-wrap gap-3">
|
||||
{item.stocks.map((stock, stockIndex) => (
|
||||
<div
|
||||
key={`${stock.warehouseId}-${stockIndex}`}
|
||||
className="bg-white/10 rounded-lg px-3 py-2 border border-white/20 hover:border-white/30 transition-colors"
|
||||
>
|
||||
{/* Название города */}
|
||||
<div className="text-white text-sm font-medium mb-1">
|
||||
{stock.warehouseName}
|
||||
</div>
|
||||
|
||||
{/* Цифры */}
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<div className="text-center">
|
||||
<div className={`font-bold ${stock.quantity > 0 ? 'text-green-400' : 'text-white/30'}`}>
|
||||
{stock.quantity}
|
||||
</div>
|
||||
<div className="text-white/50">остаток</div>
|
||||
</div>
|
||||
|
||||
{(stock.inWayToClient > 0 || stock.inWayFromClient > 0) && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-white/20" />
|
||||
|
||||
{stock.inWayToClient > 0 && (
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-orange-400">{stock.inWayToClient}</div>
|
||||
<div className="text-white/50">к клиенту</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stock.inWayFromClient > 0 && (
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-red-400">{stock.inWayFromClient}</div>
|
||||
<div className="text-white/50">от клиента</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
37
src/components/wb-warehouse/table-header.tsx
Normal file
37
src/components/wb-warehouse/table-header.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
"use client"
|
||||
|
||||
import React from 'react'
|
||||
import { Package, Warehouse, TrendingUp, TrendingDown, MapPin, Info } from 'lucide-react'
|
||||
|
||||
export function TableHeader() {
|
||||
return (
|
||||
<div className="mb-3 p-4 rounded-xl bg-gradient-to-r from-white/5 to-white/10 border border-white/10">
|
||||
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-white/80">
|
||||
<div className="col-span-3 flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-blue-400" />
|
||||
<span>Товар</span>
|
||||
</div>
|
||||
<div className="col-span-1 text-center flex items-center justify-center gap-1">
|
||||
<Warehouse className="h-3 w-3 text-green-400" />
|
||||
<span>Остаток</span>
|
||||
</div>
|
||||
<div className="col-span-1 text-center flex items-center justify-center gap-1">
|
||||
<TrendingUp className="h-3 w-3 text-orange-400" />
|
||||
<span>К клиенту</span>
|
||||
</div>
|
||||
<div className="col-span-1 text-center flex items-center justify-center gap-1">
|
||||
<TrendingDown className="h-3 w-3 text-red-400" />
|
||||
<span>От клиента</span>
|
||||
</div>
|
||||
<div className="col-span-1 text-center flex items-center justify-center gap-1">
|
||||
<MapPin className="h-3 w-3 text-purple-400" />
|
||||
<span>Складов</span>
|
||||
</div>
|
||||
<div className="col-span-5 flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-blue-400" />
|
||||
<span>Характеристики</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
894
src/components/wb-warehouse/wb-warehouse-dashboard-old.tsx
Normal file
894
src/components/wb-warehouse/wb-warehouse-dashboard-old.tsx
Normal file
@ -0,0 +1,894 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
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 { Skeleton } from '@/components/ui/skeleton'
|
||||
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, Search, Package, Warehouse, TrendingDown, MapPin, Info } 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?: Array<{
|
||||
big?: string
|
||||
c246x328?: string
|
||||
c516x688?: string
|
||||
square?: string
|
||||
tm?: string
|
||||
}>
|
||||
mediaFiles?: string[]
|
||||
characteristics?: Array<{
|
||||
id: number
|
||||
name: string
|
||||
value: string[] | string
|
||||
}>
|
||||
subjectName?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface WBWarehouse {
|
||||
id: number
|
||||
name: string
|
||||
cargoType: number
|
||||
deliveryType: number
|
||||
}
|
||||
|
||||
export function WBWarehouseDashboard() {
|
||||
const { user } = useAuth()
|
||||
const { getSidebarMargin } = useSidebar()
|
||||
|
||||
const [stocks, setStocks] = useState<WBStock[]>([])
|
||||
const [warehouses, setWarehouses] = useState<WBWarehouse[]>([])
|
||||
const [analyticsData, setAnalyticsData] = useState<Array<{warehouseId: number; warehouseName: string; toClient: number; fromClient: number}>>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('all')
|
||||
|
||||
// Статистика
|
||||
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)
|
||||
|
||||
// Загрузка данных
|
||||
const loadWarehouseData = async (showToast = false) => {
|
||||
const isInitialLoad = loading
|
||||
if (!isInitialLoad) setRefreshing(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)
|
||||
|
||||
console.log('WB Warehouse: Starting data load...')
|
||||
|
||||
// Сначала получаем карточки товаров - это основа для всего
|
||||
console.log('WB Warehouse: Getting cards...')
|
||||
const cards = await WildberriesService.getAllCards(apiToken).catch(() => [])
|
||||
const nmIds = cards.map(card => card.nmID).filter(id => id > 0)
|
||||
console.log('WB Warehouse: Found cards:', cards.length)
|
||||
console.log('WB Warehouse: Card IDs for analytics:', nmIds)
|
||||
|
||||
if (cards.length === 0) {
|
||||
console.log('WB Warehouse: No cards found, cannot proceed with analytics')
|
||||
setStocks([])
|
||||
setWarehouses([])
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем данные по складам для каждого товара отдельно
|
||||
console.log('WB Warehouse: Getting stocks analytics for each card separately...')
|
||||
const analyticsResults = []
|
||||
|
||||
for (const nmId of nmIds) {
|
||||
console.log(`WB Warehouse: Getting analytics for nmId: ${nmId}`)
|
||||
try {
|
||||
const result = await wbService.getStocksReportByOffices({
|
||||
nmIds: [nmId], // Один товар за раз
|
||||
stockType: '' // все склады
|
||||
})
|
||||
analyticsResults.push({ nmId, data: result })
|
||||
console.log(`WB Warehouse: Got analytics for ${nmId}:`, result)
|
||||
|
||||
// Пауза между запросами чтобы не превысить лимиты API
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
} catch (error) {
|
||||
console.error(`WB Warehouse: Error loading analytics for ${nmId}:`, error)
|
||||
analyticsResults.push({ nmId, data: { data: { regions: [] } } })
|
||||
}
|
||||
}
|
||||
|
||||
console.log('WB Warehouse: Cards loaded:', cards.length)
|
||||
console.log('WB Warehouse: Analytics data received')
|
||||
|
||||
// Объединяем карточки товаров с данными Analytics API
|
||||
const combinedStocks = combineCardsWithIndividualAnalytics(cards, analyticsResults)
|
||||
console.log('WB Warehouse: Combined stocks:', combinedStocks.length)
|
||||
|
||||
// Отключаем общую аналитику - будем показывать детализацию по товарам в карточках
|
||||
setAnalyticsData([])
|
||||
|
||||
// Используем объединенные данные
|
||||
setStocks(combinedStocks)
|
||||
|
||||
// Извлекаем информацию о складах из данных Analytics API
|
||||
const warehousesFromAnalytics = extractWarehousesFromStocks(combinedStocks)
|
||||
setWarehouses(warehousesFromAnalytics)
|
||||
|
||||
// Обновляем статистику
|
||||
updateStatistics(combinedStocks, warehousesFromAnalytics)
|
||||
|
||||
if (showToast) {
|
||||
toast.success('Данные обновлены')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки данных склада:', error)
|
||||
toast.error('Ошибка загрузки данных склада')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Объединение карточек товаров с индивидуальными данными Analytics API
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const combineCardsWithIndividualAnalytics = (cards: any[], analyticsResults: any[]): WBStock[] => {
|
||||
const stocksMap = new Map<number, WBStock>()
|
||||
|
||||
console.log('WB Warehouse: Combining cards with individual analytics...')
|
||||
console.log('WB Warehouse: Cards count:', cards.length)
|
||||
console.log('WB Warehouse: Analytics results count:', analyticsResults.length)
|
||||
|
||||
// Создаем карту Analytics результатов по nmId
|
||||
const analyticsMap = new Map()
|
||||
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: [], // Заполним из Analytics API
|
||||
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
|
||||
}
|
||||
|
||||
// Получаем данные Analytics для этого конкретного товара
|
||||
const analyticsData = analyticsMap.get(card.nmID)
|
||||
console.log(`WB Warehouse: Processing analytics for card ${card.nmID}:`, analyticsData)
|
||||
|
||||
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) => {
|
||||
console.log(`WB Warehouse: Adding office ${office.officeName} for card ${card.nmID}`)
|
||||
console.log(`WB Warehouse: Office metrics:`, office.metrics)
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.log(`WB Warehouse: No analytics data found for card ${card.nmID}`)
|
||||
}
|
||||
|
||||
stocksMap.set(card.nmID, stock)
|
||||
})
|
||||
|
||||
console.log('WB Warehouse: Final stocks after combining:', stocksMap.size)
|
||||
return Array.from(stocksMap.values()).sort((a, b) => b.totalQuantity - a.totalQuantity)
|
||||
}
|
||||
|
||||
// Объединение карточек товаров с данными Analytics API (старая функция)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const combineCardsWithAnalytics = (cards: any[], analyticsResponse: any): WBStock[] => {
|
||||
const stocksMap = new Map<number, WBStock>()
|
||||
|
||||
console.log('WB Warehouse: Combining cards with analytics...')
|
||||
console.log('WB Warehouse: Cards count:', cards.length)
|
||||
console.log('WB Warehouse: Analytics response:', analyticsResponse)
|
||||
|
||||
// Сначала создаем записи для всех карточек товаров
|
||||
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: [], // Заполним из Analytics API
|
||||
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
|
||||
}
|
||||
|
||||
stocksMap.set(card.nmID, stock)
|
||||
})
|
||||
|
||||
console.log('WB Warehouse: Created stocks from cards:', stocksMap.size)
|
||||
|
||||
// Теперь дополняем данными из Analytics API
|
||||
if (analyticsResponse?.data?.regions) {
|
||||
console.log('WB Warehouse: Processing analytics regions:', analyticsResponse.data.regions.length)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
analyticsResponse.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) => {
|
||||
console.log(`WB Warehouse: Processing office ${office.officeName} (${office.officeID})`)
|
||||
console.log('WB Warehouse: Office metrics:', office.metrics)
|
||||
|
||||
// Пока что добавляем данные склада ко всем товарам
|
||||
// TODO: нужно понять как Analytics API связывает товары со складами
|
||||
stocksMap.forEach((stock, nmId) => {
|
||||
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
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
console.log('WB Warehouse: Final stocks after combining:', stocksMap.size)
|
||||
return Array.from(stocksMap.values()).sort((a, b) => b.totalQuantity - a.totalQuantity)
|
||||
}
|
||||
|
||||
// Извлечение информации о складах из данных Analytics API
|
||||
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 filteredStocks = stocks.filter(item => {
|
||||
const matchesSearch = searchTerm === '' ||
|
||||
item.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.vendorCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.brand.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
|
||||
const matchesWarehouse = selectedWarehouse === 'all' ||
|
||||
item.stocks.some(s => s.warehouseId.toString() === selectedWarehouse)
|
||||
|
||||
return matchesSearch && matchesWarehouse
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.organization?.type === 'SELLER') {
|
||||
loadWarehouseData()
|
||||
}
|
||||
}, [user])
|
||||
|
||||
// Проверяем настройку API ключа
|
||||
const hasWBApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')?.isActive
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
|
||||
<div className="h-full w-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-xl font-semibold text-white mb-2">Настройте API ключ Wildberries</h3>
|
||||
<p className="text-white/60 mb-4">
|
||||
Для просмотра остатков товаров на складах WB необходимо добавить API ключ
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => window.location.href = '/settings'}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white"
|
||||
>
|
||||
Перейти в настройки
|
||||
</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" />
|
||||
<p className="text-white/60">
|
||||
{searchTerm || selectedWarehouse !== 'all'
|
||||
? 'Товары не найдены по заданным фильтрам'
|
||||
: 'Нет карточек товаров в WB'
|
||||
}
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="overflow-y-auto pr-2 max-h-full">
|
||||
{/* Красивая шапка таблицы */}
|
||||
<div className="mb-3 p-4 rounded-xl bg-gradient-to-r from-white/5 to-white/10 border border-white/10">
|
||||
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-white/80">
|
||||
<div className="col-span-3 flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-blue-400" />
|
||||
<span>Товар</span>
|
||||
</div>
|
||||
<div className="col-span-1 text-center flex items-center justify-center gap-1">
|
||||
<Warehouse className="h-3 w-3 text-green-400" />
|
||||
<span>Остаток</span>
|
||||
</div>
|
||||
<div className="col-span-1 text-center flex items-center justify-center gap-1">
|
||||
<TrendingUp className="h-3 w-3 text-orange-400" />
|
||||
<span>К клиенту</span>
|
||||
</div>
|
||||
<div className="col-span-1 text-center flex items-center justify-center gap-1">
|
||||
<TrendingDown className="h-3 w-3 text-red-400" />
|
||||
<span>От клиента</span>
|
||||
</div>
|
||||
<div className="col-span-1 text-center flex items-center justify-center gap-1">
|
||||
<MapPin className="h-3 w-3 text-purple-400" />
|
||||
<span>Складов</span>
|
||||
</div>
|
||||
<div className="col-span-5 flex items-center gap-2">
|
||||
<Info className="h-4 w-4 text-blue-400" />
|
||||
<span>Характеристики</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица товаров */}
|
||||
<div className="space-y-1">
|
||||
{filteredStocks.map((item, index) => (
|
||||
<StockTableRow key={`${item.nmId}-${index}`} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Табличная строка товара
|
||||
function StockTableRow({ item }: { item: WBStock }) {
|
||||
|
||||
// Получение изображений карточки через WildberriesService
|
||||
const getCardImages = (item: WBStock): string[] => {
|
||||
if (item.photos && item.photos.length > 0) {
|
||||
const urls = item.photos
|
||||
.map(photo => photo.c246x328 || photo.c516x688 || photo.big)
|
||||
.filter((url): url is string => Boolean(url))
|
||||
return urls
|
||||
}
|
||||
|
||||
if (item.mediaFiles && item.mediaFiles.length > 0) {
|
||||
return item.mediaFiles
|
||||
}
|
||||
|
||||
const vol = Math.floor(item.nmId / 100000)
|
||||
const part = Math.floor(item.nmId / 1000)
|
||||
const fallbackUrl = `https://basket-${String(vol).padStart(2, '0')}.wbbasket.ru/vol${vol}/part${part}/${item.nmId}/images/c246x328/1.webp`
|
||||
return [fallbackUrl]
|
||||
}
|
||||
|
||||
const getStockStatus = (quantity: number) => {
|
||||
if (quantity === 0) return {
|
||||
color: 'text-red-400',
|
||||
bgColor: 'bg-red-500/10',
|
||||
label: 'Нет в наличии'
|
||||
}
|
||||
if (quantity < 10) return {
|
||||
color: 'text-orange-400',
|
||||
bgColor: 'bg-orange-500/10',
|
||||
label: 'Мало'
|
||||
}
|
||||
return {
|
||||
color: 'text-green-400',
|
||||
bgColor: 'bg-green-500/10',
|
||||
label: 'В наличии'
|
||||
}
|
||||
}
|
||||
|
||||
const stockStatus = getStockStatus(item.totalQuantity)
|
||||
const images = getCardImages(item)
|
||||
const mainImage = images[0] || null
|
||||
|
||||
// Отбираем ключевые характеристики для отображения в таблице
|
||||
const keyCharacteristics = item.characteristics?.slice(0, 3) || []
|
||||
|
||||
return (
|
||||
<div className="group">
|
||||
{/* Основная строка товара */}
|
||||
<div
|
||||
className="grid grid-cols-12 gap-4 p-4 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/20 transition-all duration-300"
|
||||
>
|
||||
{/* Товар (3 колонки) */}
|
||||
<div className="col-span-3 flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg overflow-hidden bg-white/10 flex-shrink-0">
|
||||
{mainImage ? (
|
||||
<img
|
||||
src={mainImage}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Package className="h-5 w-5 text-white/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-blue-300 bg-blue-500/20 px-2 py-0.5 rounded">
|
||||
{item.brand || 'Без бренда'}
|
||||
</span>
|
||||
<span className="text-white/40 text-xs">#{item.nmId}</span>
|
||||
</div>
|
||||
<h3 className="text-white text-sm font-medium line-clamp-1 mb-1">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="text-white/60 text-xs">
|
||||
{item.vendorCode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Остаток */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-bold ${stockStatus.color}`}>
|
||||
{item.totalQuantity.toLocaleString()}
|
||||
</div>
|
||||
<div className={`text-xs px-2 py-0.5 rounded ${stockStatus.bgColor} ${stockStatus.color}`}>
|
||||
{stockStatus.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* К клиенту */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-orange-400">
|
||||
{item.stocks.reduce((sum, s) => sum + s.inWayToClient, 0)}
|
||||
</div>
|
||||
<div className="text-xs text-white/60">в пути</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* От клиента */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-red-400">
|
||||
{item.stocks.reduce((sum, s) => sum + s.inWayFromClient, 0)}
|
||||
</div>
|
||||
<div className="text-xs text-white/60">возвраты</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Складов */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-purple-400">
|
||||
{item.stocks.length}
|
||||
</div>
|
||||
<div className="text-xs text-white/60">активных</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Характеристики (5 колонок) */}
|
||||
<div className="col-span-5 flex items-center">
|
||||
<div className="space-y-1 w-full">
|
||||
{keyCharacteristics.map((char, index) => (
|
||||
<div key={index} className="flex justify-between text-xs">
|
||||
<span className="text-white/60 truncate w-1/2">{char.name}:</span>
|
||||
<span className="text-white truncate w-1/2 text-right">
|
||||
{Array.isArray(char.value) ? char.value.join(', ') : String(char.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{item.subjectName && (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-white/60">Категория:</span>
|
||||
<span className="text-blue-300 truncate text-right">{item.subjectName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Города в модулях */}
|
||||
<div className="grid grid-cols-12 gap-4 p-3 bg-white/[0.02] border-l-2 border-blue-400/30">
|
||||
<div className="col-span-12 flex flex-wrap gap-3">
|
||||
{item.stocks.map((stock, stockIndex) => (
|
||||
<div
|
||||
key={`${stock.warehouseId}-${stockIndex}`}
|
||||
className="bg-white/10 rounded-lg px-3 py-2 border border-white/20 hover:border-white/30 transition-colors"
|
||||
>
|
||||
{/* Название города */}
|
||||
<div className="text-white text-sm font-medium mb-1">
|
||||
{stock.warehouseName}
|
||||
</div>
|
||||
|
||||
{/* Цифры */}
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<div className="text-center">
|
||||
<div className={`font-bold ${stock.quantity > 0 ? 'text-green-400' : 'text-white/30'}`}>
|
||||
{stock.quantity}
|
||||
</div>
|
||||
<div className="text-white/50">остаток</div>
|
||||
</div>
|
||||
|
||||
{(stock.inWayToClient > 0 || stock.inWayFromClient > 0) && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-white/20" />
|
||||
|
||||
{stock.inWayToClient > 0 && (
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-orange-400">{stock.inWayToClient}</div>
|
||||
<div className="text-white/50">к клиенту</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stock.inWayFromClient > 0 && (
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-red-400">{stock.inWayFromClient}</div>
|
||||
<div className="text-white/50">от клиента</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Супер современная карточка товара (СТАРАЯ ВЕРСИЯ - НЕ ИСПОЛЬЗУЕТСЯ)
|
||||
function StockCard({ item }: { item: WBStock }) {
|
||||
// Получение изображений карточки через WildberriesService
|
||||
const getCardImages = (item: WBStock): string[] => {
|
||||
if (item.photos && item.photos.length > 0) {
|
||||
const urls = item.photos
|
||||
.map(photo => photo.c246x328 || photo.c516x688 || photo.big)
|
||||
.filter((url): url is string => Boolean(url))
|
||||
return urls
|
||||
}
|
||||
|
||||
if (item.mediaFiles && item.mediaFiles.length > 0) {
|
||||
return item.mediaFiles
|
||||
}
|
||||
|
||||
const vol = Math.floor(item.nmId / 100000)
|
||||
const part = Math.floor(item.nmId / 1000)
|
||||
const fallbackUrl = `https://basket-${String(vol).padStart(2, '0')}.wbbasket.ru/vol${vol}/part${part}/${item.nmId}/images/c246x328/1.webp`
|
||||
return [fallbackUrl]
|
||||
}
|
||||
|
||||
const getStockStatus = (quantity: number) => {
|
||||
if (quantity === 0) return {
|
||||
color: 'from-red-500/20 to-red-600/5 border-red-500/30',
|
||||
textColor: 'text-red-400',
|
||||
label: 'Нет в наличии',
|
||||
icon: '❌'
|
||||
}
|
||||
if (quantity < 10) return {
|
||||
color: 'from-orange-500/20 to-orange-600/5 border-orange-500/30',
|
||||
textColor: 'text-orange-400',
|
||||
label: 'Мало',
|
||||
icon: '⚠️'
|
||||
}
|
||||
return {
|
||||
color: 'from-green-500/20 to-green-600/5 border-green-500/30',
|
||||
textColor: 'text-green-400',
|
||||
label: 'В наличии',
|
||||
icon: '✅'
|
||||
}
|
||||
}
|
||||
|
||||
const stockStatus = getStockStatus(item.totalQuantity)
|
||||
const images = getCardImages(item)
|
||||
const mainImage = images[0] || null
|
||||
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-2xl bg-gradient-to-br from-white/5 to-white/[0.02] border border-white/10 hover:border-white/20 transition-all duration-500 hover:scale-[1.02] hover:shadow-2xl hover:shadow-blue-500/10">
|
||||
{/* Градиентный фон при hover */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/0 via-purple-500/0 to-pink-500/0 group-hover:from-blue-500/5 group-hover:via-purple-500/5 group-hover:to-pink-500/5 transition-all duration-700" />
|
||||
|
||||
<div className="relative p-5">
|
||||
{/* Хедер карточки */}
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
{/* Изображение товара */}
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 rounded-xl overflow-hidden bg-gradient-to-br from-white/10 to-white/5 flex-shrink-0 relative group/image">
|
||||
{mainImage ? (
|
||||
<img
|
||||
src={mainImage}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover group-hover/image:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Package className="h-6 w-6 text-white/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* WB Badge */}
|
||||
<div className="absolute -top-1 -right-1">
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 text-white text-xs px-2 py-0.5 rounded-full font-bold shadow-lg">
|
||||
WB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Инфо товара */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-blue-300 font-medium bg-blue-500/20 px-2 py-0.5 rounded-md">
|
||||
{item.brand || 'Без бренда'}
|
||||
</span>
|
||||
<span className="text-white/40 text-xs">#{item.nmId}</span>
|
||||
</div>
|
||||
<h3 className="text-white font-semibold text-sm leading-tight line-clamp-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Статус */}
|
||||
<div className={`ml-2 px-2 py-1 rounded-lg bg-gradient-to-r ${stockStatus.color} border text-xs font-medium ${stockStatus.textColor} flex items-center gap-1`}>
|
||||
<span>{stockStatus.icon}</span>
|
||||
{stockStatus.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-white/60 text-xs">
|
||||
Артикул: <span className="text-white/80 font-mono">{item.vendorCode}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Компактная статистика */}
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<div className="text-center p-2 rounded-lg bg-gradient-to-br from-green-500/10 to-green-600/5 border border-green-500/20">
|
||||
<div className="text-green-400 text-lg font-bold">{item.totalQuantity.toLocaleString()}</div>
|
||||
<div className="text-green-300/60 text-xs">Остаток</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-gradient-to-br from-purple-500/10 to-purple-600/5 border border-purple-500/20">
|
||||
<div className="text-purple-400 text-lg font-bold">{item.stocks.length}</div>
|
||||
<div className="text-purple-300/60 text-xs">Складов</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-gradient-to-br from-orange-500/10 to-orange-600/5 border border-orange-500/20">
|
||||
<div className="text-orange-400 text-lg font-bold">
|
||||
{item.stocks.reduce((sum, s) => sum + s.inWayToClient, 0)}
|
||||
</div>
|
||||
<div className="text-orange-300/60 text-xs">К клиенту</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-gradient-to-br from-red-500/10 to-red-600/5 border border-red-500/20">
|
||||
<div className="text-red-400 text-lg font-bold">
|
||||
{item.stocks.reduce((sum, s) => sum + s.inWayFromClient, 0)}
|
||||
</div>
|
||||
<div className="text-red-300/60 text-xs">От клиента</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Склады - компактно */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-white/80 font-medium text-xs uppercase tracking-wide flex items-center gap-2">
|
||||
<Warehouse className="h-3 w-3" />
|
||||
Склады
|
||||
</h4>
|
||||
<div className="space-y-1.5">
|
||||
{item.stocks.slice(0, 3).map((stock, stockIndex) => (
|
||||
<div key={`${stock.warehouseId}-${stockIndex}`} className="flex items-center justify-between p-2 rounded-lg bg-white/5 hover:bg-white/10 transition-colors duration-200">
|
||||
<div className="flex-1">
|
||||
<div className="text-white text-xs font-medium truncate">{stock.warehouseName}</div>
|
||||
<div className="text-white/50 text-xs">ID: {stock.warehouseId}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<div className="text-center">
|
||||
<div className={`font-bold ${stock.quantity > 0 ? 'text-green-400' : 'text-white/30'}`}>
|
||||
{stock.quantity}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`font-bold ${stock.inWayToClient > 0 ? 'text-orange-400' : 'text-white/30'}`}>
|
||||
{stock.inWayToClient}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`font-bold ${stock.inWayFromClient > 0 ? 'text-red-400' : 'text-white/30'}`}>
|
||||
{stock.inWayFromClient}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{item.stocks.length > 3 && (
|
||||
<div className="text-center text-white/40 text-xs py-1">
|
||||
+{item.stocks.length - 3} ещё складов
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Категория */}
|
||||
{item.subjectName && (
|
||||
<div className="mt-3 pt-3 border-t border-white/10">
|
||||
<div className="text-white/60 text-xs">
|
||||
<span className="text-white/40">Категория:</span> {item.subjectName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,344 @@
|
||||
"use client"
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
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?: Array<{
|
||||
big?: string
|
||||
c246x328?: string
|
||||
c516x688?: string
|
||||
square?: string
|
||||
tm?: string
|
||||
}>
|
||||
mediaFiles?: string[]
|
||||
characteristics?: Array<{
|
||||
name: string
|
||||
value: string | string[]
|
||||
}>
|
||||
subjectName: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface WBWarehouse {
|
||||
id: number
|
||||
name: string
|
||||
cargoType: number
|
||||
deliveryType: number
|
||||
}
|
||||
|
||||
export function WBWarehouseDashboard() {
|
||||
const { user } = useAuth()
|
||||
const { isCollapsed, getSidebarMargin } = useSidebar()
|
||||
|
||||
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[]>([])
|
||||
|
||||
const hasWBApiKey = user?.wildberriesApiKey
|
||||
|
||||
// Комбинирование карточек с индивидуальными данными аналитики
|
||||
// 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 (!user?.wildberriesApiKey) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const apiToken = user.wildberriesApiKey
|
||||
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-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}>
|
||||
<div className="h-full w-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>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
"use client"
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
@ -6,21 +7,14 @@ import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
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 { Skeleton } from '@/components/ui/skeleton'
|
||||
import { WildberriesService } from '@/services/wildberries-service'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Search,
|
||||
Package,
|
||||
Warehouse,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
RefreshCw,
|
||||
Filter,
|
||||
MapPin
|
||||
} from 'lucide-react'
|
||||
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
|
||||
@ -38,21 +32,11 @@ interface WBStock {
|
||||
}>
|
||||
totalQuantity: number
|
||||
totalReserved: number
|
||||
photos?: Array<{
|
||||
big?: string
|
||||
c246x328?: string
|
||||
c516x688?: string
|
||||
square?: string
|
||||
tm?: string
|
||||
}>
|
||||
mediaFiles?: string[]
|
||||
characteristics?: Array<{
|
||||
id: number
|
||||
name: string
|
||||
value: string[] | string
|
||||
}>
|
||||
subjectName?: string
|
||||
description?: string
|
||||
photos: any[]
|
||||
mediaFiles: any[]
|
||||
characteristics: any[]
|
||||
subjectName: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface WBWarehouse {
|
||||
@ -64,15 +48,12 @@ interface WBWarehouse {
|
||||
|
||||
export function WBWarehouseDashboard() {
|
||||
const { user } = useAuth()
|
||||
const { getSidebarMargin } = useSidebar()
|
||||
const { isCollapsed, getSidebarMargin } = useSidebar()
|
||||
|
||||
const [stocks, setStocks] = useState<WBStock[]>([])
|
||||
const [warehouses, setWarehouses] = useState<WBWarehouse[]>([])
|
||||
const [analyticsData, setAnalyticsData] = useState<Array<{warehouseId: number; warehouseName: string; toClient: number; fromClient: number}>>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>('all')
|
||||
|
||||
// Статистика
|
||||
const [totalProducts, setTotalProducts] = useState(0)
|
||||
@ -81,11 +62,115 @@ export function WBWarehouseDashboard() {
|
||||
const [totalFromClient, setTotalFromClient] = useState(0)
|
||||
const [activeWarehouses, setActiveWarehouses] = useState(0)
|
||||
|
||||
// Загрузка данных
|
||||
const loadWarehouseData = async (showToast = false) => {
|
||||
const isInitialLoad = loading
|
||||
if (!isInitialLoad) setRefreshing(true)
|
||||
// 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')
|
||||
|
||||
@ -107,289 +192,73 @@ export function WBWarehouseDashboard() {
|
||||
|
||||
const wbService = new WildberriesService(apiToken)
|
||||
|
||||
console.log('WB Warehouse: Starting data load...')
|
||||
|
||||
// Сначала получаем карточки товаров - это основа для всего
|
||||
console.log('WB Warehouse: Getting cards...')
|
||||
// 1. Получаем карточки товаров
|
||||
const cards = await WildberriesService.getAllCards(apiToken).catch(() => [])
|
||||
const nmIds = cards.map(card => card.nmID).filter(id => id > 0)
|
||||
console.log('WB Warehouse: Found cards:', cards.length)
|
||||
console.log('WB Warehouse: Card IDs for analytics:', nmIds)
|
||||
console.log('WB Warehouse: Loaded cards:', cards.length)
|
||||
|
||||
if (cards.length === 0) {
|
||||
console.log('WB Warehouse: No cards found, cannot proceed with analytics')
|
||||
setStocks([])
|
||||
setWarehouses([])
|
||||
toast.error('Нет карточек товаров в WB')
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем данные по складам для каждого товара отдельно
|
||||
console.log('WB Warehouse: Getting stocks analytics for each card separately...')
|
||||
const analyticsResults = []
|
||||
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) {
|
||||
console.log(`WB Warehouse: Getting analytics for nmId: ${nmId}`)
|
||||
try {
|
||||
console.log(`WB Warehouse: Fetching analytics for nmId ${nmId}`)
|
||||
const result = await wbService.getStocksReportByOffices({
|
||||
nmIds: [nmId], // Один товар за раз
|
||||
stockType: '' // все склады
|
||||
nmIds: [nmId],
|
||||
stockType: ''
|
||||
})
|
||||
analyticsResults.push({ nmId, data: result })
|
||||
console.log(`WB Warehouse: Got analytics for ${nmId}:`, result)
|
||||
|
||||
// Пауза между запросами чтобы не превысить лимиты API
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
} catch (error) {
|
||||
console.error(`WB Warehouse: Error loading analytics for ${nmId}:`, error)
|
||||
analyticsResults.push({ nmId, data: { data: { regions: [] } } })
|
||||
console.error(`WB Warehouse: Error fetching analytics for nmId ${nmId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('WB Warehouse: Cards loaded:', cards.length)
|
||||
console.log('WB Warehouse: Analytics data received')
|
||||
console.log('WB Warehouse: Analytics results:', analyticsResults.length)
|
||||
|
||||
// Объединяем карточки товаров с данными Analytics API
|
||||
// 3. Комбинируем данные
|
||||
const combinedStocks = combineCardsWithIndividualAnalytics(cards, analyticsResults)
|
||||
console.log('WB Warehouse: Combined stocks:', combinedStocks.length)
|
||||
|
||||
// Отключаем общую аналитику - будем показывать детализацию по товарам в карточках
|
||||
setAnalyticsData([])
|
||||
// 4. Извлекаем склады и обновляем статистику
|
||||
const extractedWarehouses = extractWarehousesFromStocks(combinedStocks)
|
||||
|
||||
// Используем объединенные данные
|
||||
setStocks(combinedStocks)
|
||||
setWarehouses(extractedWarehouses)
|
||||
updateStatistics(combinedStocks, extractedWarehouses)
|
||||
|
||||
// Извлекаем информацию о складах из данных Analytics API
|
||||
const warehousesFromAnalytics = extractWarehousesFromStocks(combinedStocks)
|
||||
setWarehouses(warehousesFromAnalytics)
|
||||
|
||||
// Обновляем статистику
|
||||
updateStatistics(combinedStocks, warehousesFromAnalytics)
|
||||
|
||||
if (showToast) {
|
||||
toast.success('Данные обновлены')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки данных склада:', error)
|
||||
toast.error('Ошибка загрузки данных склада')
|
||||
toast.success(`Загружено товаров: ${combinedStocks.length}`)
|
||||
} catch (error: any) {
|
||||
console.error('WB Warehouse: Error loading data:', error)
|
||||
toast.error('Ошибка загрузки данных: ' + (error.message || 'Неизвестная ошибка'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Объединение карточек товаров с индивидуальными данными Analytics API
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const combineCardsWithIndividualAnalytics = (cards: any[], analyticsResults: any[]): WBStock[] => {
|
||||
const stocksMap = new Map<number, WBStock>()
|
||||
|
||||
console.log('WB Warehouse: Combining cards with individual analytics...')
|
||||
console.log('WB Warehouse: Cards count:', cards.length)
|
||||
console.log('WB Warehouse: Analytics results count:', analyticsResults.length)
|
||||
|
||||
// Создаем карту Analytics результатов по nmId
|
||||
const analyticsMap = new Map()
|
||||
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: [], // Заполним из Analytics API
|
||||
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
|
||||
}
|
||||
|
||||
// Получаем данные Analytics для этого конкретного товара
|
||||
const analyticsData = analyticsMap.get(card.nmID)
|
||||
console.log(`WB Warehouse: Processing analytics for card ${card.nmID}:`, analyticsData)
|
||||
|
||||
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) => {
|
||||
console.log(`WB Warehouse: Adding office ${office.officeName} for card ${card.nmID}`)
|
||||
console.log(`WB Warehouse: Office metrics:`, office.metrics)
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.log(`WB Warehouse: No analytics data found for card ${card.nmID}`)
|
||||
}
|
||||
|
||||
stocksMap.set(card.nmID, stock)
|
||||
})
|
||||
|
||||
console.log('WB Warehouse: Final stocks after combining:', stocksMap.size)
|
||||
return Array.from(stocksMap.values()).sort((a, b) => b.totalQuantity - a.totalQuantity)
|
||||
}
|
||||
|
||||
// Объединение карточек товаров с данными Analytics API (старая функция)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const combineCardsWithAnalytics = (cards: any[], analyticsResponse: any): WBStock[] => {
|
||||
const stocksMap = new Map<number, WBStock>()
|
||||
|
||||
console.log('WB Warehouse: Combining cards with analytics...')
|
||||
console.log('WB Warehouse: Cards count:', cards.length)
|
||||
console.log('WB Warehouse: Analytics response:', analyticsResponse)
|
||||
|
||||
// Сначала создаем записи для всех карточек товаров
|
||||
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: [], // Заполним из Analytics API
|
||||
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
|
||||
}
|
||||
|
||||
stocksMap.set(card.nmID, stock)
|
||||
})
|
||||
|
||||
console.log('WB Warehouse: Created stocks from cards:', stocksMap.size)
|
||||
|
||||
// Теперь дополняем данными из Analytics API
|
||||
if (analyticsResponse?.data?.regions) {
|
||||
console.log('WB Warehouse: Processing analytics regions:', analyticsResponse.data.regions.length)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
analyticsResponse.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) => {
|
||||
console.log(`WB Warehouse: Processing office ${office.officeName} (${office.officeID})`)
|
||||
console.log('WB Warehouse: Office metrics:', office.metrics)
|
||||
|
||||
// Пока что добавляем данные склада ко всем товарам
|
||||
// TODO: нужно понять как Analytics API связывает товары со складами
|
||||
stocksMap.forEach((stock, nmId) => {
|
||||
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
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
console.log('WB Warehouse: Final stocks after combining:', stocksMap.size)
|
||||
return Array.from(stocksMap.values()).sort((a, b) => b.totalQuantity - a.totalQuantity)
|
||||
}
|
||||
|
||||
// Извлечение информации о складах из данных Analytics API
|
||||
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 filteredStocks = stocks.filter(item => {
|
||||
const matchesSearch = searchTerm === '' ||
|
||||
item.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.vendorCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.brand.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
|
||||
const matchesWarehouse = selectedWarehouse === 'all' ||
|
||||
item.stocks.some(s => s.warehouseId.toString() === selectedWarehouse)
|
||||
|
||||
return matchesSearch && matchesWarehouse
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.organization?.type === 'SELLER') {
|
||||
if (hasWBApiKey) {
|
||||
loadWarehouseData()
|
||||
}
|
||||
}, [user])
|
||||
}, [hasWBApiKey])
|
||||
|
||||
// Проверяем настройку API ключа
|
||||
const hasWBApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')?.isActive
|
||||
// Фильтрация товаров
|
||||
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-screen flex overflow-hidden">
|
||||
@ -398,87 +267,14 @@ export function WBWarehouseDashboard() {
|
||||
<div className="h-full w-full flex flex-col">
|
||||
|
||||
{/* Результирующие вкладки */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-4">
|
||||
{/* Товаров */}
|
||||
<div className="group relative overflow-hidden rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 border border-blue-500/20 p-3 hover:border-blue-400/40 transition-all duration-300 hover:scale-105">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Package className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-blue-300 text-xs font-medium uppercase tracking-wide">Товаров</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">
|
||||
{loading ? <div className="h-5 w-8 bg-white/10 rounded animate-pulse" /> : totalProducts.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-blue-500/0 via-blue-500/5 to-blue-500/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</div>
|
||||
|
||||
{/* Общий остаток */}
|
||||
<div className="group relative overflow-hidden rounded-xl bg-gradient-to-br from-green-500/20 to-green-600/10 border border-green-500/20 p-3 hover:border-green-400/40 transition-all duration-300 hover:scale-105">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Warehouse className="h-4 w-4 text-green-400" />
|
||||
<span className="text-green-300 text-xs font-medium uppercase tracking-wide">Остаток</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">
|
||||
{loading ? <div className="h-5 w-8 bg-white/10 rounded animate-pulse" /> : totalStocks.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-green-500/0 via-green-500/5 to-green-500/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</div>
|
||||
|
||||
{/* К клиенту */}
|
||||
<div className="group relative overflow-hidden rounded-xl bg-gradient-to-br from-orange-500/20 to-orange-600/10 border border-orange-500/20 p-3 hover:border-orange-400/40 transition-all duration-300 hover:scale-105">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrendingUp className="h-4 w-4 text-orange-400" />
|
||||
<span className="text-orange-300 text-xs font-medium uppercase tracking-wide">К клиенту</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">
|
||||
{loading ? <div className="h-5 w-8 bg-white/10 rounded animate-pulse" /> : totalReserved.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-orange-500/0 via-orange-500/5 to-orange-500/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</div>
|
||||
|
||||
{/* От клиента */}
|
||||
<div className="group relative overflow-hidden rounded-xl bg-gradient-to-br from-red-500/20 to-red-600/10 border border-red-500/20 p-3 hover:border-red-400/40 transition-all duration-300 hover:scale-105">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrendingDown className="h-4 w-4 text-red-400" />
|
||||
<span className="text-red-300 text-xs font-medium uppercase tracking-wide">От клиента</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">
|
||||
{loading ? <div className="h-5 w-8 bg-white/10 rounded animate-pulse" /> : totalFromClient.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-red-500/0 via-red-500/5 to-red-500/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</div>
|
||||
|
||||
{/* Складов */}
|
||||
<div className="group relative overflow-hidden rounded-xl bg-gradient-to-br from-purple-500/20 to-purple-600/10 border border-purple-500/20 p-3 hover:border-purple-400/40 transition-all duration-300 hover:scale-105">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<MapPin className="h-4 w-4 text-purple-400" />
|
||||
<span className="text-purple-300 text-xs font-medium uppercase tracking-wide">Складов</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-white">
|
||||
{loading ? <div className="h-5 w-8 bg-white/10 rounded animate-pulse" /> : activeWarehouses}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-purple-500/0 via-purple-500/5 to-purple-500/0 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
</div>
|
||||
</div>
|
||||
<StatsCards
|
||||
totalProducts={totalProducts}
|
||||
totalStocks={totalStocks}
|
||||
totalReserved={totalReserved}
|
||||
totalFromClient={totalFromClient}
|
||||
activeWarehouses={activeWarehouses}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Аналитика по складам WB */}
|
||||
{analyticsData.length > 0 && (
|
||||
@ -507,111 +303,27 @@ export function WBWarehouseDashboard() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Фильтры */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<div className="flex-1 relative group">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40 group-focus-within:text-blue-400 transition-colors duration-200" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск товаров..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full h-12 pl-12 pr-4 rounded-xl bg-white/5 border border-white/10 text-white placeholder:text-white/40 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/30 transition-all duration-200 hover:bg-white/10"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<Filter className="absolute left-4 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40 group-focus-within:text-purple-400 transition-colors duration-200" />
|
||||
<select
|
||||
value={selectedWarehouse}
|
||||
onChange={(e) => setSelectedWarehouse(e.target.value)}
|
||||
className="w-full sm:w-48 h-12 pl-12 pr-8 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/50 focus:border-purple-500/30 transition-all duration-200 hover:bg-white/10 appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="all">Все склады</option>
|
||||
{warehouses.map(warehouse => (
|
||||
<option key={warehouse.id} value={warehouse.id.toString()} className="bg-gray-800">
|
||||
{warehouse.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute right-4 top-1/2 transform -translate-y-1/2 pointer-events-none">
|
||||
<svg className="h-4 w-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Поиск */}
|
||||
<SearchBar
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
/>
|
||||
|
||||
{/* Список товаров */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="overflow-y-auto pr-2 max-h-full">
|
||||
{/* Заголовки таблицы */}
|
||||
<div className="sticky top-0 bg-black/50 backdrop-blur-sm border-b border-white/10 p-4 mb-2">
|
||||
<div className="grid grid-cols-12 gap-4 text-xs font-medium text-white/60 uppercase tracking-wide">
|
||||
<div className="col-span-3">Товар</div>
|
||||
<div className="col-span-1 text-center">Остаток</div>
|
||||
<div className="col-span-1 text-center">К клиенту</div>
|
||||
<div className="col-span-1 text-center">От клиента</div>
|
||||
<div className="col-span-1 text-center">Складов</div>
|
||||
<div className="col-span-5">Характеристики</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skeleton строки */}
|
||||
<div className="space-y-1">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<div key={i} className="grid grid-cols-12 gap-4 p-4 rounded-xl bg-white/5 border border-white/10">
|
||||
<div className="col-span-3 flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-white/10 animate-pulse" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 bg-white/10 rounded animate-pulse" style={{ width: '70%' }} />
|
||||
<div className="h-2 bg-white/10 rounded animate-pulse" style={{ width: '50%' }} />
|
||||
<div className="h-2 bg-white/10 rounded animate-pulse" style={{ width: '40%' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-1">
|
||||
<div className="h-5 w-8 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
<div className="h-3 w-12 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-1">
|
||||
<div className="h-5 w-6 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
<div className="h-3 w-8 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-1">
|
||||
<div className="h-5 w-6 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
<div className="h-3 w-10 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-1">
|
||||
<div className="h-5 w-4 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
<div className="h-3 w-12 bg-white/10 rounded animate-pulse mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-5 space-y-1">
|
||||
<div className="h-2 bg-white/10 rounded animate-pulse" style={{ width: '90%' }} />
|
||||
<div className="h-2 bg-white/10 rounded animate-pulse" style={{ width: '75%' }} />
|
||||
<div className="h-2 bg-white/10 rounded animate-pulse" style={{ width: '65%' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<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-xl font-semibold text-white mb-2">Настройте API ключ Wildberries</h3>
|
||||
<p className="text-white/60 mb-4">
|
||||
Для просмотра остатков товаров на складах WB необходимо добавить API ключ
|
||||
</p>
|
||||
<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-500 hover:bg-blue-600 text-white"
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Перейти в настройки
|
||||
</Button>
|
||||
@ -619,26 +331,12 @@ export function WBWarehouseDashboard() {
|
||||
) : 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" />
|
||||
<p className="text-white/60">
|
||||
{searchTerm || selectedWarehouse !== 'all'
|
||||
? 'Товары не найдены по заданным фильтрам'
|
||||
: 'Нет карточек товаров в WB'
|
||||
}
|
||||
</p>
|
||||
<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">
|
||||
{/* Заголовки таблицы */}
|
||||
<div className="sticky top-0 bg-black/50 backdrop-blur-sm border-b border-white/10 p-4 mb-2">
|
||||
<div className="grid grid-cols-12 gap-4 text-xs font-medium text-white/60 uppercase tracking-wide">
|
||||
<div className="col-span-3">Товар</div>
|
||||
<div className="col-span-1 text-center">Остаток</div>
|
||||
<div className="col-span-1 text-center">К клиенту</div>
|
||||
<div className="col-span-1 text-center">От клиента</div>
|
||||
<div className="col-span-1 text-center">Складов</div>
|
||||
<div className="col-span-5">Характеристики</div>
|
||||
</div>
|
||||
</div>
|
||||
<TableHeader />
|
||||
|
||||
{/* Таблица товаров */}
|
||||
<div className="space-y-1">
|
||||
@ -654,382 +352,3 @@ export function WBWarehouseDashboard() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Табличная строка товара
|
||||
function StockTableRow({ item }: { item: WBStock }) {
|
||||
|
||||
// Получение изображений карточки через WildberriesService
|
||||
const getCardImages = (item: WBStock): string[] => {
|
||||
if (item.photos && item.photos.length > 0) {
|
||||
const urls = item.photos
|
||||
.map(photo => photo.c246x328 || photo.c516x688 || photo.big)
|
||||
.filter((url): url is string => Boolean(url))
|
||||
return urls
|
||||
}
|
||||
|
||||
if (item.mediaFiles && item.mediaFiles.length > 0) {
|
||||
return item.mediaFiles
|
||||
}
|
||||
|
||||
const vol = Math.floor(item.nmId / 100000)
|
||||
const part = Math.floor(item.nmId / 1000)
|
||||
const fallbackUrl = `https://basket-${String(vol).padStart(2, '0')}.wbbasket.ru/vol${vol}/part${part}/${item.nmId}/images/c246x328/1.webp`
|
||||
return [fallbackUrl]
|
||||
}
|
||||
|
||||
const getStockStatus = (quantity: number) => {
|
||||
if (quantity === 0) return {
|
||||
color: 'text-red-400',
|
||||
bgColor: 'bg-red-500/10',
|
||||
label: 'Нет в наличии'
|
||||
}
|
||||
if (quantity < 10) return {
|
||||
color: 'text-orange-400',
|
||||
bgColor: 'bg-orange-500/10',
|
||||
label: 'Мало'
|
||||
}
|
||||
return {
|
||||
color: 'text-green-400',
|
||||
bgColor: 'bg-green-500/10',
|
||||
label: 'В наличии'
|
||||
}
|
||||
}
|
||||
|
||||
const stockStatus = getStockStatus(item.totalQuantity)
|
||||
const images = getCardImages(item)
|
||||
const mainImage = images[0] || null
|
||||
|
||||
// Отбираем ключевые характеристики для отображения в таблице
|
||||
const keyCharacteristics = item.characteristics?.slice(0, 3) || []
|
||||
|
||||
return (
|
||||
<div className="group">
|
||||
{/* Основная строка товара */}
|
||||
<div
|
||||
className="grid grid-cols-12 gap-4 p-4 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/20 transition-all duration-300"
|
||||
>
|
||||
{/* Товар (3 колонки) */}
|
||||
<div className="col-span-3 flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg overflow-hidden bg-white/10 flex-shrink-0">
|
||||
{mainImage ? (
|
||||
<img
|
||||
src={mainImage}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Package className="h-5 w-5 text-white/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-blue-300 bg-blue-500/20 px-2 py-0.5 rounded">
|
||||
{item.brand || 'Без бренда'}
|
||||
</span>
|
||||
<span className="text-white/40 text-xs">#{item.nmId}</span>
|
||||
</div>
|
||||
<h3 className="text-white text-sm font-medium line-clamp-1 mb-1">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="text-white/60 text-xs">
|
||||
{item.vendorCode}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Остаток */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-bold ${stockStatus.color}`}>
|
||||
{item.totalQuantity.toLocaleString()}
|
||||
</div>
|
||||
<div className={`text-xs px-2 py-0.5 rounded ${stockStatus.bgColor} ${stockStatus.color}`}>
|
||||
{stockStatus.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* К клиенту */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-orange-400">
|
||||
{item.stocks.reduce((sum, s) => sum + s.inWayToClient, 0)}
|
||||
</div>
|
||||
<div className="text-xs text-white/60">в пути</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* От клиента */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-red-400">
|
||||
{item.stocks.reduce((sum, s) => sum + s.inWayFromClient, 0)}
|
||||
</div>
|
||||
<div className="text-xs text-white/60">возвраты</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Складов */}
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold text-purple-400">
|
||||
{item.stocks.length}
|
||||
</div>
|
||||
<div className="text-xs text-white/60">активных</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Характеристики (5 колонок) */}
|
||||
<div className="col-span-5 flex items-center">
|
||||
<div className="space-y-1 w-full">
|
||||
{keyCharacteristics.map((char, index) => (
|
||||
<div key={index} className="flex justify-between text-xs">
|
||||
<span className="text-white/60 truncate w-1/2">{char.name}:</span>
|
||||
<span className="text-white truncate w-1/2 text-right">
|
||||
{Array.isArray(char.value) ? char.value.join(', ') : String(char.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{item.subjectName && (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-white/60">Категория:</span>
|
||||
<span className="text-blue-300 truncate text-right">{item.subjectName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Города в модулях */}
|
||||
<div className="grid grid-cols-12 gap-4 p-3 bg-white/[0.02] border-l-2 border-blue-400/30">
|
||||
<div className="col-span-12 flex flex-wrap gap-3">
|
||||
{item.stocks.map((stock, stockIndex) => (
|
||||
<div
|
||||
key={`${stock.warehouseId}-${stockIndex}`}
|
||||
className="bg-white/10 rounded-lg px-3 py-2 border border-white/20 hover:border-white/30 transition-colors"
|
||||
>
|
||||
{/* Название города */}
|
||||
<div className="text-white text-sm font-medium mb-1">
|
||||
{stock.warehouseName}
|
||||
</div>
|
||||
|
||||
{/* Цифры */}
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<div className="text-center">
|
||||
<div className={`font-bold ${stock.quantity > 0 ? 'text-green-400' : 'text-white/30'}`}>
|
||||
{stock.quantity}
|
||||
</div>
|
||||
<div className="text-white/50">остаток</div>
|
||||
</div>
|
||||
|
||||
{(stock.inWayToClient > 0 || stock.inWayFromClient > 0) && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-white/20" />
|
||||
|
||||
{stock.inWayToClient > 0 && (
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-orange-400">{stock.inWayToClient}</div>
|
||||
<div className="text-white/50">к клиенту</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stock.inWayFromClient > 0 && (
|
||||
<div className="text-center">
|
||||
<div className="font-bold text-red-400">{stock.inWayFromClient}</div>
|
||||
<div className="text-white/50">от клиента</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Супер современная карточка товара (СТАРАЯ ВЕРСИЯ - НЕ ИСПОЛЬЗУЕТСЯ)
|
||||
function StockCard({ item }: { item: WBStock }) {
|
||||
// Получение изображений карточки через WildberriesService
|
||||
const getCardImages = (item: WBStock): string[] => {
|
||||
if (item.photos && item.photos.length > 0) {
|
||||
const urls = item.photos
|
||||
.map(photo => photo.c246x328 || photo.c516x688 || photo.big)
|
||||
.filter((url): url is string => Boolean(url))
|
||||
return urls
|
||||
}
|
||||
|
||||
if (item.mediaFiles && item.mediaFiles.length > 0) {
|
||||
return item.mediaFiles
|
||||
}
|
||||
|
||||
const vol = Math.floor(item.nmId / 100000)
|
||||
const part = Math.floor(item.nmId / 1000)
|
||||
const fallbackUrl = `https://basket-${String(vol).padStart(2, '0')}.wbbasket.ru/vol${vol}/part${part}/${item.nmId}/images/c246x328/1.webp`
|
||||
return [fallbackUrl]
|
||||
}
|
||||
|
||||
const getStockStatus = (quantity: number) => {
|
||||
if (quantity === 0) return {
|
||||
color: 'from-red-500/20 to-red-600/5 border-red-500/30',
|
||||
textColor: 'text-red-400',
|
||||
label: 'Нет в наличии',
|
||||
icon: '❌'
|
||||
}
|
||||
if (quantity < 10) return {
|
||||
color: 'from-orange-500/20 to-orange-600/5 border-orange-500/30',
|
||||
textColor: 'text-orange-400',
|
||||
label: 'Мало',
|
||||
icon: '⚠️'
|
||||
}
|
||||
return {
|
||||
color: 'from-green-500/20 to-green-600/5 border-green-500/30',
|
||||
textColor: 'text-green-400',
|
||||
label: 'В наличии',
|
||||
icon: '✅'
|
||||
}
|
||||
}
|
||||
|
||||
const stockStatus = getStockStatus(item.totalQuantity)
|
||||
const images = getCardImages(item)
|
||||
const mainImage = images[0] || null
|
||||
|
||||
return (
|
||||
<div className="group relative overflow-hidden rounded-2xl bg-gradient-to-br from-white/5 to-white/[0.02] border border-white/10 hover:border-white/20 transition-all duration-500 hover:scale-[1.02] hover:shadow-2xl hover:shadow-blue-500/10">
|
||||
{/* Градиентный фон при hover */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-500/0 via-purple-500/0 to-pink-500/0 group-hover:from-blue-500/5 group-hover:via-purple-500/5 group-hover:to-pink-500/5 transition-all duration-700" />
|
||||
|
||||
<div className="relative p-5">
|
||||
{/* Хедер карточки */}
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
{/* Изображение товара */}
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 rounded-xl overflow-hidden bg-gradient-to-br from-white/10 to-white/5 flex-shrink-0 relative group/image">
|
||||
{mainImage ? (
|
||||
<img
|
||||
src={mainImage}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover group-hover/image:scale-110 transition-transform duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Package className="h-6 w-6 text-white/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* WB Badge */}
|
||||
<div className="absolute -top-1 -right-1">
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 text-white text-xs px-2 py-0.5 rounded-full font-bold shadow-lg">
|
||||
WB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Инфо товара */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-blue-300 font-medium bg-blue-500/20 px-2 py-0.5 rounded-md">
|
||||
{item.brand || 'Без бренда'}
|
||||
</span>
|
||||
<span className="text-white/40 text-xs">#{item.nmId}</span>
|
||||
</div>
|
||||
<h3 className="text-white font-semibold text-sm leading-tight line-clamp-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Статус */}
|
||||
<div className={`ml-2 px-2 py-1 rounded-lg bg-gradient-to-r ${stockStatus.color} border text-xs font-medium ${stockStatus.textColor} flex items-center gap-1`}>
|
||||
<span>{stockStatus.icon}</span>
|
||||
{stockStatus.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-white/60 text-xs">
|
||||
Артикул: <span className="text-white/80 font-mono">{item.vendorCode}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Компактная статистика */}
|
||||
<div className="grid grid-cols-4 gap-2 mb-4">
|
||||
<div className="text-center p-2 rounded-lg bg-gradient-to-br from-green-500/10 to-green-600/5 border border-green-500/20">
|
||||
<div className="text-green-400 text-lg font-bold">{item.totalQuantity.toLocaleString()}</div>
|
||||
<div className="text-green-300/60 text-xs">Остаток</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-gradient-to-br from-purple-500/10 to-purple-600/5 border border-purple-500/20">
|
||||
<div className="text-purple-400 text-lg font-bold">{item.stocks.length}</div>
|
||||
<div className="text-purple-300/60 text-xs">Складов</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-gradient-to-br from-orange-500/10 to-orange-600/5 border border-orange-500/20">
|
||||
<div className="text-orange-400 text-lg font-bold">
|
||||
{item.stocks.reduce((sum, s) => sum + s.inWayToClient, 0)}
|
||||
</div>
|
||||
<div className="text-orange-300/60 text-xs">К клиенту</div>
|
||||
</div>
|
||||
<div className="text-center p-2 rounded-lg bg-gradient-to-br from-red-500/10 to-red-600/5 border border-red-500/20">
|
||||
<div className="text-red-400 text-lg font-bold">
|
||||
{item.stocks.reduce((sum, s) => sum + s.inWayFromClient, 0)}
|
||||
</div>
|
||||
<div className="text-red-300/60 text-xs">От клиента</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Склады - компактно */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-white/80 font-medium text-xs uppercase tracking-wide flex items-center gap-2">
|
||||
<Warehouse className="h-3 w-3" />
|
||||
Склады
|
||||
</h4>
|
||||
<div className="space-y-1.5">
|
||||
{item.stocks.slice(0, 3).map((stock, stockIndex) => (
|
||||
<div key={`${stock.warehouseId}-${stockIndex}`} className="flex items-center justify-between p-2 rounded-lg bg-white/5 hover:bg-white/10 transition-colors duration-200">
|
||||
<div className="flex-1">
|
||||
<div className="text-white text-xs font-medium truncate">{stock.warehouseName}</div>
|
||||
<div className="text-white/50 text-xs">ID: {stock.warehouseId}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<div className="text-center">
|
||||
<div className={`font-bold ${stock.quantity > 0 ? 'text-green-400' : 'text-white/30'}`}>
|
||||
{stock.quantity}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`font-bold ${stock.inWayToClient > 0 ? 'text-orange-400' : 'text-white/30'}`}>
|
||||
{stock.inWayToClient}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className={`font-bold ${stock.inWayFromClient > 0 ? 'text-red-400' : 'text-white/30'}`}>
|
||||
{stock.inWayFromClient}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{item.stocks.length > 3 && (
|
||||
<div className="text-center text-white/40 text-xs py-1">
|
||||
+{item.stocks.length - 3} ещё складов
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Категория */}
|
||||
{item.subjectName && (
|
||||
<div className="mt-3 pt-3 border-t border-white/10">
|
||||
<div className="text-white/60 text-xs">
|
||||
<span className="text-white/40">Категория:</span> {item.subjectName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user