This commit is contained in:
Veronika Smirnova
2025-07-22 14:49:34 +03:00
15 changed files with 1688 additions and 486 deletions

View File

@ -0,0 +1,485 @@
"use client"
import { useState, useEffect } from 'react'
import { useQuery, useLazyQuery } from '@apollo/client'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { GET_WILDBERRIES_CAMPAIGN_STATS } from '@/graphql/queries'
import { TrendingUp, Search, Calendar, Eye, MousePointer, ShoppingCart, DollarSign, Percent, AlertCircle } from 'lucide-react'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
type ChartConfig
} from '@/components/ui/chart'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from 'recharts'
interface CampaignStatsProps {
selectedPeriod: string
useCustomDates: boolean
startDate: string
endDate: string
}
interface CampaignStats {
advertId: number
views: number
clicks: number
ctr: number
cpc: number
sum: number
atbs: number
orders: number
cr: number
shks: number
sum_price: number
dates: string[]
days: Array<{
date: string
views: number
clicks: number
ctr: number
cpc: number
sum: number
atbs: number
orders: number
cr: number
shks: number
sum_price: number
}>
boosterStats: Array<{
date: string
views: number
clicks: number
ctr: number
cpc: number
sum: number
atbs: number
orders: number
cr: number
shks: number
sum_price: number
}>
}
export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endDate }: CampaignStatsProps) {
const [campaignIds, setCampaignIds] = useState('')
const [campaignStats, setCampaignStats] = useState<CampaignStats[]>([])
const [getCampaignStats, { loading, error }] = useLazyQuery(GET_WILDBERRIES_CAMPAIGN_STATS, {
onCompleted: (data) => {
if (data.getWildberriesCampaignStats.success) {
setCampaignStats(data.getWildberriesCampaignStats.data)
}
},
onError: (error) => {
console.error('Campaign stats error:', error)
}
})
const handleGetStats = () => {
if (!campaignIds.trim()) return
const ids = campaignIds.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id))
if (ids.length === 0) return
let campaigns
if (useCustomDates && startDate && endDate) {
// Используем интервал для пользовательских дат
campaigns = ids.map(id => ({
id,
interval: {
begin: startDate,
end: endDate
}
}))
} else {
// Для предустановленных периодов вычисляем даты
const endDateCalc = new Date()
const startDateCalc = new Date()
switch (selectedPeriod) {
case 'week':
startDateCalc.setDate(endDateCalc.getDate() - 7)
break
case 'month':
startDateCalc.setMonth(endDateCalc.getMonth() - 1)
break
case 'quarter':
startDateCalc.setMonth(endDateCalc.getMonth() - 3)
break
}
campaigns = ids.map(id => ({
id,
interval: {
begin: startDateCalc.toISOString().split('T')[0],
end: endDateCalc.toISOString().split('T')[0]
}
}))
}
getCampaignStats({
variables: {
input: { campaigns }
}
})
}
const formatCurrency = (value: number) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value)
}
const formatNumber = (value: number) => {
return new Intl.NumberFormat('ru-RU').format(value)
}
const formatPercent = (value: number) => {
return `${value.toFixed(2)}%`
}
// Подготовка данных для графика
const chartData = campaignStats.length > 0 ? campaignStats[0]?.days?.map(day => ({
date: new Date(day.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }),
views: day.views,
clicks: day.clicks,
sum: day.sum,
orders: day.orders
})) || [] : []
const chartConfig = {
views: {
label: "Показы",
color: "#8b5cf6",
},
clicks: {
label: "Клики",
color: "#06b6d4",
},
sum: {
label: "Затраты (₽)",
color: "#f59e0b",
},
orders: {
label: "Заказы",
color: "#10b981",
},
}
return (
<div className="h-full flex flex-col space-y-4 overflow-hidden">
{/* Форма поиска кампаний */}
<Card className="glass-card flex-shrink-0 p-4">
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-white/80 mb-2">
ID кампаний (через запятую)
</label>
<Input
placeholder="Например: 12345, 67890, 11111"
value={campaignIds}
onChange={(e) => setCampaignIds(e.target.value)}
className="bg-white/5 border-white/20 text-white placeholder:text-white/40"
/>
</div>
<Button
onClick={handleGetStats}
disabled={loading || !campaignIds.trim()}
className="bg-white/10 hover:bg-white/20 border border-white/20 text-white flex items-center gap-2 mt-6"
>
{loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
Загрузка...
</>
) : (
<>
<Search className="h-4 w-4" />
Получить статистику
</>
)}
</Button>
</div>
</Card>
{/* Ошибки */}
{error && (
<Alert className="bg-red-500/10 border-red-500/30 text-red-400">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{error.message}
</AlertDescription>
</Alert>
)}
{/* Результаты */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Card key={i} className="glass-card p-6">
<Skeleton className="h-8 w-48 mb-4 bg-white/10" />
<div className="grid grid-cols-4 gap-4">
{[1, 2, 3, 4].map((j) => (
<Skeleton key={j} className="h-16 bg-white/5" />
))}
</div>
</Card>
))}
</div>
) : campaignStats.length > 0 ? (
<div className="space-y-6">
{/* Общая статистика по всем кампаниям */}
<Card className="glass-card p-6">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
Общая статистика ({campaignStats.length} кампаний)
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4 mb-6">
{/* Показы */}
<div className="bg-white/5 rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<Eye className="h-4 w-4 text-purple-400" />
<span className="text-sm text-white/60">Показы</span>
</div>
<div className="text-xl font-bold text-white">
{formatNumber(campaignStats.reduce((sum, stat) => sum + stat.views, 0))}
</div>
</div>
{/* Клики */}
<div className="bg-white/5 rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<MousePointer className="h-4 w-4 text-cyan-400" />
<span className="text-sm text-white/60">Клики</span>
</div>
<div className="text-xl font-bold text-white">
{formatNumber(campaignStats.reduce((sum, stat) => sum + stat.clicks, 0))}
</div>
</div>
{/* CTR */}
<div className="bg-white/5 rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<Percent className="h-4 w-4 text-green-400" />
<span className="text-sm text-white/60">CTR</span>
</div>
<div className="text-xl font-bold text-white">
{formatPercent(
campaignStats.reduce((sum, stat, _, arr) => {
const totalViews = arr.reduce((s, st) => s + st.views, 0)
const totalClicks = arr.reduce((s, st) => s + st.clicks, 0)
return totalViews > 0 ? (totalClicks / totalViews) * 100 : 0
}, 0)
)}
</div>
</div>
{/* CPC */}
<div className="bg-white/5 rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<DollarSign className="h-4 w-4 text-amber-400" />
<span className="text-sm text-white/60">CPC</span>
</div>
<div className="text-xl font-bold text-white">
{formatCurrency(
campaignStats.reduce((sum, stat, _, arr) => {
const totalClicks = arr.reduce((s, st) => s + st.clicks, 0)
const totalSum = arr.reduce((s, st) => s + st.sum, 0)
return totalClicks > 0 ? totalSum / totalClicks : 0
}, 0)
)}
</div>
</div>
{/* Затраты */}
<div className="bg-white/5 rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<DollarSign className="h-4 w-4 text-red-400" />
<span className="text-sm text-white/60">Затраты</span>
</div>
<div className="text-xl font-bold text-white">
{formatCurrency(campaignStats.reduce((sum, stat) => sum + stat.sum, 0))}
</div>
</div>
{/* Заказы */}
<div className="bg-white/5 rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<ShoppingCart className="h-4 w-4 text-green-400" />
<span className="text-sm text-white/60">Заказы</span>
</div>
<div className="text-xl font-bold text-white">
{formatNumber(campaignStats.reduce((sum, stat) => sum + stat.orders, 0))}
</div>
</div>
{/* CR */}
<div className="bg-white/5 rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<Percent className="h-4 w-4 text-blue-400" />
<span className="text-sm text-white/60">CR</span>
</div>
<div className="text-xl font-bold text-white">
{formatPercent(
campaignStats.reduce((sum, stat, _, arr) => {
const totalClicks = arr.reduce((s, st) => s + st.clicks, 0)
const totalOrders = arr.reduce((s, st) => s + st.orders, 0)
return totalClicks > 0 ? (totalOrders / totalClicks) * 100 : 0
}, 0)
)}
</div>
</div>
{/* Выручка */}
<div className="bg-white/5 rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<DollarSign className="h-4 w-4 text-emerald-400" />
<span className="text-sm text-white/60">Выручка</span>
</div>
<div className="text-xl font-bold text-white">
{formatCurrency(campaignStats.reduce((sum, stat) => sum + stat.sum_price, 0))}
</div>
</div>
</div>
{/* График */}
{chartData.length > 0 && (
<div className="mt-6">
<h4 className="text-md font-medium text-white mb-4">Динамика по дням</h4>
<div className="h-80">
<ChartContainer config={chartConfig}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis
dataKey="date"
tick={{ fill: 'rgba(255,255,255,0.6)', fontSize: 12 }}
axisLine={false}
/>
<YAxis
tick={{ fill: 'rgba(255,255,255,0.6)', fontSize: 12 }}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<ChartLegend content={<ChartLegendContent />} />
<Line
type="monotone"
dataKey="views"
stroke="#8b5cf6"
strokeWidth={2}
dot={{ fill: '#8b5cf6', strokeWidth: 2, r: 4 }}
/>
<Line
type="monotone"
dataKey="clicks"
stroke="#06b6d4"
strokeWidth={2}
dot={{ fill: '#06b6d4', strokeWidth: 2, r: 4 }}
/>
<Line
type="monotone"
dataKey="sum"
stroke="#f59e0b"
strokeWidth={2}
dot={{ fill: '#f59e0b', strokeWidth: 2, r: 4 }}
/>
<Line
type="monotone"
dataKey="orders"
stroke="#10b981"
strokeWidth={2}
dot={{ fill: '#10b981', strokeWidth: 2, r: 4 }}
/>
</LineChart>
</ChartContainer>
</div>
</div>
)}
</Card>
{/* Детальная статистика по каждой кампании */}
{campaignStats.map((campaign) => (
<Card key={campaign.advertId} className="glass-card p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">
Кампания #{campaign.advertId}
</h3>
<Badge variant="outline" className="border-white/20 text-white">
{campaign.days.length} дней
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4">
<div className="bg-white/5 rounded-lg p-3">
<div className="text-sm text-white/60 mb-1">Показы</div>
<div className="font-bold text-white">{formatNumber(campaign.views)}</div>
</div>
<div className="bg-white/5 rounded-lg p-3">
<div className="text-sm text-white/60 mb-1">Клики</div>
<div className="font-bold text-white">{formatNumber(campaign.clicks)}</div>
</div>
<div className="bg-white/5 rounded-lg p-3">
<div className="text-sm text-white/60 mb-1">CTR</div>
<div className="font-bold text-white">{formatPercent(campaign.ctr)}</div>
</div>
<div className="bg-white/5 rounded-lg p-3">
<div className="text-sm text-white/60 mb-1">CPC</div>
<div className="font-bold text-white">{formatCurrency(campaign.cpc)}</div>
</div>
<div className="bg-white/5 rounded-lg p-3">
<div className="text-sm text-white/60 mb-1">Затраты</div>
<div className="font-bold text-white">{formatCurrency(campaign.sum)}</div>
</div>
<div className="bg-white/5 rounded-lg p-3">
<div className="text-sm text-white/60 mb-1">Заказы</div>
<div className="font-bold text-white">{formatNumber(campaign.orders)}</div>
</div>
<div className="bg-white/5 rounded-lg p-3">
<div className="text-sm text-white/60 mb-1">CR</div>
<div className="font-bold text-white">{formatPercent(campaign.cr)}</div>
</div>
<div className="bg-white/5 rounded-lg p-3">
<div className="text-sm text-white/60 mb-1">Выручка</div>
<div className="font-bold text-white">{formatCurrency(campaign.sum_price)}</div>
</div>
</div>
</Card>
))}
</div>
) : (
<Card className="glass-card h-full overflow-hidden p-6">
<div className="flex items-center justify-center h-full">
<div className="text-center">
<TrendingUp className="h-12 w-12 text-white/40 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Статистика рекламных кампаний</h3>
<p className="text-white/60 mb-4">Введите ID кампаний для получения детальной статистики</p>
<p className="text-white/40 text-sm">
Поддерживается API Wildberries /adv/v2/fullstats
</p>
</div>
</div>
</Card>
)}
</div>
</div>
)
}

