Merge branch 'main' of https://gittea.biveki.ru/Sfera/sfera
This commit is contained in:
@ -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>
|
||||||
|
@ -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 {
|
||||||
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Добавляем админ запросы и мутации к основным резолверам
|
// Добавляем админ запросы и мутации к основным резолверам
|
||||||
|
@ -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!
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
Reference in New Issue
Block a user