This commit is contained in:
Veronika Smirnova
2025-07-22 13:29:31 +03:00
13 changed files with 2388 additions and 122 deletions

View File

@ -7,6 +7,13 @@ import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Progress } from '@/components/ui/progress'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Calendar,
Check,
@ -29,12 +36,20 @@ import {
Briefcase,
Eye,
Plus,
Minus
Minus,
Store,
Boxes,
ChevronDown,
ChevronRight,
Hash,
Package2,
Truck
} from 'lucide-react'
export function BusinessDemo() {
const [selectedProduct] = useState(null)
const [cartQuantity, setCartQuantity] = useState(1)
const [expandedSeller, setExpandedSeller] = useState(false)
// Данные для демонстрации
const scheduleData = Array.from({ length: 30 }, (_, i) => ({
@ -92,6 +107,39 @@ export function BusinessDemo() {
}
]
// Данные для поставки фулфилмента
const fulfillmentSupply = {
id: "1",
supplyNumber: "ФФ-2024-001",
supplyDate: "2024-01-15",
seller: {
id: "seller1",
name: "TechStore LLC",
storeName: "ТехноМагазин",
managerName: "Иванов Иван Иванович",
phone: "+7 (495) 123-45-67",
email: "contact@techstore.ru",
inn: "7701234567",
},
itemsQuantity: 150,
cargoPlaces: 5,
volume: 12.5,
responsibleEmployeeId: "emp1",
logisticsPartnerId: "log1",
status: "planned",
totalValue: 2500000,
}
const employees = [
{ id: "emp1", firstName: "Иван", lastName: "Петров", position: "Менеджер склада" },
{ id: "emp2", firstName: "Мария", lastName: "Сидорова", position: "Логист" }
]
const logisticsPartners = [
{ id: "log1", name: "ТК Энергия", fullName: "ООО ТК Энергия" },
{ id: "log2", name: "СДЭК", fullName: "ООО СДЭК" }
]
const wholesalers = [
{
id: '1',
@ -165,6 +213,52 @@ export function BusinessDemo() {
}).format(price)
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
}).format(amount)
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})
}
const getStatusBadge = (status: string) => {
const statusConfig = {
planned: {
color: "text-blue-300 border-blue-400/30",
label: "Запланировано",
},
"in-transit": {
color: "text-yellow-300 border-yellow-400/30",
label: "В пути",
},
delivered: {
color: "text-green-300 border-green-400/30",
label: "Доставлено",
},
"in-processing": {
color: "text-purple-300 border-purple-400/30",
label: "Обрабатывается",
},
}
const config =
statusConfig[status as keyof typeof statusConfig] || statusConfig.planned
return (
<Badge variant="outline" className={`glass-secondary ${config.color}`}>
{config.label}
</Badge>
)
}
return (
<div className="space-y-6">
{/* Табель рабочего времени */}
@ -385,6 +479,238 @@ export function BusinessDemo() {
</CardContent>
</Card>
{/* Карточка поставки фулфилмента */}
<Card className="glass-card border-white/10">
<CardHeader>
<CardTitle className="text-white">Карточка поставки фулфилмента</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Card className="glass-card p-4 hover:bg-white/10 transition-colors">
<div className="space-y-3">
{/* Компактный блок с названием магазина */}
<div className="flex items-center justify-between bg-white/5 rounded-lg p-2">
<div className="flex items-center gap-3">
<div className="p-1.5 bg-green-500/20 rounded">
<Store className="h-3 w-3 text-green-400" />
</div>
<div className="flex items-center gap-2">
<span className="text-white font-medium text-sm">
{fulfillmentSupply.seller.storeName}
</span>
<span className="text-white/60 text-xs"> </span>
<span className="text-white/80 text-xs">
{fulfillmentSupply.seller.managerName}
</span>
<span className="text-white/60 text-xs"> </span>
<span className="text-white/80 text-xs">
{fulfillmentSupply.seller.phone}
</span>
<span className="text-white/60 text-xs"> </span>
<div className="flex items-center gap-1">
<a
href={`https://t.me/${fulfillmentSupply.seller.phone.replace(
/[^\d]/g,
""
)}`}
target="_blank"
rel="noopener noreferrer"
className="p-1 bg-blue-500/20 rounded hover:bg-blue-500/30 transition-colors"
title="Написать в Telegram"
>
<svg
className="h-3 w-3 text-blue-400"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
</a>
<a
href={`https://wa.me/${fulfillmentSupply.seller.phone.replace(
/[^\d]/g,
""
)}`}
target="_blank"
rel="noopener noreferrer"
className="p-1 bg-green-500/20 rounded hover:bg-green-500/30 transition-colors"
title="Написать в WhatsApp"
>
<svg
className="h-3 w-3 text-green-400"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0020.885 3.488" />
</svg>
</a>
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setExpandedSeller(!expandedSeller)}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{expandedSeller ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</Button>
</div>
{/* Скрытая детальная информация о магазине */}
{expandedSeller && (
<div className="bg-white/5 rounded-lg p-3 space-y-2">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-white/60 text-xs">
Юридическое название
</p>
<p className="text-white text-sm">
{fulfillmentSupply.seller.name}
</p>
</div>
<div>
<p className="text-white/60 text-xs">ИНН</p>
<p className="text-white text-sm font-mono">
{fulfillmentSupply.seller.inn}
</p>
</div>
</div>
<div>
<p className="text-white/60 text-xs">Email</p>
<p className="text-white text-sm">
{fulfillmentSupply.seller.email}
</p>
</div>
</div>
)}
{/* Единый блок со всеми параметрами в одной строке */}
<div className="bg-white/5 rounded-lg p-4">
<div className="grid grid-cols-2 lg:grid-cols-9 gap-4 items-center">
{/* Номер поставки */}
<div className="text-center">
<p className="text-white/60 text-xs mb-1">Номер</p>
<p className="text-white font-semibold text-sm">
{fulfillmentSupply.supplyNumber}
</p>
</div>
{/* Дата поставки */}
<div className="text-center">
<p className="text-white/60 text-xs mb-1">Дата</p>
<p className="text-white font-semibold text-sm">
{formatDate(fulfillmentSupply.supplyDate)}
</p>
</div>
{/* Количество товаров */}
<div className="text-center">
<p className="text-white/60 text-xs mb-1">Товаров</p>
<p className="text-white font-semibold text-sm">
{fulfillmentSupply.itemsQuantity}
</p>
</div>
{/* Количество мест */}
<div className="text-center">
<p className="text-white/60 text-xs mb-1">Мест</p>
<p className="text-white font-semibold text-sm">
{fulfillmentSupply.cargoPlaces}
</p>
</div>
{/* Объём */}
<div className="text-center">
<p className="text-white/60 text-xs mb-1">Объём</p>
<p className="text-white font-semibold text-sm">
{fulfillmentSupply.volume} м³
</p>
</div>
{/* Стоимость */}
<div className="text-center">
<p className="text-white/60 text-xs mb-1">Стоимость</p>
<p className="text-green-400 font-semibold text-sm">
{formatCurrency(fulfillmentSupply.totalValue)}
</p>
</div>
{/* Ответственный сотрудник */}
<div className="col-span-1">
<p className="text-white/60 text-xs mb-1">
Ответственный
</p>
<Select defaultValue={fulfillmentSupply.responsibleEmployeeId}>
<SelectTrigger className="h-8 glass-input bg-white/10 border-white/20 text-white hover:bg-white/15 focus:bg-white/15 focus:ring-1 focus:ring-purple-400/50 text-xs">
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent className="bg-gray-900/95 backdrop-blur border-white/20 text-white">
{employees.map((employee) => (
<SelectItem
key={employee.id}
value={employee.id}
className="text-white hover:bg-white/10 focus:bg-white/10 text-xs"
>
<div className="flex flex-col">
<span className="font-medium">
{employee.firstName} {employee.lastName}
</span>
<span className="text-xs text-white/60">
{employee.position}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Логистический партнер */}
<div className="col-span-1">
<p className="text-white/60 text-xs mb-1">Логистика</p>
<Select defaultValue={fulfillmentSupply.logisticsPartnerId}>
<SelectTrigger className="h-8 glass-input bg-white/10 border-white/20 text-white hover:bg-white/15 focus:bg-white/15 focus:ring-1 focus:ring-orange-400/50 text-xs">
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent className="bg-gray-900/95 backdrop-blur border-white/20 text-white">
{logisticsPartners.map((partner) => (
<SelectItem
key={partner.id}
value={partner.id}
className="text-white hover:bg-white/10 focus:bg-white/10 text-xs"
>
<div className="flex flex-col">
<span className="font-medium">
{partner.name || partner.fullName || "Без названия"}
</span>
<span className="text-xs text-white/60">
Логистический партнер
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Статус */}
<div className="text-center">
<p className="text-white/60 text-xs mb-1">Статус</p>
<div className="flex justify-center">
{getStatusBadge(fulfillmentSupply.status)}
</div>
</div>
</div>
</div>
</div>
</Card>
</CardContent>
</Card>
{/* Карточки оптовиков */}
<Card className="glass-card border-white/10">
<CardHeader>

View File

@ -138,6 +138,10 @@ export function Sidebar() {
router.push("/fulfillment-statistics");
};
const handleSellerStatisticsClick = () => {
router.push("/seller-statistics");
};
const handlePartnersClick = () => {
router.push("/partners");
};
@ -153,6 +157,9 @@ export function Sidebar() {
const isFulfillmentStatisticsActive = pathname.startsWith(
"/fulfillment-statistics"
);
const isSellerStatisticsActive = pathname.startsWith(
"/seller-statistics"
);
const isEmployeesActive = pathname.startsWith("/employees");
const isSuppliesActive =
pathname.startsWith("/supplies") ||
@ -412,6 +419,25 @@ export function Sidebar() {
</Button>
)}
{/* Статистика - для селлеров */}
{user?.organization?.type === "SELLER" && (
<Button
variant={isSellerStatisticsActive ? "secondary" : "ghost"}
className={`w-full ${
isCollapsed ? "justify-center px-2 h-9" : "justify-start h-10"
} text-left transition-all duration-200 text-xs ${
isSellerStatisticsActive
? "bg-white/20 text-white hover:bg-white/30"
: "text-white/80 hover:bg-white/10 hover:text-white"
} cursor-pointer`}
onClick={handleSellerStatisticsClick}
title={isCollapsed ? "Статистика" : ""}
>
<BarChart3 className="h-4 w-4 flex-shrink-0" />
{!isCollapsed && <span className="ml-3">Статистика</span>}
</Button>
)}
{/* Входящие поставки - для фулфилмент */}
{user?.organization?.type === "FULFILLMENT" && (
<Button

View File

@ -0,0 +1,525 @@
"use client"
import { 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 {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
ResponsiveContainer,
} from 'recharts'
// GraphQL query для получения статистики WB
const GET_WILDBERRIES_STATISTICS = gql`
query GetWildberriesStatistics(
$period: String
$startDate: String
$endDate: String
) {
getWildberriesStatistics(
period: $period
startDate: $startDate
endDate: $endDate
) {
success
message
data {
date
sales
orders
advertising
refusals
returns
revenue
buyoutPercentage
}
}
}
`
interface SalesTabProps {
selectedPeriod: string
useCustomDates?: boolean
startDate?: string
endDate?: string
}
// Mock данные для графиков
const mockChartData = [
{ date: '01.11', sales: 45, orders: 52, advertising: 32, refusals: 8, returns: 3 },
{ date: '02.11', sales: 35, orders: 41, advertising: 28, refusals: 6, returns: 2 },
{ date: '03.11', sales: 52, orders: 61, advertising: 39, refusals: 9, returns: 4 },
{ date: '04.11', sales: 38, orders: 45, advertising: 31, refusals: 7, returns: 2 },
{ date: '05.11', sales: 58, orders: 69, advertising: 45, refusals: 11, returns: 5 },
{ date: '06.11', sales: 47, orders: 55, advertising: 37, refusals: 8, returns: 3 },
{ date: '07.11', sales: 56, orders: 66, advertising: 42, refusals: 10, returns: 4 },
]
// Конфигурация chart
const chartConfig = {
sales: {
label: "Продажи",
color: "#10b981", // зеленый
},
orders: {
label: "Заказы",
color: "#3b82f6", // синий
},
advertising: {
label: "Реклама",
color: "#f59e0b", // оранжевый
},
refusals: {
label: "Отказы",
color: "#ef4444", // красный
},
returns: {
label: "Возвраты",
color: "#8b5cf6", // фиолетовый
},
} satisfies ChartConfig
// Mock данные для таблицы
const mockTableData = [
{
date: '01.11.2024',
salesUnits: 45,
buyoutPercentage: 82.2,
advertising: 320,
orders: 52,
refusals: 8,
returns: 3,
revenue: 1250
},
{
date: '02.11.2024',
salesUnits: 35,
buyoutPercentage: 85.7,
advertising: 280,
orders: 41,
refusals: 6,
returns: 2,
revenue: 980
},
{
date: '03.11.2024',
salesUnits: 52,
buyoutPercentage: 78.8,
advertising: 390,
orders: 61,
refusals: 9,
returns: 4,
revenue: 1450
},
{
date: '04.11.2024',
salesUnits: 38,
buyoutPercentage: 84.4,
advertising: 310,
orders: 45,
refusals: 7,
returns: 2,
revenue: 1120
},
{
date: '05.11.2024',
salesUnits: 58,
buyoutPercentage: 80.6,
advertising: 450,
orders: 69,
refusals: 11,
returns: 5,
revenue: 1680
},
{
date: '06.11.2024',
salesUnits: 47,
buyoutPercentage: 83.0,
advertising: 370,
orders: 55,
refusals: 8,
returns: 3,
revenue: 1350
},
{
date: '07.11.2024',
salesUnits: 56,
buyoutPercentage: 81.2,
advertising: 420,
orders: 66,
refusals: 10,
returns: 4,
revenue: 1580
},
]
export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }: SalesTabProps) {
// Состояния для чекбоксов фильтрации
const [visibleMetrics, setVisibleMetrics] = useState({
sales: true,
orders: true,
advertising: true,
refusals: true,
returns: true,
})
// Получаем данные из WB API
const { data: wbData, loading, error } = useQuery(GET_WILDBERRIES_STATISTICS, {
variables: useCustomDates
? { startDate, endDate }
: { period: selectedPeriod },
errorPolicy: 'all',
skip: useCustomDates && (!startDate || !endDate) // Не запрашиваем пока не выбраны обе даты
})
// Данные для графика и таблицы
const [chartData, setChartData] = useState<typeof mockChartData>([])
const [tableData, setTableData] = useState<typeof mockTableData>([])
useEffect(() => {
if (wbData?.getWildberriesStatistics?.success && wbData.getWildberriesStatistics.data) {
const realData = wbData.getWildberriesStatistics.data
// Обновляем данные для графика
const newChartData = realData.map((item: {
date: string;
sales: number;
orders: number;
advertising: number;
refusals: number;
returns: number;
}) => ({
date: new Date(item.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }),
sales: item.sales,
orders: item.orders,
advertising: Math.round(item.advertising),
refusals: item.refusals,
returns: item.returns
}))
// Обновляем данные для таблицы
const newTableData = realData.map((item: {
date: string;
sales: number;
orders: number;
advertising: number;
refusals: number;
returns: number;
revenue: number;
buyoutPercentage: number;
}) => ({
date: new Date(item.date).toLocaleDateString('ru-RU'),
salesUnits: item.sales,
buyoutPercentage: item.buyoutPercentage,
advertising: Math.round(item.advertising),
orders: item.orders,
refusals: item.refusals,
returns: item.returns,
revenue: Math.round(item.revenue)
}))
setChartData(newChartData)
setTableData(newTableData)
}
}, [wbData])
// Функция для переключения видимости метрики
const toggleMetric = (metric: keyof typeof visibleMetrics) => {
setVisibleMetrics(prev => ({
...prev,
[metric]: !prev[metric]
}))
}
// Проверяем состояние загрузки и данных
const isLoading = loading || (useCustomDates && (!startDate || !endDate))
const hasData = tableData.length > 0
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">
<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" />
<div className="flex flex-wrap gap-2 mb-4">
{[1,2,3,4,5].map(i => (
<Skeleton key={i} className="h-8 w-20" />
))}
</div>
<Skeleton className="flex-1 w-full" />
</div>
</Card>
<Card className="glass-card p-4 flex-1">
<Skeleton className="h-6 w-40 mb-4" />
<div className="space-y-2">
{[1,2,3,4,5].map(i => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
</Card>
</div>
)
}
// Если нет данных или активности
if (!hasData || !hasAnyActivity) {
return (
<div className="h-full flex items-center justify-center">
<Card className="glass-card p-8 text-center max-w-md">
<div className="mb-4 flex justify-center">
<TrendingUp className="h-16 w-16 text-white/30" />
</div>
<h3 className="text-xl font-semibold text-white mb-2">
{!hasData ? 'Нет данных' : 'Нет активности'}
</h3>
<p className="text-white/60 text-sm">
{!hasData
? 'За выбранный период данные отсутствуют'
: 'За выбранный период не было продаж, заказов или рекламной активности'
}
</p>
{error && (
<div className="mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
<p className="text-red-400 text-xs">Ошибка: {error.message}</p>
</div>
)}
</Card>
</div>
)
}
return (
<div className="h-full flex flex-col space-y-3">
{/* График с фильтрами */}
<Card className="glass-card p-4 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>
{/* Компактные чекбоксы для фильтрации */}
<div className="mb-2 pb-2 border-b border-white/10">
<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 (
<div
key={key}
className={`flex items-center space-x-1.5 p-1.5 rounded-lg transition-all duration-200 cursor-pointer ${
isVisible
? 'bg-white/10 border border-white/20 shadow-sm'
: 'bg-white/5 border border-white/10 hover:bg-white/8'
}`}
onClick={() => toggleMetric(key as keyof typeof visibleMetrics)}
>
<Checkbox
id={key}
checked={isVisible}
onCheckedChange={() => toggleMetric(key as keyof typeof visibleMetrics)}
className="border-white/30 data-[state=checked]:bg-white/20 data-[state=checked]:border-white/50"
/>
<label
htmlFor={key}
className={`text-xs cursor-pointer select-none flex items-center gap-1.5 transition-colors duration-200 ${
isVisible ? 'text-white font-medium' : 'text-white/60'
}`}
>
<div
className={`w-2.5 h-2.5 rounded-sm transition-all duration-200 ${
isVisible ? 'opacity-100 scale-100' : 'opacity-50 scale-90'
}`}
style={{ backgroundColor: config.color }}
/>
{config.label}
</label>
</div>
)
})}
</div>
</div>
<div className="flex-1 min-h-0">
<ChartContainer config={chartConfig} className="min-h-[200px] h-full w-full">
<BarChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) => value.slice(0, 5)}
/>
<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}
/>
)}
</BarChart>
</ChartContainer>
</div>
</div>
</Card>
{/* Таблица данных */}
<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="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>
</tr>
</thead>
<tbody>
{tableData.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>
<td className="p-2 text-xs">
<Badge
variant="secondary"
className={`${
row.buyoutPercentage >= 80
? 'bg-green-500/20 text-green-400'
: 'bg-yellow-500/20 text-yellow-400'
}`}
>
{row.buyoutPercentage}%
</Badge>
</td>
<td className="p-2 text-white/80 text-xs">{row.advertising.toLocaleString('ru-RU')}</td>
<td className="p-2 text-white/80 text-xs">{row.orders}</td>
<td className="p-2 text-xs">
<Badge variant="secondary" className="bg-red-500/20 text-red-400 text-xs px-2 py-0.5">
{row.refusals}
</Badge>
</td>
<td className="p-2 text-xs">
<Badge variant="secondary" className="bg-orange-500/20 text-orange-400 text-xs px-2 py-0.5">
{row.returns}
</Badge>
</td>
<td className="p-2 text-white text-xs font-medium">
{row.revenue.toLocaleString('ru-RU')}
</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>
</div>
)
}

