Добавлен новый компонент для компактного выбора кампаний в AdvertisingTab, обновлены интерфейсы для типизации данных API. Реализован GraphQL запрос для получения списка кампаний Wildberries. Оптимизирована логика отображения статистики кампаний и добавлены новые поля для обработки данных. Обновлены резолверы и типы для поддержки новых данных.
This commit is contained in:
@ -8,8 +8,32 @@ import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { GET_WILDBERRIES_CAMPAIGN_STATS } from '@/graphql/queries'
|
||||
import { TrendingUp, Search, Calendar, Eye, MousePointer, ShoppingCart, DollarSign, Percent, AlertCircle } from 'lucide-react'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { GET_WILDBERRIES_CAMPAIGN_STATS, GET_WILDBERRIES_CAMPAIGNS_LIST } from '@/graphql/queries'
|
||||
import {
|
||||
TrendingUp,
|
||||
Search,
|
||||
Calendar,
|
||||
Eye,
|
||||
MousePointer,
|
||||
ShoppingCart,
|
||||
DollarSign,
|
||||
Percent,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Globe,
|
||||
Package,
|
||||
Target,
|
||||
BarChart3,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Settings,
|
||||
Filter,
|
||||
ArrowUpDown
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
@ -18,13 +42,63 @@ import {
|
||||
ChartLegendContent,
|
||||
type ChartConfig
|
||||
} from '@/components/ui/chart'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from 'recharts'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid } from 'recharts'
|
||||
|
||||
interface CampaignStatsProps {
|
||||
selectedPeriod: string
|
||||
useCustomDates: boolean
|
||||
startDate: string
|
||||
endDate: string
|
||||
// Интерфейсы для типизации данных API
|
||||
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 {
|
||||
@ -39,38 +113,556 @@ interface CampaignStats {
|
||||
cr: number
|
||||
shks: number
|
||||
sum_price: number
|
||||
dates: string[]
|
||||
days: Array<{
|
||||
date: string
|
||||
views: number
|
||||
clicks: number
|
||||
ctr: number
|
||||
cpc: number
|
||||
sum: number
|
||||
atbs: number
|
||||
orders: number
|
||||
cr: number
|
||||
shks: number
|
||||
sum_price: number
|
||||
}>
|
||||
boosterStats: Array<{
|
||||
date: string
|
||||
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 CampaignStatsProps {
|
||||
selectedPeriod: string
|
||||
useCustomDates: boolean
|
||||
startDate: string
|
||||
endDate: string
|
||||
}
|
||||
|
||||
// Интерфейсы для списка кампаний
|
||||
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(false)
|
||||
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 || []
|
||||
|
||||
// Функции для получения названий типов и статусов
|
||||
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" /> : <Maximize2 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" />
|
||||
) : (
|
||||
<>
|
||||
<Search className="h-3 w-3 mr-1" />
|
||||
Загрузить
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
// Компонент сверх-компактной таблицы кампаний
|
||||
const UltraCompactCampaignTable = ({
|
||||
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 '—'
|
||||
if (value < 1000) return `${value}₽`
|
||||
if (value < 1000000) return `${(value / 1000).toFixed(1)}K₽`
|
||||
return `${(value / 1000000).toFixed(1)}M₽`
|
||||
}
|
||||
|
||||
const formatNumber = (value: number) => {
|
||||
if (value === 0) return '—'
|
||||
if (value < 1000) return value.toString()
|
||||
if (value < 1000000) return `${(value / 1000).toFixed(1)}K`
|
||||
return `${(value / 1000000).toFixed(1)}M`
|
||||
}
|
||||
|
||||
const formatPercent = (value: number) => {
|
||||
return value === 0 ? '—' : `${value.toFixed(1)}%`
|
||||
}
|
||||
|
||||
const getAppTypeIcon = (appType: number) => {
|
||||
const iconClass = "h-3 w-3"
|
||||
switch (appType) {
|
||||
case 1: return <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 [campaignIds, setCampaignIds] = useState('')
|
||||
const [selectedCampaignIds, setSelectedCampaignIds] = useState<number[]>([])
|
||||
const [campaignStats, setCampaignStats] = useState<CampaignStats[]>([])
|
||||
const [expandedCampaigns, setExpandedCampaigns] = useState<Set<number>>(new Set())
|
||||
const [showChart, setShowChart] = useState(false)
|
||||
|
||||
const [getCampaignStats, { loading, error }] = useLazyQuery(GET_WILDBERRIES_CAMPAIGN_STATS, {
|
||||
onCompleted: (data) => {
|
||||
@ -83,15 +675,13 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
||||
}
|
||||
})
|
||||
|
||||
const handleGetStats = () => {
|
||||
if (!campaignIds.trim()) return
|
||||
|
||||
const ids = campaignIds.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id))
|
||||
const handleCampaignsSelected = (ids: number[]) => {
|
||||
if (ids.length === 0) return
|
||||
|
||||
setSelectedCampaignIds(ids)
|
||||
|
||||
let campaigns
|
||||
if (useCustomDates && startDate && endDate) {
|
||||
// Используем интервал для пользовательских дат
|
||||
campaigns = ids.map(id => ({
|
||||
id,
|
||||
interval: {
|
||||
@ -100,7 +690,6 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
// Для предустановленных периодов вычисляем даты
|
||||
const endDateCalc = new Date()
|
||||
const startDateCalc = new Date()
|
||||
|
||||
@ -132,6 +721,16 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
||||
})
|
||||
}
|
||||
|
||||
const toggleCampaignExpanded = (campaignId: number) => {
|
||||
const newExpanded = new Set(expandedCampaigns)
|
||||
if (newExpanded.has(campaignId)) {
|
||||
newExpanded.delete(campaignId)
|
||||
} else {
|
||||
newExpanded.add(campaignId)
|
||||
}
|
||||
setExpandedCampaigns(newExpanded)
|
||||
}
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
@ -178,46 +777,19 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col space-y-4 overflow-hidden">
|
||||
{/* Форма поиска кампаний */}
|
||||
<Card className="glass-card flex-shrink-0 p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-white/80 mb-2">
|
||||
ID кампаний (через запятую)
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Например: 12345, 67890, 11111"
|
||||
value={campaignIds}
|
||||
onChange={(e) => setCampaignIds(e.target.value)}
|
||||
className="bg-white/5 border-white/20 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleGetStats}
|
||||
disabled={loading || !campaignIds.trim()}
|
||||
className="bg-white/10 hover:bg-white/20 border border-white/20 text-white flex items-center gap-2 mt-6"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||
Загрузка...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="h-4 w-4" />
|
||||
Получить статистику
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="h-full flex flex-col space-y-3 overflow-hidden">
|
||||
{/* Компактный селектор кампаний */}
|
||||
<CompactCampaignSelector
|
||||
onCampaignsSelected={handleCampaignsSelected}
|
||||
selectedCampaigns={selectedCampaignIds}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Ошибки */}
|
||||
{error && (
|
||||
<Alert className="bg-red-500/10 border-red-500/30 text-red-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<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>
|
||||
@ -226,57 +798,61 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
||||
{/* Результаты */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="glass-card p-6">
|
||||
<Skeleton className="h-8 w-48 mb-4 bg-white/10" />
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((j) => (
|
||||
<Skeleton key={j} className="h-16 bg-white/5" />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
<Skeleton key={i} className="h-12 bg-white/10" />
|
||||
))}
|
||||
</div>
|
||||
) : campaignStats.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{/* Общая статистика по всем кампаниям */}
|
||||
<Card className="glass-card p-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
Общая статистика ({campaignStats.length} кампаний)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{/* Компактная общая статистика */}
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowChart(!showChart)}
|
||||
className="h-6 px-2 text-xs text-white/60 hover:bg-white/10"
|
||||
>
|
||||
{showChart ? <Minimize2 className="h-3 w-3" /> : <BarChart3 className="h-3 w-3" />}
|
||||
{showChart ? 'Скрыть' : 'График'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4 mb-6">
|
||||
<div className="grid grid-cols-4 md:grid-cols-8 gap-2 mb-3">
|
||||
{/* Показы */}
|
||||
<div className="bg-white/5 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Eye className="h-4 w-4 text-purple-400" />
|
||||
<span className="text-sm text-white/60">Показы</span>
|
||||
<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-xl font-bold text-white">
|
||||
<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-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<MousePointer className="h-4 w-4 text-cyan-400" />
|
||||
<span className="text-sm text-white/60">Клики</span>
|
||||
<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-xl font-bold text-white">
|
||||
<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-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Percent className="h-4 w-4 text-green-400" />
|
||||
<span className="text-sm text-white/60">CTR</span>
|
||||
<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-xl font-bold text-white">
|
||||
<div className="text-sm font-bold text-white">
|
||||
{formatPercent(
|
||||
campaignStats.reduce((sum, stat, _, arr) => {
|
||||
const totalViews = arr.reduce((s, st) => s + st.views, 0)
|
||||
@ -288,12 +864,12 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
||||
</div>
|
||||
|
||||
{/* CPC */}
|
||||
<div className="bg-white/5 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<DollarSign className="h-4 w-4 text-amber-400" />
|
||||
<span className="text-sm text-white/60">CPC</span>
|
||||
<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-xl font-bold text-white">
|
||||
<div className="text-sm font-bold text-white">
|
||||
{formatCurrency(
|
||||
campaignStats.reduce((sum, stat, _, arr) => {
|
||||
const totalClicks = arr.reduce((s, st) => s + st.clicks, 0)
|
||||
@ -305,34 +881,34 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
||||
</div>
|
||||
|
||||
{/* Затраты */}
|
||||
<div className="bg-white/5 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<DollarSign className="h-4 w-4 text-red-400" />
|
||||
<span className="text-sm text-white/60">Затраты</span>
|
||||
<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-xl font-bold text-white">
|
||||
<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-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<ShoppingCart className="h-4 w-4 text-green-400" />
|
||||
<span className="text-sm text-white/60">Заказы</span>
|
||||
<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-xl font-bold text-white">
|
||||
<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-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Percent className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm text-white/60">CR</span>
|
||||
<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-xl font-bold text-white">
|
||||
<div className="text-sm font-bold text-white">
|
||||
{formatPercent(
|
||||
campaignStats.reduce((sum, stat, _, arr) => {
|
||||
const totalClicks = arr.reduce((s, st) => s + st.clicks, 0)
|
||||
@ -344,126 +920,80 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
||||
</div>
|
||||
|
||||
{/* Выручка */}
|
||||
<div className="bg-white/5 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<DollarSign className="h-4 w-4 text-emerald-400" />
|
||||
<span className="text-sm text-white/60">Выручка</span>
|
||||
<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-xl font-bold text-white">
|
||||
<div className="text-sm font-bold text-white">
|
||||
{formatCurrency(campaignStats.reduce((sum, stat) => sum + stat.sum_price, 0))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* График */}
|
||||
{chartData.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-md font-medium text-white mb-4">Динамика по дням</h4>
|
||||
<div className="h-80">
|
||||
<ChartContainer config={chartConfig}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: 'rgba(255,255,255,0.6)', fontSize: 12 }}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: 'rgba(255,255,255,0.6)', fontSize: 12 }}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="views"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#8b5cf6', strokeWidth: 2, r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="clicks"
|
||||
stroke="#06b6d4"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#06b6d4', strokeWidth: 2, r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="sum"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#f59e0b', strokeWidth: 2, r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="orders"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#10b981', strokeWidth: 2, r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Компактный график */}
|
||||
{showChart && chartData.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="h-32">
|
||||
<ChartContainer config={chartConfig}>
|
||||
<LineChart data={chartData}>
|
||||
<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}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="views"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={1}
|
||||
dot={{ fill: '#8b5cf6', strokeWidth: 1, r: 2 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="clicks"
|
||||
stroke="#06b6d4"
|
||||
strokeWidth={1}
|
||||
dot={{ fill: '#06b6d4', strokeWidth: 1, r: 2 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="orders"
|
||||
stroke="#10b981"
|
||||
strokeWidth={1}
|
||||
dot={{ fill: '#10b981', strokeWidth: 1, r: 2 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Детальная статистика по каждой кампании */}
|
||||
{campaignStats.map((campaign) => (
|
||||
<Card key={campaign.advertId} className="glass-card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
Кампания #{campaign.advertId}
|
||||
</h3>
|
||||
<Badge variant="outline" className="border-white/20 text-white">
|
||||
{campaign.days.length} дней
|
||||
</Badge>
|
||||
{/* Сверх-компактная таблица кампаний */}
|
||||
<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">
|
||||
{campaignStats.reduce((sum, stat) => sum + stat.days.length, 0)} дней данных
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4">
|
||||
<div className="bg-white/5 rounded-lg p-3">
|
||||
<div className="text-sm text-white/60 mb-1">Показы</div>
|
||||
<div className="font-bold text-white">{formatNumber(campaign.views)}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 rounded-lg p-3">
|
||||
<div className="text-sm text-white/60 mb-1">Клики</div>
|
||||
<div className="font-bold text-white">{formatNumber(campaign.clicks)}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 rounded-lg p-3">
|
||||
<div className="text-sm text-white/60 mb-1">CTR</div>
|
||||
<div className="font-bold text-white">{formatPercent(campaign.ctr)}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 rounded-lg p-3">
|
||||
<div className="text-sm text-white/60 mb-1">CPC</div>
|
||||
<div className="font-bold text-white">{formatCurrency(campaign.cpc)}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 rounded-lg p-3">
|
||||
<div className="text-sm text-white/60 mb-1">Затраты</div>
|
||||
<div className="font-bold text-white">{formatCurrency(campaign.sum)}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 rounded-lg p-3">
|
||||
<div className="text-sm text-white/60 mb-1">Заказы</div>
|
||||
<div className="font-bold text-white">{formatNumber(campaign.orders)}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 rounded-lg p-3">
|
||||
<div className="text-sm text-white/60 mb-1">CR</div>
|
||||
<div className="font-bold text-white">{formatPercent(campaign.cr)}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 rounded-lg p-3">
|
||||
<div className="text-sm text-white/60 mb-1">Выручка</div>
|
||||
<div className="font-bold text-white">{formatCurrency(campaign.sum_price)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<UltraCompactCampaignTable
|
||||
campaigns={campaignStats}
|
||||
expandedCampaigns={expandedCampaigns}
|
||||
onToggleExpand={toggleCampaignExpanded}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="glass-card h-full overflow-hidden p-6">
|
||||
@ -471,7 +1001,7 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
|
||||
<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">Введите ID кампаний для получения детальной статистики</p>
|
||||
<p className="text-white/60 mb-4">Выберите кампании для получения детальной статистики</p>
|
||||
<p className="text-white/40 text-sm">
|
||||
Поддерживается API Wildberries /adv/v2/fullstats
|
||||
</p>
|
||||
|
@ -712,7 +712,10 @@ export const GET_WILDBERRIES_CAMPAIGN_STATS = gql`
|
||||
cr
|
||||
shks
|
||||
sum_price
|
||||
dates
|
||||
interval {
|
||||
begin
|
||||
end
|
||||
}
|
||||
days {
|
||||
date
|
||||
views
|
||||
@ -725,25 +728,65 @@ export const GET_WILDBERRIES_CAMPAIGN_STATS = gql`
|
||||
cr
|
||||
shks
|
||||
sum_price
|
||||
apps {
|
||||
views
|
||||
clicks
|
||||
ctr
|
||||
cpc
|
||||
sum
|
||||
atbs
|
||||
orders
|
||||
cr
|
||||
shks
|
||||
sum_price
|
||||
appType
|
||||
nm {
|
||||
views
|
||||
clicks
|
||||
ctr
|
||||
cpc
|
||||
sum
|
||||
atbs
|
||||
orders
|
||||
cr
|
||||
shks
|
||||
sum_price
|
||||
name
|
||||
nmId
|
||||
}
|
||||
}
|
||||
}
|
||||
boosterStats {
|
||||
date
|
||||
views
|
||||
clicks
|
||||
ctr
|
||||
cpc
|
||||
sum
|
||||
atbs
|
||||
orders
|
||||
cr
|
||||
shks
|
||||
sum_price
|
||||
nm
|
||||
avg_position
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const GET_WILDBERRIES_CAMPAIGNS_LIST = gql`
|
||||
query GetWildberriesCampaignsList {
|
||||
getWildberriesCampaignsList {
|
||||
success
|
||||
message
|
||||
data {
|
||||
adverts {
|
||||
type
|
||||
status
|
||||
count
|
||||
advert_list {
|
||||
advertId
|
||||
changeTime
|
||||
}
|
||||
}
|
||||
all
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Админ запросы
|
||||
export const ADMIN_ME = gql`
|
||||
query AdminMe {
|
||||
|
@ -5836,6 +5836,73 @@ const wildberriesQueries = {
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
getWildberriesCampaignsList: async (
|
||||
_: unknown,
|
||||
__: unknown,
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.user) {
|
||||
throw new GraphQLError("Требуется авторизация", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Получаем организацию пользователя и её WB API ключ
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: context.user.id },
|
||||
include: {
|
||||
organization: {
|
||||
include: {
|
||||
apiKeys: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.organization) {
|
||||
throw new GraphQLError("Организация не найдена");
|
||||
}
|
||||
|
||||
if (user.organization.type !== "SELLER") {
|
||||
throw new GraphQLError("Доступно только для продавцов");
|
||||
}
|
||||
|
||||
const wbApiKeyRecord = user.organization.apiKeys?.find(
|
||||
(key) => key.marketplace === "WILDBERRIES" && key.isActive
|
||||
);
|
||||
|
||||
if (!wbApiKeyRecord) {
|
||||
throw new GraphQLError("WB API ключ не настроен");
|
||||
}
|
||||
|
||||
// Создаем экземпляр сервиса
|
||||
const wbService = new WildberriesService(wbApiKeyRecord.apiKey);
|
||||
|
||||
// Получаем список кампаний
|
||||
const campaignsList = await wbService.getCampaignsList();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: campaignsList,
|
||||
message: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching WB campaigns list:", error);
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Ошибка получения списка кампаний",
|
||||
data: {
|
||||
adverts: [],
|
||||
all: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Добавляем админ запросы и мутации к основным резолверам
|
||||
|
@ -102,6 +102,9 @@ export const typeDefs = gql`
|
||||
getWildberriesCampaignStats(
|
||||
input: WildberriesCampaignStatsInput!
|
||||
): WildberriesCampaignStatsResponse!
|
||||
|
||||
# Список кампаний Wildberries
|
||||
getWildberriesCampaignsList: WildberriesCampaignsListResponse!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
@ -1057,9 +1060,14 @@ export const typeDefs = gql`
|
||||
cr: Float!
|
||||
shks: Int!
|
||||
sum_price: Float!
|
||||
dates: [String!]!
|
||||
interval: WildberriesCampaignInterval
|
||||
days: [WildberriesCampaignDayStats!]!
|
||||
boosterStats: [WildberriesCampaignDayStats!]!
|
||||
boosterStats: [WildberriesBoosterStats!]!
|
||||
}
|
||||
|
||||
type WildberriesCampaignInterval {
|
||||
begin: String!
|
||||
end: String!
|
||||
}
|
||||
|
||||
type WildberriesCampaignDayStats {
|
||||
@ -1074,5 +1082,66 @@ export const typeDefs = gql`
|
||||
cr: Float!
|
||||
shks: Int!
|
||||
sum_price: Float!
|
||||
apps: [WildberriesAppStats!]
|
||||
}
|
||||
|
||||
type WildberriesAppStats {
|
||||
views: Int!
|
||||
clicks: Int!
|
||||
ctr: Float!
|
||||
cpc: Float!
|
||||
sum: Float!
|
||||
atbs: Int!
|
||||
orders: Int!
|
||||
cr: Float!
|
||||
shks: Int!
|
||||
sum_price: Float!
|
||||
appType: Int!
|
||||
nm: [WildberriesProductStats!]
|
||||
}
|
||||
|
||||
type WildberriesProductStats {
|
||||
views: Int!
|
||||
clicks: Int!
|
||||
ctr: Float!
|
||||
cpc: Float!
|
||||
sum: Float!
|
||||
atbs: Int!
|
||||
orders: Int!
|
||||
cr: Float!
|
||||
shks: Int!
|
||||
sum_price: Float!
|
||||
name: String!
|
||||
nmId: Int!
|
||||
}
|
||||
|
||||
type WildberriesBoosterStats {
|
||||
date: String!
|
||||
nm: Int!
|
||||
avg_position: Float!
|
||||
}
|
||||
|
||||
# Типы для списка кампаний
|
||||
type WildberriesCampaignsListResponse {
|
||||
success: Boolean!
|
||||
message: String
|
||||
data: WildberriesCampaignsData!
|
||||
}
|
||||
|
||||
type WildberriesCampaignsData {
|
||||
adverts: [WildberriesCampaignGroup!]!
|
||||
all: Int!
|
||||
}
|
||||
|
||||
type WildberriesCampaignGroup {
|
||||
type: Int!
|
||||
status: Int!
|
||||
count: Int!
|
||||
advert_list: [WildberriesCampaignItem!]!
|
||||
}
|
||||
|
||||
type WildberriesCampaignItem {
|
||||
advertId: Int!
|
||||
changeTime: String!
|
||||
}
|
||||
`;
|
||||
|
Reference in New Issue
Block a user