diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1b458c9..490b555 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -108,6 +108,8 @@ model Organization { fulfillmentSupplyOrders SupplyOrder[] @relation("SupplyOrderFulfillmentCenter") wildberriesSupplies WildberriesSupply[] supplySuppliers SupplySupplier[] @relation("SupplySuppliers") + externalAds ExternalAd[] @relation("ExternalAds") + wbWarehouseCaches WBWarehouseCache[] @relation("WBWarehouseCaches") @@map("organizations") } @@ -510,3 +512,37 @@ model SupplySupplier { @@map("supply_suppliers") } + +model ExternalAd { + id String @id @default(cuid()) + name String // Название рекламы + url String // URL рекламы + cost Decimal @db.Decimal(12, 2) // Стоимость + date DateTime // Дата рекламы + nmId String // ID товара Wildberries + clicks Int @default(0) // Количество кликов + organizationId String // ID организации + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organization Organization @relation("ExternalAds", fields: [organizationId], references: [id], onDelete: Cascade) + + @@index([organizationId, date]) + @@map("external_ads") +} + +model WBWarehouseCache { + id String @id @default(cuid()) + organizationId String // ID организации + cacheDate DateTime // Дата кеширования (только дата, без времени) + data Json // Кешированные данные склада WB + totalProducts Int @default(0) // Общее количество товаров + totalStocks Int @default(0) // Общее количество остатков + totalReserved Int @default(0) // Общее количество в резерве + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organization Organization @relation("WBWarehouseCaches", fields: [organizationId], references: [id], onDelete: Cascade) + + @@unique([organizationId, cacheDate]) + @@index([organizationId, cacheDate]) + @@map("wb_warehouse_caches") +} diff --git a/src/app/api/track-click/route.ts b/src/app/api/track-click/route.ts new file mode 100644 index 0000000..51b4eda --- /dev/null +++ b/src/app/api/track-click/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server' +import { clickStorage } from '@/lib/click-storage' + +export async function POST(request: NextRequest) { + try { + const body = await request.text() + const { linkId, timestamp } = JSON.parse(body) + + // Записываем клик через общий storage + const totalClicks = clickStorage.recordClick(linkId) + + console.log(`API: Click tracked for ${linkId} at ${timestamp}. Total clicks: ${totalClicks}`) + + return NextResponse.json({ + success: true, + linkId, + timestamp, + totalClicks + }) + } catch (error) { + console.error('Error tracking click:', error) + return NextResponse.json({ success: false, error: 'Failed to track click' }, { status: 500 }) + } +} + +// API для получения статистики кликов +export async function GET(request: NextRequest) { + try { + const linkId = request.nextUrl.searchParams.get('linkId') + + if (linkId) { + const clicks = clickStorage.getClicks(linkId) + return NextResponse.json({ linkId, clicks }) + } + + // Возвращаем всю статистику + const allStats = clickStorage.getAllClicks() + return NextResponse.json(allStats) + } catch (error) { + console.error('Error getting click stats:', error) + return NextResponse.json({ error: 'Failed to get click stats' }, { status: 500 }) + } +} \ No newline at end of file diff --git a/src/app/track/[linkId]/route.ts b/src/app/track/[linkId]/route.ts new file mode 100644 index 0000000..d632902 --- /dev/null +++ b/src/app/track/[linkId]/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server' +import { clickStorage } from '@/lib/click-storage' + +export async function GET( + request: NextRequest, + { params }: { params: { linkId: string } } +) { + const { linkId } = params + + try { + // Получаем целевую ссылку из параметров + const redirectUrl = request.nextUrl.searchParams.get('redirect') + + if (!redirectUrl) { + console.error(`No redirect URL for link: ${linkId}`) + return NextResponse.redirect(new URL('/', request.url)) + } + + // Декодируем URL + const decodedUrl = decodeURIComponent(redirectUrl) + + // Записываем клик через общий storage + const totalClicks = clickStorage.recordClick(linkId) + + console.log(`Redirect: ${linkId} -> ${decodedUrl} (click #${totalClicks})`) + + // Мгновенный серверный редирект на целевую ссылку + return NextResponse.redirect(decodedUrl) + + } catch (error) { + console.error('Error processing tracking link:', error) + return NextResponse.redirect(new URL('/', request.url)) + } +} \ No newline at end of file diff --git a/src/components/seller-statistics/advertising-tab.tsx b/src/components/seller-statistics/advertising-tab.tsx index 6ea4595..82b65a3 100644 --- a/src/components/seller-statistics/advertising-tab.tsx +++ b/src/components/seller-statistics/advertising-tab.tsx @@ -1,50 +1,113 @@ "use client" -import { useState, useEffect } from 'react' -import { useQuery, useLazyQuery } from '@apollo/client' +import React, { useState, useEffect, useRef } from 'react' +import { useQuery, useLazyQuery, useMutation } from '@apollo/client' +import { WildberriesService } from '@/services/wildberries-service' +import { useAuth } from '@/hooks/useAuth' 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 { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' import { Alert, AlertDescription } from '@/components/ui/alert' import { Checkbox } from '@/components/ui/checkbox' -import { GET_WILDBERRIES_CAMPAIGN_STATS, GET_WILDBERRIES_CAMPAIGNS_LIST } from '@/graphql/queries' +import { Badge } from '@/components/ui/badge' +import { GET_WILDBERRIES_CAMPAIGN_STATS, GET_WILDBERRIES_CAMPAIGNS_LIST, GET_EXTERNAL_ADS } from '@/graphql/queries' +import { CREATE_EXTERNAL_AD, DELETE_EXTERNAL_AD, UPDATE_EXTERNAL_AD, UPDATE_EXTERNAL_AD_CLICKS } from '@/graphql/mutations' import { TrendingUp, - Search, - Calendar, + TrendingDown, Eye, MousePointer, ShoppingCart, - DollarSign, - Percent, - AlertCircle, - ChevronDown, + DollarSign, ChevronRight, - Monitor, - Smartphone, - Globe, - Package, - Target, + ChevronDown, + Plus, + Trash2, + ExternalLink, + Copy, + AlertCircle, BarChart3, - Maximize2, Minimize2, - Settings, - Filter, - ArrowUpDown + Calendar, + Package, + Link, + Smartphone, + Monitor, + Globe, + Target, + ArrowUpDown, + Percent } from 'lucide-react' import { + ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, - ChartLegend, - ChartLegendContent, - type ChartConfig } from '@/components/ui/chart' -import { LineChart, Line, XAxis, YAxis, CartesianGrid } from 'recharts' +import { LineChart, Line, XAxis, YAxis, CartesianGrid, BarChart, Bar, ResponsiveContainer, ComposedChart } from 'recharts' + +// Импортируем новую простую таблицу +import { SimpleAdvertisingTable } from './simple-advertising-table' + +// Интерфейсы для новой структуры таблицы +interface ExternalAd { + id: string + name: string + url: string + cost: number + clicks?: number +} + +interface ProductAdvertising { + wbCampaigns: { + campaignId: number + views: number + clicks: number + cost: number + orders: number + }[] + externalAds: ExternalAd[] +} + +interface ProductData { + nmId: number + name: string + totalViews: number + totalClicks: number + totalCost: number + totalOrders: number + totalRevenue: number + advertising: ProductAdvertising +} + +interface DailyAdvertisingData { + date: string + totalSum: number + totalOrders: number + totalRevenue: number + products: ProductData[] +} + +interface CampaignStatsProps { + selectedPeriod: string + useCustomDates: boolean + startDate: string + endDate: string +} + +// Интерфейсы для API данных +interface GeneratedLink { + id: string + adId: string + adName: string + targetUrl: string + trackingUrl: string + clicks: number + createdAt: string +} -// Интерфейсы для типизации данных API interface CampaignProduct { views: number clicks: number @@ -118,14 +181,6 @@ interface CampaignStats { boosterStats: BoosterStat[] } -interface CampaignStatsProps { - selectedPeriod: string - useCustomDates: boolean - startDate: string - endDate: string -} - -// Интерфейсы для списка кампаний interface CampaignListItem { advertId: number changeTime: string @@ -166,19 +221,19 @@ const CompactCampaignSelector = ({ const campaigns = campaignsData?.getWildberriesCampaignsList?.data?.adverts || [] - // Автоматически выбираем активные кампании при загрузке данных + // Автоматически выбираем все доступные кампании при загрузке данных useEffect(() => { if (campaigns.length > 0 && selectedIds.size === 0) { - const activeCampaigns = campaigns - .filter((group: CampaignGroup) => group.status === 9) // Активные кампании + const allCampaigns = campaigns .flatMap((group: CampaignGroup) => group.advert_list.map((item: CampaignListItem) => item.advertId)) - .slice(0, 3) // Берем первые 3 активные кампании - if (activeCampaigns.length > 0) { - setSelectedIds(new Set(activeCampaigns)) + if (allCampaigns.length > 0) { + setSelectedIds(new Set(allCampaigns)) + // Автоматически загружаем статистику для всех кампаний + onCampaignsSelected(allCampaigns) } } - }, [campaigns]) + }, [campaigns, onCampaignsSelected]) // Функции для получения названий типов и статусов const getCampaignTypeName = (type: number) => { @@ -266,7 +321,7 @@ const CompactCampaignSelector = ({ onClick={() => setIsExpanded(!isExpanded)} className="h-7 px-2 text-white hover:bg-white/10" > - {isExpanded ? : } + {isExpanded ? : } Кампании @@ -293,7 +348,7 @@ const CompactCampaignSelector = ({
) : ( <> - + {selectedIds.size > 0 ? `Загрузить (${selectedIds.size})` : 'Выбрать'} )} @@ -400,283 +455,194 @@ const CompactCampaignSelector = ({ ) } -// Компонент сверх-компактной таблицы кампаний -const UltraCompactCampaignTable = ({ - campaigns, - expandedCampaigns, - onToggleExpand -}: { - campaigns: CampaignStats[], - expandedCampaigns: Set, - onToggleExpand: (id: number) => void -}) => { - const [sortField, setSortField] = useState('advertId') - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc') +export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endDate }: CampaignStatsProps) { + const { user } = useAuth() + + // Состояния для раскрытия строк + const [expandedDays, setExpandedDays] = useState>(new Set()) + const [expandedProducts, setExpandedProducts] = useState>(new Set()) + const [expandedCampaigns, setExpandedCampaigns] = useState>(new Set()) + + // Состояние для формы добавления внешней рекламы + const [showAddForm, setShowAddForm] = useState(null) + const [newExternalAd, setNewExternalAd] = useState({ + name: '', + url: '', + cost: '' + }) + + const [campaignStats, setCampaignStats] = useState([]) + const [productPhotos, setProductPhotos] = useState>(new Map()) + const [dailyData, setDailyData] = useState([]) + const [generatedLinksData, setGeneratedLinksData] = useState>({}) + const prevCampaignStats = useRef([]) - const formatCurrency = (value: number) => { - if (value === 0) return '—' - if (value < 1000) return `${value}₽` - if (value < 1000000) return `${(value / 1000).toFixed(1)}K₽` - return `${(value / 1000000).toFixed(1)}M₽` - } - - const formatNumber = (value: number) => { - if (value === 0) return '—' - if (value < 1000) return value.toString() - if (value < 1000000) return `${(value / 1000).toFixed(1)}K` - return `${(value / 1000000).toFixed(1)}M` - } - - const formatPercent = (value: number) => { - return value === 0 ? '—' : `${value.toFixed(1)}%` - } - - const getAppTypeIcon = (appType: number) => { - const iconClass = "h-3 w-3" - switch (appType) { - case 1: return - case 2: return - case 3: return - case 32: return - case 64: return - default: return + // Вычисляем диапазон дат для запроса внешней рекламы + const getDateRange = () => { + if (useCustomDates && startDate && endDate) { + return { dateFrom: startDate, dateTo: endDate } + } + + 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 + } + + return { + dateFrom: startDateCalc.toISOString().split('T')[0], + dateTo: endDateCalc.toISOString().split('T')[0] } } - const sortedCampaigns = [...campaigns].sort((a, b) => { - const aVal = a[sortField] as number - const bVal = b[sortField] as number - return sortDirection === 'asc' ? aVal - bVal : bVal - aVal + const { dateFrom, dateTo } = getDateRange() + + // GraphQL запросы и мутации + const { data: externalAdsData, loading: externalAdsLoading, error: externalAdsError, refetch: refetchExternalAds } = useQuery(GET_EXTERNAL_ADS, { + variables: { dateFrom, dateTo }, + skip: !user, + fetchPolicy: 'cache-and-network' }) - const handleSort = (field: keyof CampaignStats) => { - if (sortField === field) { - setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc') - } else { - setSortField(field) - setSortDirection('desc') + const [createExternalAd] = useMutation(CREATE_EXTERNAL_AD, { + onCompleted: () => { + refetchExternalAds() + }, + onError: (error) => { + console.error('Error creating external ad:', error) + }, + }) + + const [deleteExternalAd] = useMutation(DELETE_EXTERNAL_AD, { + onCompleted: () => { + refetchExternalAds() + }, + onError: (error) => { + console.error('Error deleting external ad:', error) + }, + }) + + const [updateExternalAd] = useMutation(UPDATE_EXTERNAL_AD, { + onCompleted: () => { + refetchExternalAds() + }, + onError: (error) => { + console.error('Error updating external ad:', error) + }, + }) + + const [updateExternalAdClicks] = useMutation(UPDATE_EXTERNAL_AD_CLICKS, { + onError: (error) => { + console.error('Error updating external ad clicks:', error) + }, + }) + + // Загружаем данные из localStorage только для ссылок (они остаются локальными) + useEffect(() => { + if (typeof window !== 'undefined') { + const savedLinksData = localStorage.getItem('advertisingLinksData') + + if (savedLinksData) { + try { + const linksData = JSON.parse(savedLinksData) + + // Удаляем дубликаты ссылок + const cleanedLinksData: Record = {} + Object.keys(linksData).forEach(date => { + const uniqueLinks = new Map() + linksData[date].forEach((link: GeneratedLink) => { + const key = `${link.adId}-${link.adName}` + if (!uniqueLinks.has(key) || link.clicks > (uniqueLinks.get(key)?.clicks || 0)) { + uniqueLinks.set(key, link) + } + }) + cleanedLinksData[date] = Array.from(uniqueLinks.values()) + }) + + setGeneratedLinksData(cleanedLinksData) + localStorage.setItem('advertisingLinksData', JSON.stringify(cleanedLinksData)) + } catch (error) { + console.error('Error loading links data:', error) + } + } + } + }, []) + + // Загружаем статистику кликов + const loadClickStatistics = async () => { + try { + const response = await fetch('/api/track-click') + const clickStats = await response.json() + + // Получаем свежие данные из localStorage + const savedLinksData = localStorage.getItem('advertisingLinksData') + const currentLinksData = savedLinksData ? JSON.parse(savedLinksData) : {} + + // Обновляем счетчики кликов в ссылках + setGeneratedLinksData(prev => { + const updated = { ...prev } + Object.keys(updated).forEach(date => { + updated[date] = updated[date].map(link => ({ + ...link, + clicks: clickStats[link.id] || link.clicks + })) + }) + + // Сохраняем обновленные ссылки + localStorage.setItem('advertisingLinksData', JSON.stringify(updated)) + return updated + }) + + // Обновляем клики в базе данных для внешней рекламы + if (externalAdsData?.getExternalAds?.success && externalAdsData.getExternalAds.externalAds) { + const promises = (externalAdsData.getExternalAds.externalAds as Array<{id: string, clicks: number}>).map((ad) => { + // Находим соответствующую ссылку для этой рекламы + const allLinks: GeneratedLink[] = Object.values(currentLinksData).flat() as GeneratedLink[] + const adLink = allLinks.find(link => link.adId === ad.id) + + if (adLink && clickStats[adLink.id] && clickStats[adLink.id] !== ad.clicks) { + // Обновляем клики в БД только если они изменились + return updateExternalAdClicks({ + variables: { + id: ad.id, + clicks: clickStats[adLink.id] + } + }).catch((error: unknown) => { + console.error(`Error updating clicks for ad ${ad.id}:`, error) + }) + } + return Promise.resolve() + }) + + await Promise.all(promises) + + // Обновляем данные внешней рекламы после синхронизации + refetchExternalAds() + } + } catch (error) { + console.error('Error loading click statistics:', error) } } - return ( -
- {/* Сверх-компактный заголовок таблицы */} -
-
handleSort('advertId')} - > - Кампания - -
-
handleSort('views')} - > - 👁 -
-
handleSort('clicks')} - > - 🖱 -
-
handleSort('ctr')} - > - CTR -
-
handleSort('cpc')} - > - CPC -
-
handleSort('sum')} - > - 💰 -
-
handleSort('orders')} - > - 📦 -
-
handleSort('cr')} - > - CR -
-
handleSort('shks')} - > - шт -
-
handleSort('sum_price')} - > - Выручка -
-
ROI
-
- - {/* Строки кампаний */} - {sortedCampaigns.map((campaign) => { - const isExpanded = expandedCampaigns.has(campaign.advertId) - const roi = campaign.sum > 0 ? ((campaign.sum_price - campaign.sum) / campaign.sum * 100) : 0 - - return ( -
- {/* Основная строка кампании */} -
onToggleExpand(campaign.advertId)} - > -
- {isExpanded ? : } - #{campaign.advertId} - - {campaign.days.length}д - -
- -
{formatNumber(campaign.views)}
-
{formatNumber(campaign.clicks)}
-
{formatPercent(campaign.ctr)}
-
{formatCurrency(campaign.cpc)}
-
{formatCurrency(campaign.sum)}
-
{formatNumber(campaign.orders)}
-
{formatPercent(campaign.cr)}
-
{formatNumber(campaign.shks)}
-
{formatCurrency(campaign.sum_price)}
-
0 ? 'text-green-400' : roi < 0 ? 'text-red-400' : 'text-gray-400'}`}> - {roi === 0 ? '—' : `${roi > 0 ? '+' : ''}${roi.toFixed(0)}%`} -
-
- - {/* Развернутое содержимое */} - {isExpanded && ( -
- {/* Компактная статистика по дням */} - {campaign.days.length > 0 && ( -
-

- - По дням ({campaign.days.length}) -

- -
- {campaign.days.map((day, dayIndex) => ( -
-
- {new Date(day.date).toLocaleDateString('ru-RU', { - day: '2-digit', - month: '2-digit' - })} -
-
{formatNumber(day.views)}
-
{formatNumber(day.clicks)}
-
{formatPercent(day.ctr)}
-
{formatCurrency(day.cpc)}
-
{formatCurrency(day.sum)}
-
{formatNumber(day.orders)}
-
{formatPercent(day.cr)}
-
{formatNumber(day.shks)}
-
{formatCurrency(day.sum_price)}
-
- ))} -
-
- )} - - {/* Компактная статистика по платформам */} - {campaign.days.some(day => day.apps && day.apps.length > 0) && ( -
-

- - Платформы -

- -
- {campaign.days.flatMap(day => day.apps || []) - .reduce((acc: CampaignApp[], app) => { - const existing = acc.find(a => a.appType === app.appType) - if (existing) { - existing.views += app.views - existing.clicks += app.clicks - existing.sum += app.sum - existing.orders += app.orders - existing.sum_price += app.sum_price - } else { - acc.push({ ...app }) - } - return acc - }, []) - .map((app, appIndex) => ( -
-
- {getAppTypeIcon(app.appType)} - - {app.appType === 1 ? 'Мобайл' : - app.appType === 32 ? 'Десктоп' : - app.appType === 64 ? 'Моб.WB' : `Тип${app.appType}`} - -
-
{formatNumber(app.views)}
-
{formatNumber(app.clicks)}
-
{formatPercent(app.ctr)}
-
{formatCurrency(app.sum)}
-
{formatNumber(app.orders)}
-
{formatPercent(app.cr)}
-
{formatNumber(app.shks)}
-
{formatCurrency(app.sum_price)}
-
- ))} -
-
- )} - - {/* Позиции товаров */} - {campaign.boosterStats.length > 0 && ( -
-

- - Позиции -

-
- {campaign.boosterStats.slice(0, 6).map((booster, index) => ( -
-
- {new Date(booster.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' })} -
-
- #{booster.avg_position} -
-
- ))} -
-
- )} -
- )} -
- ) - })} -
- ) -} - -export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endDate }: CampaignStatsProps) { - const [selectedCampaignIds, setSelectedCampaignIds] = useState([]) - const [campaignStats, setCampaignStats] = useState([]) - const [expandedCampaigns, setExpandedCampaigns] = useState>(new Set()) - const [showChart, setShowChart] = useState(false) + // Загружаем статистику кликов периодически + useEffect(() => { + loadClickStatistics() + const interval = setInterval(loadClickStatistics, 10000) // каждые 10 секунд + return () => clearInterval(interval) + }, []) + + const { data: campaignsData, loading: campaignsLoading } = useQuery(GET_WILDBERRIES_CAMPAIGNS_LIST, { + errorPolicy: 'all' + }) const [getCampaignStats, { loading, error }] = useLazyQuery(GET_WILDBERRIES_CAMPAIGN_STATS, { onCompleted: (data) => { @@ -689,11 +655,228 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD } }) + // Загрузка фотографий товаров (точно как на складе WB) + const loadProductPhotos = async (nmIds: number[]) => { + if (!user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')?.isActive) { + return + } + + try { + const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') + + if (!wbApiKey?.isActive) { + console.error('Advertising: API ключ Wildberries не настроен') + return + } + + const validationData = wbApiKey.validationData as Record + const apiToken = validationData?.token || + validationData?.apiKey || + validationData?.key || + (wbApiKey as { apiKey?: string }).apiKey + + if (!apiToken) { + console.error('Advertising: Токен API не найден') + return + } + + console.log('Advertising: Loading product photos...') + + // Используем точно тот же метод что и на складе + const cards = await WildberriesService.getAllCards(apiToken).catch(() => []) + console.log('Advertising: Loaded cards:', cards.length) + + if (cards.length === 0) { + console.error('Advertising: Нет карточек товаров в WB') + return + } + + const newPhotos = new Map() + const uniqueNmIds = [...new Set(nmIds)] + + cards.forEach(card => { + if (uniqueNmIds.includes(card.nmID) && card.photos && Array.isArray(card.photos) && card.photos.length > 0) { + const photo = card.photos[0] + const photoUrl = photo.big || photo.c516x688 || photo.c246x328 || photo.tm || photo.square + if (photoUrl) { + newPhotos.set(card.nmID, photoUrl) + console.log(`Advertising: Found photo for ${card.nmID}: ${photoUrl}`) + } + } + }) + + console.log(`Advertising: Loaded ${newPhotos.size} product photos`) + setProductPhotos(prev => new Map([...prev, ...newPhotos])) + + } catch (error) { + console.error('Advertising: Error loading product photos:', error) + } + } + + // Автоматически загружаем все доступные кампании + 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)) + + if (allCampaignIds.length > 0) { + handleCampaignsSelected(allCampaignIds) + } + } + }, [campaignsData, selectedPeriod, useCustomDates, startDate, endDate]) + + // Преобразование данных кампаний в новый формат таблицы + const convertCampaignDataToDailyData = (campaigns: CampaignStats[]): DailyAdvertisingData[] => { + const dailyMap = new Map() + + campaigns.forEach(campaign => { + campaign.days.forEach(day => { + const dateKey = day.date.split('T')[0] // Получаем только дату без времени + + if (!dailyMap.has(dateKey)) { + dailyMap.set(dateKey, { + date: dateKey, + totalSum: 0, + totalOrders: 0, + totalRevenue: 0, + products: [] + }) + } + + const dailyRecord = dailyMap.get(dateKey)! + + // Добавляем товары с их рекламными кампаниями + if (day.apps) { + day.apps.forEach(app => { + if (app.nm) { + app.nm.forEach(product => { + let existingProduct = dailyRecord.products.find(p => p.nmId === product.nmId) + + if (!existingProduct) { + // Создаем новый товар + existingProduct = { + nmId: product.nmId, + name: product.name, + totalViews: 0, + totalClicks: 0, + totalCost: 0, + totalOrders: 0, + totalRevenue: 0, + advertising: { + wbCampaigns: [], + externalAds: [] + } + } + dailyRecord.products.push(existingProduct) + } + + // Суммируем данные товара + existingProduct.totalViews += product.views + existingProduct.totalClicks += product.clicks + existingProduct.totalCost += product.sum + existingProduct.totalOrders += product.orders + existingProduct.totalRevenue += product.sum_price + + // Добавляем данные ВБ кампании для этого товара + const existingCampaign = existingProduct.advertising.wbCampaigns.find(c => c.campaignId === campaign.advertId) + if (existingCampaign) { + existingCampaign.views += product.views + existingCampaign.clicks += product.clicks + existingCampaign.cost += product.sum + existingCampaign.orders += product.orders + } else { + existingProduct.advertising.wbCampaigns.push({ + campaignId: campaign.advertId, + views: product.views, + clicks: product.clicks, + cost: product.sum, + orders: product.orders + }) + } + }) + } + }) + } + }) + }) + + // После создания структуры товаров, добавляем внешнюю рекламу из GraphQL данных + const result = Array.from(dailyMap.values()) + + if (externalAdsData?.getExternalAds?.success && externalAdsData.getExternalAds.externalAds) { + result.forEach(day => { + const externalAdsForDay = externalAdsData.getExternalAds.externalAds.filter( + (ad: ExternalAd & { date: string; nmId: string }) => ad.date === day.date + ) + + if (externalAdsForDay.length > 0 && day.products.length > 0) { + // Группируем внешнюю рекламу по nmId товара + const adsByProduct = externalAdsForDay.reduce((acc: Record, ad: ExternalAd & { date: string; nmId: string }) => { + if (!acc[ad.nmId]) acc[ad.nmId] = [] + acc[ad.nmId].push({ + id: ad.id, + name: ad.name, + url: ad.url, + cost: ad.cost, + clicks: ad.clicks || 0 + }) + return acc + }, {}) + + // Добавляем внешнюю рекламу к соответствующим товарам + day.products.forEach(product => { + if (adsByProduct[product.nmId.toString()]) { + product.advertising.externalAds = adsByProduct[product.nmId.toString()] + } + }) + } + }) + } + + // Обновляем общие суммы дня (ВБ реклама + внешняя реклама) + result.forEach(day => { + day.totalSum = day.products.reduce((sum, product) => + sum + product.totalCost + product.advertising.externalAds.reduce((adSum, ad) => adSum + ad.cost, 0), 0) + day.totalOrders = day.products.reduce((sum, product) => sum + product.totalOrders, 0) + day.totalRevenue = day.products.reduce((sum, product) => sum + product.totalRevenue, 0) + }) + + return result.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + } + + + + // Загружаем фотографии когда получаем статистику кампаний + useEffect(() => { + if (campaignStats.length > 0) { + const nmIds = campaignStats + .flatMap(campaign => campaign.days) + .flatMap(day => day.apps?.flatMap(app => app.nm) || []) + .map(product => product.nmId) + + // Проверяем, есть ли новые nmIds, которых еще нет в productPhotos + const newNmIds = nmIds.filter(nmId => !productPhotos.has(nmId)) + + if (newNmIds.length > 0) { + console.log('Loading photos for new products:', newNmIds.length) + loadProductPhotos(newNmIds) + } + + // Преобразуем данные в новый формат только если это первая загрузка или изменились кампании/внешняя реклама + if (dailyData.length === 0 || + JSON.stringify(campaignStats) !== JSON.stringify(prevCampaignStats.current) || + externalAdsData) { + const newDailyData = convertCampaignDataToDailyData(campaignStats) + setDailyData(newDailyData) + prevCampaignStats.current = campaignStats + } + } + }, [campaignStats, externalAdsData]) // Добавляем externalAdsData в зависимости + const handleCampaignsSelected = (ids: number[]) => { if (ids.length === 0) return - setSelectedCampaignIds(ids) - let campaigns if (useCustomDates && startDate && endDate) { campaigns = ids.map(id => ({ @@ -745,6 +928,162 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD setExpandedCampaigns(newExpanded) } + // Обработчики для внешней рекламы + const handleAddExternalAd = async (date: string, ad: Omit) => { + console.log('handleAddExternalAd called:', { date, ad }) + + try { + // Находим nmId из первого товара дня (или можно передать отдельно) + const dayData = dailyData.find(d => d.date === date) + const nmId = dayData?.products[0]?.nmId?.toString() || '0' + + await createExternalAd({ + variables: { + input: { + name: ad.name, + url: ad.url, + cost: ad.cost, + date: date, + nmId: nmId + } + } + }) + + console.log('External ad created successfully') + } catch (error) { + console.error('Error creating external ad:', error) + } + } + + const handleRemoveExternalAd = async (date: string, adId: string) => { + console.log('handleRemoveExternalAd called:', { date, adId }) + + try { + await deleteExternalAd({ + variables: { id: adId } + }) + + console.log('External ad deleted successfully') + } catch (error) { + console.error('Error deleting external ad:', error) + } + } + + const handleUpdateExternalAd = async (date: string, adId: string, updates: Partial) => { + console.log('handleUpdateExternalAd called:', { date, adId, updates }) + + try { + // Находим текущую рекламу для получения полных данных + const currentAd = dailyData + .find(d => d.date === date) + ?.products.flatMap(p => p.advertising.externalAds) + .find(ad => ad.id === adId) + + if (!currentAd) { + console.error('External ad not found') + return + } + + // Находим nmId из товара, к которому привязана реклама + const dayData = dailyData.find(d => d.date === date) + const product = dayData?.products.find(p => + p.advertising.externalAds.some(ad => ad.id === adId) + ) + const nmId = product?.nmId?.toString() || '0' + + await updateExternalAd({ + variables: { + id: adId, + input: { + name: updates.name || currentAd.name, + url: updates.url || currentAd.url, + cost: updates.cost || currentAd.cost, + date: date, + nmId: nmId + } + } + }) + + console.log('External ad updated successfully') + } catch (error) { + console.error('Error updating external ad:', error) + } + } + + // Обработчики для ссылок-кликеров + const handleGenerateLink = (date: string, adId: string, adName: string, adUrl: string) => { + // Проверяем, есть ли уже ссылка для этой рекламы в этот день + const existingLinks = generatedLinksData[date] || [] + const existingLink = existingLinks.find(link => link.adId === adId && link.adName === adName) + + if (existingLink) { + // Если ссылка уже существует, просто копируем её + navigator.clipboard.writeText(existingLink.trackingUrl).then(() => { + alert(`Ссылка уже существует и скопирована!\nПользователи будут переходить на: ${existingLink.targetUrl}`) + }) + return + } + + // Валидируем URL + let validUrl = adUrl.trim() + if (!validUrl.startsWith('http://') && !validUrl.startsWith('https://')) { + validUrl = 'https://' + validUrl + } + + const linkId = `link-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + const trackedUrl = `${window.location.origin}/track/${linkId}?redirect=${encodeURIComponent(validUrl)}` + + console.log('Generating link:', { + linkId, + originalUrl: adUrl, + validUrl, + trackedUrl, + encodedUrl: encodeURIComponent(validUrl) + }) + + const newLink: GeneratedLink = { + id: linkId, + adId, + adName, + targetUrl: validUrl, + trackingUrl: trackedUrl, + clicks: 0, + createdAt: new Date().toISOString() + } + + setGeneratedLinksData(prev => { + const newData = { + ...prev, + [date]: [...(prev[date] || []), newLink] + } + // Сохраняем данные в localStorage + localStorage.setItem('advertisingLinksData', JSON.stringify(newData)) + return newData + }) + + // Копируем ссылку в буфер обмена + navigator.clipboard.writeText(trackedUrl).then(() => { + console.log('Ссылка-кликер скопирована в буфер обмена:', trackedUrl) + alert(`Ссылка скопирована! Вставьте её в рекламу.\nПользователи будут переходить на: ${validUrl}`) + }) + } + + const handleCopyLink = (linkId: string) => { + // Найдем ссылку во всех датах + let linkToCopy: GeneratedLink | undefined + Object.values(generatedLinksData).forEach(links => { + const found = links.find(link => link.id === linkId) + if (found) linkToCopy = found + }) + + if (linkToCopy) { + navigator.clipboard.writeText(linkToCopy.trackingUrl).then(() => { + console.log('Ссылка-кликер скопирована в буфер обмена:', linkToCopy!.trackingUrl) + alert(`Ссылка скопирована! Люди будут переходить на: ${linkToCopy!.targetUrl}`) + }) + } + } + const formatCurrency = (value: number) => { return new Intl.NumberFormat('ru-RU', { style: 'currency', @@ -762,14 +1101,52 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD return `${value.toFixed(2)}%` } - // Подготовка данных для графика - const chartData = campaignStats.length > 0 ? campaignStats[0]?.days?.map(day => ({ - date: new Date(day.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }), - views: day.views, - clicks: day.clicks, - sum: day.sum, - orders: day.orders - })) || [] : [] + // Подготовка данных для графика с включением внешней рекламы + const chartData = React.useMemo(() => { + if (dailyData.length === 0) return [] + + return dailyData.map(day => { + const dayViews = day.products.reduce((sum, product) => sum + product.totalViews, 0) + const dayClicks = day.products.reduce((sum, product) => sum + product.totalClicks, 0) + const dayExternalClicks = day.products.reduce((sum, product) => + sum + product.advertising.externalAds.reduce((adSum, ad) => adSum + (ad.clicks || 0), 0), 0) + const dayOrders = day.totalOrders + const dayWbCost = day.products.reduce((sum, product) => sum + product.totalCost, 0) + const dayExternalCost = day.products.reduce((sum, product) => + sum + product.advertising.externalAds.reduce((adSum, ad) => adSum + ad.cost, 0), 0) + + return { + date: new Date(day.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }), + views: dayViews, + clicks: dayClicks + dayExternalClicks, + wbClicks: dayClicks, + externalClicks: dayExternalClicks, + sum: dayWbCost + dayExternalCost, + wbSum: dayWbCost, + externalSum: dayExternalCost, + orders: dayOrders + } + }).reverse() // График показывает от старых к новым датам + }, [dailyData]) + + // Подготовка данных для графика расходов с разделением ВБ и внешней рекламы + const spendingChartData = React.useMemo(() => { + if (dailyData.length === 0) return [] + + return dailyData.map(day => { + const wbSum = day.products.reduce((sum, product) => sum + product.totalCost, 0) + const externalSum = day.products.reduce((sum, product) => + sum + product.advertising.externalAds.reduce((adSum, ad) => adSum + ad.cost, 0), 0) + + return { + date: new Date(day.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }), + wbSum: wbSum, + externalSum: externalSum, + sum: wbSum + externalSum, // Общая сумма для совместимости + fullDate: day.date + } + }).sort((a, b) => a.fullDate.localeCompare(b.fullDate)) + }, [dailyData]) const chartConfig = { views: { @@ -777,13 +1154,29 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD color: "#8b5cf6", }, clicks: { - label: "Клики", + label: "Клики (общие)", color: "#06b6d4", }, - sum: { - label: "Затраты (₽)", + wbClicks: { + label: "Клики ВБ", + color: "#06b6d4", + }, + externalClicks: { + label: "Клики внешние", color: "#f59e0b", }, + sum: { + label: "Затраты (общие) ₽", + color: "#f59e0b", + }, + wbSum: { + label: "Затраты ВБ ₽", + color: "#3b82f6", + }, + externalSum: { + label: "Затраты внешние ₽", + color: "#ec4899", + }, orders: { label: "Заказы", color: "#10b981", @@ -792,13 +1185,6 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD return (
- {/* Компактный селектор кампаний */} - - {/* Ошибки */} {error && ( @@ -809,203 +1195,124 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD )} + {externalAdsError && ( + + + + Ошибка загрузки внешней рекламы: {externalAdsError.message} + + + )} + {/* Результаты */}
- {loading ? ( + {(loading || campaignsLoading || externalAdsLoading) ? (
{[1, 2, 3].map((i) => ( - + ))}
) : campaignStats.length > 0 ? (
- {/* Компактная общая статистика */} - -
-

- - Сводка ({campaignStats.length} кампаний) -

- -
- -
- {/* Показы */} -
-
- - Показы -
-
- {formatNumber(campaignStats.reduce((sum, stat) => sum + stat.views, 0))} + {/* График расходов */} + {spendingChartData.length > 0 && ( + +
+

+ + Расходы на рекламу +

+
+
Общие: {formatCurrency(spendingChartData.reduce((sum, day) => sum + day.sum, 0))}
+
+ +
+ ВБ: {formatCurrency(spendingChartData.reduce((sum, day) => sum + day.wbSum, 0))} +
+ +
+ Внешняя: {formatCurrency(spendingChartData.reduce((sum, day) => sum + day.externalSum, 0))} +
+
- - {/* Клики */} -
-
- - Клики -
-
- {formatNumber(campaignStats.reduce((sum, stat) => sum + stat.clicks, 0))} -
+ +
+ + + + + `${(value / 1000).toFixed(0)}K₽`} + /> + { + if (active && payload && payload.length) { + return ( +
+

{`Дата: ${label}`}

+ {payload.map((entry, index) => ( +

+ {`${entry.name}: ${formatCurrency(entry.value as number)}`} +

+ ))} +

+ {`Общие расходы: ${formatCurrency( + payload.reduce((sum, entry) => sum + (entry.value as number), 0) + )}`} +

+
+ ) + } + return null + }} + /> + + +
+
+ + )} - {/* CTR */} -
-
- - CTR -
-
- {formatPercent( - campaignStats.reduce((sum, stat, _, arr) => { - const totalViews = arr.reduce((s, st) => s + st.views, 0) - const totalClicks = arr.reduce((s, st) => s + st.clicks, 0) - return totalViews > 0 ? (totalClicks / totalViews) * 100 : 0 - }, 0) - )} -
-
- - {/* CPC */} -
-
- - CPC -
-
- {formatCurrency( - campaignStats.reduce((sum, stat, _, arr) => { - const totalClicks = arr.reduce((s, st) => s + st.clicks, 0) - const totalSum = arr.reduce((s, st) => s + st.sum, 0) - return totalClicks > 0 ? totalSum / totalClicks : 0 - }, 0) - )} -
-
- - {/* Затраты */} -
-
- - Затраты -
-
- {formatCurrency(campaignStats.reduce((sum, stat) => sum + stat.sum, 0))} -
-
- - {/* Заказы */} -
-
- - Заказы -
-
- {formatNumber(campaignStats.reduce((sum, stat) => sum + stat.orders, 0))} -
-
- - {/* CR */} -
-
- - CR -
-
- {formatPercent( - campaignStats.reduce((sum, stat, _, arr) => { - const totalClicks = arr.reduce((s, st) => s + st.clicks, 0) - const totalOrders = arr.reduce((s, st) => s + st.orders, 0) - return totalClicks > 0 ? (totalOrders / totalClicks) * 100 : 0 - }, 0) - )} -
-
- - {/* Выручка */} -
-
- - Выручка -
-
- {formatCurrency(campaignStats.reduce((sum, stat) => sum + stat.sum_price, 0))} -
-
-
- - {/* Компактный график */} - {showChart && chartData.length > 0 && ( -
-
- - - - - - } /> - - - - - -
-
- )} -
- - {/* Сверх-компактная таблица кампаний */} + {/* Новая таблица рекламы */}

- Детальная статистика + Статистика рекламы

- {campaignStats.reduce((sum, stat) => sum + stat.days.length, 0)} дней данных + {dailyData.length} дней данных
-
@@ -1015,23 +1322,10 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD

Статистика рекламных кампаний

-

Выберите кампании выше и нажмите “Загрузить” для получения статистики

-

+

Загружаем статистику по всем доступным кампаниям...

+

Поддерживается API Wildberries /adv/v2/fullstats

- - {/* Инструкция для пользователя */} -
-

- - Как начать работу: -

-
    -
  1. Разверните селектор кампаний выше (нажмите кнопку с иконкой)
  2. -
  3. Выберите нужные кампании из списка или введите ID вручную
  4. -
  5. Нажмите кнопку “Загрузить” для получения статистики
  6. -
-
diff --git a/src/components/seller-statistics/advertising-tab.tsx.backup b/src/components/seller-statistics/advertising-tab.tsx.backup new file mode 100644 index 0000000..c351aae --- /dev/null +++ b/src/components/seller-statistics/advertising-tab.tsx.backup @@ -0,0 +1,2104 @@ +"use client" + +import React, { useState, useEffect, useRef } from 'react' +import { useQuery, useLazyQuery, useMutation } from '@apollo/client' +import { WildberriesService } from '@/services/wildberries-service' +import { useAuth } from '@/hooks/useAuth' +import { Card } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Skeleton } from '@/components/ui/skeleton' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Checkbox } from '@/components/ui/checkbox' +import { Badge } from '@/components/ui/badge' +import { GET_WILDBERRIES_CAMPAIGN_STATS, GET_WILDBERRIES_CAMPAIGNS_LIST, GET_EXTERNAL_ADS } from '@/graphql/queries' +import { CREATE_EXTERNAL_AD, DELETE_EXTERNAL_AD, UPDATE_EXTERNAL_AD, UPDATE_EXTERNAL_AD_CLICKS } from '@/graphql/mutations' +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 +} from 'lucide-react' +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@/components/ui/chart' +import { LineChart, Line, XAxis, YAxis, CartesianGrid, BarChart, Bar, ResponsiveContainer, ComposedChart } from 'recharts' + +// Интерфейсы для новой структуры таблицы +interface ExternalAd { + id: string + name: string + url: string + cost: number + clicks?: number +} + +interface ProductAdvertising { + wbCampaigns: { + campaignId: number + views: number + clicks: number + cost: number + orders: number + }[] + externalAds: ExternalAd[] +} + +interface ProductData { + nmId: number + name: string + totalViews: number + totalClicks: number + totalCost: number + totalOrders: number + totalRevenue: number + advertising: ProductAdvertising +} + +interface DailyAdvertisingData { + date: string + totalSum: number + totalOrders: number + totalRevenue: number + products: ProductData[] +} + +interface CampaignStatsProps { + selectedPeriod: string + useCustomDates: boolean + startDate: string + endDate: string +} + +// Интерфейсы для API данных +interface GeneratedLink { + id: string + adId: string + adName: string + targetUrl: string + trackingUrl: string + clicks: number + createdAt: string +} + +interface CampaignProduct { + views: number + clicks: number + ctr: number + cpc: number + sum: number + atbs: number + orders: number + cr: number + shks: number + sum_price: number + name: string + nmId: number +} + +interface CampaignApp { + views: number + clicks: number + ctr: number + cpc: number + sum: number + atbs: number + orders: number + cr: number + shks: number + sum_price: number + appType: number + nm: CampaignProduct[] +} + +interface CampaignDay { + date: string + views: number + clicks: number + ctr: number + cpc: number + sum: number + atbs: number + orders: number + cr: number + shks: number + sum_price: number + apps: CampaignApp[] +} + +interface BoosterStat { + date: string + nm: number + avg_position: number +} + +interface CampaignInterval { + begin: string + end: string +} + +interface CampaignStats { + advertId: number + views: number + clicks: number + ctr: number + cpc: number + sum: number + atbs: number + orders: number + cr: number + shks: number + sum_price: number + interval?: CampaignInterval + days: CampaignDay[] + boosterStats: BoosterStat[] +} + +interface CampaignListItem { + advertId: number + changeTime: string +} + +interface CampaignGroup { + type: number + status: number + count: number + advert_list: CampaignListItem[] +} + +interface CampaignsListData { + adverts: CampaignGroup[] + all: number +} + +// Компонент компактного селектора кампаний +const CompactCampaignSelector = ({ + onCampaignsSelected, + selectedCampaigns, + loading: statsLoading +}: { + onCampaignsSelected: (ids: number[]) => void, + selectedCampaigns: number[], + loading: boolean +}) => { + const [isExpanded, setIsExpanded] = useState(true) // Автоматически разворачиваем для удобства + const [showManualInput, setShowManualInput] = useState(false) + const [manualIds, setManualIds] = useState('') + const [selectedIds, setSelectedIds] = useState>(new Set(selectedCampaigns)) + const [filterType, setFilterType] = useState('all') + const [filterStatus, setFilterStatus] = useState('all') + + const { data: campaignsData, loading, error } = useQuery(GET_WILDBERRIES_CAMPAIGNS_LIST, { + errorPolicy: 'all' + }) + + const campaigns = campaignsData?.getWildberriesCampaignsList?.data?.adverts || [] + + // Автоматически выбираем все доступные кампании при загрузке данных + useEffect(() => { + if (campaigns.length > 0 && selectedIds.size === 0) { + const allCampaigns = campaigns + .flatMap((group: CampaignGroup) => group.advert_list.map((item: CampaignListItem) => item.advertId)) + + if (allCampaigns.length > 0) { + setSelectedIds(new Set(allCampaigns)) + // Автоматически загружаем статистику для всех кампаний + onCampaignsSelected(allCampaigns) + } + } + }, [campaigns, onCampaignsSelected]) + + // Функции для получения названий типов и статусов + const getCampaignTypeName = (type: number) => { + const types: Record = { + 4: 'Авто', + 5: 'Фразы', + 6: 'Предмет', + 7: 'Бренд', + 8: 'Медиа', + 9: 'Карусель' + } + return types[type] || `Тип ${type}` + } + + const getCampaignStatusName = (status: number) => { + const statuses: Record = { + 7: 'Завершена', + 8: 'Отклонена', + 9: 'Активна', + 11: 'На паузе' + } + return statuses[status] || `Статус ${status}` + } + + const getStatusColor = (status: number) => { + const colors: Record = { + 9: 'text-green-400', + 11: 'text-yellow-400', + 7: 'text-gray-400', + 8: 'text-red-400' + } + return colors[status] || 'text-white' + } + + // Фильтрация кампаний + const filteredCampaigns = campaigns.filter((group: CampaignGroup) => + (filterType === 'all' || group.type === filterType) && + (filterStatus === 'all' || group.status === filterStatus) + ) + + const handleCampaignToggle = (campaignId: number) => { + const newSelected = new Set(selectedIds) + if (newSelected.has(campaignId)) { + newSelected.delete(campaignId) + } else { + newSelected.add(campaignId) + } + setSelectedIds(newSelected) + } + + const handleSelectAll = (group: CampaignGroup) => { + const newSelected = new Set(selectedIds) + const groupIds = group.advert_list.map((item: CampaignListItem) => item.advertId) + const allSelected = groupIds.every((id: number) => newSelected.has(id)) + + if (allSelected) { + groupIds.forEach(id => newSelected.delete(id)) + } else { + groupIds.forEach(id => newSelected.add(id)) + } + setSelectedIds(newSelected) + } + + const handleApplySelection = () => { + if (showManualInput && manualIds.trim()) { + const ids = manualIds.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id)) + onCampaignsSelected(ids) + } else { + onCampaignsSelected(Array.from(selectedIds)) + } + } + + const uniqueTypes = [...new Set(campaigns.map((group: CampaignGroup) => group.type))] as number[] + const uniqueStatuses = [...new Set(campaigns.map((group: CampaignGroup) => group.status))] as number[] + + return ( + +
+ {/* Компактный заголовок */} +
+
+ + + {selectedIds.size} + +
+ +
+ + +
+
+ + {/* Развернутый контент */} + {isExpanded && ( +
+ {showManualInput ? ( + setManualIds(e.target.value)} + className="h-8 bg-white/5 border-white/20 text-white placeholder:text-white/40 text-xs" + /> + ) : ( +
+ {/* Компактные фильтры */} +
+ + + +
+ + {/* Компактный список кампаний */} + {loading ? ( + + ) : error ? ( + + + + Ошибка: {error.message} + + + ) : ( +
+ {filteredCampaigns.map((group: CampaignGroup) => ( +
+
+
+ selectedIds.has(item.advertId))} + onCheckedChange={() => handleSelectAll(group)} + className="h-3 w-3" + /> + + {getCampaignTypeName(group.type)} + + + {getCampaignStatusName(group.status)} + + + {group.count} + +
+
+ +
+ {group.advert_list.map((campaign) => ( +
handleCampaignToggle(campaign.advertId)} + > + handleCampaignToggle(campaign.advertId)} + className="h-3 w-3" + /> + #{campaign.advertId} +
+ ))} +
+
+ ))} +
+ )} +
+ )} +
+ )} +
+
+ ) +} + +export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endDate }: CampaignStatsProps) { + onUpdateExternalAd, + onGenerateLink +}: { + dailyData: DailyAdvertisingData[], + productPhotos: Map, + generatedLinksData: Record, + onAddExternalAd: (date: string, ad: Omit) => void, + onRemoveExternalAd: (date: string, adId: string) => void, + onUpdateExternalAd: (date: string, adId: string, updates: Partial) => void, + onGenerateLink: (date: string, adId: string, adName: string, adUrl: string) => void +}) => { + const [expandedDays, setExpandedDays] = useState>(new Set()) + const [expandedProducts, setExpandedProducts] = useState>(new Set()) + const [newExternalAd, setNewExternalAd] = useState({ name: '', url: '', cost: '', productId: '' }) + const [showAddForm, setShowAddForm] = useState(null) // Показываем форму для конкретного товара + + const formatCurrency = (value: number) => { + if (value === 0) return '—' + if (value < 100) { + return `${value.toFixed(2)}₽` + } + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB', + minimumFractionDigits: 0, + maximumFractionDigits: 0 + }).format(value) + } + + const formatNumber = (value: number) => { + return value > 0 ? new Intl.NumberFormat('ru-RU').format(value) : '—' + } + + const toggleDay = (date: string) => { + const newExpanded = new Set(expandedDays) + if (newExpanded.has(date)) { + newExpanded.delete(date) + } else { + newExpanded.add(date) + } + setExpandedDays(newExpanded) + } + + const toggleProduct = (date: string, nmId: number) => { + const key = `${date}-${nmId}` + const newExpanded = new Set(expandedProducts) + if (newExpanded.has(key)) { + newExpanded.delete(key) + } else { + newExpanded.add(key) + } + setExpandedProducts(newExpanded) + } + + const handleAddExternalAdLocal = (date: string, nmId: number) => { + console.log('handleAddExternalAdLocal called:', { date, nmId, newExternalAd }) + + if (newExternalAd.name && newExternalAd.url && newExternalAd.cost) { + console.log('Calling onAddExternalAd with:', { + date, + ad: { + name: newExternalAd.name, + url: newExternalAd.url, + cost: parseFloat(newExternalAd.cost) || 0 + } + }) + + onAddExternalAd(date, { + name: newExternalAd.name, + url: newExternalAd.url, + cost: parseFloat(newExternalAd.cost) || 0 + }) + setNewExternalAd({ name: '', url: '', cost: '', productId: '' }) + setShowAddForm(null) + } else { + console.log('Missing required fields:', { + name: newExternalAd.name, + url: newExternalAd.url, + cost: newExternalAd.cost + }) + } + } + + return ( +
+ {/* Фильтры */} +
+
+
+ + +
+
+ + +
+
+
+ + {/* Заголовок таблицы */} +
+
+
Дата
+
Сумма (руб)
+
Заказы (ед)
+
Реклама ВБ
+
Реклама внешняя
+
+
+ + {/* Строки таблицы */} +
+ {dailyData.map((day) => { + // Подсчитываем суммы за день + const dayWbCost = day.products.reduce((sum, product) => sum + product.totalCost, 0) + const dayExternalCost = day.products.reduce((sum, product) => + sum + product.advertising.externalAds.reduce((adSum, ad) => adSum + ad.cost, 0), 0) + const dayTotalCost = dayWbCost + dayExternalCost + const dayOrders = day.totalOrders + + return ( +
+
{day.date}
+
{formatCurrency(dayTotalCost)}
+
{dayOrders}
+
+ {showWbAds ? formatCurrency(dayWbCost) : '—'} +
+
+ {showExternalAds ? formatCurrency(dayExternalCost) : '—'} +
+
+ ) + })} +
+
+ ) +} + +// Старый компонент (для совместимости) +const UltraCompactCampaignTable = ({ + campaigns, + expandedCampaigns, + onToggleExpand, + productPhotos +}: { + campaigns: CampaignStats[], + expandedCampaigns: Set, + onToggleExpand: (id: number) => void, + productPhotos: Map +}) => { + const [sortField, setSortField] = useState('advertId') + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc') + + const formatCurrency = (value: number) => { + if (value === 0) return '—' + if (value < 1000) return `${value.toFixed(2)}₽` + if (value < 1000000) return `${(value / 1000).toFixed(1)}K₽` + return `${(value / 1000000).toFixed(1)}M₽` + } + + const formatNumber = (value: number) => { + if (value === 0) return '—' + if (value < 1000) return value.toString() + if (value < 1000000) return `${(value / 1000).toFixed(1)}K` + return `${(value / 1000000).toFixed(1)}M` + } + + const formatPercent = (value: number) => { + return value === 0 ? '—' : `${value.toFixed(1)}%` + } + + const getAppTypeIcon = (appType: number) => { + const iconClass = "h-3 w-3" + switch (appType) { + case 1: return + case 2: return + case 3: return + case 32: return + case 64: return + default: return + } + } + + const sortedCampaigns = [...campaigns].sort((a, b) => { + const aVal = a[sortField] as number + const bVal = b[sortField] as number + return sortDirection === 'asc' ? aVal - bVal : bVal - aVal + }) + + const handleSort = (field: keyof CampaignStats) => { + if (sortField === field) { + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc') + } else { + setSortField(field) + setSortDirection('desc') + } + } + + return ( +
+ {/* Сверх-компактный заголовок таблицы */} +
+
handleSort('advertId')} + > + Товары + +
+
handleSort('views')} + > + 👁 +
+
handleSort('clicks')} + > + 🖱 +
+
handleSort('ctr')} + > + CTR +
+
handleSort('cpc')} + > + CPC +
+
handleSort('sum')} + > + 💰 +
+
handleSort('orders')} + > + 📦 +
+
handleSort('cr')} + > + CR +
+
handleSort('shks')} + > + шт +
+
handleSort('sum_price')} + > + Выручка +
+
ROI
+
+ + {/* Строки кампаний */} + {sortedCampaigns.map((campaign) => { + const isExpanded = expandedCampaigns.has(campaign.advertId) + const roi = campaign.sum > 0 ? ((campaign.sum_price - campaign.sum) / campaign.sum * 100) : 0 + + return ( +
+ {/* Основная строка кампании с товарами */} +
onToggleExpand(campaign.advertId)} + > +
+ {isExpanded ? : } +
+
+ #{campaign.advertId} + + {campaign.days.length}д + +
+ {/* Мини-карточки товаров */} +
+ {campaign.days + .flatMap(day => day.apps?.flatMap(app => app.nm) || []) + .reduce((unique: CampaignProduct[], product) => { + if (!unique.find(p => p.nmId === product.nmId)) { + unique.push(product) + } + return unique + }, []) + .slice(0, 3) + .map((product) => ( +
+
+ + + {product.name.length > 8 ? `${product.name.slice(0, 8)}...` : product.name} + +
+
+ ))} + {campaign.days + .flatMap(day => day.apps?.flatMap(app => app.nm) || []) + .reduce((unique: CampaignProduct[], product) => { + if (!unique.find(p => p.nmId === product.nmId)) { + unique.push(product) + } + return unique + }, []).length > 3 && ( +
+ +{campaign.days + .flatMap(day => day.apps?.flatMap(app => app.nm) || []) + .reduce((unique: CampaignProduct[], product) => { + if (!unique.find(p => p.nmId === product.nmId)) { + unique.push(product) + } + return unique + }, []).length - 3} +
+ )} +
+
+
+ +
{formatNumber(campaign.views)}
+
{formatNumber(campaign.clicks)}
+
{formatPercent(campaign.ctr)}
+
{formatCurrency(campaign.cpc)}
+
{formatCurrency(campaign.sum)}
+
{formatNumber(campaign.orders)}
+
{formatPercent(campaign.cr)}
+
{formatNumber(campaign.shks)}
+
{formatCurrency(campaign.sum_price)}
+
0 ? 'text-green-400' : roi < 0 ? 'text-red-400' : 'text-gray-400'}`}> + {roi === 0 ? '—' : `${roi > 0 ? '+' : ''}${roi.toFixed(0)}%`} +
+
+ + {/* Развернутое содержимое */} + {isExpanded && ( +
+ {/* Товары в кампании - полноценные карточки */} + {campaign.days.some(day => day.apps?.some(app => app.nm && app.nm.length > 0)) && ( +
+

+ + Товары в кампании +

+ +
+ {campaign.days + .flatMap(day => day.apps?.flatMap(app => app.nm) || []) + .reduce((unique: CampaignProduct[], product) => { + const existing = unique.find(p => p.nmId === product.nmId) + if (existing) { + existing.views += product.views + existing.clicks += product.clicks + existing.sum += product.sum + existing.orders += product.orders + existing.sum_price += product.sum_price + existing.shks += product.shks + existing.ctr = existing.views > 0 ? (existing.clicks / existing.views) * 100 : 0 + existing.cr = existing.clicks > 0 ? (existing.orders / existing.clicks) * 100 : 0 + existing.cpc = existing.clicks > 0 ? existing.sum / existing.clicks : 0 + } else { + unique.push({ ...product }) + } + return unique + }, []) + .sort((a, b) => b.sum - a.sum) + .map((product) => { + const roi = product.sum > 0 ? ((product.sum_price - product.sum) / product.sum * 100) : 0 + + // Получаем фото из API или используем fallback + const photoUrl = productPhotos.get(product.nmId) + + return ( +
+ {/* Фото товара */} +
+ {photoUrl ? ( + {product.name} { + const target = e.target as HTMLImageElement + target.style.display = 'none' + const placeholder = target.nextElementSibling as HTMLElement + if (placeholder) placeholder.style.display = 'flex' + }} + /> + ) : null} +
+ +
+
+ + {/* Основная информация о товаре */} +
+
+
+
+ {product.name} +
+

+ Артикул WB: {product.nmId} +

+
+ + {/* ROI badge */} +
0 ? 'bg-green-500 text-white' : + roi < 0 ? 'bg-red-500 text-white' : + 'bg-gray-500 text-white' + }`}> + {roi === 0 ? '—' : `${roi > 0 ? '+' : ''}${roi.toFixed(0)}%`} +
+
+ + {/* Статистика в строку */} +
+
+
Показы
+
{formatNumber(product.views)}
+
+
+
Клики
+
{formatNumber(product.clicks)}
+
+
+
CTR
+
{formatPercent(product.ctr)}
+
+
+
Затраты
+
{formatCurrency(product.sum)}
+
+
+
CPC
+
{formatCurrency(product.cpc)}
+
+
+
Заказы
+
{formatNumber(product.orders)}
+
+
+
CR
+
{formatPercent(product.cr)}
+
+
+
+
+ ) + })} +
+
+ )} + + {/* Компактная статистика по дням */} + {campaign.days.length > 0 && ( +
+

+ + По дням ({campaign.days.length}) +

+ +
+ {campaign.days.map((day, dayIndex) => ( +
+
+ {new Date(day.date).toLocaleDateString('ru-RU', { + day: '2-digit', + month: '2-digit' + })} +
+
{formatNumber(day.views)}
+
{formatNumber(day.clicks)}
+
{formatPercent(day.ctr)}
+
{formatCurrency(day.cpc)}
+
{formatCurrency(day.sum)}
+
{formatNumber(day.orders)}
+
{formatPercent(day.cr)}
+
{formatNumber(day.shks)}
+
{formatCurrency(day.sum_price)}
+
+ ))} +
+
+ )} + + {/* Компактная статистика по платформам */} + {campaign.days.some(day => day.apps && day.apps.length > 0) && ( +
+

+ + Платформы +

+ +
+ {campaign.days.flatMap(day => day.apps || []) + .reduce((acc: CampaignApp[], app) => { + const existing = acc.find(a => a.appType === app.appType) + if (existing) { + existing.views += app.views + existing.clicks += app.clicks + existing.sum += app.sum + existing.orders += app.orders + existing.sum_price += app.sum_price + } else { + acc.push({ ...app }) + } + return acc + }, []) + .map((app, appIndex) => ( +
+
+ {getAppTypeIcon(app.appType)} + + {app.appType === 1 ? 'Мобайл' : + app.appType === 32 ? 'Десктоп' : + app.appType === 64 ? 'Моб.WB' : `Тип${app.appType}`} + +
+
{formatNumber(app.views)}
+
{formatNumber(app.clicks)}
+
{formatPercent(app.ctr)}
+
{formatCurrency(app.sum)}
+
{formatNumber(app.orders)}
+
{formatPercent(app.cr)}
+
{formatNumber(app.shks)}
+
{formatCurrency(app.sum_price)}
+
+ ))} +
+
+ )} + + + {/* Позиции товаров */} + {campaign.boosterStats.length > 0 && ( +
+

+ + Позиции +

+
+ {campaign.boosterStats.slice(0, 6).map((booster, index) => ( +
+
+ {new Date(booster.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' })} +
+
+ #{booster.avg_position} +
+
+ ))} +
+
+ )} +
+ )} +
+ ) + })} +
+ ) +} + +export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endDate }: CampaignStatsProps) { + const { user } = useAuth() + + // Состояния для фильтров + const [showWbAds, setShowWbAds] = useState(true) + const [showExternalAds, setShowExternalAds] = useState(true) + + // Состояния для раскрытия строк + const [expandedDays, setExpandedDays] = useState>(new Set()) + const [expandedProducts, setExpandedProducts] = useState>(new Set()) + const [expandedCampaigns, setExpandedCampaigns] = useState>(new Set()) + + // Состояние для формы добавления внешней рекламы + const [showAddForm, setShowAddForm] = useState(null) + const [newExternalAd, setNewExternalAd] = useState({ + name: '', + url: '', + cost: '' + }) + + const [campaignStats, setCampaignStats] = useState([]) + const [productPhotos, setProductPhotos] = useState>(new Map()) + const [dailyData, setDailyData] = useState([]) + const [generatedLinksData, setGeneratedLinksData] = useState>({}) + const prevCampaignStats = useRef([]) + + // Вычисляем диапазон дат для запроса внешней рекламы + const getDateRange = () => { + if (useCustomDates && startDate && endDate) { + return { dateFrom: startDate, dateTo: endDate } + } + + 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 + } + + return { + dateFrom: startDateCalc.toISOString().split('T')[0], + dateTo: endDateCalc.toISOString().split('T')[0] + } + } + + const { dateFrom, dateTo } = getDateRange() + + // GraphQL запросы и мутации + const { data: externalAdsData, loading: externalAdsLoading, error: externalAdsError, refetch: refetchExternalAds } = useQuery(GET_EXTERNAL_ADS, { + variables: { dateFrom, dateTo }, + skip: !user, + fetchPolicy: 'cache-and-network' + }) + + const [createExternalAd] = useMutation(CREATE_EXTERNAL_AD, { + onCompleted: () => { + refetchExternalAds() + }, + onError: (error) => { + console.error('Error creating external ad:', error) + }, + }) + + const [deleteExternalAd] = useMutation(DELETE_EXTERNAL_AD, { + onCompleted: () => { + refetchExternalAds() + }, + onError: (error) => { + console.error('Error deleting external ad:', error) + }, + }) + + const [updateExternalAd] = useMutation(UPDATE_EXTERNAL_AD, { + onCompleted: () => { + refetchExternalAds() + }, + onError: (error) => { + console.error('Error updating external ad:', error) + }, + }) + + const [updateExternalAdClicks] = useMutation(UPDATE_EXTERNAL_AD_CLICKS, { + onError: (error) => { + console.error('Error updating external ad clicks:', error) + }, + }) + + // Загружаем данные из localStorage только для ссылок (они остаются локальными) + useEffect(() => { + if (typeof window !== 'undefined') { + const savedLinksData = localStorage.getItem('advertisingLinksData') + + if (savedLinksData) { + try { + const linksData = JSON.parse(savedLinksData) + + // Удаляем дубликаты ссылок + const cleanedLinksData: Record = {} + Object.keys(linksData).forEach(date => { + const uniqueLinks = new Map() + linksData[date].forEach((link: GeneratedLink) => { + const key = `${link.adId}-${link.adName}` + if (!uniqueLinks.has(key) || link.clicks > (uniqueLinks.get(key)?.clicks || 0)) { + uniqueLinks.set(key, link) + } + }) + cleanedLinksData[date] = Array.from(uniqueLinks.values()) + }) + + setGeneratedLinksData(cleanedLinksData) + localStorage.setItem('advertisingLinksData', JSON.stringify(cleanedLinksData)) + } catch (error) { + console.error('Error loading links data:', error) + } + } + } + }, []) + + // Загружаем статистику кликов + const loadClickStatistics = async () => { + try { + const response = await fetch('/api/track-click') + const clickStats = await response.json() + + // Получаем свежие данные из localStorage + const savedLinksData = localStorage.getItem('advertisingLinksData') + const currentLinksData = savedLinksData ? JSON.parse(savedLinksData) : {} + + // Обновляем счетчики кликов в ссылках + setGeneratedLinksData(prev => { + const updated = { ...prev } + Object.keys(updated).forEach(date => { + updated[date] = updated[date].map(link => ({ + ...link, + clicks: clickStats[link.id] || link.clicks + })) + }) + + // Сохраняем обновленные ссылки + localStorage.setItem('advertisingLinksData', JSON.stringify(updated)) + return updated + }) + + // Обновляем клики в базе данных для внешней рекламы + if (externalAdsData?.getExternalAds?.success && externalAdsData.getExternalAds.externalAds) { + const promises = (externalAdsData.getExternalAds.externalAds as Array<{id: string, clicks: number}>).map((ad) => { + // Находим соответствующую ссылку для этой рекламы + const allLinks: GeneratedLink[] = Object.values(currentLinksData).flat() as GeneratedLink[] + const adLink = allLinks.find(link => link.adId === ad.id) + + if (adLink && clickStats[adLink.id] && clickStats[adLink.id] !== ad.clicks) { + // Обновляем клики в БД только если они изменились + return updateExternalAdClicks({ + variables: { + id: ad.id, + clicks: clickStats[adLink.id] + } + }).catch((error: unknown) => { + console.error(`Error updating clicks for ad ${ad.id}:`, error) + }) + } + return Promise.resolve() + }) + + await Promise.all(promises) + + // Обновляем данные внешней рекламы после синхронизации + refetchExternalAds() + } + } catch (error) { + console.error('Error loading click statistics:', error) + } + } + + // Загружаем статистику кликов периодически + useEffect(() => { + loadClickStatistics() + const interval = setInterval(loadClickStatistics, 10000) // каждые 10 секунд + return () => clearInterval(interval) + }, []) + + const { data: campaignsData, loading: campaignsLoading } = useQuery(GET_WILDBERRIES_CAMPAIGNS_LIST, { + errorPolicy: 'all' + }) + + const [getCampaignStats, { loading, error }] = useLazyQuery(GET_WILDBERRIES_CAMPAIGN_STATS, { + onCompleted: (data) => { + if (data.getWildberriesCampaignStats.success) { + setCampaignStats(data.getWildberriesCampaignStats.data) + } + }, + onError: (error) => { + console.error('Campaign stats error:', error) + } + }) + + // Загрузка фотографий товаров (точно как на складе WB) + const loadProductPhotos = async (nmIds: number[]) => { + if (!user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')?.isActive) { + return + } + + try { + const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') + + if (!wbApiKey?.isActive) { + console.error('Advertising: API ключ Wildberries не настроен') + return + } + + const validationData = wbApiKey.validationData as Record + const apiToken = validationData?.token || + validationData?.apiKey || + validationData?.key || + (wbApiKey as { apiKey?: string }).apiKey + + if (!apiToken) { + console.error('Advertising: Токен API не найден') + return + } + + console.log('Advertising: Loading product photos...') + + // Используем точно тот же метод что и на складе + const cards = await WildberriesService.getAllCards(apiToken).catch(() => []) + console.log('Advertising: Loaded cards:', cards.length) + + if (cards.length === 0) { + console.error('Advertising: Нет карточек товаров в WB') + return + } + + const newPhotos = new Map() + const uniqueNmIds = [...new Set(nmIds)] + + cards.forEach(card => { + if (uniqueNmIds.includes(card.nmID) && card.photos && Array.isArray(card.photos) && card.photos.length > 0) { + const photo = card.photos[0] + const photoUrl = photo.big || photo.c516x688 || photo.c246x328 || photo.tm || photo.square + if (photoUrl) { + newPhotos.set(card.nmID, photoUrl) + console.log(`Advertising: Found photo for ${card.nmID}: ${photoUrl}`) + } + } + }) + + console.log(`Advertising: Loaded ${newPhotos.size} product photos`) + setProductPhotos(prev => new Map([...prev, ...newPhotos])) + + } catch (error) { + console.error('Advertising: Error loading product photos:', error) + } + } + + // Автоматически загружаем все доступные кампании + 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)) + + if (allCampaignIds.length > 0) { + handleCampaignsSelected(allCampaignIds) + } + } + }, [campaignsData, selectedPeriod, useCustomDates, startDate, endDate]) + + // Преобразование данных кампаний в новый формат таблицы + const convertCampaignDataToDailyData = (campaigns: CampaignStats[]): DailyAdvertisingData[] => { + const dailyMap = new Map() + + campaigns.forEach(campaign => { + campaign.days.forEach(day => { + const dateKey = day.date.split('T')[0] // Получаем только дату без времени + + if (!dailyMap.has(dateKey)) { + dailyMap.set(dateKey, { + date: dateKey, + totalSum: 0, + totalOrders: 0, + totalRevenue: 0, + products: [] + }) + } + + const dailyRecord = dailyMap.get(dateKey)! + + // Добавляем товары с их рекламными кампаниями + if (day.apps) { + day.apps.forEach(app => { + if (app.nm) { + app.nm.forEach(product => { + let existingProduct = dailyRecord.products.find(p => p.nmId === product.nmId) + + if (!existingProduct) { + // Создаем новый товар + existingProduct = { + nmId: product.nmId, + name: product.name, + totalViews: 0, + totalClicks: 0, + totalCost: 0, + totalOrders: 0, + totalRevenue: 0, + advertising: { + wbCampaigns: [], + externalAds: [] + } + } + dailyRecord.products.push(existingProduct) + } + + // Суммируем данные товара + existingProduct.totalViews += product.views + existingProduct.totalClicks += product.clicks + existingProduct.totalCost += product.sum + existingProduct.totalOrders += product.orders + existingProduct.totalRevenue += product.sum_price + + // Добавляем данные ВБ кампании для этого товара + const existingCampaign = existingProduct.advertising.wbCampaigns.find(c => c.campaignId === campaign.advertId) + if (existingCampaign) { + existingCampaign.views += product.views + existingCampaign.clicks += product.clicks + existingCampaign.cost += product.sum + existingCampaign.orders += product.orders + } else { + existingProduct.advertising.wbCampaigns.push({ + campaignId: campaign.advertId, + views: product.views, + clicks: product.clicks, + cost: product.sum, + orders: product.orders + }) + } + }) + } + }) + } + }) + }) + + // После создания структуры товаров, добавляем внешнюю рекламу из GraphQL данных + const result = Array.from(dailyMap.values()) + + if (externalAdsData?.getExternalAds?.success && externalAdsData.getExternalAds.externalAds) { + result.forEach(day => { + const externalAdsForDay = externalAdsData.getExternalAds.externalAds.filter( + (ad: ExternalAd & { date: string; nmId: string }) => ad.date === day.date + ) + + if (externalAdsForDay.length > 0 && day.products.length > 0) { + // Группируем внешнюю рекламу по nmId товара + const adsByProduct = externalAdsForDay.reduce((acc: Record, ad: ExternalAd & { date: string; nmId: string }) => { + if (!acc[ad.nmId]) acc[ad.nmId] = [] + acc[ad.nmId].push({ + id: ad.id, + name: ad.name, + url: ad.url, + cost: ad.cost, + clicks: ad.clicks || 0 + }) + return acc + }, {}) + + // Добавляем внешнюю рекламу к соответствующим товарам + day.products.forEach(product => { + if (adsByProduct[product.nmId.toString()]) { + product.advertising.externalAds = adsByProduct[product.nmId.toString()] + } + }) + } + }) + } + + // Обновляем общие суммы дня (ВБ реклама + внешняя реклама) + result.forEach(day => { + day.totalSum = day.products.reduce((sum, product) => + sum + product.totalCost + product.advertising.externalAds.reduce((adSum, ad) => adSum + ad.cost, 0), 0) + day.totalOrders = day.products.reduce((sum, product) => sum + product.totalOrders, 0) + day.totalRevenue = day.products.reduce((sum, product) => sum + product.totalRevenue, 0) + }) + + return result.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) + } + + + + // Загружаем фотографии когда получаем статистику кампаний + useEffect(() => { + if (campaignStats.length > 0) { + const nmIds = campaignStats + .flatMap(campaign => campaign.days) + .flatMap(day => day.apps?.flatMap(app => app.nm) || []) + .map(product => product.nmId) + + // Проверяем, есть ли новые nmIds, которых еще нет в productPhotos + const newNmIds = nmIds.filter(nmId => !productPhotos.has(nmId)) + + if (newNmIds.length > 0) { + console.log('Loading photos for new products:', newNmIds.length) + loadProductPhotos(newNmIds) + } + + // Преобразуем данные в новый формат только если это первая загрузка или изменились кампании/внешняя реклама + if (dailyData.length === 0 || + JSON.stringify(campaignStats) !== JSON.stringify(prevCampaignStats.current) || + externalAdsData) { + const newDailyData = convertCampaignDataToDailyData(campaignStats) + setDailyData(newDailyData) + prevCampaignStats.current = campaignStats + } + } + }, [campaignStats, externalAdsData]) // Добавляем externalAdsData в зависимости + + const handleCampaignsSelected = (ids: number[]) => { + if (ids.length === 0) return + + 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) + if (newExpanded.has(campaignId)) { + newExpanded.delete(campaignId) + } else { + newExpanded.add(campaignId) + } + setExpandedCampaigns(newExpanded) + } + + // Обработчики для внешней рекламы + const handleAddExternalAd = async (date: string, ad: Omit) => { + console.log('handleAddExternalAd called:', { date, ad }) + + try { + // Находим nmId из первого товара дня (или можно передать отдельно) + const dayData = dailyData.find(d => d.date === date) + const nmId = dayData?.products[0]?.nmId?.toString() || '0' + + await createExternalAd({ + variables: { + input: { + name: ad.name, + url: ad.url, + cost: ad.cost, + date: date, + nmId: nmId + } + } + }) + + console.log('External ad created successfully') + } catch (error) { + console.error('Error creating external ad:', error) + } + } + + const handleRemoveExternalAd = async (date: string, adId: string) => { + console.log('handleRemoveExternalAd called:', { date, adId }) + + try { + await deleteExternalAd({ + variables: { id: adId } + }) + + console.log('External ad deleted successfully') + } catch (error) { + console.error('Error deleting external ad:', error) + } + } + + const handleUpdateExternalAd = async (date: string, adId: string, updates: Partial) => { + console.log('handleUpdateExternalAd called:', { date, adId, updates }) + + try { + // Находим текущую рекламу для получения полных данных + const currentAd = dailyData + .find(d => d.date === date) + ?.products.flatMap(p => p.advertising.externalAds) + .find(ad => ad.id === adId) + + if (!currentAd) { + console.error('External ad not found') + return + } + + // Находим nmId из товара, к которому привязана реклама + const dayData = dailyData.find(d => d.date === date) + const product = dayData?.products.find(p => + p.advertising.externalAds.some(ad => ad.id === adId) + ) + const nmId = product?.nmId?.toString() || '0' + + await updateExternalAd({ + variables: { + id: adId, + input: { + name: updates.name || currentAd.name, + url: updates.url || currentAd.url, + cost: updates.cost || currentAd.cost, + date: date, + nmId: nmId + } + } + }) + + console.log('External ad updated successfully') + } catch (error) { + console.error('Error updating external ad:', error) + } + } + + // Обработчики для ссылок-кликеров + const handleGenerateLink = (date: string, adId: string, adName: string, adUrl: string) => { + // Проверяем, есть ли уже ссылка для этой рекламы в этот день + const existingLinks = generatedLinksData[date] || [] + const existingLink = existingLinks.find(link => link.adId === adId && link.adName === adName) + + if (existingLink) { + // Если ссылка уже существует, просто копируем её + navigator.clipboard.writeText(existingLink.trackingUrl).then(() => { + alert(`Ссылка уже существует и скопирована!\nПользователи будут переходить на: ${existingLink.targetUrl}`) + }) + return + } + + // Валидируем URL + let validUrl = adUrl.trim() + if (!validUrl.startsWith('http://') && !validUrl.startsWith('https://')) { + validUrl = 'https://' + validUrl + } + + const linkId = `link-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + const trackedUrl = `${window.location.origin}/track/${linkId}?redirect=${encodeURIComponent(validUrl)}` + + console.log('Generating link:', { + linkId, + originalUrl: adUrl, + validUrl, + trackedUrl, + encodedUrl: encodeURIComponent(validUrl) + }) + + const newLink: GeneratedLink = { + id: linkId, + adId, + adName, + targetUrl: validUrl, + trackingUrl: trackedUrl, + clicks: 0, + createdAt: new Date().toISOString() + } + + setGeneratedLinksData(prev => { + const newData = { + ...prev, + [date]: [...(prev[date] || []), newLink] + } + // Сохраняем данные в localStorage + localStorage.setItem('advertisingLinksData', JSON.stringify(newData)) + return newData + }) + + // Копируем ссылку в буфер обмена + navigator.clipboard.writeText(trackedUrl).then(() => { + console.log('Ссылка-кликер скопирована в буфер обмена:', trackedUrl) + alert(`Ссылка скопирована! Вставьте её в рекламу.\nПользователи будут переходить на: ${validUrl}`) + }) + } + + const handleCopyLink = (linkId: string) => { + // Найдем ссылку во всех датах + let linkToCopy: GeneratedLink | undefined + Object.values(generatedLinksData).forEach(links => { + const found = links.find(link => link.id === linkId) + if (found) linkToCopy = found + }) + + if (linkToCopy) { + navigator.clipboard.writeText(linkToCopy.trackingUrl).then(() => { + console.log('Ссылка-кликер скопирована в буфер обмена:', linkToCopy!.trackingUrl) + alert(`Ссылка скопирована! Люди будут переходить на: ${linkToCopy!.targetUrl}`) + }) + } + } + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB', + minimumFractionDigits: 0, + maximumFractionDigits: 0 + }).format(value) + } + + const formatNumber = (value: number) => { + return new Intl.NumberFormat('ru-RU').format(value) + } + + const formatPercent = (value: number) => { + return `${value.toFixed(2)}%` + } + + // Подготовка данных для графика с включением внешней рекламы + const chartData = React.useMemo(() => { + if (dailyData.length === 0) return [] + + return dailyData.map(day => { + const dayViews = day.products.reduce((sum, product) => sum + product.totalViews, 0) + const dayClicks = day.products.reduce((sum, product) => sum + product.totalClicks, 0) + const dayExternalClicks = day.products.reduce((sum, product) => + sum + product.advertising.externalAds.reduce((adSum, ad) => adSum + (ad.clicks || 0), 0), 0) + const dayOrders = day.totalOrders + const dayWbCost = day.products.reduce((sum, product) => sum + product.totalCost, 0) + const dayExternalCost = day.products.reduce((sum, product) => + sum + product.advertising.externalAds.reduce((adSum, ad) => adSum + ad.cost, 0), 0) + + return { + date: new Date(day.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }), + views: dayViews, + clicks: dayClicks + dayExternalClicks, + wbClicks: dayClicks, + externalClicks: dayExternalClicks, + sum: dayWbCost + dayExternalCost, + wbSum: dayWbCost, + externalSum: dayExternalCost, + orders: dayOrders + } + }).reverse() // График показывает от старых к новым датам + }, [dailyData]) + + // Подготовка данных для графика расходов с разделением ВБ и внешней рекламы + const spendingChartData = React.useMemo(() => { + if (dailyData.length === 0) return [] + + return dailyData.map(day => { + const wbSum = day.products.reduce((sum, product) => sum + product.totalCost, 0) + const externalSum = day.products.reduce((sum, product) => + sum + product.advertising.externalAds.reduce((adSum, ad) => adSum + ad.cost, 0), 0) + + return { + date: new Date(day.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }), + wbSum: wbSum, + externalSum: externalSum, + sum: wbSum + externalSum, // Общая сумма для совместимости + fullDate: day.date + } + }).sort((a, b) => a.fullDate.localeCompare(b.fullDate)) + }, [dailyData]) + + const chartConfig = { + views: { + label: "Показы", + color: "#8b5cf6", + }, + clicks: { + label: "Клики (общие)", + color: "#06b6d4", + }, + wbClicks: { + label: "Клики ВБ", + color: "#06b6d4", + }, + externalClicks: { + label: "Клики внешние", + color: "#f59e0b", + }, + sum: { + label: "Затраты (общие) ₽", + color: "#f59e0b", + }, + wbSum: { + label: "Затраты ВБ ₽", + color: "#3b82f6", + }, + externalSum: { + label: "Затраты внешние ₽", + color: "#ec4899", + }, + orders: { + label: "Заказы", + color: "#10b981", + }, + } + + return ( +
+ {/* Ошибки */} + {error && ( + + + + {error.message} + + + )} + + {externalAdsError && ( + + + + Ошибка загрузки внешней рекламы: {externalAdsError.message} + + + )} + + {/* Результаты */} +
+ {(loading || campaignsLoading || externalAdsLoading) ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : campaignStats.length > 0 ? ( +
+ {/* График расходов */} + {spendingChartData.length > 0 && ( + +
+

+ + Расходы на рекламу +

+
+
Общие: {formatCurrency(spendingChartData.reduce((sum, day) => sum + day.sum, 0))}
+
+ +
+ ВБ: {formatCurrency(spendingChartData.reduce((sum, day) => sum + day.wbSum, 0))} +
+ +
+ Внешняя: {formatCurrency(spendingChartData.reduce((sum, day) => sum + day.externalSum, 0))} +
+
+
+
+ +
+ + + + + `${(value / 1000).toFixed(0)}K₽`} + /> + { + if (active && payload && payload.length) { + return ( +
+

{`Дата: ${label}`}

+ {payload.map((entry, index) => ( +

+ {`${entry.name}: ${formatCurrency(entry.value as number)}`} +

+ ))} +

+ {`Общие расходы: ${formatCurrency( + payload.reduce((sum, entry) => sum + (entry.value as number), 0) + )}`} +

+
+ ) + } + return null + }} + /> + + +
+
+
+
+ )} + + {/* Компактная общая статистика */} + +
+

+ + Сводка ({campaignStats.length} кампаний) +

+ +
+ +
+ {/* Показы */} +
+
+ + Показы +
+
+ {formatNumber(campaignStats.reduce((sum, stat) => sum + stat.views, 0))} +
+
+ + {/* Клики */} +
+
+ + Клики +
+
+ {formatNumber(campaignStats.reduce((sum, stat) => sum + stat.clicks, 0))} +
+
+ + {/* CTR */} +
+
+ + CTR +
+
+ {formatPercent( + campaignStats.reduce((sum, stat, _, arr) => { + const totalViews = arr.reduce((s, st) => s + st.views, 0) + const totalClicks = arr.reduce((s, st) => s + st.clicks, 0) + return totalViews > 0 ? (totalClicks / totalViews) * 100 : 0 + }, 0) + )} +
+
+ + {/* CPC */} +
+
+ + CPC +
+
+ {formatCurrency( + campaignStats.reduce((sum, stat, _, arr) => { + const totalClicks = arr.reduce((s, st) => s + st.clicks, 0) + const totalSum = arr.reduce((s, st) => s + st.sum, 0) + return totalClicks > 0 ? totalSum / totalClicks : 0 + }, 0) + )} +
+
+ + {/* Затраты */} +
+
+ + Затраты +
+
+ {formatCurrency(campaignStats.reduce((sum, stat) => sum + stat.sum, 0))} +
+
+ + {/* Заказы */} +
+
+ + Заказы +
+
+ {formatNumber(campaignStats.reduce((sum, stat) => sum + stat.orders, 0))} +
+
+ + {/* CR */} +
+
+ + CR +
+
+ {formatPercent( + campaignStats.reduce((sum, stat, _, arr) => { + const totalClicks = arr.reduce((s, st) => s + st.clicks, 0) + const totalOrders = arr.reduce((s, st) => s + st.orders, 0) + return totalClicks > 0 ? (totalOrders / totalClicks) * 100 : 0 + }, 0) + )} +
+
+ + {/* Выручка */} +
+
+ + Выручка +
+
+ {formatCurrency(campaignStats.reduce((sum, stat) => sum + stat.sum_price, 0))} +
+
+
+ + +
+ + {/* Новая таблица рекламы */} + +
+

+ + Статистика рекламы +

+
+ {dailyData.length} дней данных +
+
+ + {/* Простая таблица согласно дизайну Figma */} +
+ {/* Фильтры */} +
+
+
+ setShowWbAds(checked === true)} + className="border-white/30" + /> + +
+
+ setShowExternalAds(checked === true)} + className="border-white/30" + /> + +
+
+
+ + {/* Заголовок таблицы */} +
+
+
Дата
+
Сумма (руб)
+
Заказы (ед)
+
Реклама ВБ
+
Реклама внешняя
+
+
+ + {/* Строки таблицы */} +
+ {dailyData.map((day) => { + // Подсчитываем суммы за день + const dayWbCost = day.products.reduce((sum, product) => sum + product.totalCost, 0) + const dayExternalCost = day.products.reduce((sum, product) => + sum + product.advertising.externalAds.reduce((adSum, ad) => adSum + ad.cost, 0), 0) + const dayTotalCost = dayWbCost + dayExternalCost + const dayOrders = day.totalOrders + + return ( +
+
{day.date}
+
{formatCurrency(dayTotalCost)}
+
{dayOrders}
+
+ {showWbAds ? formatCurrency(dayWbCost) : '—'} +
+
+ {showExternalAds ? formatCurrency(dayExternalCost) : '—'} +
+
+ ) + })} +
+
+
+
+ ) : ( + +
+
+ +

Статистика рекламных кампаний

+

Загружаем статистику по всем доступным кампаниям...

+

+ Поддерживается API Wildberries /adv/v2/fullstats +

+
+
+
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/seller-statistics/simple-advertising-table.tsx b/src/components/seller-statistics/simple-advertising-table.tsx new file mode 100644 index 0000000..1a110a0 --- /dev/null +++ b/src/components/seller-statistics/simple-advertising-table.tsx @@ -0,0 +1,386 @@ +"use client" + +import React, { useState } from 'react' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + ChevronDown, + ChevronRight, + Plus, + Trash2, + Link, + Package, + Eye, + MousePointer, + ShoppingCart, + DollarSign +} from 'lucide-react' + +interface ExternalAd { + id: string + name: string + url: string + cost: number + clicks?: number +} + +interface ProductAdvertising { + wbCampaigns: { + campaignId: number + views: number + clicks: number + cost: number + orders: number + }[] + externalAds: ExternalAd[] +} + +interface ProductData { + nmId: number + name: string + totalViews: number + totalClicks: number + totalCost: number + totalOrders: number + totalRevenue: number + advertising: ProductAdvertising +} + +interface DailyAdvertisingData { + date: string + totalSum: number + totalOrders: number + totalRevenue: number + products: ProductData[] +} + +interface GeneratedLink { + id: string + adId: string + adName: string + targetUrl: string + trackingUrl: string + clicks: number + createdAt: string +} + +interface SimpleAdvertisingTableProps { + dailyData: DailyAdvertisingData[] + productPhotos?: Map + generatedLinksData?: Record + onAddExternalAd?: (date: string, ad: Omit) => void + onRemoveExternalAd?: (date: string, adId: string) => void + onUpdateExternalAd?: (date: string, adId: string, updates: Partial) => void + onGenerateLink?: (date: string, adId: string, adName: string, adUrl: string) => void +} + +export function SimpleAdvertisingTable({ + dailyData, + productPhotos = new Map(), + generatedLinksData = {}, + onAddExternalAd, + onRemoveExternalAd, + onUpdateExternalAd, + onGenerateLink +}: SimpleAdvertisingTableProps) { + const [showWbAds, setShowWbAds] = useState(true) + const [showExternalAds, setShowExternalAds] = useState(true) + const [expandedDays, setExpandedDays] = useState>(new Set()) + const [expandedProducts, setExpandedProducts] = useState>(new Set()) + const [showAddForm, setShowAddForm] = useState(null) + const [newExternalAd, setNewExternalAd] = useState({ + name: '', + url: '', + cost: '' + }) + + const formatCurrency = (value: number) => { + if (value === 0) return '—' + if (value < 100) { + return `${value.toFixed(2)}₽` + } + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB', + minimumFractionDigits: 0, + maximumFractionDigits: 0 + }).format(value) + } + + const formatNumber = (value: number) => { + return value > 0 ? new Intl.NumberFormat('ru-RU').format(value) : '—' + } + + const toggleDay = (date: string) => { + const newExpanded = new Set(expandedDays) + if (newExpanded.has(date)) { + newExpanded.delete(date) + } else { + newExpanded.add(date) + } + setExpandedDays(newExpanded) + } + + const toggleProduct = (date: string, nmId: number) => { + const key = `${date}-${nmId}` + const newExpanded = new Set(expandedProducts) + if (newExpanded.has(key)) { + newExpanded.delete(key) + } else { + newExpanded.add(key) + } + setExpandedProducts(newExpanded) + } + + const handleAddExternalAdLocal = (date: string, nmId: number) => { + if (newExternalAd.name && newExternalAd.url && newExternalAd.cost && onAddExternalAd) { + onAddExternalAd(date, { + name: newExternalAd.name, + url: newExternalAd.url, + cost: parseFloat(newExternalAd.cost) || 0 + }) + setNewExternalAd({ name: '', url: '', cost: '' }) + setShowAddForm(null) + } + } + + return ( +
+ {/* Фильтры */} +
+
+
+ setShowWbAds(checked === true)} + className="border-white/30" + /> + +
+
+ setShowExternalAds(checked === true)} + className="border-white/30" + /> + +
+
+
+ + {/* Заголовок таблицы - как в Figma */} +
+
+
Дата
+
Сумма (руб)
+
Заказы (ед)
+
Реклама ВБ
+
Реклама внешняя
+
+
+ + {/* Строки таблицы с раскрывающимся содержимым */} +
+ {dailyData.map((day) => { + const dayWbCost = day.products.reduce((sum, product) => sum + product.totalCost, 0) + const dayExternalCost = day.products.reduce((sum, product) => + sum + product.advertising.externalAds.reduce((adSum, ad) => adSum + ad.cost, 0), 0) + const dayTotalCost = dayWbCost + dayExternalCost + const dayOrders = day.totalOrders + const isExpanded = expandedDays.has(day.date) + + return ( +
+ {/* Основная строка дня - как в Figma */} +
toggleDay(day.date)} + > +
+ {isExpanded ? : } + {day.date} +
+
{formatCurrency(dayTotalCost)}
+
{dayOrders}
+
+ {showWbAds ? formatCurrency(dayWbCost) : '—'} +
+
+ {showExternalAds ? formatCurrency(dayExternalCost) : '—'} +
+
+ + {/* Раскрывающееся содержимое с товарами */} + {isExpanded && ( +
+ {day.products.map((product) => { + const productKey = `${day.date}-${product.nmId}` + const isProductExpanded = expandedProducts.has(productKey) + const productExternalCost = product.advertising.externalAds.reduce((sum, ad) => sum + ad.cost, 0) + + return ( +
+ {/* Строка товара */} +
toggleProduct(day.date, product.nmId)} + > +
+ {isProductExpanded ? : } +
+ {productPhotos.has(product.nmId) && ( + {product.name} + )} +
+
{product.name}
+
#{product.nmId}
+
+
+
+
{formatCurrency(product.totalCost + productExternalCost)}
+
{product.totalOrders}
+
+ {showWbAds ? formatCurrency(product.totalCost) : '—'} +
+
+ {showExternalAds ? formatCurrency(productExternalCost) : '—'} +
+
+ + {/* Раскрывающееся содержимое товара с кампаниями */} + {isProductExpanded && ( +
+ {/* ВБ кампании */} + {showWbAds && product.advertising.wbCampaigns.map((campaign) => ( +
+
+ + ВБ #{campaign.campaignId} +
+
{formatCurrency(campaign.cost)}
+
{campaign.orders}
+
{formatCurrency(campaign.cost)}
+
+
+ ))} + + {/* Внешняя реклама */} + {showExternalAds && product.advertising.externalAds.map((ad) => ( +
+
+ + {ad.name} +
+
{formatCurrency(ad.cost)}
+
+
+
+ {formatCurrency(ad.cost)} +
+ {onGenerateLink && ( + + )} + {onRemoveExternalAd && ( + + )} +
+
+
+ ))} + + {/* Кнопка добавления внешней рекламы */} + {onAddExternalAd && ( +
+
+ {showAddForm === productKey ? ( +
+ setNewExternalAd(prev => ({ ...prev, name: e.target.value }))} + className="h-6 bg-white/10 border-white/20 text-white text-xs" + /> + setNewExternalAd(prev => ({ ...prev, url: e.target.value }))} + className="h-6 bg-white/10 border-white/20 text-white text-xs" + /> + setNewExternalAd(prev => ({ ...prev, cost: e.target.value }))} + className="h-6 w-20 bg-white/10 border-white/20 text-white text-xs" + /> + + +
+ ) : ( + + )} +
+
+ )} +
+ )} +
+ ) + })} +
+ )} +
+ ) + })} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/wb-warehouse/wb-warehouse-dashboard.tsx b/src/components/wb-warehouse/wb-warehouse-dashboard.tsx index 95d1193..41b5f0f 100644 --- a/src/components/wb-warehouse/wb-warehouse-dashboard.tsx +++ b/src/components/wb-warehouse/wb-warehouse-dashboard.tsx @@ -1,7 +1,7 @@ "use client" /* eslint-disable @typescript-eslint/no-explicit-any */ -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import { useAuth } from '@/hooks/useAuth' import { Sidebar } from '@/components/dashboard/sidebar' import { useSidebar } from '@/hooks/useSidebar' @@ -10,12 +10,351 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { WildberriesWarehouseTab } from './wildberries-warehouse-tab' import { MyWarehouseTab } from './my-warehouse-tab' import { FulfillmentWarehouseTab } from './fulfillment-warehouse-tab' +import { WildberriesService } from '@/services/wildberries-service' +import { toast } from 'sonner' +import { useQuery, useMutation } from '@apollo/client' +import { GET_WB_WAREHOUSE_DATA } from '@/graphql/queries' +import { SAVE_WB_WAREHOUSE_CACHE } from '@/graphql/mutations' + +interface WBStock { + nmId: number + vendorCode: string + title: string + brand: string + price: number + stocks: Array<{ + warehouseId: number + warehouseName: string + quantity: number + quantityFull: number + inWayToClient: number + inWayFromClient: number + }> + totalQuantity: number + totalReserved: number + photos: any[] + mediaFiles: any[] + characteristics: any[] + subjectName: string + description: string +} + +interface WBWarehouse { + id: number + name: string + cargoType: number + deliveryType: number +} export function WBWarehouseDashboard() { const { user } = useAuth() const { isCollapsed, getSidebarMargin } = useSidebar() const [activeTab, setActiveTab] = useState('fulfillment') + // Состояние данных WB Warehouse + const [stocks, setStocks] = useState([]) + const [warehouses, setWarehouses] = useState([]) + const [loading, setLoading] = useState(false) + const [initialized, setInitialized] = useState(false) + + // Статистика + 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([]) + + // Проверяем настройку API ключа + const hasWBApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')?.isActive + + // GraphQL хуки для работы с кешем + const { data: cacheData, loading: cacheLoading, refetch: refetchCache } = useQuery(GET_WB_WAREHOUSE_DATA, { + skip: !hasWBApiKey, + fetchPolicy: 'cache-and-network', + }) + + const [saveCache] = useMutation(SAVE_WB_WAREHOUSE_CACHE) + + // Комбинирование карточек с индивидуальными данными аналитики + const combineCardsWithIndividualAnalytics = (cards: any[], analyticsResults: any[]): WBStock[] => { + const stocksMap = new Map() + + // Создаем карту аналитических данных для быстрого поиска + 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 || ''), + description: String(card.description || ''), + } + + // Получаем аналитические данные для данного 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) => { + 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, + }) + }) + } + }) + } + + // Подсчитываем общие показатели + stock.totalQuantity = stock.stocks.reduce((sum, s) => sum + s.quantity, 0) + stock.totalReserved = stock.stocks.reduce((sum, s) => sum + s.inWayToClient, 0) + + stocksMap.set(card.nmID, stock) + }) + + return Array.from(stocksMap.values()) + } + + // Извлечение складов из данных о товарах + const extractWarehousesFromStocks = (stocksData: WBStock[]): WBWarehouse[] => { + const warehousesMap = new Map() + + stocksData.forEach(item => { + item.stocks.forEach(stock => { + if (!warehousesMap.has(stock.warehouseId)) { + warehousesMap.set(stock.warehouseId, { + id: stock.warehouseId, + name: stock.warehouseName, + cargoType: 0, + deliveryType: 0 + }) + } + }) + }) + + return Array.from(warehousesMap.values()) + } + + // Обновление статистики + const updateStatistics = (stocksData: WBStock[], warehousesData: WBWarehouse[]) => { + setTotalProducts(stocksData.length) + + const totalStocksCount = stocksData.reduce((sum, item) => sum + item.totalQuantity, 0) + setTotalStocks(totalStocksCount) + + const totalReservedCount = stocksData.reduce((sum, item) => sum + item.totalReserved, 0) + setTotalReserved(totalReservedCount) + + 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 loadWarehouseDataFromCache = (cacheData: any) => { + try { + const parsedData = typeof cacheData.data === 'string' ? JSON.parse(cacheData.data) : cacheData.data + + const cachedStocks = parsedData.stocks || [] + const cachedWarehouses = parsedData.warehouses || [] + const cachedAnalytics = parsedData.analyticsData || [] + + setStocks(cachedStocks) + setWarehouses(cachedWarehouses) + setAnalyticsData(cachedAnalytics) + + // Обновляем статистику из кеша + setTotalProducts(cacheData.totalProducts) + setTotalStocks(cacheData.totalStocks) + setTotalReserved(cacheData.totalReserved) + + const totalFromClientCount = (cachedStocks || []).reduce((sum: number, item: WBStock) => + sum + item.stocks.reduce((stockSum, stock) => stockSum + stock.inWayFromClient, 0), 0 + ) + setTotalFromClient(totalFromClientCount) + + const warehousesWithStock = new Set( + (cachedStocks || []).flatMap((item: WBStock) => item.stocks.map(s => s.warehouseId)) + ) + setActiveWarehouses(warehousesWithStock.size) + + console.log('WB Warehouse: Data loaded from cache:', cachedStocks?.length || 0, 'items') + toast.success(`Загружено из кеша: ${cachedStocks?.length || 0} товаров`) + } catch (error) { + console.error('WB Warehouse: Error parsing cache data:', error) + toast.error('Ошибка загрузки данных из кеша') + // Если кеш поврежден, загружаем из API + loadWarehouseDataFromAPI() + } finally { + setInitialized(true) + } + } + + // Загрузка данных из API и сохранение в кеш + const loadWarehouseDataFromAPI = async () => { + if (!hasWBApiKey) return + + setLoading(true) + try { + const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') + + if (!wbApiKey?.isActive) { + toast.error('API ключ Wildberries не настроен') + return + } + + const validationData = wbApiKey.validationData as Record + const apiToken = validationData?.token || + validationData?.apiKey || + validationData?.key || + (wbApiKey as { apiKey?: string }).apiKey + + if (!apiToken) { + toast.error('Токен API не найден') + return + } + + const wbService = new WildberriesService(apiToken) + + // 1. Получаем карточки товаров + const cards = await WildberriesService.getAllCards(apiToken).catch(() => []) + console.log('WB Warehouse: Loaded cards:', cards.length) + + if (cards.length === 0) { + toast.error('Нет карточек товаров в WB') + return + } + + const nmIds = cards.map(card => card.nmID).filter(id => id > 0) + console.log('WB Warehouse: NM IDs to process:', nmIds.length) + + // 2. Получаем аналитику для каждого товара индивидуально + const analyticsResults = [] + for (const nmId of nmIds) { + try { + console.log(`WB Warehouse: Fetching analytics for nmId ${nmId}`) + const result = await wbService.getStocksReportByOffices({ + nmIds: [nmId], + stockType: '' + }) + analyticsResults.push({ nmId, data: result }) + await new Promise(resolve => setTimeout(resolve, 1000)) + } catch (error) { + console.error(`WB Warehouse: Error fetching analytics for nmId ${nmId}:`, error) + } + } + + console.log('WB Warehouse: Analytics results:', analyticsResults.length) + + // 3. Комбинируем данные + const combinedStocks = combineCardsWithIndividualAnalytics(cards, analyticsResults) + console.log('WB Warehouse: Combined stocks:', combinedStocks.length) + + // 4. Извлекаем склады и обновляем статистику + const extractedWarehouses = extractWarehousesFromStocks(combinedStocks) + + // 5. Подготавливаем статистику + const stats = { + totalProducts: combinedStocks.length, + totalStocks: combinedStocks.reduce((sum, item) => sum + item.totalQuantity, 0), + totalReserved: combinedStocks.reduce((sum, item) => sum + item.totalReserved, 0), + } + + // 6. Сохраняем в кеш + try { + await saveCache({ + variables: { + input: { + data: JSON.stringify({ + stocks: combinedStocks, + warehouses: extractedWarehouses, + analyticsData: analyticsData, + }), + totalProducts: stats.totalProducts, + totalStocks: stats.totalStocks, + totalReserved: stats.totalReserved, + } + } + }) + console.log('WB Warehouse: Data saved to cache') + } catch (cacheError) { + console.error('WB Warehouse: Error saving to cache:', cacheError) + } + + // 7. Обновляем состояние + setStocks(combinedStocks) + setWarehouses(extractedWarehouses) + updateStatistics(combinedStocks, extractedWarehouses) + + toast.success(`Загружено товаров: ${combinedStocks.length}`) + } catch (error) { + console.error('WB Warehouse: Error loading data from API:', error) + toast.error('Ошибка при загрузке данных из API') + } finally { + setLoading(false) + setInitialized(true) + } + } + + // Основная функция загрузки данных + const loadWarehouseData = async () => { + if (!hasWBApiKey) { + setInitialized(true) + return + } + + // Сначала пытаемся получить данные из кеша + try { + const result = await refetchCache() + const cacheResponse = result.data?.getWBWarehouseData + + if (cacheResponse?.success && cacheResponse?.fromCache && cacheResponse?.cache) { + // Данные найдены в кеше + loadWarehouseDataFromCache(cacheResponse.cache) + } else { + // Кеша нет или он устарел, загружаем из API + console.log('WB Warehouse: No cache found, loading from API') + await loadWarehouseDataFromAPI() + } + } catch (error) { + console.error('WB Warehouse: Error checking cache:', error) + // При ошибке кеша загружаем из API + await loadWarehouseDataFromAPI() + } + } + + // Загружаем данные только один раз при инициализации + useEffect(() => { + if (!cacheLoading && user?.organization && !initialized) { + loadWarehouseData() + } + }, [cacheLoading, user?.organization, initialized]) + return (
@@ -50,7 +389,20 @@ export function WBWarehouseDashboard() { - + diff --git a/src/components/wb-warehouse/wildberries-warehouse-tab.tsx b/src/components/wb-warehouse/wildberries-warehouse-tab.tsx index 1e80f36..4023dfa 100644 --- a/src/components/wb-warehouse/wildberries-warehouse-tab.tsx +++ b/src/components/wb-warehouse/wildberries-warehouse-tab.tsx @@ -1,11 +1,9 @@ "use client" /* eslint-disable @typescript-eslint/no-explicit-any */ -import React, { useState, useEffect } from 'react' -import { useAuth } from '@/hooks/useAuth' +import React, { useState } from 'react' 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' @@ -44,203 +42,36 @@ interface WBWarehouse { deliveryType: number } -export function WildberriesWarehouseTab() { - const { user } = useAuth() - - const [stocks, setStocks] = useState([]) - const [warehouses, setWarehouses] = useState([]) - const [loading, setLoading] = useState(false) +interface WildberriesWarehouseTabProps { + stocks: WBStock[] + warehouses: WBWarehouse[] + loading: boolean + initialized: boolean + cacheLoading: boolean + totalProducts: number + totalStocks: number + totalReserved: number + totalFromClient: number + activeWarehouses: number + analyticsData: any[] + onRefresh: () => Promise +} + +export function WildberriesWarehouseTab({ + stocks, + warehouses, + loading, + initialized, + cacheLoading, + totalProducts, + totalStocks, + totalReserved, + totalFromClient, + activeWarehouses, + analyticsData, + onRefresh +}: WildberriesWarehouseTabProps) { 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([]) - - // Проверяем настройку API ключа - const hasWBApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')?.isActive - - // Комбинирование карточек с индивидуальными данными аналитики - const combineCardsWithIndividualAnalytics = (cards: any[], analyticsResults: any[]): WBStock[] => { - const stocksMap = new Map() - - // Создаем карту аналитических данных для быстрого поиска - const analyticsMap = new Map() // Map nmId to its analytics data - analyticsResults.forEach(result => { - analyticsMap.set(result.nmId, result.data) - }) - - cards.forEach(card => { - const stock: WBStock = { - nmId: card.nmID, - vendorCode: String(card.vendorCode || card.supplierVendorCode || ''), - title: String(card.title || card.object || `Товар ${card.nmID}`), - brand: String(card.brand || ''), - price: 0, - stocks: [], - totalQuantity: 0, - totalReserved: 0, - photos: Array.isArray(card.photos) ? card.photos : [], - mediaFiles: Array.isArray(card.mediaFiles) ? card.mediaFiles : [], - characteristics: Array.isArray(card.characteristics) ? card.characteristics : [], - subjectName: String(card.subjectName || card.object || ''), - description: String(card.description || '') - } - - if (card.sizes && card.sizes.length > 0) { - stock.price = Number(card.sizes[0].price || card.sizes[0].discountedPrice) || 0 - } - - const analyticsData = analyticsMap.get(card.nmID) - if (analyticsData?.data?.regions) { - analyticsData.data.regions.forEach((region: any) => { - if (region.offices && region.offices.length > 0) { - region.offices.forEach((office: any) => { - stock.stocks.push({ - warehouseId: office.officeID, - warehouseName: office.officeName, - quantity: office.metrics?.stockCount || 0, - quantityFull: office.metrics?.stockCount || 0, - inWayToClient: office.metrics?.toClientCount || 0, - inWayFromClient: office.metrics?.fromClientCount || 0 - }) - - stock.totalQuantity += office.metrics?.stockCount || 0 - stock.totalReserved += office.metrics?.toClientCount || 0 - }) - } - }) - } - - stocksMap.set(card.nmID, stock) - }) - - return Array.from(stocksMap.values()).sort((a, b) => b.totalQuantity - a.totalQuantity) - } - - // Извлечение информации о складах из данных - const extractWarehousesFromStocks = (stocksData: WBStock[]): WBWarehouse[] => { - const warehousesMap = new Map() - - stocksData.forEach(stock => { - stock.stocks.forEach(stockInfo => { - if (!warehousesMap.has(stockInfo.warehouseId)) { - warehousesMap.set(stockInfo.warehouseId, { - id: stockInfo.warehouseId, - name: stockInfo.warehouseName, - cargoType: 1, - deliveryType: 1 - }) - } - }) - }) - - return Array.from(warehousesMap.values()) - } - - // Обновление статистики - const updateStatistics = (stocksData: WBStock[], warehousesData: WBWarehouse[]) => { - setTotalProducts(stocksData.length) - setTotalStocks(stocksData.reduce((sum, item) => sum + item.totalQuantity, 0)) - setTotalReserved(stocksData.reduce((sum, item) => sum + item.totalReserved, 0)) - - const totalFromClientCount = stocksData.reduce((sum, item) => - sum + item.stocks.reduce((stockSum, stock) => stockSum + stock.inWayFromClient, 0), 0 - ) - setTotalFromClient(totalFromClientCount) - - const warehousesWithStock = new Set( - stocksData.flatMap(item => item.stocks.map(s => s.warehouseId)) - ) - setActiveWarehouses(warehousesWithStock.size) - } - - // Загрузка данных склада - const loadWarehouseData = async () => { - if (!hasWBApiKey) return - - setLoading(true) - try { - const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES') - - if (!wbApiKey?.isActive) { - toast.error('API ключ Wildberries не настроен') - return - } - - const validationData = wbApiKey.validationData as Record - const apiToken = validationData?.token || - validationData?.apiKey || - validationData?.key || - (wbApiKey as { apiKey?: string }).apiKey - - if (!apiToken) { - toast.error('Токен API не найден') - return - } - - const wbService = new WildberriesService(apiToken) - - // 1. Получаем карточки товаров - const cards = await WildberriesService.getAllCards(apiToken).catch(() => []) - console.log('WB Warehouse: Loaded cards:', cards.length) - - if (cards.length === 0) { - toast.error('Нет карточек товаров в WB') - return - } - - const nmIds = cards.map(card => card.nmID).filter(id => id > 0) - console.log('WB Warehouse: NM IDs to process:', nmIds.length) - - // 2. Получаем аналитику для каждого товара индивидуально - const analyticsResults = [] - for (const nmId of nmIds) { - try { - console.log(`WB Warehouse: Fetching analytics for nmId ${nmId}`) - const result = await wbService.getStocksReportByOffices({ - nmIds: [nmId], - stockType: '' - }) - analyticsResults.push({ nmId, data: result }) - await new Promise(resolve => setTimeout(resolve, 1000)) - } catch (error) { - console.error(`WB Warehouse: Error fetching analytics for nmId ${nmId}:`, error) - } - } - - console.log('WB Warehouse: Analytics results:', analyticsResults.length) - - // 3. Комбинируем данные - const combinedStocks = combineCardsWithIndividualAnalytics(cards, analyticsResults) - console.log('WB Warehouse: Combined stocks:', combinedStocks.length) - - // 4. Извлекаем склады и обновляем статистику - const extractedWarehouses = extractWarehousesFromStocks(combinedStocks) - - setStocks(combinedStocks) - setWarehouses(extractedWarehouses) - updateStatistics(combinedStocks, extractedWarehouses) - - toast.success(`Загружено товаров: ${combinedStocks.length}`) - } catch (error: any) { - console.error('WB Warehouse: Error loading data:', error) - toast.error('Ошибка загрузки данных: ' + (error.message || 'Неизвестная ошибка')) - } finally { - setLoading(false) - } - } - - useEffect(() => { - if (hasWBApiKey) { - loadWarehouseData() - } - }, [hasWBApiKey]) // Фильтрация товаров const filteredStocks = stocks.filter(item => { @@ -254,6 +85,15 @@ export function WildberriesWarehouseTab() { ) }) + const handleRefresh = async () => { + try { + await onRefresh() + } catch (error) { + console.error('Error refreshing data:', error) + toast.error('Ошибка при обновлении данных') + } + } + return (
{/* Статистика */} @@ -263,80 +103,103 @@ export function WildberriesWarehouseTab() { totalReserved={totalReserved} totalFromClient={totalFromClient} activeWarehouses={activeWarehouses} - loading={loading} + loading={!initialized || loading || cacheLoading} /> {/* Аналитика по складам WB */} - {analyticsData.length > 0 && ( + {initialized && analyticsData.length > 0 && (

- Движение товаров по складам WB + Аналитика по складам WB

-
- {analyticsData.map((warehouse) => ( - -
{warehouse.warehouseName}
-
-
- К клиенту: - {warehouse.toClient} -
-
- От клиента: - {warehouse.fromClient} -
+
+ {analyticsData.slice(0, 6).map((item, index) => ( +
+
Склад {index + 1}
+
+ {JSON.stringify(item).length > 50 + ? `${JSON.stringify(item).substring(0, 50)}...` + : JSON.stringify(item) + }
- +
))}
)} - {/* Поиск */} - - - {/* Список товаров */} -
- {loading ? ( -
- - -
- ) : !hasWBApiKey ? ( - - -

Настройте API Wildberries

-

Для просмотра остатков добавьте API ключ Wildberries в настройках

- -
- ) : filteredStocks.length === 0 ? ( - - -

Товары не найдены

-

Попробуйте изменить параметры поиска

-
- ) : ( -
- - - {/* Таблица товаров */} -
- {filteredStocks.map((item, index) => ( - - ))} + {/* Основной контент */} + +
+
+
+

+ + Склад Wildberries +

+

+ Управление товарами на складах Wildberries +

+
+
+
- )} -
+ + {/* Поиск */} +
+ +
+
+ + {/* Контент с таблицей */} +
+ {!initialized || loading || cacheLoading ? ( +
+ +
+ ) : filteredStocks.length === 0 ? ( +
+
+ +

+ {searchTerm ? 'Товары не найдены' : 'Нет данных о товарах'} +

+ {!searchTerm && ( + + )} +
+
+ ) : ( +
+
+ {/* Заголовок таблицы */} + + + {/* Строки товаров */} + {filteredStocks.map((item) => ( + + ))} +
+
+ )} +
+
) } \ No newline at end of file diff --git a/src/graphql/mutations.ts b/src/graphql/mutations.ts index 76ddfc2..7bbefd0 100644 --- a/src/graphql/mutations.ts +++ b/src/graphql/mutations.ts @@ -1033,6 +1033,70 @@ export const REMOVE_FROM_FAVORITES = gql` } `; +// Мутации для внешней рекламы +export const CREATE_EXTERNAL_AD = gql` + mutation CreateExternalAd($input: ExternalAdInput!) { + createExternalAd(input: $input) { + success + message + externalAd { + id + name + url + cost + date + nmId + clicks + organizationId + createdAt + updatedAt + } + } + } +`; + +export const UPDATE_EXTERNAL_AD = gql` + mutation UpdateExternalAd($id: ID!, $input: ExternalAdInput!) { + updateExternalAd(id: $id, input: $input) { + success + message + externalAd { + id + name + url + cost + date + nmId + clicks + organizationId + createdAt + updatedAt + } + } + } +`; + +export const DELETE_EXTERNAL_AD = gql` + mutation DeleteExternalAd($id: ID!) { + deleteExternalAd(id: $id) { + success + message + externalAd { + id + } + } + } +`; + +export const UPDATE_EXTERNAL_AD_CLICKS = gql` + mutation UpdateExternalAdClicks($id: ID!, $clicks: Int!) { + updateExternalAdClicks(id: $id, clicks: $clicks) { + success + message + } + } +`; + // Мутации для категорий export const CREATE_CATEGORY = gql` mutation CreateCategory($input: CategoryInput!) { @@ -1248,3 +1312,25 @@ export const UPDATE_SUPPLY_ORDER_STATUS = gql` } } `; + +// Мутации для кеша склада WB +export const SAVE_WB_WAREHOUSE_CACHE = gql` + mutation SaveWBWarehouseCache($input: WBWarehouseCacheInput!) { + saveWBWarehouseCache(input: $input) { + success + message + fromCache + cache { + id + organizationId + cacheDate + data + totalProducts + totalStocks + totalReserved + createdAt + updatedAt + } + } + } +`; diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index 661a6de..9ccd61d 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -829,6 +829,27 @@ export const GET_WILDBERRIES_CAMPAIGNS_LIST = gql` } `; +export const GET_EXTERNAL_ADS = gql` + query GetExternalAds($dateFrom: String!, $dateTo: String!) { + getExternalAds(dateFrom: $dateFrom, dateTo: $dateTo) { + success + message + externalAds { + id + name + url + cost + date + nmId + clicks + organizationId + createdAt + updatedAt + } + } + } +` + // Админ запросы export const ADMIN_ME = gql` query AdminMe { @@ -934,3 +955,25 @@ export const GET_PENDING_SUPPLIES_COUNT = gql` } } `; + +// Запросы для кеша склада WB +export const GET_WB_WAREHOUSE_DATA = gql` + query GetWBWarehouseData { + getWBWarehouseData { + success + message + fromCache + cache { + id + organizationId + cacheDate + data + totalProducts + totalStocks + totalReserved + createdAt + updatedAt + } + } + } +`; diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index 295dcec..8f23b3d 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -5115,6 +5115,59 @@ export const resolvers = { }; } }, + + updateExternalAdClicks: async ( + _: unknown, + { id, clicks }: { id: string; clicks: number }, + 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 existingAd = await prisma.externalAd.findFirst({ + where: { + id, + organizationId: user.organization.id, + }, + }); + + if (!existingAd) { + throw new GraphQLError("Внешняя реклама не найдена"); + } + + await prisma.externalAd.update({ + where: { id }, + data: { clicks }, + }); + + return { + success: true, + message: "Клики успешно обновлены", + externalAd: null, + }; + } catch (error) { + console.error("Error updating external ad clicks:", error); + return { + success: false, + message: error instanceof Error ? error.message : "Ошибка обновления кликов", + externalAd: null, + }; + } + }, }, // Резолверы типов @@ -6117,14 +6170,394 @@ const wildberriesQueries = { }, }; +// Резолверы для внешней рекламы +const externalAdQueries = { + getExternalAds: async ( + _: unknown, + { dateFrom, dateTo }: { dateFrom: string; dateTo: 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 externalAds = await prisma.externalAd.findMany({ + where: { + organizationId: user.organization.id, + date: { + gte: new Date(dateFrom), + lte: new Date(dateTo + 'T23:59:59.999Z'), + }, + }, + orderBy: { + date: 'desc', + }, + }); + + return { + success: true, + message: null, + externalAds: externalAds.map(ad => ({ + ...ad, + cost: parseFloat(ad.cost.toString()), + date: ad.date.toISOString().split('T')[0], + createdAt: ad.createdAt.toISOString(), + updatedAt: ad.updatedAt.toISOString(), + })), + }; + } catch (error) { + console.error("Error fetching external ads:", error); + return { + success: false, + message: error instanceof Error ? error.message : "Ошибка получения внешней рекламы", + externalAds: [], + }; + } + }, +}; + +const externalAdMutations = { + createExternalAd: async ( + _: unknown, + { input }: { input: { name: string; url: string; cost: number; date: string; nmId: 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 externalAd = await prisma.externalAd.create({ + data: { + name: input.name, + url: input.url, + cost: input.cost, + date: new Date(input.date), + nmId: input.nmId, + organizationId: user.organization.id, + }, + }); + + return { + success: true, + message: "Внешняя реклама успешно создана", + externalAd: { + ...externalAd, + cost: parseFloat(externalAd.cost.toString()), + date: externalAd.date.toISOString().split('T')[0], + createdAt: externalAd.createdAt.toISOString(), + updatedAt: externalAd.updatedAt.toISOString(), + }, + }; + } catch (error) { + console.error("Error creating external ad:", error); + return { + success: false, + message: error instanceof Error ? error.message : "Ошибка создания внешней рекламы", + externalAd: null, + }; + } + }, + + updateExternalAd: async ( + _: unknown, + { id, input }: { id: string; input: { name: string; url: string; cost: number; date: string; nmId: 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 existingAd = await prisma.externalAd.findFirst({ + where: { + id, + organizationId: user.organization.id, + }, + }); + + if (!existingAd) { + throw new GraphQLError("Внешняя реклама не найдена"); + } + + const externalAd = await prisma.externalAd.update({ + where: { id }, + data: { + name: input.name, + url: input.url, + cost: input.cost, + date: new Date(input.date), + nmId: input.nmId, + }, + }); + + return { + success: true, + message: "Внешняя реклама успешно обновлена", + externalAd: { + ...externalAd, + cost: parseFloat(externalAd.cost.toString()), + date: externalAd.date.toISOString().split('T')[0], + createdAt: externalAd.createdAt.toISOString(), + updatedAt: externalAd.updatedAt.toISOString(), + }, + }; + } catch (error) { + console.error("Error updating external ad:", error); + return { + success: false, + message: error instanceof Error ? error.message : "Ошибка обновления внешней рекламы", + externalAd: null, + }; + } + }, + + deleteExternalAd: async ( + _: unknown, + { id }: { id: 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 existingAd = await prisma.externalAd.findFirst({ + where: { + id, + organizationId: user.organization.id, + }, + }); + + if (!existingAd) { + throw new GraphQLError("Внешняя реклама не найдена"); + } + + await prisma.externalAd.delete({ + where: { id }, + }); + + return { + success: true, + message: "Внешняя реклама успешно удалена", + externalAd: null, + }; + } catch (error) { + console.error("Error deleting external ad:", error); + return { + success: false, + message: error instanceof Error ? error.message : "Ошибка удаления внешней рекламы", + externalAd: null, + }; + } + }, + +}; + +// Резолверы для кеша склада WB +const wbWarehouseCacheQueries = { + getWBWarehouseData: async ( + _: unknown, + __: unknown, + 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 cache = await prisma.wBWarehouseCache.findFirst({ + where: { + organizationId: user.organization.id, + cacheDate: today, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + if (cache) { + // Возвращаем данные из кеша + return { + success: true, + message: "Данные получены из кеша", + cache: { + ...cache, + cacheDate: cache.cacheDate.toISOString().split('T')[0], + createdAt: cache.createdAt.toISOString(), + updatedAt: cache.updatedAt.toISOString(), + }, + fromCache: true, + }; + } else { + // Кеша нет, нужно загрузить данные из API + return { + success: true, + message: "Кеш не найден, требуется загрузка из API", + cache: null, + fromCache: false, + }; + } + } catch (error) { + console.error("Error getting WB warehouse cache:", error); + return { + success: false, + message: error instanceof Error ? error.message : "Ошибка получения кеша склада WB", + cache: null, + fromCache: false, + }; + } + }, +}; + +const wbWarehouseCacheMutations = { + saveWBWarehouseCache: async ( + _: unknown, + { input }: { input: { data: string; totalProducts: number; totalStocks: number; totalReserved: number } }, + 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); + + // Используем upsert для создания или обновления кеша + const cache = await prisma.wBWarehouseCache.upsert({ + where: { + organizationId_cacheDate: { + organizationId: user.organization.id, + cacheDate: today, + }, + }, + update: { + data: input.data, + totalProducts: input.totalProducts, + totalStocks: input.totalStocks, + totalReserved: input.totalReserved, + }, + create: { + organizationId: user.organization.id, + cacheDate: today, + data: input.data, + totalProducts: input.totalProducts, + totalStocks: input.totalStocks, + totalReserved: input.totalReserved, + }, + }); + + return { + success: true, + message: "Кеш склада WB успешно сохранен", + cache: { + ...cache, + cacheDate: cache.cacheDate.toISOString().split('T')[0], + createdAt: cache.createdAt.toISOString(), + updatedAt: cache.updatedAt.toISOString(), + }, + fromCache: false, + }; + } catch (error) { + console.error("Error saving WB warehouse cache:", error); + return { + success: false, + message: error instanceof Error ? error.message : "Ошибка сохранения кеша склада WB", + cache: null, + fromCache: false, + }; + } + }, +}; + // Добавляем админ запросы и мутации к основным резолверам resolvers.Query = { ...resolvers.Query, ...adminQueries, ...wildberriesQueries, + ...externalAdQueries, + ...wbWarehouseCacheQueries, }; resolvers.Mutation = { ...resolvers.Mutation, ...adminMutations, + ...externalAdMutations, + ...wbWarehouseCacheMutations, }; diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index 417e6ff..cb7557d 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -108,6 +108,12 @@ export const typeDefs = gql` # Список кампаний Wildberries getWildberriesCampaignsList: WildberriesCampaignsListResponse! + + # Типы для внешней рекламы + getExternalAds(dateFrom: String!, dateTo: String!): ExternalAdsResponse! + + # Типы для кеша склада WB + getWBWarehouseData: WBWarehouseCacheResponse! } type Mutation { @@ -244,6 +250,12 @@ export const typeDefs = gql` # Админ мутации adminLogin(username: String!, password: String!): AdminAuthResponse! adminLogout: Boolean! + + # Типы для внешней рекламы + createExternalAd(input: ExternalAdInput!): ExternalAdResponse! + updateExternalAd(id: ID!, input: ExternalAdInput!): ExternalAdResponse! + deleteExternalAd(id: ID!): ExternalAdResponse! + updateExternalAdClicks(id: ID!, clicks: Int!): ExternalAdResponse! } # Типы данных @@ -1149,4 +1161,84 @@ export const typeDefs = gql` advertId: Int! changeTime: String! } + + # Типы для внешней рекламы + type ExternalAd { + id: ID! + name: String! + url: String! + cost: Float! + date: String! + nmId: String! + clicks: Int! + organizationId: String! + createdAt: String! + updatedAt: String! + } + + input ExternalAdInput { + name: String! + url: String! + cost: Float! + date: String! + nmId: String! + } + + type ExternalAdResponse { + success: Boolean! + message: String + externalAd: ExternalAd + } + + type ExternalAdsResponse { + success: Boolean! + message: String + externalAds: [ExternalAd!]! + } + + extend type Query { + getExternalAds(dateFrom: String!, dateTo: String!): ExternalAdsResponse! + } + + extend type Mutation { + createExternalAd(input: ExternalAdInput!): ExternalAdResponse! + updateExternalAd(id: ID!, input: ExternalAdInput!): ExternalAdResponse! + deleteExternalAd(id: ID!): ExternalAdResponse! + updateExternalAdClicks(id: ID!, clicks: Int!): ExternalAdResponse! + } + + # Типы для кеша склада WB + type WBWarehouseCache { + id: ID! + organizationId: String! + cacheDate: String! + data: String! # JSON строка с данными + totalProducts: Int! + totalStocks: Int! + totalReserved: Int! + createdAt: String! + updatedAt: String! + } + + type WBWarehouseCacheResponse { + success: Boolean! + message: String + cache: WBWarehouseCache + fromCache: Boolean! # Указывает, получены ли данные из кеша + } + + input WBWarehouseCacheInput { + data: String! # JSON строка с данными склада + totalProducts: Int! + totalStocks: Int! + totalReserved: Int! + } + + extend type Query { + getWBWarehouseData: WBWarehouseCacheResponse! + } + + extend type Mutation { + saveWBWarehouseCache(input: WBWarehouseCacheInput!): WBWarehouseCacheResponse! + } `; diff --git a/src/lib/click-storage.ts b/src/lib/click-storage.ts new file mode 100644 index 0000000..80272f9 --- /dev/null +++ b/src/lib/click-storage.ts @@ -0,0 +1,30 @@ +// Общее хранилище кликов для всех API роутов +class ClickStorage { + private static instance: ClickStorage + private storage = new Map() + + 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.log(`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) + } +} + +export const clickStorage = ClickStorage.getInstance() \ No newline at end of file