View File

@ -0,0 +1,168 @@
"use client"
import { useState } from 'react'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
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 { DateRangePicker } from '@/components/ui/date-picker'
import { BarChart3, PieChart, TrendingUp, Calendar } from 'lucide-react'
export function SellerStatisticsDashboard() {
const { getSidebarMargin } = useSidebar()
const [selectedPeriod, setSelectedPeriod] = useState('week')
const [useCustomDates, setUseCustomDates] = useState(false)
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
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>
{/* Основной контент с табами */}
<div className="flex-1 overflow-hidden">
<Tabs defaultValue="sales" className="h-full flex flex-col">
<TabsList className="grid w-full grid-cols-3 bg-white/5 backdrop-blur border border-white/10 rounded-xl flex-shrink-0 h-11">
<TabsTrigger
value="sales"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/60 flex items-center gap-2 text-sm rounded-lg"
>
<BarChart3 className="h-4 w-4" />
Продажи
</TabsTrigger>
<TabsTrigger
value="advertising"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/60 flex items-center gap-2 text-sm rounded-lg"
>
<TrendingUp className="h-4 w-4" />
Реклама
</TabsTrigger>
<TabsTrigger
value="other"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/60 flex items-center gap-2 text-sm rounded-lg"
>
<PieChart className="h-4 w-4" />
Иное
</TabsTrigger>
</TabsList>
{/* Контент вкладок */}
<div className="flex-1 overflow-hidden mt-3">
<TabsContent value="sales" className="h-full m-0 overflow-hidden">
<SalesTab
selectedPeriod={selectedPeriod}
useCustomDates={useCustomDates}
startDate={startDate}
endDate={endDate}
/>
</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>
</TabsContent>
<TabsContent value="other" 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">
<PieChart 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>
</TabsContent>
</div>
</Tabs>
</div>
</div>
</main>
</div>
)
}

