Files
sfera/src/components/seller-statistics/advertising-tab.tsx.backup

2104 lines
84 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<Set<number>>(new Set(selectedCampaigns))
const [filterType, setFilterType] = useState<number | 'all'>('all')
const [filterStatus, setFilterStatus] = useState<number | 'all'>('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<number, string> = {
4: 'Авто',
5: 'Фразы',
6: 'Предмет',
7: 'Бренд',
8: 'Медиа',
9: 'Карусель'
}
return types[type] || `Тип ${type}`
}
const getCampaignStatusName = (status: number) => {
const statuses: Record<number, string> = {
7: 'Завершена',
8: 'Отклонена',
9: 'Активна',
11: 'На паузе'
}
return statuses[status] || `Статус ${status}`
}
const getStatusColor = (status: number) => {
const colors: Record<number, string> = {
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 (
<Card className="glass-card p-3">
<div className="space-y-3">
{/* Компактный заголовок */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="h-7 px-2 text-white hover:bg-white/10"
>
{isExpanded ? <Minimize2 className="h-3 w-3" /> : <BarChart3 className="h-3 w-3" />}
<span className="ml-1 text-sm">Кампании</span>
</Button>
<Badge variant="outline" className="border-white/20 text-white text-xs">
{selectedIds.size}
</Badge>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setShowManualInput(!showManualInput)}
className="h-7 px-2 text-xs text-white/60 hover:bg-white/10"
>
{showManualInput ? 'Список' : 'Ручной'}
</Button>
<Button
onClick={handleApplySelection}
disabled={statsLoading || (showManualInput ? !manualIds.trim() : selectedIds.size === 0)}
size="sm"
className="h-7 px-3 bg-blue-600 hover:bg-blue-700 text-white text-xs"
>
{statsLoading ? (
<div className="animate-spin rounded-full h-3 w-3 border-b border-white" />
) : (
<>
<Eye className="h-3 w-3 mr-1" />
{selectedIds.size > 0 ? `Загрузить (${selectedIds.size})` : 'Выбрать'}
</>
)}
</Button>
</div>
</div>
{/* Развернутый контент */}
{isExpanded && (
<div className="space-y-3">
{showManualInput ? (
<Input
placeholder="ID через запятую: 12345, 67890"
value={manualIds}
onChange={(e) => setManualIds(e.target.value)}
className="h-8 bg-white/5 border-white/20 text-white placeholder:text-white/40 text-xs"
/>
) : (
<div className="space-y-3">
{/* Компактные фильтры */}
<div className="flex items-center gap-2 text-xs">
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
className="h-7 bg-white/5 border border-white/20 rounded px-2 text-white text-xs"
>
<option value="all">Все типы</option>
{uniqueTypes.map((type: number) => (
<option key={type} value={type}>{getCampaignTypeName(type)}</option>
))}
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value === 'all' ? 'all' : parseInt(e.target.value))}
className="h-7 bg-white/5 border border-white/20 rounded px-2 text-white text-xs"
>
<option value="all">Все статусы</option>
{uniqueStatuses.map((status: number) => (
<option key={status} value={status}>{getCampaignStatusName(status)}</option>
))}
</select>
</div>
{/* Компактный список кампаний */}
{loading ? (
<Skeleton className="h-20 bg-white/10" />
) : error ? (
<Alert className="bg-red-500/10 border-red-500/30 text-red-400 py-2">
<AlertCircle className="h-3 w-3" />
<AlertDescription className="text-xs">
Ошибка: {error.message}
</AlertDescription>
</Alert>
) : (
<div className="max-h-32 overflow-y-auto space-y-2">
{filteredCampaigns.map((group: CampaignGroup) => (
<div key={`${group.type}-${group.status}`} className="bg-white/5 rounded p-2">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<Checkbox
checked={group.advert_list.every(item => selectedIds.has(item.advertId))}
onCheckedChange={() => handleSelectAll(group)}
className="h-3 w-3"
/>
<span className="text-xs font-medium text-white">
{getCampaignTypeName(group.type)}
</span>
<span className={`text-xs ${getStatusColor(group.status)}`}>
{getCampaignStatusName(group.status)}
</span>
<Badge variant="outline" className="border-white/20 text-white text-xs px-1 py-0">
{group.count}
</Badge>
</div>
</div>
<div className="grid grid-cols-4 gap-1 ml-4">
{group.advert_list.map((campaign) => (
<div
key={campaign.advertId}
className="flex items-center gap-1 p-1 bg-white/5 rounded cursor-pointer hover:bg-white/10 text-xs"
onClick={() => handleCampaignToggle(campaign.advertId)}
>
<Checkbox
checked={selectedIds.has(campaign.advertId)}
onCheckedChange={() => handleCampaignToggle(campaign.advertId)}
className="h-3 w-3"
/>
<span className="text-white truncate">#{campaign.advertId}</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
</Card>
)
}
export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endDate }: CampaignStatsProps) {
onUpdateExternalAd,
onGenerateLink
}: {
dailyData: DailyAdvertisingData[],
productPhotos: Map<number, string>,
generatedLinksData: Record<string, GeneratedLink[]>,
onAddExternalAd: (date: string, ad: Omit<ExternalAd, 'id'>) => void,
onRemoveExternalAd: (date: string, adId: string) => void,
onUpdateExternalAd: (date: string, adId: string, updates: Partial<ExternalAd>) => void,
onGenerateLink: (date: string, adId: string, adName: string, adUrl: string) => void
}) => {
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set())
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set())
const [newExternalAd, setNewExternalAd] = useState({ name: '', url: '', cost: '', productId: '' })
const [showAddForm, setShowAddForm] = useState<string | null>(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 (
<div className="bg-white/5 rounded-lg border border-white/10 overflow-hidden">
{/* Фильтры */}
<div className="p-4 border-b border-white/10">
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Checkbox
id="wb-ads"
checked={showWbAds}
onCheckedChange={setShowWbAds}
className="border-white/30"
/>
<Label htmlFor="wb-ads" className="text-white/80 text-sm">Реклама ВБ</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="external-ads"
checked={showExternalAds}
onCheckedChange={setShowExternalAds}
className="border-white/30"
/>
<Label htmlFor="external-ads" className="text-white/80 text-sm">Реклама внешняя</Label>
</div>
</div>
</div>
{/* Заголовок таблицы */}
<div className="bg-purple-600/20 border-b border-white/10">
<div className="grid grid-cols-5 gap-4 p-3 text-white text-sm font-medium">
<div>Дата</div>
<div className="text-center">Сумма (руб)</div>
<div className="text-center">Заказы (ед)</div>
<div className="text-center">Реклама ВБ</div>
<div className="text-center">Реклама внешняя</div>
</div>
</div>
{/* Строки таблицы */}
<div className="max-h-96 overflow-y-auto">
{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 (
<div key={day.date} className="grid grid-cols-5 gap-4 p-3 border-b border-white/5 hover:bg-white/5 text-white/80 text-sm">
<div className="font-medium">{day.date}</div>
<div className="text-center">{formatCurrency(dayTotalCost)}</div>
<div className="text-center">{dayOrders}</div>
<div className="text-center">
{showWbAds ? formatCurrency(dayWbCost) : '—'}
</div>
<div className="text-center">
{showExternalAds ? formatCurrency(dayExternalCost) : '—'}
</div>
</div>
)
})}
</div>
</div>
)
}
// Старый компонент (для совместимости)
const UltraCompactCampaignTable = ({
campaigns,
expandedCampaigns,
onToggleExpand,
productPhotos
}: {
campaigns: CampaignStats[],
expandedCampaigns: Set<number>,
onToggleExpand: (id: number) => void,
productPhotos: Map<number, string>
}) => {
const [sortField, setSortField] = useState<keyof CampaignStats>('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 <Smartphone className={`${iconClass} text-blue-400`} />
case 2: return <Monitor className={`${iconClass} text-green-400`} />
case 3: return <Globe className={`${iconClass} text-purple-400`} />
case 32: return <Monitor className={`${iconClass} text-emerald-400`} />
case 64: return <Globe className={`${iconClass} text-amber-400`} />
default: return <Target className={`${iconClass} text-gray-400`} />
}
}
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 (
<div className="space-y-2">
{/* Сверх-компактный заголовок таблицы */}
<div className="grid grid-cols-12 gap-1 p-2 bg-white/10 rounded text-xs font-medium text-white/80">
<div
className="col-span-2 flex items-center gap-1 cursor-pointer hover:text-white"
onClick={() => handleSort('advertId')}
>
Товары
<ArrowUpDown className="h-3 w-3" />
</div>
<div
className="text-center cursor-pointer hover:text-white"
onClick={() => handleSort('views')}
>
👁
</div>
<div
className="text-center cursor-pointer hover:text-white"
onClick={() => handleSort('clicks')}
>
🖱
</div>
<div
className="text-center cursor-pointer hover:text-white"
onClick={() => handleSort('ctr')}
>
CTR
</div>
<div
className="text-center cursor-pointer hover:text-white"
onClick={() => handleSort('cpc')}
>
CPC
</div>
<div
className="text-center cursor-pointer hover:text-white"
onClick={() => handleSort('sum')}
>
💰
</div>
<div
className="text-center cursor-pointer hover:text-white"
onClick={() => handleSort('orders')}
>
📦
</div>
<div
className="text-center cursor-pointer hover:text-white"
onClick={() => handleSort('cr')}
>
CR
</div>
<div
className="text-center cursor-pointer hover:text-white"
onClick={() => handleSort('shks')}
>
шт
</div>
<div
className="col-span-2 text-center cursor-pointer hover:text-white"
onClick={() => handleSort('sum_price')}
>
Выручка
</div>
<div className="text-center">ROI</div>
</div>
{/* Строки кампаний */}
{sortedCampaigns.map((campaign) => {
const isExpanded = expandedCampaigns.has(campaign.advertId)
const roi = campaign.sum > 0 ? ((campaign.sum_price - campaign.sum) / campaign.sum * 100) : 0
return (
<div key={campaign.advertId} className="space-y-1">
{/* Основная строка кампании с товарами */}
<div
className="grid grid-cols-12 gap-1 p-2 bg-white/5 rounded border border-white/10 hover:bg-white/10 transition-colors cursor-pointer text-xs"
onClick={() => onToggleExpand(campaign.advertId)}
>
<div className="col-span-2 flex items-center gap-1">
{isExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<div className="flex flex-col gap-1 flex-1">
<div className="flex items-center gap-1">
<span className="font-medium text-white text-xs">#{campaign.advertId}</span>
<Badge variant="outline" className="border-white/20 text-white text-xs px-1 py-0">
{campaign.days.length}д
</Badge>
</div>
{/* Мини-карточки товаров */}
<div className="flex flex-wrap gap-1">
{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) => (
<div
key={product.nmId}
className="bg-white/10 rounded px-1 py-0.5 text-xs text-white/80 hover:bg-white/20 transition-colors"
title={product.name}
>
<div className="flex items-center gap-1">
<Package className="h-2 w-2" />
<span className="truncate max-w-12">
{product.name.length > 8 ? `${product.name.slice(0, 8)}...` : product.name}
</span>
</div>
</div>
))}
{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 && (
<div className="bg-white/5 rounded px-1 py-0.5 text-xs text-white/60">
+{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}
</div>
)}
</div>
</div>
</div>
<div className="text-center text-white">{formatNumber(campaign.views)}</div>
<div className="text-center text-white">{formatNumber(campaign.clicks)}</div>
<div className="text-center text-white">{formatPercent(campaign.ctr)}</div>
<div className="text-center text-white">{formatCurrency(campaign.cpc)}</div>
<div className="text-center text-white">{formatCurrency(campaign.sum)}</div>
<div className="text-center text-white">{formatNumber(campaign.orders)}</div>
<div className="text-center text-white">{formatPercent(campaign.cr)}</div>
<div className="text-center text-white">{formatNumber(campaign.shks)}</div>
<div className="col-span-2 text-center text-white">{formatCurrency(campaign.sum_price)}</div>
<div className={`text-center font-medium ${roi > 0 ? 'text-green-400' : roi < 0 ? 'text-red-400' : 'text-gray-400'}`}>
{roi === 0 ? '—' : `${roi > 0 ? '+' : ''}${roi.toFixed(0)}%`}
</div>
</div>
{/* Развернутое содержимое */}
{isExpanded && (
<div className="ml-4 space-y-2">
{/* Товары в кампании - полноценные карточки */}
{campaign.days.some(day => day.apps?.some(app => app.nm && app.nm.length > 0)) && (
<div className="bg-white/2 rounded p-3 border border-white/5">
<h4 className="text-sm font-medium text-white mb-3 flex items-center gap-2">
<Package className="h-4 w-4" />
Товары в кампании
</h4>
<div className="space-y-2">
{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 (
<div key={product.nmId} className="flex items-center gap-3 bg-white/5 rounded-lg p-3 border border-white/10 hover:bg-white/10 transition-colors">
{/* Фото товара */}
<div className="relative flex-shrink-0">
{photoUrl ? (
<img
src={photoUrl}
alt={product.name}
className="w-16 h-16 object-cover rounded-lg bg-white/10"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const placeholder = target.nextElementSibling as HTMLElement
if (placeholder) placeholder.style.display = 'flex'
}}
/>
) : null}
<div className={`w-16 h-16 bg-white/10 rounded-lg items-center justify-center ${photoUrl ? 'hidden' : 'flex'}`}>
<Package className="h-6 w-6 text-white/40" />
</div>
</div>
{/* Основная информация о товаре */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0 pr-2">
<h5 className="text-white font-medium text-sm mb-1 overflow-hidden" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }} title={product.name}>
{product.name}
</h5>
<p className="text-white/60 text-xs">
Артикул WB: {product.nmId}
</p>
</div>
{/* ROI badge */}
<div className={`px-2 py-1 rounded text-xs font-medium flex-shrink-0 ${
roi > 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)}%`}
</div>
</div>
{/* Статистика в строку */}
<div className="grid grid-cols-4 md:grid-cols-7 gap-2 text-xs">
<div className="bg-white/5 rounded p-2 text-center">
<div className="text-white/60 mb-1">Показы</div>
<div className="text-white font-medium">{formatNumber(product.views)}</div>
</div>
<div className="bg-white/5 rounded p-2 text-center">
<div className="text-white/60 mb-1">Клики</div>
<div className="text-white font-medium">{formatNumber(product.clicks)}</div>
</div>
<div className="bg-white/5 rounded p-2 text-center">
<div className="text-white/60 mb-1">CTR</div>
<div className="text-white font-medium">{formatPercent(product.ctr)}</div>
</div>
<div className="bg-white/5 rounded p-2 text-center">
<div className="text-white/60 mb-1">Затраты</div>
<div className="text-white font-medium">{formatCurrency(product.sum)}</div>
</div>
<div className="bg-white/5 rounded p-2 text-center">
<div className="text-white/60 mb-1">CPC</div>
<div className="text-white font-medium">{formatCurrency(product.cpc)}</div>
</div>
<div className="bg-white/5 rounded p-2 text-center">
<div className="text-white/60 mb-1">Заказы</div>
<div className="text-white font-medium">{formatNumber(product.orders)}</div>
</div>
<div className="bg-white/5 rounded p-2 text-center">
<div className="text-white/60 mb-1">CR</div>
<div className="text-white font-medium">{formatPercent(product.cr)}</div>
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Компактная статистика по дням */}
{campaign.days.length > 0 && (
<div className="bg-white/2 rounded p-2 border border-white/5">
<h4 className="text-xs font-medium text-white/70 mb-2 flex items-center gap-1">
<Calendar className="h-3 w-3" />
По дням ({campaign.days.length})
</h4>
<div className="space-y-1 max-h-32 overflow-y-auto">
{campaign.days.map((day, dayIndex) => (
<div key={dayIndex} className="grid grid-cols-11 gap-1 p-1 bg-white/5 rounded text-xs">
<div className="col-span-2 text-white/80">
{new Date(day.date).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit'
})}
</div>
<div className="text-center text-white">{formatNumber(day.views)}</div>
<div className="text-center text-white">{formatNumber(day.clicks)}</div>
<div className="text-center text-white">{formatPercent(day.ctr)}</div>
<div className="text-center text-white">{formatCurrency(day.cpc)}</div>
<div className="text-center text-white">{formatCurrency(day.sum)}</div>
<div className="text-center text-white">{formatNumber(day.orders)}</div>
<div className="text-center text-white">{formatPercent(day.cr)}</div>
<div className="text-center text-white">{formatNumber(day.shks)}</div>
<div className="text-center text-white">{formatCurrency(day.sum_price)}</div>
</div>
))}
</div>
</div>
)}
{/* Компактная статистика по платформам */}
{campaign.days.some(day => day.apps && day.apps.length > 0) && (
<div className="bg-white/2 rounded p-2 border border-white/5">
<h4 className="text-xs font-medium text-white/70 mb-2 flex items-center gap-1">
<BarChart3 className="h-3 w-3" />
Платформы
</h4>
<div className="space-y-1">
{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) => (
<div key={appIndex} className="grid grid-cols-10 gap-1 p-1 bg-white/5 rounded text-xs">
<div className="col-span-2 flex items-center gap-1">
{getAppTypeIcon(app.appType)}
<span className="text-white/80 truncate">
{app.appType === 1 ? 'Мобайл' :
app.appType === 32 ? 'Десктоп' :
app.appType === 64 ? 'Моб.WB' : `Тип${app.appType}`}
</span>
</div>
<div className="text-center text-white">{formatNumber(app.views)}</div>
<div className="text-center text-white">{formatNumber(app.clicks)}</div>
<div className="text-center text-white">{formatPercent(app.ctr)}</div>
<div className="text-center text-white">{formatCurrency(app.sum)}</div>
<div className="text-center text-white">{formatNumber(app.orders)}</div>
<div className="text-center text-white">{formatPercent(app.cr)}</div>
<div className="text-center text-white">{formatNumber(app.shks)}</div>
<div className="text-center text-white">{formatCurrency(app.sum_price)}</div>
</div>
))}
</div>
</div>
)}
{/* Позиции товаров */}
{campaign.boosterStats.length > 0 && (
<div className="bg-white/2 rounded p-2 border border-white/5">
<h4 className="text-xs font-medium text-white/70 mb-2 flex items-center gap-1">
<TrendingUp className="h-3 w-3" />
Позиции
</h4>
<div className="grid grid-cols-3 gap-1">
{campaign.boosterStats.slice(0, 6).map((booster, index) => (
<div key={index} className="bg-white/5 rounded p-1 text-xs">
<div className="text-white/60">
{new Date(booster.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' })}
</div>
<div className="text-white font-medium">
#{booster.avg_position}
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
})}
</div>
)
}
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<Set<string>>(new Set())
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set())
const [expandedCampaigns, setExpandedCampaigns] = useState<Set<number>>(new Set())
// Состояние для формы добавления внешней рекламы
const [showAddForm, setShowAddForm] = useState<string | null>(null)
const [newExternalAd, setNewExternalAd] = useState({
name: '',
url: '',
cost: ''
})
const [campaignStats, setCampaignStats] = useState<CampaignStats[]>([])
const [productPhotos, setProductPhotos] = useState<Map<number, string>>(new Map())
const [dailyData, setDailyData] = useState<DailyAdvertisingData[]>([])
const [generatedLinksData, setGeneratedLinksData] = useState<Record<string, GeneratedLink[]>>({})
const prevCampaignStats = useRef<CampaignStats[]>([])
// Вычисляем диапазон дат для запроса внешней рекламы
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<string, GeneratedLink[]> = {}
Object.keys(linksData).forEach(date => {
const uniqueLinks = new Map<string, GeneratedLink>()
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<string, string>
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<number, string>()
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<string, DailyAdvertisingData>()
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<string, ExternalAd[]>, 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<ExternalAd, 'id'>) => {
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<ExternalAd>) => {
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 (
<div className="h-full flex flex-col space-y-3 overflow-hidden">
{/* Ошибки */}
{error && (
<Alert className="bg-red-500/10 border-red-500/30 text-red-400 py-2">
<AlertCircle className="h-3 w-3" />
<AlertDescription className="text-xs">
{error.message}
</AlertDescription>
</Alert>
)}
{externalAdsError && (
<Alert className="bg-red-500/10 border-red-500/30 text-red-400 mb-4">
<AlertCircle className="h-3 w-3" />
<AlertDescription className="text-xs">
Ошибка загрузки внешней рекламы: {externalAdsError.message}
</AlertDescription>
</Alert>
)}
{/* Результаты */}
<div className="flex-1 overflow-auto">
{(loading || campaignsLoading || externalAdsLoading) ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 bg-white/10" />
))}
</div>
) : campaignStats.length > 0 ? (
<div className="space-y-3">
{/* График расходов */}
{spendingChartData.length > 0 && (
<Card className="glass-card p-3">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Расходы на рекламу
</h3>
<div className="text-xs text-white/60">
<div>Общие: {formatCurrency(spendingChartData.reduce((sum, day) => sum + day.sum, 0))}</div>
<div className="flex gap-3 mt-1">
<span className="flex items-center gap-1">
<div className="w-2 h-2 bg-blue-500 rounded"></div>
ВБ: {formatCurrency(spendingChartData.reduce((sum, day) => sum + day.wbSum, 0))}
</span>
<span className="flex items-center gap-1">
<div className="w-2 h-2 bg-pink-500 rounded"></div>
Внешняя: {formatCurrency(spendingChartData.reduce((sum, day) => sum + day.externalSum, 0))}
</span>
</div>
</div>
</div>
<div className="h-48">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={spendingChartData}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis
dataKey="date"
tick={{ fill: 'rgba(255,255,255,0.6)', fontSize: 10 }}
axisLine={false}
/>
<YAxis
tick={{ fill: 'rgba(255,255,255,0.6)', fontSize: 10 }}
axisLine={false}
tickFormatter={(value) => `${(value / 1000).toFixed(0)}K₽`}
/>
<ChartTooltip
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div className="bg-black/80 p-2 rounded border border-white/20">
<p className="text-white text-xs">{`Дата: ${label}`}</p>
{payload.map((entry, index) => (
<p key={index} className="text-xs" style={{ color: entry.color }}>
{`${entry.name}: ${formatCurrency(entry.value as number)}`}
</p>
))}
<p className="text-white text-xs border-t border-white/20 pt-1 mt-1">
{`Общие расходы: ${formatCurrency(
payload.reduce((sum, entry) => sum + (entry.value as number), 0)
)}`}
</p>
</div>
)
}
return null
}}
/>
<Bar
dataKey="wbSum"
fill="#3b82f6"
name="ВБ реклама"
radius={[2, 2, 0, 0]}
opacity={0.8}
/>
<Bar
dataKey="externalSum"
fill="#ec4899"
name="Внешняя реклама"
radius={[2, 2, 0, 0]}
opacity={0.8}
/>
</BarChart>
</ResponsiveContainer>
</div>
</Card>
)}
{/* Компактная общая статистика */}
<Card className="glass-card p-3">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Сводка ({campaignStats.length} кампаний)
</h3>
</div>
<div className="grid grid-cols-4 md:grid-cols-8 gap-2 mb-3">
{/* Показы */}
<div className="bg-white/5 rounded p-2">
<div className="flex items-center gap-1 mb-1">
<Eye className="h-3 w-3 text-purple-400" />
<span className="text-xs text-white/60">Показы</span>
</div>
<div className="text-sm font-bold text-white">
{formatNumber(campaignStats.reduce((sum, stat) => sum + stat.views, 0))}
</div>
</div>
{/* Клики */}
<div className="bg-white/5 rounded p-2">
<div className="flex items-center gap-1 mb-1">
<MousePointer className="h-3 w-3 text-cyan-400" />
<span className="text-xs text-white/60">Клики</span>
</div>
<div className="text-sm font-bold text-white">
{formatNumber(campaignStats.reduce((sum, stat) => sum + stat.clicks, 0))}
</div>
</div>
{/* CTR */}
<div className="bg-white/5 rounded p-2">
<div className="flex items-center gap-1 mb-1">
<Percent className="h-3 w-3 text-green-400" />
<span className="text-xs text-white/60">CTR</span>
</div>
<div className="text-sm font-bold text-white">
{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)
)}
</div>
</div>
{/* CPC */}
<div className="bg-white/5 rounded p-2">
<div className="flex items-center gap-1 mb-1">
<DollarSign className="h-3 w-3 text-amber-400" />
<span className="text-xs text-white/60">CPC</span>
</div>
<div className="text-sm font-bold text-white">
{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)
)}
</div>
</div>
{/* Затраты */}
<div className="bg-white/5 rounded p-2">
<div className="flex items-center gap-1 mb-1">
<DollarSign className="h-3 w-3 text-red-400" />
<span className="text-xs text-white/60">Затраты</span>
</div>
<div className="text-sm font-bold text-white">
{formatCurrency(campaignStats.reduce((sum, stat) => sum + stat.sum, 0))}
</div>
</div>
{/* Заказы */}
<div className="bg-white/5 rounded p-2">
<div className="flex items-center gap-1 mb-1">
<ShoppingCart className="h-3 w-3 text-green-400" />
<span className="text-xs text-white/60">Заказы</span>
</div>
<div className="text-sm font-bold text-white">
{formatNumber(campaignStats.reduce((sum, stat) => sum + stat.orders, 0))}
</div>
</div>
{/* CR */}
<div className="bg-white/5 rounded p-2">
<div className="flex items-center gap-1 mb-1">
<Percent className="h-3 w-3 text-blue-400" />
<span className="text-xs text-white/60">CR</span>
</div>
<div className="text-sm font-bold text-white">
{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)
)}
</div>
</div>
{/* Выручка */}
<div className="bg-white/5 rounded p-2">
<div className="flex items-center gap-1 mb-1">
<DollarSign className="h-3 w-3 text-emerald-400" />
<span className="text-xs text-white/60">Выручка</span>
</div>
<div className="text-sm font-bold text-white">
{formatCurrency(campaignStats.reduce((sum, stat) => sum + stat.sum_price, 0))}
</div>
</div>
</div>
</Card>
{/* Новая таблица рекламы */}
<Card className="glass-card p-3">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
Статистика рекламы
</h3>
<div className="text-xs text-white/60">
{dailyData.length} дней данных
</div>
</div>
{/* Простая таблица согласно дизайну Figma */}
<div className="bg-white/5 rounded-lg border border-white/10 overflow-hidden">
{/* Фильтры */}
<div className="p-4 border-b border-white/10">
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Checkbox
id="wb-ads"
checked={showWbAds}
onCheckedChange={(checked) => setShowWbAds(checked === true)}
className="border-white/30"
/>
<Label htmlFor="wb-ads" className="text-white/80 text-sm">Реклама ВБ</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="external-ads"
checked={showExternalAds}
onCheckedChange={(checked) => setShowExternalAds(checked === true)}
className="border-white/30"
/>
<Label htmlFor="external-ads" className="text-white/80 text-sm">Реклама внешняя</Label>
</div>
</div>
</div>
{/* Заголовок таблицы */}
<div className="bg-purple-600/20 border-b border-white/10">
<div className="grid grid-cols-5 gap-4 p-3 text-white text-sm font-medium">
<div>Дата</div>
<div className="text-center">Сумма (руб)</div>
<div className="text-center">Заказы (ед)</div>
<div className="text-center">Реклама ВБ</div>
<div className="text-center">Реклама внешняя</div>
</div>
</div>
{/* Строки таблицы */}
<div className="max-h-96 overflow-y-auto">
{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 (
<div key={day.date} className="grid grid-cols-5 gap-4 p-3 border-b border-white/5 hover:bg-white/5 text-white/80 text-sm">
<div className="font-medium">{day.date}</div>
<div className="text-center">{formatCurrency(dayTotalCost)}</div>
<div className="text-center">{dayOrders}</div>
<div className="text-center">
{showWbAds ? formatCurrency(dayWbCost) : '—'}
</div>
<div className="text-center">
{showExternalAds ? formatCurrency(dayExternalCost) : '—'}
</div>
</div>
)
})}
</div>
</div>
</Card>
</div>
) : (
<Card className="glass-card h-full overflow-hidden p-6">
<div className="flex items-center justify-center h-full">
<div className="text-center">
<TrendingUp className="h-12 w-12 text-white/40 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Статистика рекламных кампаний</h3>
<p className="text-white/60 mb-4">Загружаем статистику по всем доступным кампаниям...</p>
<p className="text-white/40 text-sm">
Поддерживается API Wildberries /adv/v2/fullstats
</p>
</div>
</div>
</Card>
)}
</div>
</div>
)
}