From 547e6e7d955800dbad826d9cd2bee0d2db0f90ec Mon Sep 17 00:00:00 2001 From: Bivekich Date: Fri, 8 Aug 2025 09:24:15 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84?= =?UTF-8?q?=D0=B5=D0=B9=D1=81=D0=B0=20=D0=B8=20=D0=BE=D0=BF=D1=82=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен компонент AppShell в RootLayout для улучшения структуры - Обновлен компонент Sidebar для предотвращения дублирования при рендеринге - Оптимизированы импорты в компонентах AdvertisingTab и SalesTab - Реализована логика кэширования статистики селлера в GraphQL резолверах --- .vscode/settings.json | 4 +- src/app/layout.tsx | 7 +- src/components/dashboard/sidebar.tsx | 62 ++--- src/components/layout/app-shell.tsx | 19 ++ src/components/layout/sidebar-root-context.ts | 6 + .../seller-statistics/advertising-tab.tsx | 193 ++++++++------- .../seller-statistics/sales-tab.tsx | 125 ++++++---- .../simple-advertising-table.tsx | 31 ++- .../wb-warehouse/wb-warehouse-dashboard.tsx | 40 ++-- .../wildberries-warehouse-tab.tsx | 66 ++--- src/graphql/resolvers.ts | 225 +++++++++++++++++- src/graphql/typedefs.ts | 53 +++++ src/lib/click-storage.ts | 54 ++--- 13 files changed, 610 insertions(+), 275 deletions(-) create mode 100644 src/components/layout/app-shell.tsx create mode 100644 src/components/layout/sidebar-root-context.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 7f9ff7d..5e93290 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,8 +2,8 @@ "editor.formatOnSave": true, "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.codeActionsOnSave": { - "source.fixAll.eslint": true, - "source.organizeImports": true + "source.fixAll.eslint": "explicit", + "source.organizeImports": "explicit" }, "typescript.tsdk": "node_modules/typescript/lib", "typescript.enablePromptUseWorkspaceTsdk": true, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 36903ef..4abe71d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,3 +1,4 @@ +import { AppShell } from '@/components/layout/app-shell' import { Toaster } from '@/components/ui/sonner' import './globals.css' @@ -7,7 +8,11 @@ export default function RootLayout({ children }: { children: React.ReactNode }) return ( - {children} + +
+ {children} +
+
diff --git a/src/components/dashboard/sidebar.tsx b/src/components/dashboard/sidebar.tsx index 0cb0639..189a481 100644 --- a/src/components/dashboard/sidebar.tsx +++ b/src/components/dashboard/sidebar.tsx @@ -2,24 +2,24 @@ import { useQuery } from '@apollo/client' import { - Settings, - LogOut, - Store, - MessageCircle, - Wrench, - Warehouse, - Users, - Truck, - Handshake, - ChevronLeft, - ChevronRight, - BarChart3, - Home, - DollarSign, + BarChart3, + ChevronLeft, + ChevronRight, + DollarSign, + Handshake, + Home, + LogOut, + MessageCircle, + Settings, + Store, + Truck, + Users, + Warehouse, + Wrench, } from 'lucide-react' -import { useRouter, usePathname } from 'next/navigation' +import { usePathname, useRouter } from 'next/navigation' -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { GET_CONVERSATIONS, GET_INCOMING_REQUESTS, GET_PENDING_SUPPLIES_COUNT } from '@/graphql/queries' import { useAuth } from '@/hooks/useAuth' @@ -83,7 +83,17 @@ function WholesaleOrdersNotification() { ) } -export function Sidebar() { +declare global { + interface Window { + __SIDEBAR_ROOT_MOUNTED__?: boolean + } +} + +export function Sidebar({ isRootInstance = false }: { isRootInstance?: boolean } = {}) { + // Если уже есть корневой сайдбар и это не корневой экземпляр — не рендерим дубликат + if (typeof window !== 'undefined' && !isRootInstance && (window as any).__SIDEBAR_ROOT_MOUNTED__) { + return null + } const { user, logout } = useAuth() const router = useRouter() const pathname = usePathname() @@ -236,6 +246,11 @@ export function Sidebar() { pathname.startsWith('/supplier-orders') const isPartnersActive = pathname.startsWith('/partners') + // Помечаем, что корневой экземпляр смонтирован + if (typeof window !== 'undefined' && isRootInstance) { + ;(window as any).__SIDEBAR_ROOT_MOUNTED__ = true + } + return (
{/* Основной сайдбар */} @@ -255,7 +270,6 @@ export function Sidebar() { size="icon" onClick={toggleSidebar} className="relative h-12 w-12 rounded-full bg-gradient-to-br from-white/20 to-white/5 border border-white/30 hover:from-white/30 hover:to-white/10 transition-all duration-300 ease-out hover:scale-110 active:scale-95 backdrop-blur-xl shadow-lg hover:shadow-xl hover:shadow-purple-500/20 group-hover:border-purple-300/50" - title={isCollapsed ? 'Развернуть сайдбар' : 'Свернуть сайдбар'} > {/* Простая анимированная иконка */}
@@ -270,17 +284,7 @@ export function Sidebar() {
- {/* Подсказка только в свернутом состоянии */} - {isCollapsed && ( -
-
-
-
- ⚡ Развернуть -
-
-
- )} + {/* Убраны текстовые подсказки при наведении */}
diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx new file mode 100644 index 0000000..9fba4dd --- /dev/null +++ b/src/components/layout/app-shell.tsx @@ -0,0 +1,19 @@ +'use client' + +import { usePathname } from 'next/navigation' + +import { Sidebar } from '@/components/dashboard/sidebar' + +export function AppShell({ children }: { children: React.ReactNode }) { + const pathname = usePathname() + + const hideSidebar = pathname === '/login' || pathname === '/register' + + return ( + <> + {!hideSidebar && } +
{children}
+ + ) +} + diff --git a/src/components/layout/sidebar-root-context.ts b/src/components/layout/sidebar-root-context.ts new file mode 100644 index 0000000..4dde0b2 --- /dev/null +++ b/src/components/layout/sidebar-root-context.ts @@ -0,0 +1,6 @@ +'use client' + +import { createContext } from 'react' + +export const SidebarRootContext = createContext(false) + diff --git a/src/components/seller-statistics/advertising-tab.tsx b/src/components/seller-statistics/advertising-tab.tsx index 98d06ba..020aca5 100644 --- a/src/components/seller-statistics/advertising-tab.tsx +++ b/src/components/seller-statistics/advertising-tab.tsx @@ -1,61 +1,39 @@ 'use client' -import { useQuery, useLazyQuery, useMutation } from '@apollo/client' +import { useLazyQuery, useMutation, useQuery } from '@apollo/client' import { - TrendingUp, - TrendingDown, - Eye, - MousePointer, - ShoppingCart, - DollarSign, - ChevronRight, - ChevronDown, - Plus, - Trash2, - ExternalLink, - Copy, - AlertCircle, - BarChart3, - Minimize2, - Calendar, - Package, - Link, - Smartphone, - Monitor, - Globe, - Target, - ArrowUpDown, - Percent, + AlertCircle, + BarChart3, + Eye, + Minimize2, + TrendingUp } from 'lucide-react' -import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - BarChart, - Bar, - ResponsiveContainer, - ComposedChart, + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + XAxis, + YAxis } from 'recharts' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' -import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart' +import { ChartTooltip } from '@/components/ui/chart' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' import { - CREATE_EXTERNAL_AD, - DELETE_EXTERNAL_AD, - UPDATE_EXTERNAL_AD, - UPDATE_EXTERNAL_AD_CLICKS, + CREATE_EXTERNAL_AD, + DELETE_EXTERNAL_AD, + UPDATE_EXTERNAL_AD, + UPDATE_EXTERNAL_AD_CLICKS, } from '@/graphql/mutations' -import { GET_WILDBERRIES_CAMPAIGN_STATS, GET_WILDBERRIES_CAMPAIGNS_LIST, GET_EXTERNAL_ADS } from '@/graphql/queries' +import { GET_EXTERNAL_ADS, GET_WILDBERRIES_CAMPAIGN_STATS, GET_WILDBERRIES_CAMPAIGNS_LIST } from '@/graphql/queries' import { useAuth } from '@/hooks/useAuth' import { WildberriesService } from '@/services/wildberries-service' @@ -770,19 +748,76 @@ const AdvertisingTab = React.memo(({ } } - // Автоматически загружаем все доступные кампании - useEffect(() => { - if (campaignsData?.getWildberriesCampaignsList?.data?.adverts) { - const campaigns = campaignsData.getWildberriesCampaignsList.data.adverts - const allCampaignIds = campaigns.flatMap((group: CampaignGroup) => - group.advert_list.map((item: CampaignListItem) => item.advertId), - ) + // Функция запуска загрузки статистики кампаний (стабилизирована) + const handleCampaignsSelected = useCallback((ids: number[]) => { + if (ids.length === 0) return - if (allCampaignIds.length > 0) { - handleCampaignsSelected(allCampaignIds) + let campaigns + if (useCustomDates && startDate && endDate) { + campaigns = ids.map((id) => ({ + id, + interval: { + begin: startDate, + end: endDate, + }, + })) + } else { + const endDateCalc = new Date() + const startDateCalc = new Date() + + switch (selectedPeriod) { + case 'week': + startDateCalc.setDate(endDateCalc.getDate() - 7) + break + case 'month': + startDateCalc.setMonth(endDateCalc.getMonth() - 1) + break + case 'quarter': + startDateCalc.setMonth(endDateCalc.getMonth() - 3) + break } + + campaigns = ids.map((id) => ({ + id, + interval: { + begin: startDateCalc.toISOString().split('T')[0], + end: endDateCalc.toISOString().split('T')[0], + }, + })) } - }, [campaignsData, selectedPeriod, useCustomDates, startDate, endDate]) + + getCampaignStats({ + variables: { + input: { campaigns }, + }, + }) + }, [useCustomDates, startDate, endDate, selectedPeriod, getCampaignStats]) + + // Ключ загрузки для защиты от повторов + const loadKey = useMemo( + () => (useCustomDates && startDate && endDate ? `custom_${startDate}_${endDate}` : selectedPeriod), + [useCustomDates, startDate, endDate, selectedPeriod], + ) + const fetchingRef = useRef(false) + const lastLoadedKeyRef = useRef(null) + + // Автозагрузка всех кампаний для выбранного периода (однократно на ключ) + useEffect(() => { + const adverts = campaignsData?.getWildberriesCampaignsList?.data?.adverts + if (!adverts) return + if (fetchingRef.current) return + if (lastLoadedKeyRef.current === loadKey) return + + const allCampaignIds = adverts.flatMap((group: CampaignGroup) => + group.advert_list.map((item: CampaignListItem) => item.advertId), + ) + if (allCampaignIds.length === 0) return + + fetchingRef.current = true + handleCampaignsSelected(allCampaignIds) + lastLoadedKeyRef.current = loadKey + fetchingRef.current = false + }, [campaignsData, loadKey, handleCampaignsSelected]) // Преобразование данных кампаний в новый формат таблицы const convertCampaignDataToDailyData = (campaigns: CampaignStats[]): DailyAdvertisingData[] => { @@ -1018,8 +1053,8 @@ const AdvertisingTab = React.memo(({ setDailyData(newDailyData) prevCampaignStats.current = campaignStats - // Сохраняем данные в кэш - if (setCachedData) { + // Сохраняем данные в кэш (через ref, чтобы не зациклиться на изменении ссылки функции) + if (setCachedDataRef.current) { const cacheData = { dailyData: newDailyData, campaignStats: campaignStats, @@ -1033,56 +1068,20 @@ const AdvertisingTab = React.memo(({ 0, ), } - setCachedData(cacheData) + setCachedDataRef.current(cacheData) console.warn('Advertising: Data cached successfully') } } } - }, [campaignStats, externalAdsData, setCachedData]) // Добавляем externalAdsData и setCachedData в зависимости + }, [campaignStats, externalAdsData]) - const handleCampaignsSelected = (ids: number[]) => { - if (ids.length === 0) return + // Храним setCachedData в ref, чтобы не триггерить эффект из-за смены ссылки на функцию в родителе + const setCachedDataRef = useRef(setCachedData) + useEffect(() => { + setCachedDataRef.current = setCachedData + }, [setCachedData]) - let campaigns - if (useCustomDates && startDate && endDate) { - campaigns = ids.map((id) => ({ - id, - interval: { - begin: startDate, - end: endDate, - }, - })) - } else { - const endDateCalc = new Date() - const startDateCalc = new Date() - - switch (selectedPeriod) { - case 'week': - startDateCalc.setDate(endDateCalc.getDate() - 7) - break - case 'month': - startDateCalc.setMonth(endDateCalc.getMonth() - 1) - break - case 'quarter': - startDateCalc.setMonth(endDateCalc.getMonth() - 3) - break - } - - campaigns = ids.map((id) => ({ - id, - interval: { - begin: startDateCalc.toISOString().split('T')[0], - end: endDateCalc.toISOString().split('T')[0], - }, - })) - } - - getCampaignStats({ - variables: { - input: { campaigns }, - }, - }) - } + const toggleCampaignExpanded = (campaignId: number) => { const newExpanded = new Set(expandedCampaigns) diff --git a/src/components/seller-statistics/sales-tab.tsx b/src/components/seller-statistics/sales-tab.tsx index d33ce79..09e5731 100644 --- a/src/components/seller-statistics/sales-tab.tsx +++ b/src/components/seller-statistics/sales-tab.tsx @@ -1,10 +1,9 @@ 'use client' -import { useQuery } from '@apollo/client' -import { gql } from '@apollo/client' -import { TrendingUp, Info, BarChart3, ChevronDown, ChevronUp } from 'lucide-react' -import React, { useState, useEffect, useMemo, useCallback } from 'react' -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from 'recharts' +import { gql, useQuery } from '@apollo/client' +import { BarChart3, ChevronDown, ChevronUp, Info, TrendingUp } from 'lucide-react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts' import { Badge } from '@/components/ui/badge' import { Card } from '@/components/ui/card' @@ -192,9 +191,20 @@ const SalesTab = React.memo(({ skip: true, // Изначально пропускаем запрос, будем запускать вручную }) + // Ключ загрузки для защиты от повторов + const loadKey = useMemo( + () => (useCustomDates && startDate && endDate ? `custom_${startDate}_${endDate}` : selectedPeriod), + [useCustomDates, startDate, endDate, selectedPeriod], + ) + const loadingRef = useRef(false) + const lastLoadedKeyRef = useRef(null) + // Эффект для проверки кэша и загрузки данных useEffect(() => { const loadData = async () => { + if (loadingRef.current) return + if (lastLoadedKeyRef.current === loadKey) return + loadingRef.current = true // Сначала проверяем локальный кэш if (getCachedData) { const cachedData = getCachedData() @@ -202,6 +212,8 @@ const SalesTab = React.memo(({ setChartData(cachedData.chartData || mockChartData) setTableData(cachedData.tableData || mockTableData) console.warn('Sales: Using cached data') + lastLoadedKeyRef.current = loadKey + loadingRef.current = false return } } @@ -210,15 +222,24 @@ const SalesTab = React.memo(({ if (setIsLoadingData) setIsLoadingData(true) try { - const result = await refetch() + const refetchVars = useCustomDates ? { startDate, endDate } : { period: selectedPeriod } + let result = await refetch(refetchVars) + // Retry 1 раз при 429 + const errMsg = (result as unknown as { error?: { message?: string } })?.error?.message || '' + if (!result.data?.getWildberriesStatistics?.success && errMsg.includes('429')) { + await new Promise((r) => setTimeout(r, 1200)) + result = await refetch(refetchVars) + } if (result.data?.getWildberriesStatistics?.success) { console.warn('Sales: Loading fresh data from API') // Обрабатываем данные в существующем useEffect + lastLoadedKeyRef.current = loadKey } } catch (error) { console.error('Sales: Error loading data:', error) } finally { if (setIsLoadingData) setIsLoadingData(false) + loadingRef.current = false } } @@ -227,14 +248,12 @@ const SalesTab = React.memo(({ if (!shouldSkip) { loadData() } - }, [selectedPeriod, useCustomDates, startDate, endDate, getCachedData, refetch, setIsLoadingData]) + }, [selectedPeriod, useCustomDates, startDate, endDate, getCachedData, refetch, setIsLoadingData, loadKey]) - useEffect(() => { - if (wbData?.getWildberriesStatistics?.success && wbData.getWildberriesStatistics.data) { - const realData = wbData.getWildberriesStatistics.data - - // Улучшенная агрегация с более надежной обработкой дат - const aggregateByDate = ( + // Применение данных: агрегация, сортировка, установка состояний и кэша + const applyData = useCallback((realData: Array<{ date: string; sales: number; orders: number; advertising: number; refusals: number; returns: number; revenue: number; buyoutPercentage: number }>) => { + // Улучшенная агрегация с более надежной обработкой дат + const aggregateByDate = ( data: Array<{ date: string sales: number @@ -325,51 +344,55 @@ const SalesTab = React.memo(({ : 0, })) } + const aggregatedData = aggregateByDate(realData) - const aggregatedData = aggregateByDate(realData) + // Сортируем по дате (новые сверху) + const sortedData = aggregatedData.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) - // Сортируем по дате (новые сверху) - const sortedData = aggregatedData.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + // Обновляем данные для графика + const newChartData = sortedData.map((item) => ({ + date: new Date(item.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }), + sales: item.sales, + orders: item.orders, + advertising: Math.round(item.advertising), + refusals: item.refusals, + returns: item.returns, + })) - // Обновляем данные для графика - const newChartData = sortedData.map((item) => ({ - date: new Date(item.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }), - sales: item.sales, - orders: item.orders, - advertising: Math.round(item.advertising), - refusals: item.refusals, - returns: item.returns, - })) + // Обновляем данные для таблицы + const newTableData = sortedData.map((item) => ({ + date: new Date(item.date).toLocaleDateString('ru-RU'), + salesUnits: item.sales, + buyoutPercentage: item.buyoutPercentage, + advertising: Math.round(item.advertising), + orders: item.orders, + refusals: item.refusals, + returns: item.returns, + revenue: Math.round(item.revenue), + })) - // Обновляем данные для таблицы - const newTableData = sortedData.map((item) => ({ - date: new Date(item.date).toLocaleDateString('ru-RU'), - salesUnits: item.sales, - buyoutPercentage: item.buyoutPercentage, - advertising: Math.round(item.advertising), - orders: item.orders, - refusals: item.refusals, - returns: item.returns, - revenue: Math.round(item.revenue), - })) + setChartData(newChartData.reverse()) // Для графика - старые даты слева + setTableData(newTableData) // Для таблицы - новые даты сверху - setChartData(newChartData.reverse()) // Для графика - старые даты слева - setTableData(newTableData) // Для таблицы - новые даты сверху - - // Сохраняем данные в кэш - if (setCachedData) { - const cacheData = { - chartData: newChartData, - tableData: newTableData, - totalSales: newTableData.reduce((sum, item) => sum + item.sales, 0), - totalOrders: newTableData.reduce((sum, item) => sum + item.orders, 0), - productsCount: newTableData.length, - } - setCachedData(cacheData) - console.warn('Sales: Data cached successfully') + // Сохраняем данные в кэш + if (setCachedData) { + const cacheData = { + chartData: newChartData, + tableData: newTableData, + totalSales: newTableData.reduce((sum, item) => sum + item.salesUnits, 0), + totalOrders: newTableData.reduce((sum, item) => sum + item.orders, 0), + productsCount: newTableData.length, } + setCachedData(cacheData) + console.warn('Sales: Data cached successfully') } - }, [wbData, setCachedData]) + }, [setCachedData]) + + useEffect(() => { + if (wbData?.getWildberriesStatistics?.success && wbData.getWildberriesStatistics.data) { + applyData(wbData.getWildberriesStatistics.data) + } + }, [wbData, applyData]) // Функция для переключения видимости метрики const toggleMetric = (metric: keyof typeof visibleMetrics) => { diff --git a/src/components/seller-statistics/simple-advertising-table.tsx b/src/components/seller-statistics/simple-advertising-table.tsx index b97fc38..589e086 100644 --- a/src/components/seller-statistics/simple-advertising-table.tsx +++ b/src/components/seller-statistics/simple-advertising-table.tsx @@ -2,20 +2,13 @@ import { useQuery } from '@apollo/client' import { - ChevronDown, - ChevronRight, - Plus, - Trash2, - Link, - Copy, - Eye, - MousePointer, - ShoppingCart, - DollarSign, - Search, - Package, + Copy, + Package, + Plus, + Search, + Trash2 } from 'lucide-react' -import React, { useState } from 'react' +import { useState } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -374,11 +367,16 @@ export function SimpleAdvertisingTable({ {/* Реклама внешняя - многострочная ячейка с кнопками */}
- {product.advertising.externalAds.map((ad) => ( + {product.advertising.externalAds.map((ad) => { + const overlayClicks = generatedLinksData[day.date]?.find( + (link) => link.adId === ad.id, + )?.clicks + const displayClicks = overlayClicks ?? ad.clicks ?? 0 + return (
{ad.name}
{formatCurrency(ad.cost)}
-
{ad.clicks || 0} кликов
+
{displayClicks} кликов
{onGenerateLink && (
- ))} + ) + })} {/* Инлайн форма добавления внешней рекламы */} {onAddExternalAd && ( diff --git a/src/components/wb-warehouse/wb-warehouse-dashboard.tsx b/src/components/wb-warehouse/wb-warehouse-dashboard.tsx index bb1f8ef..f59d515 100644 --- a/src/components/wb-warehouse/wb-warehouse-dashboard.tsx +++ b/src/components/wb-warehouse/wb-warehouse-dashboard.tsx @@ -1,8 +1,8 @@ 'use client' /* eslint-disable @typescript-eslint/no-explicit-any */ -import { useQuery, useMutation } from '@apollo/client' -import React, { useState, useEffect } from 'react' +import { useMutation, useQuery } from '@apollo/client' +import { useEffect, useState } from 'react' import { toast } from 'sonner' import { Sidebar } from '@/components/dashboard/sidebar' @@ -112,17 +112,17 @@ export function WBWarehouseDashboard() { // Получаем аналитические данные для данного nmId const analytics = analyticsMap.get(card.nmID) - if (analytics && Array.isArray(analytics)) { - analytics.forEach((item: any) => { - if (item.stocks && Array.isArray(item.stocks)) { - item.stocks.forEach((stockItem: any) => { + if (analytics && analytics.data && analytics.data.regions && Array.isArray(analytics.data.regions)) { + analytics.data.regions.forEach((region: any) => { + if (region.offices && Array.isArray(region.offices)) { + region.offices.forEach((office: any) => { stock.stocks.push({ - warehouseId: stockItem.warehouseId || 0, - warehouseName: String(stockItem.warehouseName || 'Неизвестный склад'), - quantity: Number(stockItem.quantity) || 0, - quantityFull: Number(stockItem.quantityFull) || 0, - inWayToClient: Number(stockItem.inWayToClient) || 0, - inWayFromClient: Number(stockItem.inWayFromClient) || 0, + warehouseId: office.officeID || 0, + warehouseName: String(office.officeName || 'Неизвестный склад'), + quantity: Number(office.metrics?.stockCount) || 0, + quantityFull: Number(office.metrics?.stockCount) || 0, + inWayToClient: Number(office.metrics?.toClientCount) || 0, + inWayFromClient: Number(office.metrics?.fromClientCount) || 0, }) }) } @@ -363,12 +363,12 @@ export function WBWarehouseDashboard() { }, [cacheLoading, user?.organization, initialized]) return ( -
+
-
-
+
+
{/* Табы */} - + -
- +
+ - + - +
diff --git a/src/components/wb-warehouse/wildberries-warehouse-tab.tsx b/src/components/wb-warehouse/wildberries-warehouse-tab.tsx index 205a3d4..bcdc2d5 100644 --- a/src/components/wb-warehouse/wildberries-warehouse-tab.tsx +++ b/src/components/wb-warehouse/wildberries-warehouse-tab.tsx @@ -1,8 +1,8 @@ 'use client' /* eslint-disable @typescript-eslint/no-explicit-any */ -import { TrendingUp, Package } from 'lucide-react' -import React, { useState } from 'react' +import { Package, TrendingUp } from 'lucide-react' +import { useState } from 'react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' @@ -97,41 +97,45 @@ export function WildberriesWarehouseTab({ } return ( -
+
{/* Статистика */} - +
+ +
{/* Аналитика по складам WB */} {initialized && analyticsData.length > 0 && ( - -

- - Аналитика по складам WB -

-
- {analyticsData.slice(0, 6).map((item, index) => ( -
-
Склад {index + 1}
-
- {JSON.stringify(item).length > 50 - ? `${JSON.stringify(item).substring(0, 50)}...` - : JSON.stringify(item)} +
+ +

+ + Аналитика по складам WB +

+
+ {analyticsData.slice(0, 6).map((item, index) => ( +
+
Склад {index + 1}
+
+ {JSON.stringify(item).length > 50 + ? `${JSON.stringify(item).substring(0, 50)}...` + : JSON.stringify(item)} +
-
- ))} -
- + ))} +
+ +
)} {/* Основной контент */} - +
@@ -155,7 +159,7 @@ export function WildberriesWarehouseTab({
{/* Контент с таблицей */} -
+
{!initialized || loading || cacheLoading ? (
@@ -173,7 +177,7 @@ export function WildberriesWarehouseTab({
) : ( -
+
{/* Заголовок таблицы */} diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index 977801d..027834f 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -9,7 +9,7 @@ import { MarketplaceService } from '@/services/marketplace-service' import { SmsService } from '@/services/sms-service' import { WildberriesService } from '@/services/wildberries-service' -import '@/lib/seed-init' // Автоматическая инициализация БД +import '@/lib/seed-init'; // Автоматическая инициализация БД // Сервисы const smsService = new SmsService() @@ -7489,6 +7489,68 @@ const wildberriesQueries = { } } catch (error) { console.error('Error fetching WB statistics:', error) + // Фолбэк: пробуем вернуть последние данные из кеша статистики селлера + try { + const user = await prisma.user.findUnique({ + where: { id: context.user!.id }, + include: { organization: true }, + }) + + if (user?.organization) { + const whereCache: any = { + organizationId: user.organization.id, + period: startDate && endDate ? 'custom' : period ?? 'week', + } + if (startDate && endDate) { + whereCache.dateFrom = new Date(startDate) + whereCache.dateTo = new Date(endDate) + } + + const cache = await prisma.sellerStatsCache.findFirst({ + where: whereCache, + orderBy: { createdAt: 'desc' }, + }) + + if (cache?.productsData) { + // Ожидаем, что productsData — строка JSON с полями, сохраненными клиентом + const parsed = JSON.parse(cache.productsData as unknown as string) as { + tableData?: Array<{ + date: string + salesUnits: number + orders: number + advertising: number + refusals: number + returns: number + revenue: number + buyoutPercentage: number + }> + } + + const table = parsed.tableData ?? [] + const dataFromCache = table.map((row) => ({ + date: row.date, + sales: row.salesUnits, + orders: row.orders, + advertising: row.advertising, + refusals: row.refusals, + returns: row.returns, + revenue: row.revenue, + buyoutPercentage: row.buyoutPercentage, + })) + + if (dataFromCache.length > 0) { + return { + success: true, + data: dataFromCache, + message: 'Данные возвращены из кеша из-за ошибки WB API', + } + } + } + } + } catch (fallbackErr) { + console.error('Seller stats cache fallback failed:', fallbackErr) + } + return { success: false, message: error instanceof Error ? error.message : 'Ошибка получения статистики', @@ -8186,6 +8248,84 @@ resolvers.Query = { ...wildberriesQueries, ...externalAdQueries, ...wbWarehouseCacheQueries, + // Кеш статистики селлера + getSellerStatsCache: async ( + _: unknown, + args: { period: string; dateFrom?: string | null; dateTo?: string | null }, + context: Context, + ) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Организация не найдена') + } + + const today = new Date() + today.setHours(0, 0, 0, 0) + + // Для custom учитываем диапазон, иначе только period + const where: any = { + organizationId: user.organization.id, + cacheDate: today, + period: args.period, + } + if (args.period === 'custom') { + if (!args.dateFrom || !args.dateTo) { + throw new GraphQLError('Для custom необходимо указать dateFrom и dateTo') + } + where.dateFrom = new Date(args.dateFrom) + where.dateTo = new Date(args.dateTo) + } + + const cache = await prisma.sellerStatsCache.findFirst({ + where, + orderBy: { createdAt: 'desc' }, + }) + + if (!cache) { + return { + success: true, + message: 'Кеш не найден', + cache: null, + fromCache: false, + } + } + + return { + success: true, + message: 'Данные получены из кеша', + cache: { + ...cache, + cacheDate: cache.cacheDate.toISOString().split('T')[0], + dateFrom: cache.dateFrom ? cache.dateFrom.toISOString().split('T')[0] : null, + dateTo: cache.dateTo ? cache.dateTo.toISOString().split('T')[0] : null, + productsTotalSales: cache.productsTotalSales ? Number(cache.productsTotalSales) : null, + advertisingTotalCost: cache.advertisingTotalCost ? Number(cache.advertisingTotalCost) : null, + createdAt: cache.createdAt.toISOString(), + updatedAt: cache.updatedAt.toISOString(), + }, + fromCache: true, + } + } catch (error) { + console.error('Error getting Seller Stats cache:', error) + return { + success: false, + message: error instanceof Error ? error.message : 'Ошибка получения кеша статистики', + cache: null, + fromCache: false, + } + } + }, } resolvers.Mutation = { @@ -8193,4 +8333,87 @@ resolvers.Mutation = { ...adminMutations, ...externalAdMutations, ...wbWarehouseCacheMutations, + // Сохранение кеша статистики селлера + saveSellerStatsCache: async ( + _: unknown, + { input }: { input: { period: string; dateFrom?: string | null; dateTo?: string | null; productsData?: string | null; productsTotalSales?: number | null; productsTotalOrders?: number | null; productsCount?: number | null; advertisingData?: string | null; advertisingTotalCost?: number | null; advertisingTotalViews?: number | null; advertisingTotalClicks?: number | null; expiresAt: string } }, + context: Context, + ) => { + if (!context.user) { + throw new GraphQLError('Требуется авторизация', { + extensions: { code: 'UNAUTHENTICATED' }, + }) + } + + try { + const user = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }) + + if (!user?.organization) { + throw new GraphQLError('Организация не найдена') + } + + const today = new Date() + today.setHours(0, 0, 0, 0) + + const data: any = { + organizationId: user.organization.id, + cacheDate: today, + period: input.period, + dateFrom: input.period === 'custom' && input.dateFrom ? new Date(input.dateFrom) : null, + dateTo: input.period === 'custom' && input.dateTo ? new Date(input.dateTo) : null, + productsData: input.productsData ?? null, + productsTotalSales: input.productsTotalSales ?? null, + productsTotalOrders: input.productsTotalOrders ?? null, + productsCount: input.productsCount ?? null, + advertisingData: input.advertisingData ?? null, + advertisingTotalCost: input.advertisingTotalCost ?? null, + advertisingTotalViews: input.advertisingTotalViews ?? null, + advertisingTotalClicks: input.advertisingTotalClicks ?? null, + expiresAt: new Date(input.expiresAt), + } + + // upsert с составным уникальным ключом, содержащим NULL, в Prisma вызывает валидацию. + // Делаем вручную: findFirst по уникальному набору, затем update или create. + const existing = await prisma.sellerStatsCache.findFirst({ + where: { + organizationId: user.organization.id, + cacheDate: today, + period: input.period, + dateFrom: data.dateFrom, + dateTo: data.dateTo, + }, + }) + + const cache = existing + ? await prisma.sellerStatsCache.update({ where: { id: existing.id }, data }) + : await prisma.sellerStatsCache.create({ data }) + + return { + success: true, + message: 'Кеш статистики сохранен', + cache: { + ...cache, + cacheDate: cache.cacheDate.toISOString().split('T')[0], + dateFrom: cache.dateFrom ? cache.dateFrom.toISOString().split('T')[0] : null, + dateTo: cache.dateTo ? cache.dateTo.toISOString().split('T')[0] : null, + productsTotalSales: cache.productsTotalSales ? Number(cache.productsTotalSales) : null, + advertisingTotalCost: cache.advertisingTotalCost ? Number(cache.advertisingTotalCost) : null, + createdAt: cache.createdAt.toISOString(), + updatedAt: cache.updatedAt.toISOString(), + }, + fromCache: false, + } + } catch (error) { + console.error('Error saving Seller Stats cache:', error) + return { + success: false, + message: error instanceof Error ? error.message : 'Ошибка сохранения кеша статистики', + cache: null, + fromCache: false, + } + } + }, } diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index 3ada0b4..b3eea24 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -1325,6 +1325,59 @@ export const typeDefs = gql` saveWBWarehouseCache(input: WBWarehouseCacheInput!): WBWarehouseCacheResponse! } + # Типы для кеша статистики продаж селлера + type SellerStatsCache { + id: ID! + organizationId: String! + cacheDate: String! + period: String! + dateFrom: String + dateTo: String + + productsData: String + productsTotalSales: Float + productsTotalOrders: Int + productsCount: Int + + advertisingData: String + advertisingTotalCost: Float + advertisingTotalViews: Int + advertisingTotalClicks: Int + + expiresAt: String! + createdAt: String! + updatedAt: String! + } + + type SellerStatsCacheResponse { + success: Boolean! + message: String + cache: SellerStatsCache + fromCache: Boolean! + } + + input SellerStatsCacheInput { + period: String! + dateFrom: String + dateTo: String + productsData: String + productsTotalSales: Float + productsTotalOrders: Int + productsCount: Int + advertisingData: String + advertisingTotalCost: Float + advertisingTotalViews: Int + advertisingTotalClicks: Int + expiresAt: String! + } + + extend type Query { + getSellerStatsCache(period: String!, dateFrom: String, dateTo: String): SellerStatsCacheResponse! + } + + extend type Mutation { + saveSellerStatsCache(input: SellerStatsCacheInput!): SellerStatsCacheResponse! + } # Типы для заявок на возврат WB type WbReturnClaim { id: String! diff --git a/src/lib/click-storage.ts b/src/lib/click-storage.ts index 4cf95c2..60d00ed 100644 --- a/src/lib/click-storage.ts +++ b/src/lib/click-storage.ts @@ -1,30 +1,30 @@ -// Общее хранилище кликов для всех API роутов -class ClickStorage { - private static instance: ClickStorage - private storage = new Map() +// Глобальное хранилище кликов на уровне процесса, общее для всех роутов +// Важно: в serverless/edge окружении память не шарится между инстансами. +// Для dev/Node runtime это обеспечит единый Map для разных route-бандлов. - static getInstance(): ClickStorage { - if (!ClickStorage.instance) { - ClickStorage.instance = new ClickStorage() - } - return ClickStorage.instance - } - - recordClick(linkId: string): number { - const currentClicks = this.storage.get(linkId) || 0 - const newClicks = currentClicks + 1 - this.storage.set(linkId, newClicks) - console.warn(`Click recorded for ${linkId}: ${newClicks} total`) - return newClicks - } - - getClicks(linkId: string): number { - return this.storage.get(linkId) || 0 - } - - getAllClicks(): Record { - return Object.fromEntries(this.storage) - } +type GlobalWithClickStorage = typeof globalThis & { + __CLICK_STORAGE__?: Map } -export const clickStorage = ClickStorage.getInstance() +const g = globalThis as GlobalWithClickStorage +if (!g.__CLICK_STORAGE__) { + g.__CLICK_STORAGE__ = new Map() +} + +const storage = g.__CLICK_STORAGE__ + +export const clickStorage = { + recordClick(linkId: string): number { + const currentClicks = storage!.get(linkId) || 0 + const newClicks = currentClicks + 1 + storage!.set(linkId, newClicks) + console.warn(`Click recorded for ${linkId}: ${newClicks} total`) + return newClicks + }, + getClicks(linkId: string): number { + return storage!.get(linkId) || 0 + }, + getAllClicks(): Record { + return Object.fromEntries(storage!) + }, +}