353
src/components/ui/chart.tsx Normal file
View File

@ -0,0 +1,353 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@ -0,0 +1,78 @@
"use client"
import * as React from "react"
import { Calendar } from "lucide-react"
import { cn } from "@/lib/utils"
interface DatePickerProps {
value?: string
onChange?: (date: string) => void
placeholder?: string
className?: string
disabled?: boolean
}
export function DatePicker({
value,
onChange,
placeholder = "Выберите дату",
className,
disabled,
}: DatePickerProps) {
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e.target.value)
}
return (
<div className="relative">
<input
type="date"
value={value}
onChange={handleDateChange}
disabled={disabled}
className={cn(
"flex h-9 w-full rounded-lg border border-white/10 bg-white/5 backdrop-blur px-3 py-1 text-sm text-white placeholder:text-white/50 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/20 disabled:cursor-not-allowed disabled:opacity-50",
"pr-8", // Place for calendar icon
className
)}
/>
<Calendar className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/40 pointer-events-none" />
</div>
)
}
interface DateRangePickerProps {
startDate?: string
endDate?: string
onStartDateChange?: (date: string) => void
onEndDateChange?: (date: string) => void
className?: string
disabled?: boolean
}
export function DateRangePicker({
startDate,
endDate,
onStartDateChange,
onEndDateChange,
className,
disabled,
}: DateRangePickerProps) {
return (
<div className={cn("flex items-center gap-2", className)}>
<DatePicker
value={startDate}
onChange={onStartDateChange}
placeholder="От"
disabled={disabled}
/>
<span className="text-white/40 text-sm"></span>
<DatePicker
value={endDate}
onChange={onEndDateChange}
placeholder="До"
disabled={disabled}
/>
</div>
)
}

View File

@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
className={cn("bg-white/10 animate-pulse rounded-md", className)}
{...props}
/>
)