Compare commits
2 Commits
7877f61d5a
...
98595cc67c
Author | SHA1 | Date | |
---|---|---|---|
98595cc67c | |||
c174a9f83c |
@ -108,6 +108,8 @@ model Organization {
|
|||||||
fulfillmentSupplyOrders SupplyOrder[] @relation("SupplyOrderFulfillmentCenter")
|
fulfillmentSupplyOrders SupplyOrder[] @relation("SupplyOrderFulfillmentCenter")
|
||||||
wildberriesSupplies WildberriesSupply[]
|
wildberriesSupplies WildberriesSupply[]
|
||||||
supplySuppliers SupplySupplier[] @relation("SupplySuppliers")
|
supplySuppliers SupplySupplier[] @relation("SupplySuppliers")
|
||||||
|
externalAds ExternalAd[] @relation("ExternalAds")
|
||||||
|
wbWarehouseCaches WBWarehouseCache[] @relation("WBWarehouseCaches")
|
||||||
|
|
||||||
@@map("organizations")
|
@@map("organizations")
|
||||||
}
|
}
|
||||||
@ -510,3 +512,37 @@ model SupplySupplier {
|
|||||||
|
|
||||||
@@map("supply_suppliers")
|
@@map("supply_suppliers")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model ExternalAd {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String // Название рекламы
|
||||||
|
url String // URL рекламы
|
||||||
|
cost Decimal @db.Decimal(12, 2) // Стоимость
|
||||||
|
date DateTime // Дата рекламы
|
||||||
|
nmId String // ID товара Wildberries
|
||||||
|
clicks Int @default(0) // Количество кликов
|
||||||
|
organizationId String // ID организации
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
organization Organization @relation("ExternalAds", fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([organizationId, date])
|
||||||
|
@@map("external_ads")
|
||||||
|
}
|
||||||
|
|
||||||
|
model WBWarehouseCache {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
organizationId String // ID организации
|
||||||
|
cacheDate DateTime // Дата кеширования (только дата, без времени)
|
||||||
|
data Json // Кешированные данные склада WB
|
||||||
|
totalProducts Int @default(0) // Общее количество товаров
|
||||||
|
totalStocks Int @default(0) // Общее количество остатков
|
||||||
|
totalReserved Int @default(0) // Общее количество в резерве
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
organization Organization @relation("WBWarehouseCaches", fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([organizationId, cacheDate])
|
||||||
|
@@index([organizationId, cacheDate])
|
||||||
|
@@map("wb_warehouse_caches")
|
||||||
|
}
|
||||||
|
43
src/app/api/track-click/route.ts
Normal file
43
src/app/api/track-click/route.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { clickStorage } from '@/lib/click-storage'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
const { linkId, timestamp } = JSON.parse(body)
|
||||||
|
|
||||||
|
// Записываем клик через общий storage
|
||||||
|
const totalClicks = clickStorage.recordClick(linkId)
|
||||||
|
|
||||||
|
console.log(`API: Click tracked for ${linkId} at ${timestamp}. Total clicks: ${totalClicks}`)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
linkId,
|
||||||
|
timestamp,
|
||||||
|
totalClicks
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error tracking click:', error)
|
||||||
|
return NextResponse.json({ success: false, error: 'Failed to track click' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API для получения статистики кликов
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const linkId = request.nextUrl.searchParams.get('linkId')
|
||||||
|
|
||||||
|
if (linkId) {
|
||||||
|
const clicks = clickStorage.getClicks(linkId)
|
||||||
|
return NextResponse.json({ linkId, clicks })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Возвращаем всю статистику
|
||||||
|
const allStats = clickStorage.getAllClicks()
|
||||||
|
return NextResponse.json(allStats)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting click stats:', error)
|
||||||
|
return NextResponse.json({ error: 'Failed to get click stats' }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
34
src/app/track/[linkId]/route.ts
Normal file
34
src/app/track/[linkId]/route.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { clickStorage } from '@/lib/click-storage'
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { linkId: string } }
|
||||||
|
) {
|
||||||
|
const { linkId } = params
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Получаем целевую ссылку из параметров
|
||||||
|
const redirectUrl = request.nextUrl.searchParams.get('redirect')
|
||||||
|
|
||||||
|
if (!redirectUrl) {
|
||||||
|
console.error(`No redirect URL for link: ${linkId}`)
|
||||||
|
return NextResponse.redirect(new URL('/', request.url))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Декодируем URL
|
||||||
|
const decodedUrl = decodeURIComponent(redirectUrl)
|
||||||
|
|
||||||
|
// Записываем клик через общий storage
|
||||||
|
const totalClicks = clickStorage.recordClick(linkId)
|
||||||
|
|
||||||
|
console.log(`Redirect: ${linkId} -> ${decodedUrl} (click #${totalClicks})`)
|
||||||
|
|
||||||
|
// Мгновенный серверный редирект на целевую ссылку
|
||||||
|
return NextResponse.redirect(decodedUrl)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing tracking link:', error)
|
||||||
|
return NextResponse.redirect(new URL('/', request.url))
|
||||||
|
}
|
||||||
|
}
|
@ -1,50 +1,113 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { useQuery, useLazyQuery } from '@apollo/client'
|
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 { Card } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { GET_WILDBERRIES_CAMPAIGN_STATS, GET_WILDBERRIES_CAMPAIGNS_LIST } from '@/graphql/queries'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { GET_WILDBERRIES_CAMPAIGN_STATS, GET_WILDBERRIES_CAMPAIGNS_LIST, GET_EXTERNAL_ADS } from '@/graphql/queries'
|
||||||
|
import { CREATE_EXTERNAL_AD, DELETE_EXTERNAL_AD, UPDATE_EXTERNAL_AD, UPDATE_EXTERNAL_AD_CLICKS } from '@/graphql/mutations'
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Search,
|
TrendingDown,
|
||||||
Calendar,
|
|
||||||
Eye,
|
Eye,
|
||||||
MousePointer,
|
MousePointer,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Percent,
|
|
||||||
AlertCircle,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Monitor,
|
ChevronDown,
|
||||||
Smartphone,
|
Plus,
|
||||||
Globe,
|
Trash2,
|
||||||
Package,
|
ExternalLink,
|
||||||
Target,
|
Copy,
|
||||||
|
AlertCircle,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Maximize2,
|
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Settings,
|
Calendar,
|
||||||
Filter,
|
Package,
|
||||||
ArrowUpDown
|
Link,
|
||||||
|
Smartphone,
|
||||||
|
Monitor,
|
||||||
|
Globe,
|
||||||
|
Target,
|
||||||
|
ArrowUpDown,
|
||||||
|
Percent
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
|
ChartConfig,
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
ChartLegend,
|
|
||||||
ChartLegendContent,
|
|
||||||
type ChartConfig
|
|
||||||
} from '@/components/ui/chart'
|
} from '@/components/ui/chart'
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid } from 'recharts'
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, BarChart, Bar, ResponsiveContainer, ComposedChart } from 'recharts'
|
||||||
|
|
||||||
|
// Импортируем новую простую таблицу
|
||||||
|
import { SimpleAdvertisingTable } from './simple-advertising-table'
|
||||||
|
|
||||||
|
// Интерфейсы для новой структуры таблицы
|
||||||
|
interface ExternalAd {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
cost: number
|
||||||
|
clicks?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductAdvertising {
|
||||||
|
wbCampaigns: {
|
||||||
|
campaignId: number
|
||||||
|
views: number
|
||||||
|
clicks: number
|
||||||
|
cost: number
|
||||||
|
orders: number
|
||||||
|
}[]
|
||||||
|
externalAds: ExternalAd[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductData {
|
||||||
|
nmId: number
|
||||||
|
name: string
|
||||||
|
totalViews: number
|
||||||
|
totalClicks: number
|
||||||
|
totalCost: number
|
||||||
|
totalOrders: number
|
||||||
|
totalRevenue: number
|
||||||
|
advertising: ProductAdvertising
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyAdvertisingData {
|
||||||
|
date: string
|
||||||
|
totalSum: number
|
||||||
|
totalOrders: number
|
||||||
|
totalRevenue: number
|
||||||
|
products: ProductData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignStatsProps {
|
||||||
|
selectedPeriod: string
|
||||||
|
useCustomDates: boolean
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Интерфейсы для API данных
|
||||||
|
interface GeneratedLink {
|
||||||
|
id: string
|
||||||
|
adId: string
|
||||||
|
adName: string
|
||||||
|
targetUrl: string
|
||||||
|
trackingUrl: string
|
||||||
|
clicks: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
// Интерфейсы для типизации данных API
|
|
||||||
interface CampaignProduct {
|
interface CampaignProduct {
|
||||||
views: number
|
views: number
|
||||||
clicks: number
|
clicks: number
|
||||||
@ -118,14 +181,6 @@ interface CampaignStats {
|
|||||||
boosterStats: BoosterStat[]
|
boosterStats: BoosterStat[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CampaignStatsProps {
|
|
||||||
selectedPeriod: string
|
|
||||||
useCustomDates: boolean
|
|
||||||
startDate: string
|
|
||||||
endDate: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Интерфейсы для списка кампаний
|
|
||||||
interface CampaignListItem {
|
interface CampaignListItem {
|
||||||
advertId: number
|
advertId: number
|
||||||
changeTime: string
|
changeTime: string
|
||||||
@ -166,19 +221,19 @@ const CompactCampaignSelector = ({
|
|||||||
|
|
||||||
const campaigns = campaignsData?.getWildberriesCampaignsList?.data?.adverts || []
|
const campaigns = campaignsData?.getWildberriesCampaignsList?.data?.adverts || []
|
||||||
|
|
||||||
// Автоматически выбираем активные кампании при загрузке данных
|
// Автоматически выбираем все доступные кампании при загрузке данных
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (campaigns.length > 0 && selectedIds.size === 0) {
|
if (campaigns.length > 0 && selectedIds.size === 0) {
|
||||||
const activeCampaigns = campaigns
|
const allCampaigns = campaigns
|
||||||
.filter((group: CampaignGroup) => group.status === 9) // Активные кампании
|
|
||||||
.flatMap((group: CampaignGroup) => group.advert_list.map((item: CampaignListItem) => item.advertId))
|
.flatMap((group: CampaignGroup) => group.advert_list.map((item: CampaignListItem) => item.advertId))
|
||||||
.slice(0, 3) // Берем первые 3 активные кампании
|
|
||||||
|
|
||||||
if (activeCampaigns.length > 0) {
|
if (allCampaigns.length > 0) {
|
||||||
setSelectedIds(new Set(activeCampaigns))
|
setSelectedIds(new Set(allCampaigns))
|
||||||
|
// Автоматически загружаем статистику для всех кампаний
|
||||||
|
onCampaignsSelected(allCampaigns)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [campaigns])
|
}, [campaigns, onCampaignsSelected])
|
||||||
|
|
||||||
// Функции для получения названий типов и статусов
|
// Функции для получения названий типов и статусов
|
||||||
const getCampaignTypeName = (type: number) => {
|
const getCampaignTypeName = (type: number) => {
|
||||||
@ -266,7 +321,7 @@ const CompactCampaignSelector = ({
|
|||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
className="h-7 px-2 text-white hover:bg-white/10"
|
className="h-7 px-2 text-white hover:bg-white/10"
|
||||||
>
|
>
|
||||||
{isExpanded ? <Minimize2 className="h-3 w-3" /> : <Maximize2 className="h-3 w-3" />}
|
{isExpanded ? <Minimize2 className="h-3 w-3" /> : <BarChart3 className="h-3 w-3" />}
|
||||||
<span className="ml-1 text-sm">Кампании</span>
|
<span className="ml-1 text-sm">Кампании</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Badge variant="outline" className="border-white/20 text-white text-xs">
|
<Badge variant="outline" className="border-white/20 text-white text-xs">
|
||||||
@ -293,7 +348,7 @@ const CompactCampaignSelector = ({
|
|||||||
<div className="animate-spin rounded-full h-3 w-3 border-b border-white" />
|
<div className="animate-spin rounded-full h-3 w-3 border-b border-white" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Search className="h-3 w-3 mr-1" />
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
{selectedIds.size > 0 ? `Загрузить (${selectedIds.size})` : 'Выбрать'}
|
{selectedIds.size > 0 ? `Загрузить (${selectedIds.size})` : 'Выбрать'}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -400,283 +455,194 @@ const CompactCampaignSelector = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Компонент сверх-компактной таблицы кампаний
|
export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endDate }: CampaignStatsProps) {
|
||||||
const UltraCompactCampaignTable = ({
|
const { user } = useAuth()
|
||||||
campaigns,
|
|
||||||
expandedCampaigns,
|
|
||||||
onToggleExpand
|
|
||||||
}: {
|
|
||||||
campaigns: CampaignStats[],
|
|
||||||
expandedCampaigns: Set<number>,
|
|
||||||
onToggleExpand: (id: number) => void
|
|
||||||
}) => {
|
|
||||||
const [sortField, setSortField] = useState<keyof CampaignStats>('advertId')
|
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
|
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
// Состояния для раскрытия строк
|
||||||
if (value === 0) return '—'
|
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set())
|
||||||
if (value < 1000) return `${value}₽`
|
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set())
|
||||||
if (value < 1000000) return `${(value / 1000).toFixed(1)}K₽`
|
const [expandedCampaigns, setExpandedCampaigns] = useState<Set<number>>(new Set())
|
||||||
return `${(value / 1000000).toFixed(1)}M₽`
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatNumber = (value: number) => {
|
// Состояние для формы добавления внешней рекламы
|
||||||
if (value === 0) return '—'
|
const [showAddForm, setShowAddForm] = useState<string | null>(null)
|
||||||
if (value < 1000) return value.toString()
|
const [newExternalAd, setNewExternalAd] = useState({
|
||||||
if (value < 1000000) return `${(value / 1000).toFixed(1)}K`
|
name: '',
|
||||||
return `${(value / 1000000).toFixed(1)}M`
|
url: '',
|
||||||
}
|
cost: ''
|
||||||
|
|
||||||
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" />}
|
|
||||||
<span className="font-medium text-white">#{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="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.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 [selectedCampaignIds, setSelectedCampaignIds] = useState<number[]>([])
|
|
||||||
const [campaignStats, setCampaignStats] = useState<CampaignStats[]>([])
|
const [campaignStats, setCampaignStats] = useState<CampaignStats[]>([])
|
||||||
const [expandedCampaigns, setExpandedCampaigns] = useState<Set<number>>(new Set())
|
const [productPhotos, setProductPhotos] = useState<Map<number, string>>(new Map())
|
||||||
const [showChart, setShowChart] = useState(false)
|
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, {
|
const [getCampaignStats, { loading, error }] = useLazyQuery(GET_WILDBERRIES_CAMPAIGN_STATS, {
|
||||||
onCompleted: (data) => {
|
onCompleted: (data) => {
|
||||||
@ -689,11 +655,228 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Загрузка фотографий товаров (точно как на складе WB)
|
||||||
|
const loadProductPhotos = async (nmIds: number[]) => {
|
||||||
|
if (!user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')?.isActive) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')
|
||||||
|
|
||||||
|
if (!wbApiKey?.isActive) {
|
||||||
|
console.error('Advertising: API ключ Wildberries не настроен')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationData = wbApiKey.validationData as Record<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[]) => {
|
const handleCampaignsSelected = (ids: number[]) => {
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
|
|
||||||
setSelectedCampaignIds(ids)
|
|
||||||
|
|
||||||
let campaigns
|
let campaigns
|
||||||
if (useCustomDates && startDate && endDate) {
|
if (useCustomDates && startDate && endDate) {
|
||||||
campaigns = ids.map(id => ({
|
campaigns = ids.map(id => ({
|
||||||
@ -745,6 +928,162 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
|||||||
setExpandedCampaigns(newExpanded)
|
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) => {
|
const formatCurrency = (value: number) => {
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@ -762,14 +1101,52 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
|||||||
return `${value.toFixed(2)}%`
|
return `${value.toFixed(2)}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Подготовка данных для графика
|
// Подготовка данных для графика с включением внешней рекламы
|
||||||
const chartData = campaignStats.length > 0 ? campaignStats[0]?.days?.map(day => ({
|
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' }),
|
date: new Date(day.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }),
|
||||||
views: day.views,
|
views: dayViews,
|
||||||
clicks: day.clicks,
|
clicks: dayClicks + dayExternalClicks,
|
||||||
sum: day.sum,
|
wbClicks: dayClicks,
|
||||||
orders: day.orders
|
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 = {
|
const chartConfig = {
|
||||||
views: {
|
views: {
|
||||||
@ -777,13 +1154,29 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
|||||||
color: "#8b5cf6",
|
color: "#8b5cf6",
|
||||||
},
|
},
|
||||||
clicks: {
|
clicks: {
|
||||||
label: "Клики",
|
label: "Клики (общие)",
|
||||||
color: "#06b6d4",
|
color: "#06b6d4",
|
||||||
},
|
},
|
||||||
sum: {
|
wbClicks: {
|
||||||
label: "Затраты (₽)",
|
label: "Клики ВБ",
|
||||||
|
color: "#06b6d4",
|
||||||
|
},
|
||||||
|
externalClicks: {
|
||||||
|
label: "Клики внешние",
|
||||||
color: "#f59e0b",
|
color: "#f59e0b",
|
||||||
},
|
},
|
||||||
|
sum: {
|
||||||
|
label: "Затраты (общие) ₽",
|
||||||
|
color: "#f59e0b",
|
||||||
|
},
|
||||||
|
wbSum: {
|
||||||
|
label: "Затраты ВБ ₽",
|
||||||
|
color: "#3b82f6",
|
||||||
|
},
|
||||||
|
externalSum: {
|
||||||
|
label: "Затраты внешние ₽",
|
||||||
|
color: "#ec4899",
|
||||||
|
},
|
||||||
orders: {
|
orders: {
|
||||||
label: "Заказы",
|
label: "Заказы",
|
||||||
color: "#10b981",
|
color: "#10b981",
|
||||||
@ -792,13 +1185,6 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col space-y-3 overflow-hidden">
|
<div className="h-full flex flex-col space-y-3 overflow-hidden">
|
||||||
{/* Компактный селектор кампаний */}
|
|
||||||
<CompactCampaignSelector
|
|
||||||
onCampaignsSelected={handleCampaignsSelected}
|
|
||||||
selectedCampaigns={selectedCampaignIds}
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Ошибки */}
|
{/* Ошибки */}
|
||||||
{error && (
|
{error && (
|
||||||
<Alert className="bg-red-500/10 border-red-500/30 text-red-400 py-2">
|
<Alert className="bg-red-500/10 border-red-500/30 text-red-400 py-2">
|
||||||
@ -809,148 +1195,51 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
|||||||
</Alert>
|
</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">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
{(loading || campaignsLoading || externalAdsLoading) ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<Skeleton key={i} className="h-12 bg-white/10" />
|
<Skeleton key={i} className="h-16 bg-white/10" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : campaignStats.length > 0 ? (
|
) : campaignStats.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Компактная общая статистика */}
|
{/* График расходов */}
|
||||||
|
{spendingChartData.length > 0 && (
|
||||||
<Card className="glass-card p-3">
|
<Card className="glass-card p-3">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
<TrendingUp className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
Сводка ({campaignStats.length} кампаний)
|
Расходы на рекламу
|
||||||
</h3>
|
</h3>
|
||||||
<Button
|
<div className="text-xs text-white/60">
|
||||||
variant="ghost"
|
<div>Общие: {formatCurrency(spendingChartData.reduce((sum, day) => sum + day.sum, 0))}</div>
|
||||||
size="sm"
|
<div className="flex gap-3 mt-1">
|
||||||
onClick={() => setShowChart(!showChart)}
|
<span className="flex items-center gap-1">
|
||||||
className="h-6 px-2 text-xs text-white/60 hover:bg-white/10"
|
<div className="w-2 h-2 bg-blue-500 rounded"></div>
|
||||||
>
|
ВБ: {formatCurrency(spendingChartData.reduce((sum, day) => sum + day.wbSum, 0))}
|
||||||
{showChart ? <Minimize2 className="h-3 w-3" /> : <BarChart3 className="h-3 w-3" />}
|
</span>
|
||||||
{showChart ? 'Скрыть' : 'График'}
|
<span className="flex items-center gap-1">
|
||||||
</Button>
|
<div className="w-2 h-2 bg-pink-500 rounded"></div>
|
||||||
</div>
|
Внешняя: {formatCurrency(spendingChartData.reduce((sum, day) => sum + day.externalSum, 0))}
|
||||||
|
</span>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Компактный график */}
|
<div className="h-48">
|
||||||
{showChart && chartData.length > 0 && (
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<div className="mt-3">
|
<BarChart data={spendingChartData}>
|
||||||
<div className="h-32">
|
|
||||||
<ChartContainer config={chartConfig}>
|
|
||||||
<LineChart data={chartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
@ -960,52 +1249,70 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
|||||||
<YAxis
|
<YAxis
|
||||||
tick={{ fill: 'rgba(255,255,255,0.6)', fontSize: 10 }}
|
tick={{ fill: 'rgba(255,255,255,0.6)', fontSize: 10 }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
|
tickFormatter={(value) => `${(value / 1000).toFixed(0)}K₽`}
|
||||||
/>
|
/>
|
||||||
<ChartTooltip content={<ChartTooltipContent />} />
|
<ChartTooltip
|
||||||
<Line
|
content={({ active, payload, label }) => {
|
||||||
type="monotone"
|
if (active && payload && payload.length) {
|
||||||
dataKey="views"
|
return (
|
||||||
stroke="#8b5cf6"
|
<div className="bg-black/80 p-2 rounded border border-white/20">
|
||||||
strokeWidth={1}
|
<p className="text-white text-xs">{`Дата: ${label}`}</p>
|
||||||
dot={{ fill: '#8b5cf6', strokeWidth: 1, r: 2 }}
|
{payload.map((entry, index) => (
|
||||||
/>
|
<p key={index} className="text-xs" style={{ color: entry.color }}>
|
||||||
<Line
|
{`${entry.name}: ${formatCurrency(entry.value as number)}`}
|
||||||
type="monotone"
|
</p>
|
||||||
dataKey="clicks"
|
))}
|
||||||
stroke="#06b6d4"
|
<p className="text-white text-xs border-t border-white/20 pt-1 mt-1">
|
||||||
strokeWidth={1}
|
{`Общие расходы: ${formatCurrency(
|
||||||
dot={{ fill: '#06b6d4', strokeWidth: 1, r: 2 }}
|
payload.reduce((sum, entry) => sum + (entry.value as number), 0)
|
||||||
/>
|
)}`}
|
||||||
<Line
|
</p>
|
||||||
type="monotone"
|
|
||||||
dataKey="orders"
|
|
||||||
stroke="#10b981"
|
|
||||||
strokeWidth={1}
|
|
||||||
dot={{ fill: '#10b981', strokeWidth: 1, r: 2 }}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
</ChartContainer>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Сверх-компактная таблица кампаний */}
|
{/* Новая таблица рекламы */}
|
||||||
<Card className="glass-card p-3">
|
<Card className="glass-card p-3">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
Детальная статистика
|
Статистика рекламы
|
||||||
</h3>
|
</h3>
|
||||||
<div className="text-xs text-white/60">
|
<div className="text-xs text-white/60">
|
||||||
{campaignStats.reduce((sum, stat) => sum + stat.days.length, 0)} дней данных
|
{dailyData.length} дней данных
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UltraCompactCampaignTable
|
<SimpleAdvertisingTable
|
||||||
campaigns={campaignStats}
|
dailyData={dailyData}
|
||||||
expandedCampaigns={expandedCampaigns}
|
productPhotos={productPhotos}
|
||||||
onToggleExpand={toggleCampaignExpanded}
|
generatedLinksData={generatedLinksData}
|
||||||
|
onAddExternalAd={handleAddExternalAd}
|
||||||
|
onRemoveExternalAd={handleRemoveExternalAd}
|
||||||
|
onUpdateExternalAd={handleUpdateExternalAd}
|
||||||
|
onGenerateLink={handleGenerateLink}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@ -1015,23 +1322,10 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<TrendingUp className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
<TrendingUp className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
||||||
<h3 className="text-lg font-semibold text-white mb-2">Статистика рекламных кампаний</h3>
|
<h3 className="text-lg font-semibold text-white mb-2">Статистика рекламных кампаний</h3>
|
||||||
<p className="text-white/60 mb-4">Выберите кампании выше и нажмите “Загрузить” для получения статистики</p>
|
<p className="text-white/60 mb-4">Загружаем статистику по всем доступным кампаниям...</p>
|
||||||
<p className="text-white/40 text-sm mb-4">
|
<p className="text-white/40 text-sm">
|
||||||
Поддерживается API Wildberries /adv/v2/fullstats
|
Поддерживается API Wildberries /adv/v2/fullstats
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Инструкция для пользователя */}
|
|
||||||
<div className="bg-white/5 rounded-lg p-4 border border-white/10">
|
|
||||||
<h4 className="text-white font-medium mb-2 flex items-center gap-2">
|
|
||||||
<Target className="h-4 w-4" />
|
|
||||||
Как начать работу:
|
|
||||||
</h4>
|
|
||||||
<ol className="text-white/60 text-sm space-y-1 list-decimal list-inside">
|
|
||||||
<li>Разверните селектор кампаний выше (нажмите кнопку с иконкой)</li>
|
|
||||||
<li>Выберите нужные кампании из списка или введите ID вручную</li>
|
|
||||||
<li>Нажмите кнопку “Загрузить” для получения статистики</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
2104
src/components/seller-statistics/advertising-tab.tsx.backup
Normal file
2104
src/components/seller-statistics/advertising-tab.tsx.backup
Normal file
@ -0,0 +1,2104 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useQuery, useLazyQuery, useMutation } from '@apollo/client'
|
||||||
|
import { WildberriesService } from '@/services/wildberries-service'
|
||||||
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { GET_WILDBERRIES_CAMPAIGN_STATS, GET_WILDBERRIES_CAMPAIGNS_LIST, GET_EXTERNAL_ADS } from '@/graphql/queries'
|
||||||
|
import { CREATE_EXTERNAL_AD, DELETE_EXTERNAL_AD, UPDATE_EXTERNAL_AD, UPDATE_EXTERNAL_AD_CLICKS } from '@/graphql/mutations'
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Eye,
|
||||||
|
MousePointer,
|
||||||
|
ShoppingCart,
|
||||||
|
DollarSign,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
|
AlertCircle,
|
||||||
|
BarChart3,
|
||||||
|
Minimize2,
|
||||||
|
Calendar,
|
||||||
|
Package,
|
||||||
|
Link,
|
||||||
|
Smartphone,
|
||||||
|
Monitor,
|
||||||
|
Globe,
|
||||||
|
Target,
|
||||||
|
ArrowUpDown,
|
||||||
|
Percent
|
||||||
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
ChartConfig,
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
} from '@/components/ui/chart'
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, BarChart, Bar, ResponsiveContainer, ComposedChart } from 'recharts'
|
||||||
|
|
||||||
|
// Интерфейсы для новой структуры таблицы
|
||||||
|
interface ExternalAd {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
cost: number
|
||||||
|
clicks?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductAdvertising {
|
||||||
|
wbCampaigns: {
|
||||||
|
campaignId: number
|
||||||
|
views: number
|
||||||
|
clicks: number
|
||||||
|
cost: number
|
||||||
|
orders: number
|
||||||
|
}[]
|
||||||
|
externalAds: ExternalAd[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductData {
|
||||||
|
nmId: number
|
||||||
|
name: string
|
||||||
|
totalViews: number
|
||||||
|
totalClicks: number
|
||||||
|
totalCost: number
|
||||||
|
totalOrders: number
|
||||||
|
totalRevenue: number
|
||||||
|
advertising: ProductAdvertising
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyAdvertisingData {
|
||||||
|
date: string
|
||||||
|
totalSum: number
|
||||||
|
totalOrders: number
|
||||||
|
totalRevenue: number
|
||||||
|
products: ProductData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignStatsProps {
|
||||||
|
selectedPeriod: string
|
||||||
|
useCustomDates: boolean
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Интерфейсы для API данных
|
||||||
|
interface GeneratedLink {
|
||||||
|
id: string
|
||||||
|
adId: string
|
||||||
|
adName: string
|
||||||
|
targetUrl: string
|
||||||
|
trackingUrl: string
|
||||||
|
clicks: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignProduct {
|
||||||
|
views: number
|
||||||
|
clicks: number
|
||||||
|
ctr: number
|
||||||
|
cpc: number
|
||||||
|
sum: number
|
||||||
|
atbs: number
|
||||||
|
orders: number
|
||||||
|
cr: number
|
||||||
|
shks: number
|
||||||
|
sum_price: number
|
||||||
|
name: string
|
||||||
|
nmId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignApp {
|
||||||
|
views: number
|
||||||
|
clicks: number
|
||||||
|
ctr: number
|
||||||
|
cpc: number
|
||||||
|
sum: number
|
||||||
|
atbs: number
|
||||||
|
orders: number
|
||||||
|
cr: number
|
||||||
|
shks: number
|
||||||
|
sum_price: number
|
||||||
|
appType: number
|
||||||
|
nm: CampaignProduct[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignDay {
|
||||||
|
date: string
|
||||||
|
views: number
|
||||||
|
clicks: number
|
||||||
|
ctr: number
|
||||||
|
cpc: number
|
||||||
|
sum: number
|
||||||
|
atbs: number
|
||||||
|
orders: number
|
||||||
|
cr: number
|
||||||
|
shks: number
|
||||||
|
sum_price: number
|
||||||
|
apps: CampaignApp[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoosterStat {
|
||||||
|
date: string
|
||||||
|
nm: number
|
||||||
|
avg_position: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignInterval {
|
||||||
|
begin: string
|
||||||
|
end: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignStats {
|
||||||
|
advertId: number
|
||||||
|
views: number
|
||||||
|
clicks: number
|
||||||
|
ctr: number
|
||||||
|
cpc: number
|
||||||
|
sum: number
|
||||||
|
atbs: number
|
||||||
|
orders: number
|
||||||
|
cr: number
|
||||||
|
shks: number
|
||||||
|
sum_price: number
|
||||||
|
interval?: CampaignInterval
|
||||||
|
days: CampaignDay[]
|
||||||
|
boosterStats: BoosterStat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignListItem {
|
||||||
|
advertId: number
|
||||||
|
changeTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignGroup {
|
||||||
|
type: number
|
||||||
|
status: number
|
||||||
|
count: number
|
||||||
|
advert_list: CampaignListItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignsListData {
|
||||||
|
adverts: CampaignGroup[]
|
||||||
|
all: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компонент компактного селектора кампаний
|
||||||
|
const CompactCampaignSelector = ({
|
||||||
|
onCampaignsSelected,
|
||||||
|
selectedCampaigns,
|
||||||
|
loading: statsLoading
|
||||||
|
}: {
|
||||||
|
onCampaignsSelected: (ids: number[]) => void,
|
||||||
|
selectedCampaigns: number[],
|
||||||
|
loading: boolean
|
||||||
|
}) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(true) // Автоматически разворачиваем для удобства
|
||||||
|
const [showManualInput, setShowManualInput] = useState(false)
|
||||||
|
const [manualIds, setManualIds] = useState('')
|
||||||
|
const [selectedIds, setSelectedIds] = useState<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>
|
||||||
|
)
|
||||||
|
}
|
386
src/components/seller-statistics/simple-advertising-table.tsx
Normal file
386
src/components/seller-statistics/simple-advertising-table.tsx
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Link,
|
||||||
|
Package,
|
||||||
|
Eye,
|
||||||
|
MousePointer,
|
||||||
|
ShoppingCart,
|
||||||
|
DollarSign
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface ExternalAd {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
cost: number
|
||||||
|
clicks?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductAdvertising {
|
||||||
|
wbCampaigns: {
|
||||||
|
campaignId: number
|
||||||
|
views: number
|
||||||
|
clicks: number
|
||||||
|
cost: number
|
||||||
|
orders: number
|
||||||
|
}[]
|
||||||
|
externalAds: ExternalAd[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductData {
|
||||||
|
nmId: number
|
||||||
|
name: string
|
||||||
|
totalViews: number
|
||||||
|
totalClicks: number
|
||||||
|
totalCost: number
|
||||||
|
totalOrders: number
|
||||||
|
totalRevenue: number
|
||||||
|
advertising: ProductAdvertising
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyAdvertisingData {
|
||||||
|
date: string
|
||||||
|
totalSum: number
|
||||||
|
totalOrders: number
|
||||||
|
totalRevenue: number
|
||||||
|
products: ProductData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneratedLink {
|
||||||
|
id: string
|
||||||
|
adId: string
|
||||||
|
adName: string
|
||||||
|
targetUrl: string
|
||||||
|
trackingUrl: string
|
||||||
|
clicks: number
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SimpleAdvertisingTableProps {
|
||||||
|
dailyData: DailyAdvertisingData[]
|
||||||
|
productPhotos?: Map<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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleAdvertisingTable({
|
||||||
|
dailyData,
|
||||||
|
productPhotos = new Map(),
|
||||||
|
generatedLinksData = {},
|
||||||
|
onAddExternalAd,
|
||||||
|
onRemoveExternalAd,
|
||||||
|
onUpdateExternalAd,
|
||||||
|
onGenerateLink
|
||||||
|
}: SimpleAdvertisingTableProps) {
|
||||||
|
const [showWbAds, setShowWbAds] = useState(true)
|
||||||
|
const [showExternalAds, setShowExternalAds] = useState(true)
|
||||||
|
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set())
|
||||||
|
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set())
|
||||||
|
const [showAddForm, setShowAddForm] = useState<string | null>(null)
|
||||||
|
const [newExternalAd, setNewExternalAd] = useState({
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
cost: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
if (value === 0) return '—'
|
||||||
|
if (value < 100) {
|
||||||
|
return `${value.toFixed(2)}₽`
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatNumber = (value: number) => {
|
||||||
|
return value > 0 ? new Intl.NumberFormat('ru-RU').format(value) : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleDay = (date: string) => {
|
||||||
|
const newExpanded = new Set(expandedDays)
|
||||||
|
if (newExpanded.has(date)) {
|
||||||
|
newExpanded.delete(date)
|
||||||
|
} else {
|
||||||
|
newExpanded.add(date)
|
||||||
|
}
|
||||||
|
setExpandedDays(newExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleProduct = (date: string, nmId: number) => {
|
||||||
|
const key = `${date}-${nmId}`
|
||||||
|
const newExpanded = new Set(expandedProducts)
|
||||||
|
if (newExpanded.has(key)) {
|
||||||
|
newExpanded.delete(key)
|
||||||
|
} else {
|
||||||
|
newExpanded.add(key)
|
||||||
|
}
|
||||||
|
setExpandedProducts(newExpanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddExternalAdLocal = (date: string, nmId: number) => {
|
||||||
|
if (newExternalAd.name && newExternalAd.url && newExternalAd.cost && onAddExternalAd) {
|
||||||
|
onAddExternalAd(date, {
|
||||||
|
name: newExternalAd.name,
|
||||||
|
url: newExternalAd.url,
|
||||||
|
cost: parseFloat(newExternalAd.cost) || 0
|
||||||
|
})
|
||||||
|
setNewExternalAd({ name: '', url: '', cost: '' })
|
||||||
|
setShowAddForm(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Заголовок таблицы - как в Figma */}
|
||||||
|
<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
|
||||||
|
const isExpanded = expandedDays.has(day.date)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={day.date}>
|
||||||
|
{/* Основная строка дня - как в Figma */}
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-5 gap-4 p-3 border-b border-white/5 hover:bg-white/5 text-white/80 text-sm cursor-pointer"
|
||||||
|
onClick={() => toggleDay(day.date)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
<span className="font-medium">{day.date}</span>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* Раскрывающееся содержимое с товарами */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="bg-white/2 border-b border-white/5">
|
||||||
|
{day.products.map((product) => {
|
||||||
|
const productKey = `${day.date}-${product.nmId}`
|
||||||
|
const isProductExpanded = expandedProducts.has(productKey)
|
||||||
|
const productExternalCost = product.advertising.externalAds.reduce((sum, ad) => sum + ad.cost, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={productKey} className="ml-6">
|
||||||
|
{/* Строка товара */}
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-5 gap-4 p-3 border-b border-white/5 hover:bg-white/5 text-white/70 text-sm cursor-pointer"
|
||||||
|
onClick={() => toggleProduct(day.date, product.nmId)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isProductExpanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{productPhotos.has(product.nmId) && (
|
||||||
|
<img
|
||||||
|
src={productPhotos.get(product.nmId)}
|
||||||
|
alt={product.name}
|
||||||
|
className="w-6 h-6 rounded object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="text-white/90 text-xs font-medium truncate max-w-32">{product.name}</div>
|
||||||
|
<div className="text-white/60 text-xs">#{product.nmId}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">{formatCurrency(product.totalCost + productExternalCost)}</div>
|
||||||
|
<div className="text-center">{product.totalOrders}</div>
|
||||||
|
<div className="text-center">
|
||||||
|
{showWbAds ? formatCurrency(product.totalCost) : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
{showExternalAds ? formatCurrency(productExternalCost) : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Раскрывающееся содержимое товара с кампаниями */}
|
||||||
|
{isProductExpanded && (
|
||||||
|
<div className="ml-6 bg-white/1 border-b border-white/5">
|
||||||
|
{/* ВБ кампании */}
|
||||||
|
{showWbAds && product.advertising.wbCampaigns.map((campaign) => (
|
||||||
|
<div key={campaign.campaignId} className="grid grid-cols-5 gap-4 p-2 text-white/60 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Eye className="h-3 w-3 text-blue-400" />
|
||||||
|
<span>ВБ #{campaign.campaignId}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">{formatCurrency(campaign.cost)}</div>
|
||||||
|
<div className="text-center">{campaign.orders}</div>
|
||||||
|
<div className="text-center">{formatCurrency(campaign.cost)}</div>
|
||||||
|
<div className="text-center">—</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Внешняя реклама */}
|
||||||
|
{showExternalAds && product.advertising.externalAds.map((ad) => (
|
||||||
|
<div key={ad.id} className="grid grid-cols-5 gap-4 p-2 text-white/60 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<MousePointer className="h-3 w-3 text-purple-400" />
|
||||||
|
<span className="truncate max-w-24">{ad.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">{formatCurrency(ad.cost)}</div>
|
||||||
|
<div className="text-center">—</div>
|
||||||
|
<div className="text-center">—</div>
|
||||||
|
<div className="text-center flex items-center justify-center gap-1">
|
||||||
|
{formatCurrency(ad.cost)}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{onGenerateLink && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onGenerateLink(day.date, ad.id, ad.name, ad.url)
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 p-0 text-blue-400 hover:bg-blue-500/20"
|
||||||
|
>
|
||||||
|
<Link className="h-2 w-2" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onRemoveExternalAd && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onRemoveExternalAd(day.date, ad.id)
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 p-0 text-red-400 hover:bg-red-500/20"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-2 w-2" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Кнопка добавления внешней рекламы */}
|
||||||
|
{onAddExternalAd && (
|
||||||
|
<div className="grid grid-cols-5 gap-4 p-2">
|
||||||
|
<div className="col-span-5">
|
||||||
|
{showAddForm === productKey ? (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input
|
||||||
|
placeholder="Название"
|
||||||
|
value={newExternalAd.name}
|
||||||
|
onChange={(e) => setNewExternalAd(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
className="h-6 bg-white/10 border-white/20 text-white text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="URL"
|
||||||
|
value={newExternalAd.url}
|
||||||
|
onChange={(e) => setNewExternalAd(prev => ({ ...prev, url: e.target.value }))}
|
||||||
|
className="h-6 bg-white/10 border-white/20 text-white text-xs"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Стоимость"
|
||||||
|
type="number"
|
||||||
|
value={newExternalAd.cost}
|
||||||
|
onChange={(e) => setNewExternalAd(prev => ({ ...prev, cost: e.target.value }))}
|
||||||
|
className="h-6 w-20 bg-white/10 border-white/20 text-white text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddExternalAdLocal(day.date, product.nmId)}
|
||||||
|
className="h-6 bg-green-600 hover:bg-green-700 text-white text-xs px-2"
|
||||||
|
>
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAddForm(null)}
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 text-white/60 hover:bg-white/10 text-xs px-2"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAddForm(productKey)}
|
||||||
|
className="h-6 px-2 bg-purple-600 hover:bg-purple-700 text-white text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="h-2 w-2 mr-1" />
|
||||||
|
Добавить внешнюю рекламу
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
@ -10,12 +10,351 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|||||||
import { WildberriesWarehouseTab } from './wildberries-warehouse-tab'
|
import { WildberriesWarehouseTab } from './wildberries-warehouse-tab'
|
||||||
import { MyWarehouseTab } from './my-warehouse-tab'
|
import { MyWarehouseTab } from './my-warehouse-tab'
|
||||||
import { FulfillmentWarehouseTab } from './fulfillment-warehouse-tab'
|
import { FulfillmentWarehouseTab } from './fulfillment-warehouse-tab'
|
||||||
|
import { WildberriesService } from '@/services/wildberries-service'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useQuery, useMutation } from '@apollo/client'
|
||||||
|
import { GET_WB_WAREHOUSE_DATA } from '@/graphql/queries'
|
||||||
|
import { SAVE_WB_WAREHOUSE_CACHE } from '@/graphql/mutations'
|
||||||
|
|
||||||
|
interface WBStock {
|
||||||
|
nmId: number
|
||||||
|
vendorCode: string
|
||||||
|
title: string
|
||||||
|
brand: string
|
||||||
|
price: number
|
||||||
|
stocks: Array<{
|
||||||
|
warehouseId: number
|
||||||
|
warehouseName: string
|
||||||
|
quantity: number
|
||||||
|
quantityFull: number
|
||||||
|
inWayToClient: number
|
||||||
|
inWayFromClient: number
|
||||||
|
}>
|
||||||
|
totalQuantity: number
|
||||||
|
totalReserved: number
|
||||||
|
photos: any[]
|
||||||
|
mediaFiles: any[]
|
||||||
|
characteristics: any[]
|
||||||
|
subjectName: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WBWarehouse {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
cargoType: number
|
||||||
|
deliveryType: number
|
||||||
|
}
|
||||||
|
|
||||||
export function WBWarehouseDashboard() {
|
export function WBWarehouseDashboard() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { isCollapsed, getSidebarMargin } = useSidebar()
|
const { isCollapsed, getSidebarMargin } = useSidebar()
|
||||||
const [activeTab, setActiveTab] = useState('fulfillment')
|
const [activeTab, setActiveTab] = useState('fulfillment')
|
||||||
|
|
||||||
|
// Состояние данных WB Warehouse
|
||||||
|
const [stocks, setStocks] = useState<WBStock[]>([])
|
||||||
|
const [warehouses, setWarehouses] = useState<WBWarehouse[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [initialized, setInitialized] = useState(false)
|
||||||
|
|
||||||
|
// Статистика
|
||||||
|
const [totalProducts, setTotalProducts] = useState(0)
|
||||||
|
const [totalStocks, setTotalStocks] = useState(0)
|
||||||
|
const [totalReserved, setTotalReserved] = useState(0)
|
||||||
|
const [totalFromClient, setTotalFromClient] = useState(0)
|
||||||
|
const [activeWarehouses, setActiveWarehouses] = useState(0)
|
||||||
|
|
||||||
|
// Analytics data
|
||||||
|
const [analyticsData, setAnalyticsData] = useState<any[]>([])
|
||||||
|
|
||||||
|
// Проверяем настройку API ключа
|
||||||
|
const hasWBApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')?.isActive
|
||||||
|
|
||||||
|
// GraphQL хуки для работы с кешем
|
||||||
|
const { data: cacheData, loading: cacheLoading, refetch: refetchCache } = useQuery(GET_WB_WAREHOUSE_DATA, {
|
||||||
|
skip: !hasWBApiKey,
|
||||||
|
fetchPolicy: 'cache-and-network',
|
||||||
|
})
|
||||||
|
|
||||||
|
const [saveCache] = useMutation(SAVE_WB_WAREHOUSE_CACHE)
|
||||||
|
|
||||||
|
// Комбинирование карточек с индивидуальными данными аналитики
|
||||||
|
const combineCardsWithIndividualAnalytics = (cards: any[], analyticsResults: any[]): WBStock[] => {
|
||||||
|
const stocksMap = new Map<number, WBStock>()
|
||||||
|
|
||||||
|
// Создаем карту аналитических данных для быстрого поиска
|
||||||
|
const analyticsMap = new Map() // Map nmId to its analytics data
|
||||||
|
analyticsResults.forEach(result => {
|
||||||
|
analyticsMap.set(result.nmId, result.data)
|
||||||
|
})
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
const stock: WBStock = {
|
||||||
|
nmId: card.nmID,
|
||||||
|
vendorCode: String(card.vendorCode || card.supplierVendorCode || ''),
|
||||||
|
title: String(card.title || card.object || `Товар ${card.nmID}`),
|
||||||
|
brand: String(card.brand || ''),
|
||||||
|
price: 0,
|
||||||
|
stocks: [],
|
||||||
|
totalQuantity: 0,
|
||||||
|
totalReserved: 0,
|
||||||
|
photos: Array.isArray(card.photos) ? card.photos : [],
|
||||||
|
mediaFiles: Array.isArray(card.mediaFiles) ? card.mediaFiles : [],
|
||||||
|
characteristics: Array.isArray(card.characteristics) ? card.characteristics : [],
|
||||||
|
subjectName: String(card.subjectName || ''),
|
||||||
|
description: String(card.description || ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем аналитические данные для данного nmId
|
||||||
|
const analytics = analyticsMap.get(card.nmID)
|
||||||
|
if (analytics && Array.isArray(analytics)) {
|
||||||
|
analytics.forEach((item: any) => {
|
||||||
|
if (item.stocks && Array.isArray(item.stocks)) {
|
||||||
|
item.stocks.forEach((stockItem: any) => {
|
||||||
|
stock.stocks.push({
|
||||||
|
warehouseId: stockItem.warehouseId || 0,
|
||||||
|
warehouseName: String(stockItem.warehouseName || 'Неизвестный склад'),
|
||||||
|
quantity: Number(stockItem.quantity) || 0,
|
||||||
|
quantityFull: Number(stockItem.quantityFull) || 0,
|
||||||
|
inWayToClient: Number(stockItem.inWayToClient) || 0,
|
||||||
|
inWayFromClient: Number(stockItem.inWayFromClient) || 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсчитываем общие показатели
|
||||||
|
stock.totalQuantity = stock.stocks.reduce((sum, s) => sum + s.quantity, 0)
|
||||||
|
stock.totalReserved = stock.stocks.reduce((sum, s) => sum + s.inWayToClient, 0)
|
||||||
|
|
||||||
|
stocksMap.set(card.nmID, stock)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(stocksMap.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлечение складов из данных о товарах
|
||||||
|
const extractWarehousesFromStocks = (stocksData: WBStock[]): WBWarehouse[] => {
|
||||||
|
const warehousesMap = new Map<number, WBWarehouse>()
|
||||||
|
|
||||||
|
stocksData.forEach(item => {
|
||||||
|
item.stocks.forEach(stock => {
|
||||||
|
if (!warehousesMap.has(stock.warehouseId)) {
|
||||||
|
warehousesMap.set(stock.warehouseId, {
|
||||||
|
id: stock.warehouseId,
|
||||||
|
name: stock.warehouseName,
|
||||||
|
cargoType: 0,
|
||||||
|
deliveryType: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(warehousesMap.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление статистики
|
||||||
|
const updateStatistics = (stocksData: WBStock[], warehousesData: WBWarehouse[]) => {
|
||||||
|
setTotalProducts(stocksData.length)
|
||||||
|
|
||||||
|
const totalStocksCount = stocksData.reduce((sum, item) => sum + item.totalQuantity, 0)
|
||||||
|
setTotalStocks(totalStocksCount)
|
||||||
|
|
||||||
|
const totalReservedCount = stocksData.reduce((sum, item) => sum + item.totalReserved, 0)
|
||||||
|
setTotalReserved(totalReservedCount)
|
||||||
|
|
||||||
|
const totalFromClientCount = stocksData.reduce((sum, item) =>
|
||||||
|
sum + item.stocks.reduce((stockSum, stock) => stockSum + stock.inWayFromClient, 0), 0
|
||||||
|
)
|
||||||
|
setTotalFromClient(totalFromClientCount)
|
||||||
|
|
||||||
|
const warehousesWithStock = new Set(
|
||||||
|
stocksData.flatMap(item => item.stocks.map(s => s.warehouseId))
|
||||||
|
)
|
||||||
|
setActiveWarehouses(warehousesWithStock.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка данных из кеша
|
||||||
|
const loadWarehouseDataFromCache = (cacheData: any) => {
|
||||||
|
try {
|
||||||
|
const parsedData = typeof cacheData.data === 'string' ? JSON.parse(cacheData.data) : cacheData.data
|
||||||
|
|
||||||
|
const cachedStocks = parsedData.stocks || []
|
||||||
|
const cachedWarehouses = parsedData.warehouses || []
|
||||||
|
const cachedAnalytics = parsedData.analyticsData || []
|
||||||
|
|
||||||
|
setStocks(cachedStocks)
|
||||||
|
setWarehouses(cachedWarehouses)
|
||||||
|
setAnalyticsData(cachedAnalytics)
|
||||||
|
|
||||||
|
// Обновляем статистику из кеша
|
||||||
|
setTotalProducts(cacheData.totalProducts)
|
||||||
|
setTotalStocks(cacheData.totalStocks)
|
||||||
|
setTotalReserved(cacheData.totalReserved)
|
||||||
|
|
||||||
|
const totalFromClientCount = (cachedStocks || []).reduce((sum: number, item: WBStock) =>
|
||||||
|
sum + item.stocks.reduce((stockSum, stock) => stockSum + stock.inWayFromClient, 0), 0
|
||||||
|
)
|
||||||
|
setTotalFromClient(totalFromClientCount)
|
||||||
|
|
||||||
|
const warehousesWithStock = new Set(
|
||||||
|
(cachedStocks || []).flatMap((item: WBStock) => item.stocks.map(s => s.warehouseId))
|
||||||
|
)
|
||||||
|
setActiveWarehouses(warehousesWithStock.size)
|
||||||
|
|
||||||
|
console.log('WB Warehouse: Data loaded from cache:', cachedStocks?.length || 0, 'items')
|
||||||
|
toast.success(`Загружено из кеша: ${cachedStocks?.length || 0} товаров`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WB Warehouse: Error parsing cache data:', error)
|
||||||
|
toast.error('Ошибка загрузки данных из кеша')
|
||||||
|
// Если кеш поврежден, загружаем из API
|
||||||
|
loadWarehouseDataFromAPI()
|
||||||
|
} finally {
|
||||||
|
setInitialized(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка данных из API и сохранение в кеш
|
||||||
|
const loadWarehouseDataFromAPI = async () => {
|
||||||
|
if (!hasWBApiKey) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')
|
||||||
|
|
||||||
|
if (!wbApiKey?.isActive) {
|
||||||
|
toast.error('API ключ Wildberries не настроен')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const validationData = wbApiKey.validationData as Record<string, string>
|
||||||
|
const apiToken = validationData?.token ||
|
||||||
|
validationData?.apiKey ||
|
||||||
|
validationData?.key ||
|
||||||
|
(wbApiKey as { apiKey?: string }).apiKey
|
||||||
|
|
||||||
|
if (!apiToken) {
|
||||||
|
toast.error('Токен API не найден')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const wbService = new WildberriesService(apiToken)
|
||||||
|
|
||||||
|
// 1. Получаем карточки товаров
|
||||||
|
const cards = await WildberriesService.getAllCards(apiToken).catch(() => [])
|
||||||
|
console.log('WB Warehouse: Loaded cards:', cards.length)
|
||||||
|
|
||||||
|
if (cards.length === 0) {
|
||||||
|
toast.error('Нет карточек товаров в WB')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nmIds = cards.map(card => card.nmID).filter(id => id > 0)
|
||||||
|
console.log('WB Warehouse: NM IDs to process:', nmIds.length)
|
||||||
|
|
||||||
|
// 2. Получаем аналитику для каждого товара индивидуально
|
||||||
|
const analyticsResults = []
|
||||||
|
for (const nmId of nmIds) {
|
||||||
|
try {
|
||||||
|
console.log(`WB Warehouse: Fetching analytics for nmId ${nmId}`)
|
||||||
|
const result = await wbService.getStocksReportByOffices({
|
||||||
|
nmIds: [nmId],
|
||||||
|
stockType: ''
|
||||||
|
})
|
||||||
|
analyticsResults.push({ nmId, data: result })
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`WB Warehouse: Error fetching analytics for nmId ${nmId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('WB Warehouse: Analytics results:', analyticsResults.length)
|
||||||
|
|
||||||
|
// 3. Комбинируем данные
|
||||||
|
const combinedStocks = combineCardsWithIndividualAnalytics(cards, analyticsResults)
|
||||||
|
console.log('WB Warehouse: Combined stocks:', combinedStocks.length)
|
||||||
|
|
||||||
|
// 4. Извлекаем склады и обновляем статистику
|
||||||
|
const extractedWarehouses = extractWarehousesFromStocks(combinedStocks)
|
||||||
|
|
||||||
|
// 5. Подготавливаем статистику
|
||||||
|
const stats = {
|
||||||
|
totalProducts: combinedStocks.length,
|
||||||
|
totalStocks: combinedStocks.reduce((sum, item) => sum + item.totalQuantity, 0),
|
||||||
|
totalReserved: combinedStocks.reduce((sum, item) => sum + item.totalReserved, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Сохраняем в кеш
|
||||||
|
try {
|
||||||
|
await saveCache({
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
data: JSON.stringify({
|
||||||
|
stocks: combinedStocks,
|
||||||
|
warehouses: extractedWarehouses,
|
||||||
|
analyticsData: analyticsData,
|
||||||
|
}),
|
||||||
|
totalProducts: stats.totalProducts,
|
||||||
|
totalStocks: stats.totalStocks,
|
||||||
|
totalReserved: stats.totalReserved,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log('WB Warehouse: Data saved to cache')
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.error('WB Warehouse: Error saving to cache:', cacheError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Обновляем состояние
|
||||||
|
setStocks(combinedStocks)
|
||||||
|
setWarehouses(extractedWarehouses)
|
||||||
|
updateStatistics(combinedStocks, extractedWarehouses)
|
||||||
|
|
||||||
|
toast.success(`Загружено товаров: ${combinedStocks.length}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WB Warehouse: Error loading data from API:', error)
|
||||||
|
toast.error('Ошибка при загрузке данных из API')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setInitialized(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Основная функция загрузки данных
|
||||||
|
const loadWarehouseData = async () => {
|
||||||
|
if (!hasWBApiKey) {
|
||||||
|
setInitialized(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сначала пытаемся получить данные из кеша
|
||||||
|
try {
|
||||||
|
const result = await refetchCache()
|
||||||
|
const cacheResponse = result.data?.getWBWarehouseData
|
||||||
|
|
||||||
|
if (cacheResponse?.success && cacheResponse?.fromCache && cacheResponse?.cache) {
|
||||||
|
// Данные найдены в кеше
|
||||||
|
loadWarehouseDataFromCache(cacheResponse.cache)
|
||||||
|
} else {
|
||||||
|
// Кеша нет или он устарел, загружаем из API
|
||||||
|
console.log('WB Warehouse: No cache found, loading from API')
|
||||||
|
await loadWarehouseDataFromAPI()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('WB Warehouse: Error checking cache:', error)
|
||||||
|
// При ошибке кеша загружаем из API
|
||||||
|
await loadWarehouseDataFromAPI()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем данные только один раз при инициализации
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cacheLoading && user?.organization && !initialized) {
|
||||||
|
loadWarehouseData()
|
||||||
|
}
|
||||||
|
}, [cacheLoading, user?.organization, initialized])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex overflow-hidden">
|
<div className="h-screen flex overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@ -50,7 +389,20 @@ export function WBWarehouseDashboard() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="wildberries" className="h-full mt-0">
|
<TabsContent value="wildberries" className="h-full mt-0">
|
||||||
<WildberriesWarehouseTab />
|
<WildberriesWarehouseTab
|
||||||
|
stocks={stocks}
|
||||||
|
warehouses={warehouses}
|
||||||
|
loading={loading}
|
||||||
|
initialized={initialized}
|
||||||
|
cacheLoading={cacheLoading}
|
||||||
|
totalProducts={totalProducts}
|
||||||
|
totalStocks={totalStocks}
|
||||||
|
totalReserved={totalReserved}
|
||||||
|
totalFromClient={totalFromClient}
|
||||||
|
activeWarehouses={activeWarehouses}
|
||||||
|
analyticsData={analyticsData}
|
||||||
|
onRefresh={loadWarehouseDataFromAPI}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="my-warehouse" className="h-full mt-0">
|
<TabsContent value="my-warehouse" className="h-full mt-0">
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { WildberriesService } from '@/services/wildberries-service'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { StatsCards } from './stats-cards'
|
import { StatsCards } from './stats-cards'
|
||||||
import { SearchBar } from './search-bar'
|
import { SearchBar } from './search-bar'
|
||||||
@ -44,204 +42,37 @@ interface WBWarehouse {
|
|||||||
deliveryType: number
|
deliveryType: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WildberriesWarehouseTab() {
|
interface WildberriesWarehouseTabProps {
|
||||||
const { user } = useAuth()
|
stocks: WBStock[]
|
||||||
|
warehouses: WBWarehouse[]
|
||||||
|
loading: boolean
|
||||||
|
initialized: boolean
|
||||||
|
cacheLoading: boolean
|
||||||
|
totalProducts: number
|
||||||
|
totalStocks: number
|
||||||
|
totalReserved: number
|
||||||
|
totalFromClient: number
|
||||||
|
activeWarehouses: number
|
||||||
|
analyticsData: any[]
|
||||||
|
onRefresh: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
const [stocks, setStocks] = useState<WBStock[]>([])
|
export function WildberriesWarehouseTab({
|
||||||
const [warehouses, setWarehouses] = useState<WBWarehouse[]>([])
|
stocks,
|
||||||
const [loading, setLoading] = useState(false)
|
warehouses,
|
||||||
|
loading,
|
||||||
|
initialized,
|
||||||
|
cacheLoading,
|
||||||
|
totalProducts,
|
||||||
|
totalStocks,
|
||||||
|
totalReserved,
|
||||||
|
totalFromClient,
|
||||||
|
activeWarehouses,
|
||||||
|
analyticsData,
|
||||||
|
onRefresh
|
||||||
|
}: WildberriesWarehouseTabProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
|
||||||
// Статистика
|
|
||||||
const [totalProducts, setTotalProducts] = useState(0)
|
|
||||||
const [totalStocks, setTotalStocks] = useState(0)
|
|
||||||
const [totalReserved, setTotalReserved] = useState(0)
|
|
||||||
const [totalFromClient, setTotalFromClient] = useState(0)
|
|
||||||
const [activeWarehouses, setActiveWarehouses] = useState(0)
|
|
||||||
|
|
||||||
// Analytics data
|
|
||||||
const [analyticsData, setAnalyticsData] = useState<any[]>([])
|
|
||||||
|
|
||||||
// Проверяем настройку API ключа
|
|
||||||
const hasWBApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')?.isActive
|
|
||||||
|
|
||||||
// Комбинирование карточек с индивидуальными данными аналитики
|
|
||||||
const combineCardsWithIndividualAnalytics = (cards: any[], analyticsResults: any[]): WBStock[] => {
|
|
||||||
const stocksMap = new Map<number, WBStock>()
|
|
||||||
|
|
||||||
// Создаем карту аналитических данных для быстрого поиска
|
|
||||||
const analyticsMap = new Map() // Map nmId to its analytics data
|
|
||||||
analyticsResults.forEach(result => {
|
|
||||||
analyticsMap.set(result.nmId, result.data)
|
|
||||||
})
|
|
||||||
|
|
||||||
cards.forEach(card => {
|
|
||||||
const stock: WBStock = {
|
|
||||||
nmId: card.nmID,
|
|
||||||
vendorCode: String(card.vendorCode || card.supplierVendorCode || ''),
|
|
||||||
title: String(card.title || card.object || `Товар ${card.nmID}`),
|
|
||||||
brand: String(card.brand || ''),
|
|
||||||
price: 0,
|
|
||||||
stocks: [],
|
|
||||||
totalQuantity: 0,
|
|
||||||
totalReserved: 0,
|
|
||||||
photos: Array.isArray(card.photos) ? card.photos : [],
|
|
||||||
mediaFiles: Array.isArray(card.mediaFiles) ? card.mediaFiles : [],
|
|
||||||
characteristics: Array.isArray(card.characteristics) ? card.characteristics : [],
|
|
||||||
subjectName: String(card.subjectName || card.object || ''),
|
|
||||||
description: String(card.description || '')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (card.sizes && card.sizes.length > 0) {
|
|
||||||
stock.price = Number(card.sizes[0].price || card.sizes[0].discountedPrice) || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const analyticsData = analyticsMap.get(card.nmID)
|
|
||||||
if (analyticsData?.data?.regions) {
|
|
||||||
analyticsData.data.regions.forEach((region: any) => {
|
|
||||||
if (region.offices && region.offices.length > 0) {
|
|
||||||
region.offices.forEach((office: any) => {
|
|
||||||
stock.stocks.push({
|
|
||||||
warehouseId: office.officeID,
|
|
||||||
warehouseName: office.officeName,
|
|
||||||
quantity: office.metrics?.stockCount || 0,
|
|
||||||
quantityFull: office.metrics?.stockCount || 0,
|
|
||||||
inWayToClient: office.metrics?.toClientCount || 0,
|
|
||||||
inWayFromClient: office.metrics?.fromClientCount || 0
|
|
||||||
})
|
|
||||||
|
|
||||||
stock.totalQuantity += office.metrics?.stockCount || 0
|
|
||||||
stock.totalReserved += office.metrics?.toClientCount || 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
stocksMap.set(card.nmID, stock)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Array.from(stocksMap.values()).sort((a, b) => b.totalQuantity - a.totalQuantity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Извлечение информации о складах из данных
|
|
||||||
const extractWarehousesFromStocks = (stocksData: WBStock[]): WBWarehouse[] => {
|
|
||||||
const warehousesMap = new Map<number, WBWarehouse>()
|
|
||||||
|
|
||||||
stocksData.forEach(stock => {
|
|
||||||
stock.stocks.forEach(stockInfo => {
|
|
||||||
if (!warehousesMap.has(stockInfo.warehouseId)) {
|
|
||||||
warehousesMap.set(stockInfo.warehouseId, {
|
|
||||||
id: stockInfo.warehouseId,
|
|
||||||
name: stockInfo.warehouseName,
|
|
||||||
cargoType: 1,
|
|
||||||
deliveryType: 1
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return Array.from(warehousesMap.values())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обновление статистики
|
|
||||||
const updateStatistics = (stocksData: WBStock[], warehousesData: WBWarehouse[]) => {
|
|
||||||
setTotalProducts(stocksData.length)
|
|
||||||
setTotalStocks(stocksData.reduce((sum, item) => sum + item.totalQuantity, 0))
|
|
||||||
setTotalReserved(stocksData.reduce((sum, item) => sum + item.totalReserved, 0))
|
|
||||||
|
|
||||||
const totalFromClientCount = stocksData.reduce((sum, item) =>
|
|
||||||
sum + item.stocks.reduce((stockSum, stock) => stockSum + stock.inWayFromClient, 0), 0
|
|
||||||
)
|
|
||||||
setTotalFromClient(totalFromClientCount)
|
|
||||||
|
|
||||||
const warehousesWithStock = new Set(
|
|
||||||
stocksData.flatMap(item => item.stocks.map(s => s.warehouseId))
|
|
||||||
)
|
|
||||||
setActiveWarehouses(warehousesWithStock.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Загрузка данных склада
|
|
||||||
const loadWarehouseData = async () => {
|
|
||||||
if (!hasWBApiKey) return
|
|
||||||
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')
|
|
||||||
|
|
||||||
if (!wbApiKey?.isActive) {
|
|
||||||
toast.error('API ключ Wildberries не настроен')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const validationData = wbApiKey.validationData as Record<string, string>
|
|
||||||
const apiToken = validationData?.token ||
|
|
||||||
validationData?.apiKey ||
|
|
||||||
validationData?.key ||
|
|
||||||
(wbApiKey as { apiKey?: string }).apiKey
|
|
||||||
|
|
||||||
if (!apiToken) {
|
|
||||||
toast.error('Токен API не найден')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const wbService = new WildberriesService(apiToken)
|
|
||||||
|
|
||||||
// 1. Получаем карточки товаров
|
|
||||||
const cards = await WildberriesService.getAllCards(apiToken).catch(() => [])
|
|
||||||
console.log('WB Warehouse: Loaded cards:', cards.length)
|
|
||||||
|
|
||||||
if (cards.length === 0) {
|
|
||||||
toast.error('Нет карточек товаров в WB')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nmIds = cards.map(card => card.nmID).filter(id => id > 0)
|
|
||||||
console.log('WB Warehouse: NM IDs to process:', nmIds.length)
|
|
||||||
|
|
||||||
// 2. Получаем аналитику для каждого товара индивидуально
|
|
||||||
const analyticsResults = []
|
|
||||||
for (const nmId of nmIds) {
|
|
||||||
try {
|
|
||||||
console.log(`WB Warehouse: Fetching analytics for nmId ${nmId}`)
|
|
||||||
const result = await wbService.getStocksReportByOffices({
|
|
||||||
nmIds: [nmId],
|
|
||||||
stockType: ''
|
|
||||||
})
|
|
||||||
analyticsResults.push({ nmId, data: result })
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`WB Warehouse: Error fetching analytics for nmId ${nmId}:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('WB Warehouse: Analytics results:', analyticsResults.length)
|
|
||||||
|
|
||||||
// 3. Комбинируем данные
|
|
||||||
const combinedStocks = combineCardsWithIndividualAnalytics(cards, analyticsResults)
|
|
||||||
console.log('WB Warehouse: Combined stocks:', combinedStocks.length)
|
|
||||||
|
|
||||||
// 4. Извлекаем склады и обновляем статистику
|
|
||||||
const extractedWarehouses = extractWarehousesFromStocks(combinedStocks)
|
|
||||||
|
|
||||||
setStocks(combinedStocks)
|
|
||||||
setWarehouses(extractedWarehouses)
|
|
||||||
updateStatistics(combinedStocks, extractedWarehouses)
|
|
||||||
|
|
||||||
toast.success(`Загружено товаров: ${combinedStocks.length}`)
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('WB Warehouse: Error loading data:', error)
|
|
||||||
toast.error('Ошибка загрузки данных: ' + (error.message || 'Неизвестная ошибка'))
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasWBApiKey) {
|
|
||||||
loadWarehouseData()
|
|
||||||
}
|
|
||||||
}, [hasWBApiKey])
|
|
||||||
|
|
||||||
// Фильтрация товаров
|
// Фильтрация товаров
|
||||||
const filteredStocks = stocks.filter(item => {
|
const filteredStocks = stocks.filter(item => {
|
||||||
if (!searchTerm) return true
|
if (!searchTerm) return true
|
||||||
@ -254,6 +85,15 @@ export function WildberriesWarehouseTab() {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
try {
|
||||||
|
await onRefresh()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing data:', error)
|
||||||
|
toast.error('Ошибка при обновлении данных')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{/* Статистика */}
|
{/* Статистика */}
|
||||||
@ -263,80 +103,103 @@ export function WildberriesWarehouseTab() {
|
|||||||
totalReserved={totalReserved}
|
totalReserved={totalReserved}
|
||||||
totalFromClient={totalFromClient}
|
totalFromClient={totalFromClient}
|
||||||
activeWarehouses={activeWarehouses}
|
activeWarehouses={activeWarehouses}
|
||||||
loading={loading}
|
loading={!initialized || loading || cacheLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Аналитика по складам WB */}
|
{/* Аналитика по складам WB */}
|
||||||
{analyticsData.length > 0 && (
|
{initialized && analyticsData.length > 0 && (
|
||||||
<Card className="glass-card border-white/10 p-4 mb-6">
|
<Card className="glass-card border-white/10 p-4 mb-6">
|
||||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center">
|
||||||
<TrendingUp className="h-5 w-5 mr-2 text-blue-400" />
|
<TrendingUp className="h-5 w-5 mr-2 text-blue-400" />
|
||||||
Движение товаров по складам WB
|
Аналитика по складам WB
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{analyticsData.map((warehouse) => (
|
{analyticsData.slice(0, 6).map((item, index) => (
|
||||||
<Card key={warehouse.warehouseId} className="bg-white/5 border-white/10 p-3">
|
<div key={index} className="bg-white/5 rounded-lg p-3">
|
||||||
<div className="text-sm font-medium text-white mb-2">{warehouse.warehouseName}</div>
|
<div className="text-sm text-white/60">Склад {index + 1}</div>
|
||||||
<div className="space-y-2">
|
<div className="text-lg font-medium text-white">
|
||||||
<div className="flex justify-between text-xs">
|
{JSON.stringify(item).length > 50
|
||||||
<span className="text-white/60">К клиенту:</span>
|
? `${JSON.stringify(item).substring(0, 50)}...`
|
||||||
<span className="text-green-400 font-medium">{warehouse.toClient}</span>
|
: JSON.stringify(item)
|
||||||
</div>
|
}
|
||||||
<div className="flex justify-between text-xs">
|
|
||||||
<span className="text-white/60">От клиента:</span>
|
|
||||||
<span className="text-orange-400 font-medium">{warehouse.fromClient}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Основной контент */}
|
||||||
|
<Card className="glass-card border-white/10 flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-white/10 flex-shrink-0">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-white flex items-center">
|
||||||
|
<Package className="h-6 w-6 mr-2 text-blue-400" />
|
||||||
|
Склад Wildberries
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/60 text-sm mt-1">
|
||||||
|
Управление товарами на складах Wildberries
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
>
|
||||||
|
{loading ? 'Обновление...' : 'Обновить данные'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Поиск */}
|
{/* Поиск */}
|
||||||
|
<div className="mt-4">
|
||||||
<SearchBar
|
<SearchBar
|
||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
onSearchChange={setSearchTerm}
|
onSearchChange={setSearchTerm}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Список товаров */}
|
{/* Контент с таблицей */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
{loading ? (
|
{!initialized || loading || cacheLoading ? (
|
||||||
<div className="overflow-y-auto pr-2 max-h-full">
|
<div className="p-6">
|
||||||
<TableHeader />
|
|
||||||
<LoadingSkeleton />
|
<LoadingSkeleton />
|
||||||
</div>
|
</div>
|
||||||
) : !hasWBApiKey ? (
|
|
||||||
<Card className="glass-card border-white/10 p-8 text-center">
|
|
||||||
<Package className="h-12 w-12 text-blue-400 mx-auto mb-4" />
|
|
||||||
<h3 className="text-lg font-medium text-white mb-2">Настройте API Wildberries</h3>
|
|
||||||
<p className="text-white/60 mb-4">Для просмотра остатков добавьте API ключ Wildberries в настройках</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => window.location.href = '/settings'}
|
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
Перейти в настройки
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
) : filteredStocks.length === 0 ? (
|
) : filteredStocks.length === 0 ? (
|
||||||
<Card className="glass-card border-white/10 p-8 text-center">
|
<div className="flex items-center justify-center h-full">
|
||||||
<Package className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-medium text-white mb-2">Товары не найдены</h3>
|
<Package className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||||
<p className="text-white/60">Попробуйте изменить параметры поиска</p>
|
<p className="text-white/60">
|
||||||
</Card>
|
{searchTerm ? 'Товары не найдены' : 'Нет данных о товарах'}
|
||||||
|
</p>
|
||||||
|
{!searchTerm && (
|
||||||
|
<Button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
className="mt-4 bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
>
|
||||||
|
Загрузить данные
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-y-auto pr-2 max-h-full">
|
<div className="h-full overflow-auto">
|
||||||
|
<div className="p-6 space-y-3">
|
||||||
|
{/* Заголовок таблицы */}
|
||||||
<TableHeader />
|
<TableHeader />
|
||||||
|
|
||||||
{/* Таблица товаров */}
|
{/* Строки товаров */}
|
||||||
<div className="space-y-1">
|
{filteredStocks.map((item) => (
|
||||||
{filteredStocks.map((item, index) => (
|
<StockTableRow key={item.nmId} item={item} />
|
||||||
<StockTableRow key={`${item.nmId}-${index}`} item={item} />
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -1033,6 +1033,70 @@ export const REMOVE_FROM_FAVORITES = gql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Мутации для внешней рекламы
|
||||||
|
export const CREATE_EXTERNAL_AD = gql`
|
||||||
|
mutation CreateExternalAd($input: ExternalAdInput!) {
|
||||||
|
createExternalAd(input: $input) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
externalAd {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
url
|
||||||
|
cost
|
||||||
|
date
|
||||||
|
nmId
|
||||||
|
clicks
|
||||||
|
organizationId
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_EXTERNAL_AD = gql`
|
||||||
|
mutation UpdateExternalAd($id: ID!, $input: ExternalAdInput!) {
|
||||||
|
updateExternalAd(id: $id, input: $input) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
externalAd {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
url
|
||||||
|
cost
|
||||||
|
date
|
||||||
|
nmId
|
||||||
|
clicks
|
||||||
|
organizationId
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DELETE_EXTERNAL_AD = gql`
|
||||||
|
mutation DeleteExternalAd($id: ID!) {
|
||||||
|
deleteExternalAd(id: $id) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
externalAd {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const UPDATE_EXTERNAL_AD_CLICKS = gql`
|
||||||
|
mutation UpdateExternalAdClicks($id: ID!, $clicks: Int!) {
|
||||||
|
updateExternalAdClicks(id: $id, clicks: $clicks) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
// Мутации для категорий
|
// Мутации для категорий
|
||||||
export const CREATE_CATEGORY = gql`
|
export const CREATE_CATEGORY = gql`
|
||||||
mutation CreateCategory($input: CategoryInput!) {
|
mutation CreateCategory($input: CategoryInput!) {
|
||||||
@ -1248,3 +1312,25 @@ export const UPDATE_SUPPLY_ORDER_STATUS = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Мутации для кеша склада WB
|
||||||
|
export const SAVE_WB_WAREHOUSE_CACHE = gql`
|
||||||
|
mutation SaveWBWarehouseCache($input: WBWarehouseCacheInput!) {
|
||||||
|
saveWBWarehouseCache(input: $input) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
fromCache
|
||||||
|
cache {
|
||||||
|
id
|
||||||
|
organizationId
|
||||||
|
cacheDate
|
||||||
|
data
|
||||||
|
totalProducts
|
||||||
|
totalStocks
|
||||||
|
totalReserved
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
@ -829,6 +829,27 @@ export const GET_WILDBERRIES_CAMPAIGNS_LIST = gql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const GET_EXTERNAL_ADS = gql`
|
||||||
|
query GetExternalAds($dateFrom: String!, $dateTo: String!) {
|
||||||
|
getExternalAds(dateFrom: $dateFrom, dateTo: $dateTo) {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
externalAds {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
url
|
||||||
|
cost
|
||||||
|
date
|
||||||
|
nmId
|
||||||
|
clicks
|
||||||
|
organizationId
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
// Админ запросы
|
// Админ запросы
|
||||||
export const ADMIN_ME = gql`
|
export const ADMIN_ME = gql`
|
||||||
query AdminMe {
|
query AdminMe {
|
||||||
@ -934,3 +955,25 @@ export const GET_PENDING_SUPPLIES_COUNT = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// Запросы для кеша склада WB
|
||||||
|
export const GET_WB_WAREHOUSE_DATA = gql`
|
||||||
|
query GetWBWarehouseData {
|
||||||
|
getWBWarehouseData {
|
||||||
|
success
|
||||||
|
message
|
||||||
|
fromCache
|
||||||
|
cache {
|
||||||
|
id
|
||||||
|
organizationId
|
||||||
|
cacheDate
|
||||||
|
data
|
||||||
|
totalProducts
|
||||||
|
totalStocks
|
||||||
|
totalReserved
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
@ -5025,6 +5025,59 @@ export const resolvers = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateExternalAdClicks: async (
|
||||||
|
_: unknown,
|
||||||
|
{ id, clicks }: { id: string; clicks: number },
|
||||||
|
context: Context
|
||||||
|
) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError("Требуется авторизация", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.organization) {
|
||||||
|
throw new GraphQLError("Организация не найдена");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что реклама принадлежит организации пользователя
|
||||||
|
const existingAd = await prisma.externalAd.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
organizationId: user.organization.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingAd) {
|
||||||
|
throw new GraphQLError("Внешняя реклама не найдена");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.externalAd.update({
|
||||||
|
where: { id },
|
||||||
|
data: { clicks },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Клики успешно обновлены",
|
||||||
|
externalAd: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating external ad clicks:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : "Ошибка обновления кликов",
|
||||||
|
externalAd: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Резолверы типов
|
// Резолверы типов
|
||||||
@ -6027,14 +6080,394 @@ const wildberriesQueries = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Резолверы для внешней рекламы
|
||||||
|
const externalAdQueries = {
|
||||||
|
getExternalAds: async (
|
||||||
|
_: unknown,
|
||||||
|
{ dateFrom, dateTo }: { dateFrom: string; dateTo: string },
|
||||||
|
context: Context
|
||||||
|
) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError("Требуется авторизация", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.organization) {
|
||||||
|
throw new GraphQLError("Организация не найдена");
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalAds = await prisma.externalAd.findMany({
|
||||||
|
where: {
|
||||||
|
organizationId: user.organization.id,
|
||||||
|
date: {
|
||||||
|
gte: new Date(dateFrom),
|
||||||
|
lte: new Date(dateTo + 'T23:59:59.999Z'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
date: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: null,
|
||||||
|
externalAds: externalAds.map(ad => ({
|
||||||
|
...ad,
|
||||||
|
cost: parseFloat(ad.cost.toString()),
|
||||||
|
date: ad.date.toISOString().split('T')[0],
|
||||||
|
createdAt: ad.createdAt.toISOString(),
|
||||||
|
updatedAt: ad.updatedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching external ads:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : "Ошибка получения внешней рекламы",
|
||||||
|
externalAds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const externalAdMutations = {
|
||||||
|
createExternalAd: async (
|
||||||
|
_: unknown,
|
||||||
|
{ input }: { input: { name: string; url: string; cost: number; date: string; nmId: string } },
|
||||||
|
context: Context
|
||||||
|
) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError("Требуется авторизация", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.organization) {
|
||||||
|
throw new GraphQLError("Организация не найдена");
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalAd = await prisma.externalAd.create({
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
url: input.url,
|
||||||
|
cost: input.cost,
|
||||||
|
date: new Date(input.date),
|
||||||
|
nmId: input.nmId,
|
||||||
|
organizationId: user.organization.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Внешняя реклама успешно создана",
|
||||||
|
externalAd: {
|
||||||
|
...externalAd,
|
||||||
|
cost: parseFloat(externalAd.cost.toString()),
|
||||||
|
date: externalAd.date.toISOString().split('T')[0],
|
||||||
|
createdAt: externalAd.createdAt.toISOString(),
|
||||||
|
updatedAt: externalAd.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating external ad:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : "Ошибка создания внешней рекламы",
|
||||||
|
externalAd: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateExternalAd: async (
|
||||||
|
_: unknown,
|
||||||
|
{ id, input }: { id: string; input: { name: string; url: string; cost: number; date: string; nmId: string } },
|
||||||
|
context: Context
|
||||||
|
) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError("Требуется авторизация", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.organization) {
|
||||||
|
throw new GraphQLError("Организация не найдена");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что реклама принадлежит организации пользователя
|
||||||
|
const existingAd = await prisma.externalAd.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
organizationId: user.organization.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingAd) {
|
||||||
|
throw new GraphQLError("Внешняя реклама не найдена");
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalAd = await prisma.externalAd.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
url: input.url,
|
||||||
|
cost: input.cost,
|
||||||
|
date: new Date(input.date),
|
||||||
|
nmId: input.nmId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Внешняя реклама успешно обновлена",
|
||||||
|
externalAd: {
|
||||||
|
...externalAd,
|
||||||
|
cost: parseFloat(externalAd.cost.toString()),
|
||||||
|
date: externalAd.date.toISOString().split('T')[0],
|
||||||
|
createdAt: externalAd.createdAt.toISOString(),
|
||||||
|
updatedAt: externalAd.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating external ad:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : "Ошибка обновления внешней рекламы",
|
||||||
|
externalAd: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteExternalAd: async (
|
||||||
|
_: unknown,
|
||||||
|
{ id }: { id: string },
|
||||||
|
context: Context
|
||||||
|
) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError("Требуется авторизация", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.organization) {
|
||||||
|
throw new GraphQLError("Организация не найдена");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что реклама принадлежит организации пользователя
|
||||||
|
const existingAd = await prisma.externalAd.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
organizationId: user.organization.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingAd) {
|
||||||
|
throw new GraphQLError("Внешняя реклама не найдена");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.externalAd.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Внешняя реклама успешно удалена",
|
||||||
|
externalAd: null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting external ad:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : "Ошибка удаления внешней рекламы",
|
||||||
|
externalAd: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
// Резолверы для кеша склада WB
|
||||||
|
const wbWarehouseCacheQueries = {
|
||||||
|
getWBWarehouseData: async (
|
||||||
|
_: unknown,
|
||||||
|
__: unknown,
|
||||||
|
context: Context
|
||||||
|
) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError("Требуется авторизация", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.organization) {
|
||||||
|
throw new GraphQLError("Организация не найдена");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем текущую дату без времени
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Ищем кеш за сегодня
|
||||||
|
const cache = await prisma.wBWarehouseCache.findFirst({
|
||||||
|
where: {
|
||||||
|
organizationId: user.organization.id,
|
||||||
|
cacheDate: today,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cache) {
|
||||||
|
// Возвращаем данные из кеша
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Данные получены из кеша",
|
||||||
|
cache: {
|
||||||
|
...cache,
|
||||||
|
cacheDate: cache.cacheDate.toISOString().split('T')[0],
|
||||||
|
createdAt: cache.createdAt.toISOString(),
|
||||||
|
updatedAt: cache.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
fromCache: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Кеша нет, нужно загрузить данные из API
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Кеш не найден, требуется загрузка из API",
|
||||||
|
cache: null,
|
||||||
|
fromCache: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting WB warehouse cache:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : "Ошибка получения кеша склада WB",
|
||||||
|
cache: null,
|
||||||
|
fromCache: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const wbWarehouseCacheMutations = {
|
||||||
|
saveWBWarehouseCache: async (
|
||||||
|
_: unknown,
|
||||||
|
{ input }: { input: { data: string; totalProducts: number; totalStocks: number; totalReserved: number } },
|
||||||
|
context: Context
|
||||||
|
) => {
|
||||||
|
if (!context.user) {
|
||||||
|
throw new GraphQLError("Требуется авторизация", {
|
||||||
|
extensions: { code: "UNAUTHENTICATED" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: context.user.id },
|
||||||
|
include: { organization: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.organization) {
|
||||||
|
throw new GraphQLError("Организация не найдена");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем текущую дату без времени
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Используем upsert для создания или обновления кеша
|
||||||
|
const cache = await prisma.wBWarehouseCache.upsert({
|
||||||
|
where: {
|
||||||
|
organizationId_cacheDate: {
|
||||||
|
organizationId: user.organization.id,
|
||||||
|
cacheDate: today,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
data: input.data,
|
||||||
|
totalProducts: input.totalProducts,
|
||||||
|
totalStocks: input.totalStocks,
|
||||||
|
totalReserved: input.totalReserved,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
organizationId: user.organization.id,
|
||||||
|
cacheDate: today,
|
||||||
|
data: input.data,
|
||||||
|
totalProducts: input.totalProducts,
|
||||||
|
totalStocks: input.totalStocks,
|
||||||
|
totalReserved: input.totalReserved,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Кеш склада WB успешно сохранен",
|
||||||
|
cache: {
|
||||||
|
...cache,
|
||||||
|
cacheDate: cache.cacheDate.toISOString().split('T')[0],
|
||||||
|
createdAt: cache.createdAt.toISOString(),
|
||||||
|
updatedAt: cache.updatedAt.toISOString(),
|
||||||
|
},
|
||||||
|
fromCache: false,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving WB warehouse cache:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : "Ошибка сохранения кеша склада WB",
|
||||||
|
cache: null,
|
||||||
|
fromCache: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Добавляем админ запросы и мутации к основным резолверам
|
// Добавляем админ запросы и мутации к основным резолверам
|
||||||
resolvers.Query = {
|
resolvers.Query = {
|
||||||
...resolvers.Query,
|
...resolvers.Query,
|
||||||
...adminQueries,
|
...adminQueries,
|
||||||
...wildberriesQueries,
|
...wildberriesQueries,
|
||||||
|
...externalAdQueries,
|
||||||
|
...wbWarehouseCacheQueries,
|
||||||
};
|
};
|
||||||
|
|
||||||
resolvers.Mutation = {
|
resolvers.Mutation = {
|
||||||
...resolvers.Mutation,
|
...resolvers.Mutation,
|
||||||
...adminMutations,
|
...adminMutations,
|
||||||
|
...externalAdMutations,
|
||||||
|
...wbWarehouseCacheMutations,
|
||||||
};
|
};
|
||||||
|
@ -108,6 +108,12 @@ export const typeDefs = gql`
|
|||||||
|
|
||||||
# Список кампаний Wildberries
|
# Список кампаний Wildberries
|
||||||
getWildberriesCampaignsList: WildberriesCampaignsListResponse!
|
getWildberriesCampaignsList: WildberriesCampaignsListResponse!
|
||||||
|
|
||||||
|
# Типы для внешней рекламы
|
||||||
|
getExternalAds(dateFrom: String!, dateTo: String!): ExternalAdsResponse!
|
||||||
|
|
||||||
|
# Типы для кеша склада WB
|
||||||
|
getWBWarehouseData: WBWarehouseCacheResponse!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
@ -244,6 +250,12 @@ export const typeDefs = gql`
|
|||||||
# Админ мутации
|
# Админ мутации
|
||||||
adminLogin(username: String!, password: String!): AdminAuthResponse!
|
adminLogin(username: String!, password: String!): AdminAuthResponse!
|
||||||
adminLogout: Boolean!
|
adminLogout: Boolean!
|
||||||
|
|
||||||
|
# Типы для внешней рекламы
|
||||||
|
createExternalAd(input: ExternalAdInput!): ExternalAdResponse!
|
||||||
|
updateExternalAd(id: ID!, input: ExternalAdInput!): ExternalAdResponse!
|
||||||
|
deleteExternalAd(id: ID!): ExternalAdResponse!
|
||||||
|
updateExternalAdClicks(id: ID!, clicks: Int!): ExternalAdResponse!
|
||||||
}
|
}
|
||||||
|
|
||||||
# Типы данных
|
# Типы данных
|
||||||
@ -1149,4 +1161,84 @@ export const typeDefs = gql`
|
|||||||
advertId: Int!
|
advertId: Int!
|
||||||
changeTime: String!
|
changeTime: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Типы для внешней рекламы
|
||||||
|
type ExternalAd {
|
||||||
|
id: ID!
|
||||||
|
name: String!
|
||||||
|
url: String!
|
||||||
|
cost: Float!
|
||||||
|
date: String!
|
||||||
|
nmId: String!
|
||||||
|
clicks: Int!
|
||||||
|
organizationId: String!
|
||||||
|
createdAt: String!
|
||||||
|
updatedAt: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input ExternalAdInput {
|
||||||
|
name: String!
|
||||||
|
url: String!
|
||||||
|
cost: Float!
|
||||||
|
date: String!
|
||||||
|
nmId: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalAdResponse {
|
||||||
|
success: Boolean!
|
||||||
|
message: String
|
||||||
|
externalAd: ExternalAd
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExternalAdsResponse {
|
||||||
|
success: Boolean!
|
||||||
|
message: String
|
||||||
|
externalAds: [ExternalAd!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
getExternalAds(dateFrom: String!, dateTo: String!): ExternalAdsResponse!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
createExternalAd(input: ExternalAdInput!): ExternalAdResponse!
|
||||||
|
updateExternalAd(id: ID!, input: ExternalAdInput!): ExternalAdResponse!
|
||||||
|
deleteExternalAd(id: ID!): ExternalAdResponse!
|
||||||
|
updateExternalAdClicks(id: ID!, clicks: Int!): ExternalAdResponse!
|
||||||
|
}
|
||||||
|
|
||||||
|
# Типы для кеша склада WB
|
||||||
|
type WBWarehouseCache {
|
||||||
|
id: ID!
|
||||||
|
organizationId: String!
|
||||||
|
cacheDate: String!
|
||||||
|
data: String! # JSON строка с данными
|
||||||
|
totalProducts: Int!
|
||||||
|
totalStocks: Int!
|
||||||
|
totalReserved: Int!
|
||||||
|
createdAt: String!
|
||||||
|
updatedAt: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type WBWarehouseCacheResponse {
|
||||||
|
success: Boolean!
|
||||||
|
message: String
|
||||||
|
cache: WBWarehouseCache
|
||||||
|
fromCache: Boolean! # Указывает, получены ли данные из кеша
|
||||||
|
}
|
||||||
|
|
||||||
|
input WBWarehouseCacheInput {
|
||||||
|
data: String! # JSON строка с данными склада
|
||||||
|
totalProducts: Int!
|
||||||
|
totalStocks: Int!
|
||||||
|
totalReserved: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
getWBWarehouseData: WBWarehouseCacheResponse!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
saveWBWarehouseCache(input: WBWarehouseCacheInput!): WBWarehouseCacheResponse!
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
30
src/lib/click-storage.ts
Normal file
30
src/lib/click-storage.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// Общее хранилище кликов для всех API роутов
|
||||||
|
class ClickStorage {
|
||||||
|
private static instance: ClickStorage
|
||||||
|
private storage = new Map<string, number>()
|
||||||
|
|
||||||
|
static getInstance(): ClickStorage {
|
||||||
|
if (!ClickStorage.instance) {
|
||||||
|
ClickStorage.instance = new ClickStorage()
|
||||||
|
}
|
||||||
|
return ClickStorage.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
recordClick(linkId: string): number {
|
||||||
|
const currentClicks = this.storage.get(linkId) || 0
|
||||||
|
const newClicks = currentClicks + 1
|
||||||
|
this.storage.set(linkId, newClicks)
|
||||||
|
console.log(`Click recorded for ${linkId}: ${newClicks} total`)
|
||||||
|
return newClicks
|
||||||
|
}
|
||||||
|
|
||||||
|
getClicks(linkId: string): number {
|
||||||
|
return this.storage.get(linkId) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllClicks(): Record<string, number> {
|
||||||
|
return Object.fromEntries(this.storage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clickStorage = ClickStorage.getInstance()
|
Reference in New Issue
Block a user