394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
"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<unknown[]>([]);
|
||
|
||
const hasWBApiKey = user?.wildberriesApiKey;
|
||
|
||
// Комбинирование карточек с индивидуальными данными аналитики
|
||
const combineCardsWithAnalytics = (
|
||
cards: unknown[],
|
||
analyticsResults: unknown[]
|
||
) => {
|
||
const stocksMap = new Map<number, WBStock>();
|
||
|
||
// Создаем карту аналитических данных для быстрого поиска
|
||
const analyticsMap = new Map(); // Map nmId to its analytics data
|
||
analyticsResults.forEach((result) => {
|
||
analyticsMap.set(result.nmId, result.data);
|
||
});
|
||
|
||
cards.forEach((card) => {
|
||
const stock: WBStock = {
|
||
nmId: card.nmID,
|
||
vendorCode: String(card.vendorCode || card.supplierVendorCode || ""),
|
||
title: String(card.title || card.object || `Товар ${card.nmID}`),
|
||
brand: String(card.brand || ""),
|
||
price: 0,
|
||
stocks: [],
|
||
totalQuantity: 0,
|
||
totalReserved: 0,
|
||
photos: Array.isArray(card.photos) ? card.photos : [],
|
||
mediaFiles: Array.isArray(card.mediaFiles) ? card.mediaFiles : [],
|
||
characteristics: Array.isArray(card.characteristics)
|
||
? card.characteristics
|
||
: [],
|
||
subjectName: String(card.subjectName || card.object || ""),
|
||
description: String(card.description || ""),
|
||
};
|
||
|
||
if (card.sizes && card.sizes.length > 0) {
|
||
stock.price =
|
||
Number(card.sizes[0].price || card.sizes[0].discountedPrice) || 0;
|
||
}
|
||
|
||
const analyticsData = analyticsMap.get(card.nmID);
|
||
if (analyticsData?.data?.regions) {
|
||
(
|
||
analyticsData.data.regions as {
|
||
offices: {
|
||
officeID: number;
|
||
officeName: string;
|
||
metrics: { stockCount: number };
|
||
}[];
|
||
}[]
|
||
).forEach((region) => {
|
||
if (region.offices && region.offices.length > 0) {
|
||
region.offices.forEach((office) => {
|
||
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 = combineCardsWithAnalytics(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: unknown) {
|
||
console.error("Error loading warehouse data:", error);
|
||
toast.error("Ошибка при загрузке данных склада");
|
||
} 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>
|
||
);
|
||
}
|