This commit is contained in:
Veronika Smirnova
2025-07-28 14:38:28 +03:00
4 changed files with 966 additions and 257 deletions

View File

@ -8,8 +8,32 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
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 { GET_WILDBERRIES_CAMPAIGN_STATS } from '@/graphql/queries' import { Checkbox } from '@/components/ui/checkbox'
import { TrendingUp, Search, Calendar, Eye, MousePointer, ShoppingCart, DollarSign, Percent, AlertCircle } from 'lucide-react' 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 { import {
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
@ -18,13 +42,63 @@ import {
ChartLegendContent, ChartLegendContent,
type ChartConfig type ChartConfig
} from '@/components/ui/chart' } from '@/components/ui/chart'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from 'recharts' import { LineChart, Line, XAxis, YAxis, CartesianGrid } from 'recharts'
interface CampaignStatsProps { // Интерфейсы для типизации данных API
selectedPeriod: string interface CampaignProduct {
useCustomDates: boolean views: number
startDate: string clicks: number
endDate: string 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 { interface CampaignStats {
@ -39,38 +113,556 @@ interface CampaignStats {
cr: number cr: number
shks: number shks: number
sum_price: number sum_price: number
dates: string[] interval?: CampaignInterval
days: Array<{ days: CampaignDay[]
date: string boosterStats: BoosterStat[]
views: number }
clicks: number
ctr: number interface CampaignStatsProps {
cpc: number selectedPeriod: string
sum: number useCustomDates: boolean
atbs: number startDate: string
orders: number endDate: string
cr: number }
shks: number
sum_price: number // Интерфейсы для списка кампаний
}> interface CampaignListItem {
boosterStats: Array<{ advertId: number
date: string changeTime: string
views: number }
clicks: number
ctr: number interface CampaignGroup {
cpc: number type: number
sum: number status: number
atbs: number count: number
orders: number advert_list: CampaignListItem[]
cr: number }
shks: number
sum_price: number 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) { export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endDate }: CampaignStatsProps) {
const [campaignIds, setCampaignIds] = useState('') const [selectedCampaignIds, setSelectedCampaignIds] = useState<number[]>([])
const [campaignStats, setCampaignStats] = useState<CampaignStats[]>([]) 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, { const [getCampaignStats, { loading, error }] = useLazyQuery(GET_WILDBERRIES_CAMPAIGN_STATS, {
onCompleted: (data) => { onCompleted: (data) => {
@ -83,15 +675,13 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
} }
}) })
const handleGetStats = () => { const handleCampaignsSelected = (ids: number[]) => {
if (!campaignIds.trim()) return
const ids = campaignIds.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id))
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 => ({
id, id,
interval: { interval: {
@ -100,7 +690,6 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
} }
})) }))
} else { } else {
// Для предустановленных периодов вычисляем даты
const endDateCalc = new Date() const endDateCalc = new Date()
const startDateCalc = 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) => { const formatCurrency = (value: number) => {
return new Intl.NumberFormat('ru-RU', { return new Intl.NumberFormat('ru-RU', {
style: 'currency', style: 'currency',
@ -164,7 +763,7 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
color: "#8b5cf6", color: "#8b5cf6",
}, },
clicks: { clicks: {
label: "Клики", label: "Клики",
color: "#06b6d4", color: "#06b6d4",
}, },
sum: { sum: {
@ -178,46 +777,19 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
} }
return ( return (
<div className="h-full flex flex-col space-y-4 overflow-hidden"> <div className="h-full flex flex-col space-y-3 overflow-hidden">
{/* Форма поиска кампаний */} {/* Компактный селектор кампаний */}
<Card className="glass-card flex-shrink-0 p-4"> <CompactCampaignSelector
<div className="flex items-center gap-4"> onCampaignsSelected={handleCampaignsSelected}
<div className="flex-1"> selectedCampaigns={selectedCampaignIds}
<label className="block text-sm font-medium text-white/80 mb-2"> loading={loading}
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>
{/* Ошибки */} {/* Ошибки */}
{error && ( {error && (
<Alert className="bg-red-500/10 border-red-500/30 text-red-400"> <Alert className="bg-red-500/10 border-red-500/30 text-red-400 py-2">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-3 w-3" />
<AlertDescription> <AlertDescription className="text-xs">
{error.message} {error.message}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@ -226,57 +798,61 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
{/* Результаты */} {/* Результаты */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{loading ? ( {loading ? (
<div className="space-y-4"> <div className="space-y-2">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<Card key={i} className="glass-card p-6"> <Skeleton key={i} className="h-12 bg-white/10" />
<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>
))} ))}
</div> </div>
) : campaignStats.length > 0 ? ( ) : campaignStats.length > 0 ? (
<div className="space-y-6"> <div className="space-y-3">
{/* Общая статистика по всем кампаниям */} {/* Компактная общая статистика */}
<Card className="glass-card p-6"> <Card className="glass-card p-3">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2"> <div className="flex items-center justify-between mb-3">
<TrendingUp className="h-5 w-5" /> <h3 className="text-sm font-semibold text-white flex items-center gap-2">
Общая статистика ({campaignStats.length} кампаний) <TrendingUp className="h-4 w-4" />
</h3> Сводка ({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="bg-white/5 rounded p-2">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-1 mb-1">
<Eye className="h-4 w-4 text-purple-400" /> <Eye className="h-3 w-3 text-purple-400" />
<span className="text-sm text-white/60">Показы</span> <span className="text-xs text-white/60">Показы</span>
</div> </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))} {formatNumber(campaignStats.reduce((sum, stat) => sum + stat.views, 0))}
</div> </div>
</div> </div>
{/* Клики */} {/* Клики */}
<div className="bg-white/5 rounded-lg p-4"> <div className="bg-white/5 rounded p-2">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-1 mb-1">
<MousePointer className="h-4 w-4 text-cyan-400" /> <MousePointer className="h-3 w-3 text-cyan-400" />
<span className="text-sm text-white/60">Клики</span> <span className="text-xs text-white/60">Клики</span>
</div> </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))} {formatNumber(campaignStats.reduce((sum, stat) => sum + stat.clicks, 0))}
</div> </div>
</div> </div>
{/* CTR */} {/* CTR */}
<div className="bg-white/5 rounded-lg p-4"> <div className="bg-white/5 rounded p-2">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-1 mb-1">
<Percent className="h-4 w-4 text-green-400" /> <Percent className="h-3 w-3 text-green-400" />
<span className="text-sm text-white/60">CTR</span> <span className="text-xs text-white/60">CTR</span>
</div> </div>
<div className="text-xl font-bold text-white"> <div className="text-sm font-bold text-white">
{formatPercent( {formatPercent(
campaignStats.reduce((sum, stat, _, arr) => { campaignStats.reduce((sum, stat, _, arr) => {
const totalViews = arr.reduce((s, st) => s + st.views, 0) const totalViews = arr.reduce((s, st) => s + st.views, 0)
@ -288,12 +864,12 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
</div> </div>
{/* CPC */} {/* CPC */}
<div className="bg-white/5 rounded-lg p-4"> <div className="bg-white/5 rounded p-2">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-1 mb-1">
<DollarSign className="h-4 w-4 text-amber-400" /> <DollarSign className="h-3 w-3 text-amber-400" />
<span className="text-sm text-white/60">CPC</span> <span className="text-xs text-white/60">CPC</span>
</div> </div>
<div className="text-xl font-bold text-white"> <div className="text-sm font-bold text-white">
{formatCurrency( {formatCurrency(
campaignStats.reduce((sum, stat, _, arr) => { campaignStats.reduce((sum, stat, _, arr) => {
const totalClicks = arr.reduce((s, st) => s + st.clicks, 0) const totalClicks = arr.reduce((s, st) => s + st.clicks, 0)
@ -305,34 +881,34 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
</div> </div>
{/* Затраты */} {/* Затраты */}
<div className="bg-white/5 rounded-lg p-4"> <div className="bg-white/5 rounded p-2">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-1 mb-1">
<DollarSign className="h-4 w-4 text-red-400" /> <DollarSign className="h-3 w-3 text-red-400" />
<span className="text-sm text-white/60">Затраты</span> <span className="text-xs text-white/60">Затраты</span>
</div> </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))} {formatCurrency(campaignStats.reduce((sum, stat) => sum + stat.sum, 0))}
</div> </div>
</div> </div>
{/* Заказы */} {/* Заказы */}
<div className="bg-white/5 rounded-lg p-4"> <div className="bg-white/5 rounded p-2">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-1 mb-1">
<ShoppingCart className="h-4 w-4 text-green-400" /> <ShoppingCart className="h-3 w-3 text-green-400" />
<span className="text-sm text-white/60">Заказы</span> <span className="text-xs text-white/60">Заказы</span>
</div> </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))} {formatNumber(campaignStats.reduce((sum, stat) => sum + stat.orders, 0))}
</div> </div>
</div> </div>
{/* CR */} {/* CR */}
<div className="bg-white/5 rounded-lg p-4"> <div className="bg-white/5 rounded p-2">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-1 mb-1">
<Percent className="h-4 w-4 text-blue-400" /> <Percent className="h-3 w-3 text-blue-400" />
<span className="text-sm text-white/60">CR</span> <span className="text-xs text-white/60">CR</span>
</div> </div>
<div className="text-xl font-bold text-white"> <div className="text-sm font-bold text-white">
{formatPercent( {formatPercent(
campaignStats.reduce((sum, stat, _, arr) => { campaignStats.reduce((sum, stat, _, arr) => {
const totalClicks = arr.reduce((s, st) => s + st.clicks, 0) const totalClicks = arr.reduce((s, st) => s + st.clicks, 0)
@ -344,126 +920,80 @@ export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endD
</div> </div>
{/* Выручка */} {/* Выручка */}
<div className="bg-white/5 rounded-lg p-4"> <div className="bg-white/5 rounded p-2">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-1 mb-1">
<DollarSign className="h-4 w-4 text-emerald-400" /> <DollarSign className="h-3 w-3 text-emerald-400" />
<span className="text-sm text-white/60">Выручка</span> <span className="text-xs text-white/60">Выручка</span>
</div> </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))} {formatCurrency(campaignStats.reduce((sum, stat) => sum + stat.sum_price, 0))}
</div> </div>
</div> </div>
</div> </div>
{/* График */} {/* Компактный график */}
{chartData.length > 0 && ( {showChart && chartData.length > 0 && (
<div className="mt-6"> <div className="mt-3">
<h4 className="text-md font-medium text-white mb-4">Динамика по дням</h4> <div className="h-32">
<div className="h-80"> <ChartContainer config={chartConfig}>
<ChartContainer config={chartConfig}> <LineChart data={chartData}>
<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" tick={{ fill: 'rgba(255,255,255,0.6)', fontSize: 10 }}
tick={{ fill: 'rgba(255,255,255,0.6)', fontSize: 12 }} axisLine={false}
axisLine={false} />
/> <YAxis
<YAxis tick={{ fill: 'rgba(255,255,255,0.6)', fontSize: 10 }}
tick={{ fill: 'rgba(255,255,255,0.6)', fontSize: 12 }} axisLine={false}
axisLine={false} />
/> <ChartTooltip content={<ChartTooltipContent />} />
<ChartTooltip content={<ChartTooltipContent />} /> <Line
<ChartLegend content={<ChartLegendContent />} /> type="monotone"
<Line dataKey="views"
type="monotone" stroke="#8b5cf6"
dataKey="views" strokeWidth={1}
stroke="#8b5cf6" dot={{ fill: '#8b5cf6', strokeWidth: 1, r: 2 }}
strokeWidth={2} />
dot={{ fill: '#8b5cf6', strokeWidth: 2, r: 4 }} <Line
/> type="monotone"
<Line dataKey="clicks"
type="monotone" stroke="#06b6d4"
dataKey="clicks" strokeWidth={1}
stroke="#06b6d4" dot={{ fill: '#06b6d4', strokeWidth: 1, r: 2 }}
strokeWidth={2} />
dot={{ fill: '#06b6d4', strokeWidth: 2, r: 4 }} <Line
/> type="monotone"
<Line dataKey="orders"
type="monotone" stroke="#10b981"
dataKey="sum" strokeWidth={1}
stroke="#f59e0b" dot={{ fill: '#10b981', strokeWidth: 1, r: 2 }}
strokeWidth={2} />
dot={{ fill: '#f59e0b', strokeWidth: 2, r: 4 }} </LineChart>
/> </ChartContainer>
<Line </div>
type="monotone" </div>
dataKey="orders" )}
stroke="#10b981"
strokeWidth={2}
dot={{ fill: '#10b981', strokeWidth: 2, r: 4 }}
/>
</LineChart>
</ChartContainer>
</div>
</div>
)}
</Card> </Card>
{/* Детальная статистика по каждой кампании */} {/* Сверх-компактная таблица кампаний */}
{campaignStats.map((campaign) => ( <Card className="glass-card p-3">
<Card key={campaign.advertId} className="glass-card p-6"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center justify-between mb-4"> <h3 className="text-sm font-semibold text-white flex items-center gap-2">
<h3 className="text-lg font-semibold text-white"> <BarChart3 className="h-4 w-4" />
Кампания #{campaign.advertId} Детальная статистика
</h3> </h3>
<Badge variant="outline" className="border-white/20 text-white"> <div className="text-xs text-white/60">
{campaign.days.length} дней {campaignStats.reduce((sum, stat) => sum + stat.days.length, 0)} дней данных
</Badge>
</div> </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"> <UltraCompactCampaignTable
<div className="text-sm text-white/60 mb-1">Показы</div> campaigns={campaignStats}
<div className="font-bold text-white">{formatNumber(campaign.views)}</div> expandedCampaigns={expandedCampaigns}
</div> onToggleExpand={toggleCampaignExpanded}
/>
<div className="bg-white/5 rounded-lg p-3"> </Card>
<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>
))}
</div> </div>
) : ( ) : (
<Card className="glass-card h-full overflow-hidden p-6"> <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"> <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">Введите ID кампаний для получения детальной статистики</p> <p className="text-white/60 mb-4">Выберите кампании для получения детальной статистики</p>
<p className="text-white/40 text-sm"> <p className="text-white/40 text-sm">
Поддерживается API Wildberries /adv/v2/fullstats Поддерживается API Wildberries /adv/v2/fullstats
</p> </p>

View File

@ -754,7 +754,10 @@ export const GET_WILDBERRIES_CAMPAIGN_STATS = gql`
cr cr
shks shks
sum_price sum_price
dates interval {
begin
end
}
days { days {
date date
views views
@ -767,25 +770,65 @@ export const GET_WILDBERRIES_CAMPAIGN_STATS = gql`
cr cr
shks shks
sum_price 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 { boosterStats {
date date
views nm
clicks avg_position
ctr
cpc
sum
atbs
orders
cr
shks
sum_price
} }
} }
} }
} }
`; `;
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` export const ADMIN_ME = gql`
query AdminMe { query AdminMe {

View File

@ -5950,6 +5950,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,
},
};
}
},
}; };
// Добавляем админ запросы и мутации к основным резолверам // Добавляем админ запросы и мутации к основным резолверам

View File

@ -105,6 +105,9 @@ export const typeDefs = gql`
getWildberriesCampaignStats( getWildberriesCampaignStats(
input: WildberriesCampaignStatsInput! input: WildberriesCampaignStatsInput!
): WildberriesCampaignStatsResponse! ): WildberriesCampaignStatsResponse!
# Список кампаний Wildberries
getWildberriesCampaignsList: WildberriesCampaignsListResponse!
} }
type Mutation { type Mutation {
@ -1060,9 +1063,14 @@ export const typeDefs = gql`
cr: Float! cr: Float!
shks: Int! shks: Int!
sum_price: Float! sum_price: Float!
dates: [String!]! interval: WildberriesCampaignInterval
days: [WildberriesCampaignDayStats!]! days: [WildberriesCampaignDayStats!]!
boosterStats: [WildberriesCampaignDayStats!]! boosterStats: [WildberriesBoosterStats!]!
}
type WildberriesCampaignInterval {
begin: String!
end: String!
} }
type WildberriesCampaignDayStats { type WildberriesCampaignDayStats {
@ -1077,5 +1085,66 @@ export const typeDefs = gql`
cr: Float! cr: Float!
shks: Int! shks: Int!
sum_price: Float! 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!
} }
`; `;