2104 lines
84 KiB
Plaintext
2104 lines
84 KiB
Plaintext
"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>
|
||
)
|
||
} |