"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

)}
) }