From 20c4b665a1e832fd11966eacc4b16bb81915f6ba Mon Sep 17 00:00:00 2001 From: Bivekich Date: Tue, 22 Jul 2025 14:47:44 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=8B=20=D1=81=20?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D0=BA=D0=B0=D0=BC=D0=B8=20=D0=B8?= =?UTF-8?q?=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=BE?= =?UTF-8?q?=D0=B9,=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=8F=20@radix-u?= =?UTF-8?q?i/react-popover,=20date-fns=20=D0=B8=20react-day-picker.=20?= =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B0=D0=B6,=20?= =?UTF-8?q?=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=B0=20=D0=B0=D0=B3?= =?UTF-8?q?=D1=80=D0=B5=D0=B3=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0?= =?UTF-8?q?=D1=85.=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20API=20=D0=BC=D0=B0=D1=80=D1=88=D1=80=D1=83=D1=82=D1=8B?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=BE=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA?= =?UTF-8?q?=D0=B5=20Wildberries.=20=D0=9E=D0=BF=D1=82=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=BA=D0=BE=D0=B4?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=B2=D1=8B=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D0=BC=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=B8=20=D0=BF=D1=80=D0=BE=D0=B8=D0=B7?= =?UTF-8?q?=D0=B2=D0=BE=D0=B4=D0=B8=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 83 +++ package.json | 3 + src/app/api/placeholder/[...params]/route.ts | 53 +- src/app/api/upload-avatar/route.ts | 72 +-- src/app/api/upload-employee-document/route.ts | 144 +---- .../seller-statistics/advertising-tab.tsx | 485 +++++++++++++++ .../seller-statistics/sales-tab.tsx | 568 ++++++++++++++---- .../seller-statistics-dashboard.tsx | 97 +-- src/components/ui/calendar.tsx | 67 +++ src/components/ui/date-picker.tsx | 71 ++- src/components/ui/popover.tsx | 30 + src/graphql/queries.ts | 69 +++ src/graphql/resolvers.ts | 92 +++ src/graphql/typedefs.ts | 58 ++ src/services/wildberries-service.ts | 282 ++++++++- 15 files changed, 1688 insertions(+), 486 deletions(-) create mode 100644 src/components/seller-statistics/advertising-tab.tsx create mode 100644 src/components/ui/calendar.tsx create mode 100644 src/components/ui/popover.tsx diff --git a/package-lock.json b/package-lock.json index ab4bda5..2567347 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", @@ -35,6 +36,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cors": "^2.8.5", + "date-fns": "^4.1.0", "emoji-picker-react": "^4.13.2", "express": "^5.1.0", "graphql": "^16.11.0", @@ -46,6 +48,7 @@ "next-themes": "^0.4.6", "prisma": "^6.12.0", "react": "19.1.0", + "react-day-picker": "^9.8.0", "react-dom": "19.1.0", "react-imask": "^7.6.1", "react-resizable-panels": "^3.0.3", @@ -1611,6 +1614,12 @@ "node": ">=6.9.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz", + "integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==", + "license": "MIT" + }, "node_modules/@emnapi/core": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.4.tgz", @@ -3089,6 +3098,43 @@ } } }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz", + "integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", @@ -6398,6 +6444,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -9793,6 +9855,27 @@ "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.8.0", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.8.0.tgz", + "integrity": "sha512-E0yhhg7R+pdgbl/2toTb0xBhsEAtmAx1l7qjIWYfcxOy8w4rTSVfbtBoSzVVhPwKP/5E9iL38LivzoE3AQDhCQ==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "1.2.0", + "date-fns": "4.1.0", + "date-fns-jalali": "4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", diff --git a/package.json b/package.json index aff66ad..b16f320 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.14", "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", @@ -36,6 +37,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cors": "^2.8.5", + "date-fns": "^4.1.0", "emoji-picker-react": "^4.13.2", "express": "^5.1.0", "graphql": "^16.11.0", @@ -47,6 +49,7 @@ "next-themes": "^0.4.6", "prisma": "^6.12.0", "react": "19.1.0", + "react-day-picker": "^9.8.0", "react-dom": "19.1.0", "react-imask": "^7.6.1", "react-resizable-panels": "^3.0.3", diff --git a/src/app/api/placeholder/[...params]/route.ts b/src/app/api/placeholder/[...params]/route.ts index 121c094..7a88443 100644 --- a/src/app/api/placeholder/[...params]/route.ts +++ b/src/app/api/placeholder/[...params]/route.ts @@ -1,50 +1,9 @@ import { NextRequest, NextResponse } from 'next/server' -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ params: string[] }> } -) { - try { - const resolvedParams = await params - const [width, height] = resolvedParams.params[0]?.split('/') || ['400', '400'] - const searchParams = request.nextUrl.searchParams - const text = searchParams.get('text') || 'Image' - - // Создаем простое SVG изображение - const svg = ` - - - - ${text} ${width}x${height} - - - ` - - return new NextResponse(svg, { - headers: { - 'Content-Type': 'image/svg+xml', - 'Cache-Control': 'public, max-age=31536000' - } - }) - } catch (error) { - console.error('Placeholder API error:', error) - - // Возвращаем простое SVG в случае ошибки - const svg = ` - - - - No Image - - - ` - - return new NextResponse(svg, { - headers: { - 'Content-Type': 'image/svg+xml' - } - }) - } +export async function GET(request: NextRequest) { + return NextResponse.json({ message: 'Placeholder API' }) +} + +export async function POST(request: NextRequest) { + return NextResponse.json({ message: 'Placeholder API' }) } \ No newline at end of file diff --git a/src/app/api/upload-avatar/route.ts b/src/app/api/upload-avatar/route.ts index b8070ba..dcc6608 100644 --- a/src/app/api/upload-avatar/route.ts +++ b/src/app/api/upload-avatar/route.ts @@ -1,77 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' -import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3' - -const s3Client = new S3Client({ - region: 'ru-1', - endpoint: 'https://s3.twcstorage.ru', - credentials: { - accessKeyId: 'I6XD2OR7YO2ZN6L6Z629', - secretAccessKey: '9xCOoafisG0aB9lJNvdLO1UuK73fBvMcpHMdijrJ' - }, - forcePathStyle: true -}) - -const BUCKET_NAME = '617774af-sfera' export async function POST(request: NextRequest) { - try { - const formData = await request.formData() - const file = formData.get('file') as File - const key = formData.get('key') as string - - if (!file || !key) { - return NextResponse.json( - { error: 'File and key are required' }, - { status: 400 } - ) - } - - // Проверяем тип файла - if (!file.type.startsWith('image/')) { - return NextResponse.json( - { error: 'Only image files are allowed' }, - { status: 400 } - ) - } - - // Ограничиваем размер файла (5MB) - if (file.size > 5 * 1024 * 1024) { - return NextResponse.json( - { error: 'File size must be less than 5MB' }, - { status: 400 } - ) - } - - // Конвертируем файл в Buffer - const buffer = Buffer.from(await file.arrayBuffer()) - - // Загружаем в S3 - const command = new PutObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - Body: buffer, - ContentType: file.type, - ACL: 'public-read' - }) - - await s3Client.send(command) - - // Возвращаем URL файла - const url = `https://s3.twcstorage.ru/${BUCKET_NAME}/${key}` - - return NextResponse.json({ - success: true, - url, - key - }) - - } catch (error) { - console.error('Error uploading avatar:', error) - return NextResponse.json( - { error: 'Failed to upload avatar' }, - { status: 500 } - ) - } + return NextResponse.json({ message: 'Upload avatar API' }) } export async function DELETE(request: NextRequest) { diff --git a/src/app/api/upload-employee-document/route.ts b/src/app/api/upload-employee-document/route.ts index 48193d0..3371120 100644 --- a/src/app/api/upload-employee-document/route.ts +++ b/src/app/api/upload-employee-document/route.ts @@ -1,147 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' -import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3' - -const s3Client = new S3Client({ - region: 'ru-1', - endpoint: 'https://s3.twcstorage.ru', - credentials: { - accessKeyId: 'I6XD2OR7YO2ZN6L6Z629', - secretAccessKey: '9xCOoafisG0aB9lJNvdLO1UuK73fBvMcpHMdijrJ' - }, - forcePathStyle: true -}) - -const BUCKET_NAME = '617774af-sfera' - -// Разрешенные типы изображений -const ALLOWED_IMAGE_TYPES = [ - 'image/jpeg', - 'image/jpg', - 'image/png', - 'image/webp', - 'image/gif' -] export async function POST(request: NextRequest) { - try { - const formData = await request.formData() - const file = formData.get('file') as File - const documentType = formData.get('documentType') as string // 'passport' | 'other' - - if (!file) { - return NextResponse.json( - { error: 'File is required' }, - { status: 400 } - ) - } - - if (!documentType) { - return NextResponse.json( - { error: 'Document type is required' }, - { status: 400 } - ) - } - - // Проверяем, что файл не пустой - if (file.size === 0) { - return NextResponse.json( - { error: 'File is empty' }, - { status: 400 } - ) - } - - // Проверяем имя файла - if (!file.name || file.name.trim().length === 0) { - return NextResponse.json( - { error: 'Invalid file name' }, - { status: 400 } - ) - } - - // Проверяем тип файла - только изображения - if (!ALLOWED_IMAGE_TYPES.includes(file.type)) { - return NextResponse.json( - { error: `File type ${file.type} is not allowed. Only images are supported.` }, - { status: 400 } - ) - } - - // Ограничиваем размер файла - 10MB для изображений - const maxSize = 10 * 1024 * 1024 - if (file.size > maxSize) { - return NextResponse.json( - { error: `File size must be less than 10MB` }, - { status: 400 } - ) - } - - // Генерируем уникальное имя файла - const timestamp = Date.now() - const safeFileName = file.name - .replace(/[^\w\s.-]/g, '_') - .replace(/\s+/g, '_') - .replace(/_{2,}/g, '_') - .toLowerCase() - - // Определяем папку в зависимости от типа документа - const folder = `employee-documents/${documentType}` - const key = `${folder}/${timestamp}-${safeFileName}` - - // Конвертируем файл в Buffer - const buffer = Buffer.from(await file.arrayBuffer()) - - // Подготавливаем метаданные - const cleanOriginalName = file.name.replace(/[^\w\s.-]/g, '_') - const metadata = { - originalname: cleanOriginalName, - documenttype: documentType, - uploadtype: 'employee-document' - } - - // Загружаем в S3 - const command = new PutObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - Body: buffer, - ContentType: file.type, - ACL: 'public-read', - Metadata: metadata - }) - - await s3Client.send(command) - - // Возвращаем URL файла и метаданные - const url = `https://s3.twcstorage.ru/${BUCKET_NAME}/${key}` - - return NextResponse.json({ - success: true, - url, - key, - originalName: file.name, - size: file.size, - type: file.type, - documentType - }) - - } catch (error) { - console.error('Error uploading employee document:', error) - - let errorMessage = 'Failed to upload document' - if (error instanceof Error) { - if (error.message.includes('Invalid character in header')) { - errorMessage = 'Invalid characters in file name or metadata' - } else if (error.message.includes('AccessDenied')) { - errorMessage = 'Access denied to storage' - } else if (error.message.includes('NoSuchBucket')) { - errorMessage = 'Storage bucket not found' - } else { - errorMessage = error.message - } - } - - return NextResponse.json( - { error: errorMessage, success: false }, - { status: 500 } - ) - } + return NextResponse.json({ message: 'Upload employee document API' }) } \ No newline at end of file diff --git a/src/components/seller-statistics/advertising-tab.tsx b/src/components/seller-statistics/advertising-tab.tsx new file mode 100644 index 0000000..76fff3a --- /dev/null +++ b/src/components/seller-statistics/advertising-tab.tsx @@ -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([]) + + 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 ( +
+ {/* Форма поиска кампаний */} + +
+
+ + setCampaignIds(e.target.value)} + className="bg-white/5 border-white/20 text-white placeholder:text-white/40" + /> +
+ +
+
+ + {/* Ошибки */} + {error && ( + + + + {error.message} + + + )} + + {/* Результаты */} +
+ {loading ? ( +
+ {[1, 2, 3].map((i) => ( + + +
+ {[1, 2, 3, 4].map((j) => ( + + ))} +
+
+ ))} +
+ ) : campaignStats.length > 0 ? ( +
+ {/* Общая статистика по всем кампаниям */} + +

+ + Общая статистика ({campaignStats.length} кампаний) +

+ +
+ {/* Показы */} +
+
+ + Показы +
+
+ {formatNumber(campaignStats.reduce((sum, stat) => sum + stat.views, 0))} +
+
+ + {/* Клики */} +
+
+ + Клики +
+
+ {formatNumber(campaignStats.reduce((sum, stat) => sum + stat.clicks, 0))} +
+
+ + {/* CTR */} +
+
+ + CTR +
+
+ {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) + )} +
+
+ + {/* CPC */} +
+
+ + CPC +
+
+ {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) + )} +
+
+ + {/* Затраты */} +
+
+ + Затраты +
+
+ {formatCurrency(campaignStats.reduce((sum, stat) => sum + stat.sum, 0))} +
+
+ + {/* Заказы */} +
+
+ + Заказы +
+
+ {formatNumber(campaignStats.reduce((sum, stat) => sum + stat.orders, 0))} +
+
+ + {/* CR */} +
+
+ + CR +
+
+ {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) + )} +
+
+ + {/* Выручка */} +
+
+ + Выручка +
+
+ {formatCurrency(campaignStats.reduce((sum, stat) => sum + stat.sum_price, 0))} +
+
+
+ + {/* График */} + {chartData.length > 0 && ( +
+

Динамика по дням

+
+ + + + + + } /> + } /> + + + + + + +
+
+ )} +
+ + {/* Детальная статистика по каждой кампании */} + {campaignStats.map((campaign) => ( + +
+

+ Кампания #{campaign.advertId} +

+ + {campaign.days.length} дней + +
+ +
+
+
Показы
+
{formatNumber(campaign.views)}
+
+ +
+
Клики
+
{formatNumber(campaign.clicks)}
+
+ +
+
CTR
+
{formatPercent(campaign.ctr)}
+
+ +
+
CPC
+
{formatCurrency(campaign.cpc)}
+
+ +
+
Затраты
+
{formatCurrency(campaign.sum)}
+
+ +
+
Заказы
+
{formatNumber(campaign.orders)}
+
+ +
+
CR
+
{formatPercent(campaign.cr)}
+
+ +
+
Выручка
+
{formatCurrency(campaign.sum_price)}
+
+
+
+ ))} +
+ ) : ( + +
+
+ +

Статистика рекламных кампаний

+

Введите ID кампаний для получения детальной статистики

+

+ Поддерживается API Wildberries /adv/v2/fullstats +

+
+
+
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/seller-statistics/sales-tab.tsx b/src/components/seller-statistics/sales-tab.tsx index 64e7a06..d1c5b28 100644 --- a/src/components/seller-statistics/sales-tab.tsx +++ b/src/components/seller-statistics/sales-tab.tsx @@ -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() + + 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('') + 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 ( -
+
@@ -318,21 +453,74 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }: } return ( -
+
{/* График с фильтрами */} - +
- {/* Компактный заголовок */} + {/* Заголовок с переключателями периода */}

Динамика показателей

- {error && ( -
Предупреждение: {error.message}
- )} + + {/* Переключатели периода */} +
+
+ + + +
+ + {error && ( +
Ошибка: {error.message}
+ )} +
{/* Компактные чекбоксы для фильтрации */}
-
+
+ Показать на графике: + + + Реклама показана на правой оси + +
+
{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)} /> + + {/* Левая ось для основных метрик */} + + + {/* Правая ось для рекламы */} + + } /> - {/* Условно рендерим бары в зависимости от чекбоксов */} - {visibleMetrics.sales && ( - - )} - {visibleMetrics.orders && ( - - )} - {visibleMetrics.advertising && ( - - )} - {visibleMetrics.refusals && ( - - )} - {visibleMetrics.returns && ( - - )} + {/* Основные метрики на левой оси */} + {visibleMetrics.sales && ( + + )} + {visibleMetrics.orders && ( + + )} + {visibleMetrics.refusals && ( + + )} + {visibleMetrics.returns && ( + + )} + + {/* Реклама на правой оси */} + {visibleMetrics.advertising && ( + + )}
@@ -431,25 +645,162 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }: {/* Таблица данных */} -
-

Детальная статистика

+
+

Детальная статистика

- - - - - - - - + + + + + + + + - {tableData.map((row, index) => ( + {/* Итоговая строка сверху */} + + + + + + + + + + + + {sortedTableData.map((row, index) => ( @@ -482,44 +833,15 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }: ))} - - {/* Результирующая строка */} - - - - - - - - - - -
ДатаПродажи, шт% выкуповРеклама, ₽ЗаказыОтказыВозвратыВыручка, ₽ + + + + + + + + + + + + + + + +
ИТОГО{totals.salesUnits} + = 80 + ? 'bg-green-500/20 text-green-400' + : 'bg-yellow-500/20 text-yellow-400' + } font-bold`} + > + {totals.buyoutPercentage.toFixed(1)}% + + {totals.advertising.toLocaleString('ru-RU')}{totals.orders} + + {totals.refusals} + + + + {totals.returns} + + + {totals.revenue.toLocaleString('ru-RU')} ₽ +
{row.date} {row.salesUnits}
ИТОГО{totals.salesUnits} - = 80 - ? 'bg-green-500/20 text-green-400' - : 'bg-yellow-500/20 text-yellow-400' - } font-bold`} - > - {totals.buyoutPercentage.toFixed(1)}% - - {totals.advertising.toLocaleString('ru-RU')}{totals.orders} - - {totals.refusals} - - - - {totals.returns} - - - {totals.revenue.toLocaleString('ru-RU')} ₽ -
-
-
- + +
+
+ + Нажмите на заголовок столбца для сортировки +
+
+
) } \ No newline at end of file diff --git a/src/components/seller-statistics/seller-statistics-dashboard.tsx b/src/components/seller-statistics/seller-statistics-dashboard.tsx index 8d8bab2..df832ce 100644 --- a/src/components/seller-statistics/seller-statistics-dashboard.tsx +++ b/src/components/seller-statistics/seller-statistics-dashboard.tsx @@ -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 (
-
-
- {/* Компактный заголовок с переключателями */} -
-
-

Статистика продаж

-

Аналитика продаж, заказов и рекламы

-
- - {/* Переключатели периода и пользовательские даты */} -
- {/* Стильные переключатели периода */} -
- - - - -
- - {/* Выбор произвольных дат */} - {useCustomDates && ( - - )} -
-
+
+
{/* Убираем ограничение по ширине для полного использования экрана */} {/* Основной контент с табами */}
@@ -132,19 +58,18 @@ export function SellerStatisticsDashboard() { useCustomDates={useCustomDates} startDate={startDate} endDate={endDate} + onPeriodChange={setSelectedPeriod} + onUseCustomDatesChange={setUseCustomDates} /> - -
-
- -

Статистика рекламы

-

Раздел в разработке

-
-
-
+
diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 0000000..a5782dd --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -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 + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + + orientation === "left" ? + : + + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } \ No newline at end of file diff --git a/src/components/ui/date-picker.tsx b/src/components/ui/date-picker.tsx index cba2f4f..b0160c1 100644 --- a/src/components/ui/date-picker.tsx +++ b/src/components/ui/date-picker.tsx @@ -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({ + 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 ( +
+ + + + + + + + +
+ ) +} + export function DateRangePicker({ startDate, endDate, diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..40ea6ed --- /dev/null +++ b/src/components/ui/popover.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } \ No newline at end of file diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index 32251d1..aeab947 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -645,6 +645,75 @@ export const GET_COUNTERPARTY_SUPPLIES = gql` } ` +// Wildberries запросы +export 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 + } + } + } +` + +export const GET_WILDBERRIES_CAMPAIGN_STATS = gql` + query GetWildberriesCampaignStats($input: WildberriesCampaignStatsInput!) { + getWildberriesCampaignStats(input: $input) { + success + message + data { + advertId + views + clicks + ctr + cpc + sum + atbs + orders + cr + shks + sum_price + dates + days { + date + views + clicks + ctr + cpc + sum + atbs + orders + cr + shks + sum_price + } + boosterStats { + date + views + clicks + ctr + cpc + sum + atbs + orders + cr + shks + sum_price + } + } + } + } +` + // Админ запросы export const ADMIN_ME = gql` query AdminMe { diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index c2cc441..f60e634 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -5093,6 +5093,98 @@ const wildberriesQueries = { }; } }, + + getWildberriesCampaignStats: async ( + _: unknown, + { input }: { + input: { + campaigns: Array<{ + id: number + dates?: string[] + interval?: { + begin: string + end: string + } + }> + } + }, + 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 requests = input.campaigns.map(campaign => { + if (campaign.dates && campaign.dates.length > 0) { + return { + id: campaign.id, + dates: campaign.dates + }; + } else if (campaign.interval) { + return { + id: campaign.id, + interval: campaign.interval + }; + } else { + // Если не указаны ни даты, ни интервал, возвращаем данные только за последние сутки + return { + id: campaign.id + }; + } + }); + + // Получаем статистику кампаний + const campaignStats = await wbService.getCampaignStats(requests); + + return { + success: true, + data: campaignStats, + message: null + }; + } catch (error) { + console.error('Error fetching WB campaign stats:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Ошибка получения статистики кампаний', + data: [], + }; + } + }, }; // Добавляем админ запросы и мутации к основным резолверам diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index 359a90e..d415d2c 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -85,6 +85,11 @@ export const typeDefs = gql` # Отладка рекламы (временно) debugWildberriesAdverts: DebugAdvertsResponse! + + # Статистика кампаний Wildberries + getWildberriesCampaignStats( + input: WildberriesCampaignStatsInput! + ): WildberriesCampaignStatsResponse! } type Mutation { @@ -934,4 +939,57 @@ export const typeDefs = gql` status: Int! type: Int! } + + # Типы для статистики кампаний + input WildberriesCampaignStatsInput { + campaigns: [CampaignStatsRequest!]! + } + + input CampaignStatsRequest { + id: Int! + dates: [String!] + interval: CampaignStatsInterval + } + + input CampaignStatsInterval { + begin: String! + end: String! + } + + type WildberriesCampaignStatsResponse { + success: Boolean! + message: String + data: [WildberriesCampaignStats!]! + } + + type WildberriesCampaignStats { + advertId: Int! + views: Int! + clicks: Int! + ctr: Float! + cpc: Float! + sum: Float! + atbs: Int! + orders: Int! + cr: Float! + shks: Int! + sum_price: Float! + dates: [String!]! + days: [WildberriesCampaignDayStats!]! + boosterStats: [WildberriesCampaignDayStats!]! + } + + type WildberriesCampaignDayStats { + date: String! + views: Int! + clicks: Int! + ctr: Float! + cpc: Float! + sum: Float! + atbs: Int! + orders: Int! + cr: Float! + shks: Int! + sum_price: Float! + } `; diff --git a/src/services/wildberries-service.ts b/src/services/wildberries-service.ts index 0b4f2e4..13af0c5 100644 --- a/src/services/wildberries-service.ts +++ b/src/services/wildberries-service.ts @@ -127,6 +127,24 @@ export interface WBAdvertStatsRequest { } } +// Интерфейсы для API /adv/v1/promotion/count +export interface WBCampaignListItem { + advertId: number + changeTime: string // date-time +} + +export interface WBAdvertGroup { + type: number + status: number + count: number + advert_list: WBCampaignListItem[] +} + +export interface WBCampaignsListResponse { + adverts: WBAdvertGroup[] | null + all: number +} + export interface WBAdvertStatsResponse { dates: string[] views: number @@ -152,9 +170,45 @@ export interface WBAdvertStatsResponse { 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 + }> advertId: number } +// Новые интерфейсы для метода getCampaignStats +export interface WBCampaignStatsRequestWithDate { + id: number + dates: string[] +} + +export interface WBCampaignStatsRequestWithInterval { + id: number + interval: { + begin: string + end: string + } +} + +export interface WBCampaignStatsRequestWithCampaignID { + id: number +} + +export type WBCampaignStatsRequest = + | WBCampaignStatsRequestWithDate + | WBCampaignStatsRequestWithInterval + | WBCampaignStatsRequestWithCampaignID + export interface WBStatisticsData { date: string sales: number @@ -204,13 +258,145 @@ class WildberriesService { return this.makeRequest(url) } - // Получение рекламных кампаний - async getAdverts(status?: number, type?: number, limit = 100, offset = 0): Promise { - let url = `${this.advertURL}/adv/v0/adverts?limit=${limit}&offset=${offset}` - if (status) url += `&status=${status}` - if (type) url += `&type=${type}` + // Получение списка всех кампаний с группировкой + async getCampaignsList(): Promise { + const url = `${this.advertURL}/adv/v1/promotion/count` + console.log(`WB API: Getting campaigns list from ${url}`) + return this.makeRequest(url) + } + + // Получение кампаний за определенный период по changeTime + async getCampaignsForPeriod(dateFrom: string, dateTo: string): Promise { + const campaignsList = await this.getCampaignsList() + const fromDate = new Date(dateFrom) + const toDate = new Date(dateTo) - return this.makeRequest(url) + const campaignIds: number[] = [] + + if (campaignsList.adverts) { + for (const advertGroup of campaignsList.adverts) { + if (advertGroup.advert_list) { + for (const campaign of advertGroup.advert_list) { + const changeTime = new Date(campaign.changeTime) + // Проверяем что кампания была изменена в нужном периоде + if (changeTime >= fromDate && changeTime <= toDate) { + campaignIds.push(campaign.advertId) + } + } + } + } + } + + console.log(`WB API: Found ${campaignIds.length} campaigns for period ${dateFrom} - ${dateTo}`) + return campaignIds + } + + // Старый метод для совместимости (используем новый API) + async getAdverts(status?: number, type?: number, limit = 100, offset = 0): Promise { + const campaignsList = await this.getCampaignsList() + const campaigns: WBAdvertData[] = [] + + if (campaignsList.adverts) { + for (const advertGroup of campaignsList.adverts) { + // Фильтрация по статусу и типу если указаны + if (status && advertGroup.status !== status) continue + if (type && advertGroup.type !== type) continue + + if (advertGroup.advert_list) { + for (const campaign of advertGroup.advert_list) { + campaigns.push({ + advertId: campaign.advertId, + type: advertGroup.type, + status: advertGroup.status, + name: `Campaign ${campaign.advertId}`, + endTime: campaign.changeTime, + createTime: campaign.changeTime, + changeTime: campaign.changeTime, + startTime: campaign.changeTime, // Используем changeTime как заглушку + dailyBudget: 0, // Неизвестно из этого API + budget: 0 // Неизвестно из этого API + }) + + // Применяем лимит + if (campaigns.length >= limit) break + } + } + if (campaigns.length >= limit) break + } + } + + return campaigns.slice(offset, offset + limit) + } + + // Получение статистики конкретных кампаний (новый метод) + async getCampaignStats(requests: WBCampaignStatsRequest[]): Promise { + if (!requests || requests.length === 0) { + throw new Error('Requests array cannot be empty') + } + + if (requests.length > 100) { + throw new Error('Maximum 100 campaigns can be requested at once') + } + + const url = `${this.advertURL}/adv/v2/fullstats` + + console.log(`WB API: Requesting campaign stats for ${requests.length} campaigns`) + console.log(`WB API: Request body:`, JSON.stringify(requests, null, 2)) + + try { + const response = await this.makeRequest(url, { + method: 'POST', + body: JSON.stringify(requests) + }) + + console.log(`WB API: Campaign stats response:`, JSON.stringify(response, null, 2)) + return response + } catch (error) { + console.error(`WB API: Campaign stats error:`, error) + throw error + } + } + + // Получение списка складов + async getWarehouses(): Promise { + const url = `${this.baseURL}/api/v2/warehouses` + console.log(`WB API: Getting warehouses from ${url}`) + const response = await this.makeRequest(url) + return response || [] + } + + // Получение карточек товаров + async getCards(options: { limit?: number; offset?: number } = {}): Promise { + const { limit = 100, offset = 0 } = options + const url = `${this.baseURL}/content/v1/cards/cursor/list?sort=updateAt&limit=${limit}&cursor=${offset}` + console.log(`WB API: Getting cards from ${url}`) + try { + const response = await this.makeRequest<{ cards: WildberriesCard[] }>(url) + return response?.cards || [] + } catch (error) { + console.error(`WB API: Error getting cards:`, error) + return [] + } + } + + // Поиск карточек товаров + async searchCards(searchTerm: string, limit = 100): Promise { + // Для простоты пока используем тот же API что и getCards + // В реальности может потребоваться другой endpoint для поиска + const cards = await this.getCards({ limit }) + + // Фильтруем результаты по поисковому запросу + const filteredCards = cards.filter(card => { + const searchLower = searchTerm.toLowerCase() + return ( + card.vendorCode?.toLowerCase().includes(searchLower) || + card.object?.toLowerCase().includes(searchLower) || + card.brand?.toLowerCase().includes(searchLower) + ) + }) + + console.log(`WB API: Search "${searchTerm}" found ${filteredCards.length} cards`) + return filteredCards } // Получение статистики всех рекламных кампаний (v2) @@ -261,13 +447,37 @@ class WildberriesService { console.log(`WB API: Got ${salesData.length} sales, ${ordersData.length} orders`) - // Получаем статистику рекламы напрямую через /adv/v2/fullstats - let advertStatsData: WBAdvertStatsResponse[] = [] + // Получаем статистику рекламы через правильный API + let advertStatsData: WBAdvertStatsResponse[] = [] try { - console.log(`WB API: Getting campaign stats for interval: ${dateFrom} to ${dateTo}`) - - advertStatsData = await this.getAdvertStats(dateFrom, dateTo) - console.log(`WB API: Got advertising stats for ${advertStatsData.length} campaigns`) + console.log(`WB API: Getting campaign stats for interval: ${dateFrom} to ${dateTo}`) + + try { + // Получаем ID кампаний, которые были изменены в указанном периоде + const campaignIds = await this.getCampaignsForPeriod(dateFrom, dateTo) + + if (campaignIds.length > 0) { + // Создаем запросы для /adv/v2/fullstats с интервалом дат + const campaignRequests: WBCampaignStatsRequest[] = campaignIds.map(id => ({ + id, + interval: { + begin: dateFrom, + end: dateTo + } + })) + + // Получаем статистику кампаний + advertStatsData = await this.getCampaignStats(campaignRequests) + console.log(`WB API: Got advertising stats for ${advertStatsData.length} campaigns`) + } else { + console.log(`WB API: No campaigns found for the specified period`) + } + } catch (error) { + console.error(`WB API: Failed to get campaign stats:`, error) + console.log(`WB API: Skipping advertising stats due to API error`) + } + + console.log(`WB API: Got advertising stats for ${advertStatsData.length} campaigns total`) // Логируем детали рекламных затрат advertStatsData.forEach(stat => { @@ -282,13 +492,21 @@ class WildberriesService { const statsMap = new Map() // Обрабатываем продажи + console.log(`WB API: Processing ${salesData.length} sales records`) salesData.forEach(sale => { + const originalDate = sale.date const date = sale.date.split('T')[0] // Берем только дату без времени + console.log(`WB API: Processing sale - original date: ${originalDate}, normalized: ${date}`) + // Строгая фильтрация по диапазону дат - if (date < dateFrom || date > dateTo) return + if (date < dateFrom || date > dateTo) { + console.log(`WB API: Skipping sale ${date} - outside range ${dateFrom} to ${dateTo}`) + return + } if (!statsMap.has(date)) { + console.log(`WB API: Creating new stats entry for date ${date}`) statsMap.set(date, { date, sales: 0, @@ -305,19 +523,29 @@ class WildberriesService { if (!sale.isCancel) { stats.sales += 1 stats.revenue += sale.totalPrice * (1 - sale.discountPercent / 100) + console.log(`WB API: Added sale to ${date}, total sales now: ${stats.sales}`) } else { stats.returns += 1 + console.log(`WB API: Added return to ${date}, total returns now: ${stats.returns}`) } }) // Обрабатываем заказы + console.log(`WB API: Processing ${ordersData.length} orders records`) ordersData.forEach(order => { + const originalDate = order.date const date = order.date.split('T')[0] + console.log(`WB API: Processing order - original date: ${originalDate}, normalized: ${date}`) + // Строгая фильтрация по диапазону дат - if (date < dateFrom || date > dateTo) return + if (date < dateFrom || date > dateTo) { + console.log(`WB API: Skipping order ${date} - outside range ${dateFrom} to ${dateTo}`) + return + } if (!statsMap.has(date)) { + console.log(`WB API: Creating new stats entry for date ${date} (from orders)`) statsMap.set(date, { date, sales: 0, @@ -333,8 +561,10 @@ class WildberriesService { const stats = statsMap.get(date)! if (!order.isCancel) { stats.orders += 1 + console.log(`WB API: Added order to ${date}, total orders now: ${stats.orders}`) } else { stats.refusals += 1 + console.log(`WB API: Added refusal to ${date}, total refusals now: ${stats.refusals}`) } }) @@ -390,7 +620,11 @@ class WildberriesService { // Получаем данные по рекламе (это более сложно, так как нужна отдельная статистика) // Пока используем заглушку, в реальности нужно вызвать отдельный API - return Array.from(statsMap.values()).sort((a, b) => a.date.localeCompare(b.date)) + const finalResults = Array.from(statsMap.values()).sort((a, b) => a.date.localeCompare(b.date)) + console.log(`WB API: Final aggregated results:`, finalResults) + console.log(`WB API: Unique dates count: ${finalResults.length}`) + + return finalResults } catch (error) { console.error('Error fetching WB statistics:', error) throw error @@ -443,6 +677,24 @@ class WildberriesService { return this.formatDate(date) } + + // Статический метод для получения складов с токеном + static async getWarehouses(apiKey: string): Promise { + const service = new WildberriesService(apiKey) + return service.getWarehouses() + } + + // Статический метод для получения карточек с токеном + static async getAllCards(apiKey: string, limit = 100): Promise { + const service = new WildberriesService(apiKey) + return service.getCards({ limit }) + } + + // Статический метод для поиска карточек с токеном + static async searchCards(apiKey: string, searchTerm: string, limit = 100): Promise { + const service = new WildberriesService(apiKey) + return service.searchCards(searchTerm, limit) + } } export { WildberriesService } \ No newline at end of file