View File

@ -1,13 +1,13 @@
"use client"
import { useState, useEffect } from 'react'
import React, { useState, useEffect } from 'react'
import { useQuery } from '@apollo/client'
import { gql } from '@apollo/client'
import { Card } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import { TrendingUp } from 'lucide-react'
import { TrendingUp, Info, BarChart3 } from 'lucide-react'
import {
ChartConfig,
ChartContainer,
@ -56,6 +56,8 @@ interface SalesTabProps {
useCustomDates?: boolean
startDate?: string
endDate?: string
onPeriodChange?: (period: string) => void
onUseCustomDatesChange?: (useCustom: boolean) => void
}
// Mock данные для графиков
@ -167,12 +169,12 @@ const mockTableData = [
},
]
export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }: SalesTabProps) {
export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate, onPeriodChange, onUseCustomDatesChange }: SalesTabProps) {
// Состояния для чекбоксов фильтрации
const [visibleMetrics, setVisibleMetrics] = useState({
sales: true,
orders: true,
advertising: true,
advertising: true, // Включаем, теперь используем двухосевой график
refusals: true,
returns: true,
})
@ -194,15 +196,99 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
if (wbData?.getWildberriesStatistics?.success && wbData.getWildberriesStatistics.data) {
const realData = wbData.getWildberriesStatistics.data
// Обновляем данные для графика
const newChartData = realData.map((item: {
// Улучшенная агрегация с более надежной обработкой дат
const aggregateByDate = (data: Array<{
date: string;
sales: number;
orders: number;
advertising: number;
refusals: number;
returns: number;
}) => ({
revenue: number;
buyoutPercentage: number;
}>) => {
const grouped = new Map<string, {
date: string;
sales: number;
orders: number;
advertising: number;
refusals: number;
returns: number;
revenue: number;
buyoutPercentages: number[];
}>()
data.forEach((item) => {
// Улучшенная нормализация даты - убираем время и часовой пояс
let normalizedDate: string
if (item.date.includes('T')) {
// Формат: 2025-07-19T03:00:00+03:00 или 2025-07-19T03:00:00
normalizedDate = item.date.split('T')[0]
} else if (item.date.includes(' ')) {
// Формат: 2025-07-19 03:00:00
normalizedDate = item.date.split(' ')[0]
} else {
// Формат: 2025-07-19
normalizedDate = item.date
}
// Дополнительная проверка на корректность формата YYYY-MM-DD
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalizedDate)) {
console.warn(`Invalid date format: ${item.date}, normalized: ${normalizedDate}`)
return
}
if (!grouped.has(normalizedDate)) {
grouped.set(normalizedDate, {
date: normalizedDate,
sales: 0,
orders: 0,
advertising: 0,
refusals: 0,
returns: 0,
revenue: 0,
buyoutPercentages: []
})
}
const group = grouped.get(normalizedDate)!
group.sales += Number(item.sales) || 0
group.orders += Number(item.orders) || 0
group.advertising += Number(item.advertising) || 0
group.refusals += Number(item.refusals) || 0
group.returns += Number(item.returns) || 0
group.revenue += Number(item.revenue) || 0
// Собираем все процента выкупа для корректного усреднения
if (item.buyoutPercentage && item.buyoutPercentage > 0) {
group.buyoutPercentages.push(Number(item.buyoutPercentage))
}
})
// Преобразуем в финальный формат
return Array.from(grouped.values()).map(group => ({
date: group.date,
sales: group.sales,
orders: group.orders,
advertising: group.advertising,
refusals: group.refusals,
returns: group.returns,
revenue: group.revenue,
buyoutPercentage: group.buyoutPercentages.length > 0
? Math.round(group.buyoutPercentages.reduce((a, b) => a + b, 0) / group.buyoutPercentages.length * 10) / 10
: group.orders > 0 ? Math.round((group.sales / group.orders) * 100 * 10) / 10 : 0
}))
}
const aggregatedData = aggregateByDate(realData)
// Сортируем по дате (новые сверху)
const sortedData = aggregatedData.sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
)
// Обновляем данные для графика
const newChartData = sortedData.map((item) => ({
date: new Date(item.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }),
sales: item.sales,
orders: item.orders,
@ -212,16 +298,7 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
}))
// Обновляем данные для таблицы
const newTableData = realData.map((item: {
date: string;
sales: number;
orders: number;
advertising: number;
refusals: number;
returns: number;
revenue: number;
buyoutPercentage: number;
}) => ({
const newTableData = sortedData.map((item) => ({
date: new Date(item.date).toLocaleDateString('ru-RU'),
salesUnits: item.sales,
buyoutPercentage: item.buyoutPercentage,
@ -231,9 +308,9 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
returns: item.returns,
revenue: Math.round(item.revenue)
}))
setChartData(newChartData)
setTableData(newTableData)
setChartData(newChartData.reverse()) // Для графика - старые даты слева
setTableData(newTableData) // Для таблицы - новые даты сверху
}
}, [wbData])
@ -248,25 +325,83 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
// Проверяем состояние загрузки и данных
const isLoading = loading || (useCustomDates && (!startDate || !endDate))
const hasData = tableData.length > 0
// Состояние для сортировки
const [sortField, setSortField] = useState<string>('')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
// Функция сортировки
const handleSort = (field: string) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortDirection('desc')
}
}
// Отсортированные данные таблицы
const sortedTableData = React.useMemo(() => {
if (!sortField) return tableData
return [...tableData].sort((a, b) => {
let aValue: string | number = a[sortField as keyof typeof a] as string | number
let bValue: string | number = b[sortField as keyof typeof b] as string | number
// Для даты используем особую логику
if (sortField === 'date') {
aValue = new Date(aValue).getTime()
bValue = new Date(bValue).getTime()
}
if (sortDirection === 'asc') {
return aValue > bValue ? 1 : -1
} else {
return aValue < bValue ? 1 : -1
}
})
}, [tableData, sortField, sortDirection])
// Вычисляем итоги
const totals = React.useMemo(() => {
if (!hasData) {
return {
salesUnits: 0,
buyoutPercentage: 0,
advertising: 0,
orders: 0,
refusals: 0,
returns: 0,
revenue: 0
}
}
const totalSales = tableData.reduce((sum, row) => sum + row.salesUnits, 0)
const totalOrders = tableData.reduce((sum, row) => sum + row.orders, 0)
// ПРАВИЛЬНЫЙ расчёт % выкупа: общие продажи / общие заказы * 100
const correctBuyoutPercentage = totalOrders > 0
? Math.round((totalSales / totalOrders) * 100 * 10) / 10
: 0
return {
salesUnits: totalSales,
buyoutPercentage: correctBuyoutPercentage,
advertising: tableData.reduce((sum, row) => sum + row.advertising, 0),
orders: totalOrders,
refusals: tableData.reduce((sum, row) => sum + row.refusals, 0),
returns: tableData.reduce((sum, row) => sum + row.returns, 0),
revenue: tableData.reduce((sum, row) => sum + row.revenue, 0)
}
}, [tableData, hasData])
const hasAnyActivity = hasData && tableData.some(row =>
row.salesUnits > 0 || row.orders > 0 || row.advertising > 0 || row.refusals > 0 || row.returns > 0
)
// Вычисляем итоги на основе текущих данных
const totals = {
salesUnits: tableData.reduce((sum: number, row: { salesUnits: number }) => sum + row.salesUnits, 0),
buyoutPercentage: tableData.length > 0 ? tableData.reduce((sum: number, row: { buyoutPercentage: number }) => sum + row.buyoutPercentage, 0) / tableData.length : 0,
advertising: tableData.reduce((sum: number, row: { advertising: number }) => sum + row.advertising, 0),
orders: tableData.reduce((sum: number, row: { orders: number }) => sum + row.orders, 0),
refusals: tableData.reduce((sum: number, row: { refusals: number }) => sum + row.refusals, 0),
returns: tableData.reduce((sum: number, row: { returns: number }) => sum + row.returns, 0),
revenue: tableData.reduce((sum: number, row: { revenue: number }) => sum + row.revenue, 0),
}
// Если загружается
if (isLoading) {
return (
<div className="h-full flex flex-col space-y-3">
<div className="h-full flex flex-col space-y-2">
<Card className="glass-card p-4 flex-shrink-0" style={{ height: '380px' }}>
<div className="h-full flex flex-col">
<Skeleton className="h-6 w-48 mb-4" />
@ -318,21 +453,74 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
}
return (
<div className="h-full flex flex-col space-y-3">
<div className="h-full flex flex-col space-y-2">
{/* График с фильтрами */}
<Card className="glass-card p-4 flex-shrink-0 overflow-hidden" style={{ height: '380px' }}>
<Card className="glass-card p-3 flex-shrink-0 overflow-hidden" style={{ height: '380px' }}>
<div className="h-full flex flex-col min-h-0">
{/* Компактный заголовок */}
{/* Заголовок с переключателями периода */}
<div className="flex items-center justify-between mb-2">
<h3 className="text-white font-semibold">Динамика показателей</h3>
{error && (
<div className="text-red-400 text-xs">Предупреждение: {error.message}</div>
)}
{/* Переключатели периода */}
<div className="flex items-center gap-2">
<div className="flex gap-1 bg-white/5 backdrop-blur border border-white/10 rounded-lg p-0.5">
<button
onClick={() => {
onPeriodChange?.('week')
onUseCustomDatesChange?.(false)
}}
className={`px-2 py-1 rounded text-xs font-medium transition-all duration-200 ${
selectedPeriod === 'week' && !useCustomDates
? 'bg-white/20 text-white shadow-sm'
: 'text-white/60 hover:bg-white/10 hover:text-white/80'
}`}
>
Неделя
</button>
<button
onClick={() => {
onPeriodChange?.('month')
onUseCustomDatesChange?.(false)
}}
className={`px-2 py-1 rounded text-xs font-medium transition-all duration-200 ${
selectedPeriod === 'month' && !useCustomDates
? 'bg-white/20 text-white shadow-sm'
: 'text-white/60 hover:bg-white/10 hover:text-white/80'
}`}
>
Месяц
</button>
<button
onClick={() => {
onPeriodChange?.('quarter')
onUseCustomDatesChange?.(false)
}}
className={`px-2 py-1 rounded text-xs font-medium transition-all duration-200 ${
selectedPeriod === 'quarter' && !useCustomDates
? 'bg-white/20 text-white shadow-sm'
: 'text-white/60 hover:bg-white/10 hover:text-white/80'
}`}
>
Квартал
</button>
</div>
{error && (
<div className="text-red-400 text-xs">Ошибка: {error.message}</div>
)}
</div>
</div>
{/* Компактные чекбоксы для фильтрации */}
<div className="mb-2 pb-2 border-b border-white/10">
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-white/60">Показать на графике:</span>
<span className="text-xs text-blue-400 flex items-center gap-1">
<BarChart3 className="w-3 h-3" />
Реклама показана на правой оси
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{Object.entries(chartConfig).map(([key, config]) => {
const isVisible = visibleMetrics[key as keyof typeof visibleMetrics]
return (
@ -382,47 +570,73 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
axisLine={false}
tickFormatter={(value) => value.slice(0, 5)}
/>
{/* Левая ось для основных метрик */}
<YAxis
yAxisId="left"
orientation="left"
tickLine={false}
axisLine={false}
className="text-white/60 text-xs"
/>
{/* Правая ось для рекламы */}
<YAxis
yAxisId="right"
orientation="right"
tickLine={false}
axisLine={false}
className="text-white/60 text-xs"
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dashed" />}
/>
{/* Условно рендерим бары в зависимости от чекбоксов */}
{visibleMetrics.sales && (
<Bar
dataKey="sales"
fill={chartConfig.sales.color}
radius={4}
/>
)}
{visibleMetrics.orders && (
<Bar
dataKey="orders"
fill={chartConfig.orders.color}
radius={4}
/>
)}
{visibleMetrics.advertising && (
<Bar
dataKey="advertising"
fill={chartConfig.advertising.color}
radius={4}
/>
)}
{visibleMetrics.refusals && (
<Bar
dataKey="refusals"
fill={chartConfig.refusals.color}
radius={4}
/>
)}
{visibleMetrics.returns && (
<Bar
dataKey="returns"
fill={chartConfig.returns.color}
radius={4}
/>
)}
{/* Основные метрики на левой оси */}
{visibleMetrics.sales && (
<Bar
yAxisId="left"
dataKey="sales"
fill={chartConfig.sales.color}
radius={4}
/>
)}
{visibleMetrics.orders && (
<Bar
yAxisId="left"
dataKey="orders"
fill={chartConfig.orders.color}
radius={4}
/>
)}
{visibleMetrics.refusals && (
<Bar
yAxisId="left"
dataKey="refusals"
fill={chartConfig.refusals.color}
radius={4}
/>
)}
{visibleMetrics.returns && (
<Bar
yAxisId="left"
dataKey="returns"
fill={chartConfig.returns.color}
radius={4}
/>
)}
{/* Реклама на правой оси */}
{visibleMetrics.advertising && (
<Bar
yAxisId="right"
dataKey="advertising"
fill={chartConfig.advertising.color}
radius={4}
/>
)}
</BarChart>
</ChartContainer>
</div>
@ -431,25 +645,162 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
{/* Таблица данных */}
<Card className="glass-card flex-1 overflow-hidden">
<div className="p-4 h-full flex flex-col">
<h3 className="text-white font-semibold mb-3 text-sm">Детальная статистика</h3>
<div className="p-3 h-full flex flex-col">
<h3 className="text-white font-semibold mb-2 text-sm">Детальная статистика</h3>
<div className="overflow-x-auto flex-1">
<table className="w-full">
<thead>
<tr className="border-b border-white/20">
<th className="text-left p-2 text-white font-semibold text-xs">Дата</th>
<th className="text-left p-2 text-white font-semibold text-xs">Продажи, шт</th>
<th className="text-left p-2 text-white font-semibold text-xs">% выкупов</th>
<th className="text-left p-2 text-white font-semibold text-xs">Реклама, </th>
<th className="text-left p-2 text-white font-semibold text-xs">Заказы</th>
<th className="text-left p-2 text-white font-semibold text-xs">Отказы</th>
<th className="text-left p-2 text-white font-semibold text-xs">Возвраты</th>
<th className="text-left p-2 text-white font-semibold text-xs">Выручка, </th>
<th className="text-left p-2 text-white font-semibold text-xs">
<button
onClick={() => handleSort('date')}
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
title="Нажмите для сортировки"
>
Дата
<span className="text-xs opacity-50">
{sortField === 'date' ? (
sortDirection === 'asc' ? '↑' : '↓'
) : '⇅'}
</span>
</button>
</th>
<th className="text-left p-2 text-white font-semibold text-xs">
<button
onClick={() => handleSort('salesUnits')}
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
title="Нажмите для сортировки"
>
Продажи, шт
<span className="text-xs opacity-50">
{sortField === 'salesUnits' ? (
sortDirection === 'asc' ? '↑' : '↓'
) : '⇅'}
</span>
</button>
</th>
<th className="text-left p-2 text-white font-semibold text-xs">
<button
onClick={() => handleSort('buyoutPercentage')}
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
title="Нажмите для сортировки"
>
% выкупов
<span className="text-xs opacity-50">
{sortField === 'buyoutPercentage' ? (
sortDirection === 'asc' ? '↑' : '↓'
) : '⇅'}
</span>
</button>
</th>
<th className="text-left p-2 text-white font-semibold text-xs">
<button
onClick={() => handleSort('advertising')}
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
title="Нажмите для сортировки"
>
Реклама,
<span className="text-xs opacity-50">
{sortField === 'advertising' ? (
sortDirection === 'asc' ? '↑' : '↓'
) : '⇅'}
</span>
</button>
</th>
<th className="text-left p-2 text-white font-semibold text-xs">
<button
onClick={() => handleSort('orders')}
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
title="Нажмите для сортировки"
>
Заказы
<span className="text-xs opacity-50">
{sortField === 'orders' ? (
sortDirection === 'asc' ? '↑' : '↓'
) : '⇅'}
</span>
</button>
</th>
<th className="text-left p-2 text-white font-semibold text-xs">
<button
onClick={() => handleSort('refusals')}
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
title="Нажмите для сортировки"
>
Отказы
<span className="text-xs opacity-50">
{sortField === 'refusals' ? (
sortDirection === 'asc' ? '↑' : '↓'
) : '⇅'}
</span>
</button>
</th>
<th className="text-left p-2 text-white font-semibold text-xs">
<button
onClick={() => handleSort('returns')}
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
title="Нажмите для сортировки"
>
Возвраты
<span className="text-xs opacity-50">
{sortField === 'returns' ? (
sortDirection === 'asc' ? '↑' : '↓'
) : '⇅'}
</span>
</button>
</th>
<th className="text-left p-2 text-white font-semibold text-xs">
<button
onClick={() => handleSort('revenue')}
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
title="Нажмите для сортировки"
>
Выручка,
<span className="text-xs opacity-50">
{sortField === 'revenue' ? (
sortDirection === 'asc' ? '↑' : '↓'
) : '⇅'}
</span>
</button>
</th>
</tr>
</thead>
<tbody>
{tableData.map((row, index) => (
{/* Итоговая строка сверху */}
<tr className="border-b-2 border-white/30 bg-white/10 font-semibold">
<td className="p-2 text-white text-xs font-bold">ИТОГО</td>
<td className="p-2 text-white text-xs font-bold">{totals.salesUnits}</td>
<td className="p-2 text-xs">
<Badge
variant="secondary"
className={`${
totals.buyoutPercentage >= 80
? 'bg-green-500/20 text-green-400'
: 'bg-yellow-500/20 text-yellow-400'
} font-bold`}
>
{totals.buyoutPercentage.toFixed(1)}%
</Badge>
</td>
<td className="p-2 text-white text-xs font-bold">{totals.advertising.toLocaleString('ru-RU')}</td>
<td className="p-2 text-white text-xs font-bold">{totals.orders}</td>
<td className="p-2 text-xs">
<Badge variant="secondary" className="bg-red-500/20 text-red-400 font-bold text-xs px-2 py-0.5">
{totals.refusals}
</Badge>
</td>
<td className="p-2 text-xs">
<Badge variant="secondary" className="bg-orange-500/20 text-orange-400 font-bold text-xs px-2 py-0.5">
{totals.returns}
</Badge>
</td>
<td className="p-2 text-white text-xs font-bold">
{totals.revenue.toLocaleString('ru-RU')}
</td>
</tr>
{sortedTableData.map((row, index) => (
<tr key={index} className="border-b border-white/10 hover:bg-white/5 transition-colors">
<td className="p-2 text-white/80 text-xs">{row.date}</td>
<td className="p-2 text-white text-xs font-medium">{row.salesUnits}</td>
@ -482,44 +833,15 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
</td>
</tr>
))}
{/* Результирующая строка */}
<tr className="border-t-2 border-white/30 bg-white/10 font-semibold">
<td className="p-2 text-white text-xs font-bold">ИТОГО</td>
<td className="p-2 text-white text-xs font-bold">{totals.salesUnits}</td>
<td className="p-2 text-xs">
<Badge
variant="secondary"
className={`${
totals.buyoutPercentage >= 80
? 'bg-green-500/20 text-green-400'
: 'bg-yellow-500/20 text-yellow-400'
} font-bold`}
>
{totals.buyoutPercentage.toFixed(1)}%
</Badge>
</td>
<td className="p-2 text-white text-xs font-bold">{totals.advertising.toLocaleString('ru-RU')}</td>
<td className="p-2 text-white text-xs font-bold">{totals.orders}</td>
<td className="p-2 text-xs">
<Badge variant="secondary" className="bg-red-500/20 text-red-400 font-bold text-xs px-2 py-0.5">
{totals.refusals}
</Badge>
</td>
<td className="p-2 text-xs">
<Badge variant="secondary" className="bg-orange-500/20 text-orange-400 font-bold text-xs px-2 py-0.5">
{totals.returns}
</Badge>
</td>
<td className="p-2 text-white text-xs font-bold">
{totals.revenue.toLocaleString('ru-RU')}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</Card>
</table>
</div>
<div className="mt-2 text-xs text-white/50 flex items-center gap-1">
<Info className="w-3 h-3" />
<span>Нажмите на заголовок столбца для сортировки</span>
</div>
</div>
</Card>
</div>
)
}

View File

@ -6,6 +6,7 @@ import { Card } from '@/components/ui/card'
import { Sidebar } from '@/components/dashboard/sidebar'
import { useSidebar } from '@/hooks/useSidebar'
import { SalesTab } from '@/components/seller-statistics/sales-tab'
import { AdvertisingTab } from '@/components/seller-statistics/advertising-tab'
import { DateRangePicker } from '@/components/ui/date-picker'
import { BarChart3, PieChart, TrendingUp, Calendar } from 'lucide-react'
@ -19,83 +20,8 @@ export function SellerStatisticsDashboard() {
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 overflow-hidden transition-all duration-300`}>
<div className="h-full w-full flex flex-col">
{/* Компактный заголовок с переключателями */}
<div className="flex items-center justify-between mb-3 flex-shrink-0">
<div>
<h1 className="text-xl font-bold text-white mb-1">Статистика продаж</h1>
<p className="text-white/50 text-sm">Аналитика продаж, заказов и рекламы</p>
</div>
{/* Переключатели периода и пользовательские даты */}
<div className="flex items-center gap-3">
{/* Стильные переключатели периода */}
<div className="flex gap-1 bg-white/5 backdrop-blur border border-white/10 rounded-xl p-1">
<button
onClick={() => {
setSelectedPeriod('week')
setUseCustomDates(false)
}}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200 ${
selectedPeriod === 'week' && !useCustomDates
? 'bg-white/20 text-white shadow-sm'
: 'text-white/60 hover:bg-white/10 hover:text-white/80'
}`}
>
Неделя
</button>
<button
onClick={() => {
setSelectedPeriod('month')
setUseCustomDates(false)
}}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200 ${
selectedPeriod === 'month' && !useCustomDates
? 'bg-white/20 text-white shadow-sm'
: 'text-white/60 hover:bg-white/10 hover:text-white/80'
}`}
>
Месяц
</button>
<button
onClick={() => {
setSelectedPeriod('quarter')
setUseCustomDates(false)
}}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200 ${
selectedPeriod === 'quarter' && !useCustomDates
? 'bg-white/20 text-white shadow-sm'
: 'text-white/60 hover:bg-white/10 hover:text-white/80'
}`}
>
Квартал
</button>
<button
onClick={() => setUseCustomDates(true)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200 flex items-center gap-1 ${
useCustomDates
? 'bg-white/20 text-white shadow-sm'
: 'text-white/60 hover:bg-white/10 hover:text-white/80'
}`}
>
<Calendar className="h-3 w-3" />
Период
</button>
</div>
{/* Выбор произвольных дат */}
{useCustomDates && (
<DateRangePicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
className="min-w-[280px]"
/>
)}
</div>
</div>
<main className={`flex-1 ${getSidebarMargin()} px-4 py-4 overflow-hidden transition-all duration-300`}>
<div className="h-full w-full flex flex-col">{/* Убираем ограничение по ширине для полного использования экрана */}
{/* Основной контент с табами */}
<div className="flex-1 overflow-hidden">
@ -132,19 +58,18 @@ export function SellerStatisticsDashboard() {
useCustomDates={useCustomDates}
startDate={startDate}
endDate={endDate}
onPeriodChange={setSelectedPeriod}
onUseCustomDatesChange={setUseCustomDates}
/>
</TabsContent>
<TabsContent value="advertising" className="h-full m-0 overflow-hidden">
<Card className="glass-card h-full overflow-hidden p-6">
<div className="flex items-center justify-center h-full">
<div className="text-center">
<TrendingUp className="h-12 w-12 text-white/40 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Статистика рекламы</h3>
<p className="text-white/60">Раздел в разработке</p>
</div>
</div>
</Card>
<AdvertisingTab
selectedPeriod={selectedPeriod}
useCustomDates={useCustomDates}
startDate={startDate}
endDate={endDate}
/>
</TabsContent>
<TabsContent value="other" className="h-full m-0 overflow-hidden">

View File

@ -0,0 +1,67 @@
"use client"
import * as React from "react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { ChevronLeft, ChevronRight } from "lucide-react"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
Chevron: ({ orientation, ...props }) =>
orientation === "left" ?
<ChevronLeft className="h-4 w-4" /> :
<ChevronRight className="h-4 w-4" />
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@ -1,8 +1,18 @@
"use client"
import * as React from "react"
import { Calendar } from "lucide-react"
import { Calendar, CalendarIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Calendar as CalendarComponent } from "@/components/ui/calendar"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { format, addDays } from "date-fns"
import { ru } from "date-fns/locale"
import { DateRange } from "react-day-picker"
interface DatePickerProps {
value?: string
@ -50,6 +60,65 @@ interface DateRangePickerProps {
disabled?: boolean
}
export function DatePickerWithRange({
onDateChange,
className,
}: {
onDateChange?: (dates: { from: Date | undefined; to: Date | undefined }) => void
className?: string
}) {
const [date, setDate] = React.useState<DateRange | undefined>({
from: new Date(2025, 3, 1), // Апрель 2025
to: addDays(new Date(2025, 3, 1), 30),
})
React.useEffect(() => {
onDateChange?.(date ? { from: date.from, to: date.to } : { from: undefined, to: undefined })
}, [date, onDateChange])
return (
<div className={cn("grid gap-2", className)}>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={"outline"}
className={cn(
"w-[300px] justify-start text-left font-normal",
!date && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date?.from ? (
date.to ? (
<>
{format(date.from, "dd.MM.yyyy", { locale: ru })} -{" "}
{format(date.to, "dd.MM.yyyy", { locale: ru })}
</>
) : (
format(date.from, "dd.MM.yyyy", { locale: ru })
)
) : (
<span>Выберите диапазон дат</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarComponent
initialFocus
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={setDate}
numberOfMonths={2}
locale={ru}
/>
</PopoverContent>
</Popover>
</div>
)
}
export function DateRangePicker({
startDate,
endDate,

View File

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }