Добавлены новые зависимости для работы с графиками и статистикой, включая @radix-ui/react-popover, date-fns и react-day-picker. Обновлены компоненты для отображения статистики продаж, улучшена агрегация данных и добавлены функции сортировки в таблицах. Обновлены API маршруты для получения данных о статистике Wildberries. Оптимизирован код для повышения читаемости и производительности.
This commit is contained in:
83
package-lock.json
generated
83
package-lock.json
generated
@ -19,6 +19,7 @@
|
|||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
@ -35,6 +36,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"emoji-picker-react": "^4.13.2",
|
"emoji-picker-react": "^4.13.2",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
@ -46,6 +48,7 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"prisma": "^6.12.0",
|
"prisma": "^6.12.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-day-picker": "^9.8.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-imask": "^7.6.1",
|
"react-imask": "^7.6.1",
|
||||||
"react-resizable-panels": "^3.0.3",
|
"react-resizable-panels": "^3.0.3",
|
||||||
@ -1611,6 +1614,12 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.4.4",
|
"version": "1.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.4.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-popper": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz",
|
"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"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
@ -9793,6 +9855,27 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.1.0",
|
"version": "19.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
"@radix-ui/react-checkbox": "^1.3.2",
|
"@radix-ui/react-checkbox": "^1.3.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.14",
|
"@radix-ui/react-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.1.14",
|
||||||
"@radix-ui/react-progress": "^1.1.7",
|
"@radix-ui/react-progress": "^1.1.7",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
@ -36,6 +37,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"emoji-picker-react": "^4.13.2",
|
"emoji-picker-react": "^4.13.2",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
@ -47,6 +49,7 @@
|
|||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"prisma": "^6.12.0",
|
"prisma": "^6.12.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
|
"react-day-picker": "^9.8.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"react-imask": "^7.6.1",
|
"react-imask": "^7.6.1",
|
||||||
"react-resizable-panels": "^3.0.3",
|
"react-resizable-panels": "^3.0.3",
|
||||||
|
@ -1,50 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(request: NextRequest) {
|
||||||
request: NextRequest,
|
return NextResponse.json({ message: 'Placeholder API' })
|
||||||
{ params }: { params: Promise<{ params: string[] }> }
|
}
|
||||||
) {
|
|
||||||
try {
|
export async function POST(request: NextRequest) {
|
||||||
const resolvedParams = await params
|
return NextResponse.json({ message: 'Placeholder API' })
|
||||||
const [width, height] = resolvedParams.params[0]?.split('/') || ['400', '400']
|
|
||||||
const searchParams = request.nextUrl.searchParams
|
|
||||||
const text = searchParams.get('text') || 'Image'
|
|
||||||
|
|
||||||
// Создаем простое SVG изображение
|
|
||||||
const svg = `
|
|
||||||
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="100%" height="100%" fill="#f0f0f0"/>
|
|
||||||
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle"
|
|
||||||
font-family="Arial, sans-serif" font-size="16" fill="#666">
|
|
||||||
${text} ${width}x${height}
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
`
|
|
||||||
|
|
||||||
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 = `
|
|
||||||
<svg width="400" height="400" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="100%" height="100%" fill="#f0f0f0"/>
|
|
||||||
<text x="50%" y="50%" text-anchor="middle" dominant-baseline="middle"
|
|
||||||
font-family="Arial, sans-serif" font-size="16" fill="#666">
|
|
||||||
No Image
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
`
|
|
||||||
|
|
||||||
return new NextResponse(svg, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'image/svg+xml'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
@ -1,77 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
return NextResponse.json({ message: 'Upload avatar API' })
|
||||||
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
|
@ -1,147 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
return NextResponse.json({ message: 'Upload employee document API' })
|
||||||
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
485
src/components/seller-statistics/advertising-tab.tsx
Normal file
485
src/components/seller-statistics/advertising-tab.tsx
Normal file
@ -0,0 +1,485 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useQuery, useLazyQuery } from '@apollo/client'
|
||||||
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { GET_WILDBERRIES_CAMPAIGN_STATS } from '@/graphql/queries'
|
||||||
|
import { TrendingUp, Search, Calendar, Eye, MousePointer, ShoppingCart, DollarSign, Percent, AlertCircle } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
type ChartConfig
|
||||||
|
} from '@/components/ui/chart'
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer } from 'recharts'
|
||||||
|
|
||||||
|
interface CampaignStatsProps {
|
||||||
|
selectedPeriod: string
|
||||||
|
useCustomDates: boolean
|
||||||
|
startDate: string
|
||||||
|
endDate: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignStats {
|
||||||
|
advertId: number
|
||||||
|
views: number
|
||||||
|
clicks: number
|
||||||
|
ctr: number
|
||||||
|
cpc: number
|
||||||
|
sum: number
|
||||||
|
atbs: number
|
||||||
|
orders: number
|
||||||
|
cr: number
|
||||||
|
shks: number
|
||||||
|
sum_price: number
|
||||||
|
dates: string[]
|
||||||
|
days: Array<{
|
||||||
|
date: string
|
||||||
|
views: number
|
||||||
|
clicks: number
|
||||||
|
ctr: number
|
||||||
|
cpc: number
|
||||||
|
sum: number
|
||||||
|
atbs: number
|
||||||
|
orders: number
|
||||||
|
cr: number
|
||||||
|
shks: number
|
||||||
|
sum_price: number
|
||||||
|
}>
|
||||||
|
boosterStats: Array<{
|
||||||
|
date: string
|
||||||
|
views: number
|
||||||
|
clicks: number
|
||||||
|
ctr: number
|
||||||
|
cpc: number
|
||||||
|
sum: number
|
||||||
|
atbs: number
|
||||||
|
orders: number
|
||||||
|
cr: number
|
||||||
|
shks: number
|
||||||
|
sum_price: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdvertisingTab({ selectedPeriod, useCustomDates, startDate, endDate }: CampaignStatsProps) {
|
||||||
|
const [campaignIds, setCampaignIds] = useState('')
|
||||||
|
const [campaignStats, setCampaignStats] = useState<CampaignStats[]>([])
|
||||||
|
|
||||||
|
const [getCampaignStats, { loading, error }] = useLazyQuery(GET_WILDBERRIES_CAMPAIGN_STATS, {
|
||||||
|
onCompleted: (data) => {
|
||||||
|
if (data.getWildberriesCampaignStats.success) {
|
||||||
|
setCampaignStats(data.getWildberriesCampaignStats.data)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Campaign stats error:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleGetStats = () => {
|
||||||
|
if (!campaignIds.trim()) return
|
||||||
|
|
||||||
|
const ids = campaignIds.split(',').map(id => parseInt(id.trim())).filter(id => !isNaN(id))
|
||||||
|
if (ids.length === 0) return
|
||||||
|
|
||||||
|
let campaigns
|
||||||
|
if (useCustomDates && startDate && endDate) {
|
||||||
|
// Используем интервал для пользовательских дат
|
||||||
|
campaigns = ids.map(id => ({
|
||||||
|
id,
|
||||||
|
interval: {
|
||||||
|
begin: startDate,
|
||||||
|
end: endDate
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
// Для предустановленных периодов вычисляем даты
|
||||||
|
const endDateCalc = new Date()
|
||||||
|
const startDateCalc = new Date()
|
||||||
|
|
||||||
|
switch (selectedPeriod) {
|
||||||
|
case 'week':
|
||||||
|
startDateCalc.setDate(endDateCalc.getDate() - 7)
|
||||||
|
break
|
||||||
|
case 'month':
|
||||||
|
startDateCalc.setMonth(endDateCalc.getMonth() - 1)
|
||||||
|
break
|
||||||
|
case 'quarter':
|
||||||
|
startDateCalc.setMonth(endDateCalc.getMonth() - 3)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
campaigns = ids.map(id => ({
|
||||||
|
id,
|
||||||
|
interval: {
|
||||||
|
begin: startDateCalc.toISOString().split('T')[0],
|
||||||
|
end: endDateCalc.toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
getCampaignStats({
|
||||||
|
variables: {
|
||||||
|
input: { campaigns }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat('ru-RU', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'RUB',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatNumber = (value: number) => {
|
||||||
|
return new Intl.NumberFormat('ru-RU').format(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPercent = (value: number) => {
|
||||||
|
return `${value.toFixed(2)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подготовка данных для графика
|
||||||
|
const chartData = campaignStats.length > 0 ? campaignStats[0]?.days?.map(day => ({
|
||||||
|
date: new Date(day.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }),
|
||||||
|
views: day.views,
|
||||||
|
clicks: day.clicks,
|
||||||
|
sum: day.sum,
|
||||||
|
orders: day.orders
|
||||||
|
})) || [] : []
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
views: {
|
||||||
|
label: "Показы",
|
||||||
|
color: "#8b5cf6",
|
||||||
|
},
|
||||||
|
clicks: {
|
||||||
|
label: "Клики",
|
||||||
|
color: "#06b6d4",
|
||||||
|
},
|
||||||
|
sum: {
|
||||||
|
label: "Затраты (₽)",
|
||||||
|
color: "#f59e0b",
|
||||||
|
},
|
||||||
|
orders: {
|
||||||
|
label: "Заказы",
|
||||||
|
color: "#10b981",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col space-y-4 overflow-hidden">
|
||||||
|
{/* Форма поиска кампаний */}
|
||||||
|
<Card className="glass-card flex-shrink-0 p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium text-white/80 mb-2">
|
||||||
|
ID кампаний (через запятую)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
placeholder="Например: 12345, 67890, 11111"
|
||||||
|
value={campaignIds}
|
||||||
|
onChange={(e) => setCampaignIds(e.target.value)}
|
||||||
|
className="bg-white/5 border-white/20 text-white placeholder:text-white/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleGetStats}
|
||||||
|
disabled={loading || !campaignIds.trim()}
|
||||||
|
className="bg-white/10 hover:bg-white/20 border border-white/20 text-white flex items-center gap-2 mt-6"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||||
|
Загрузка...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
Получить статистику
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Ошибки */}
|
||||||
|
{error && (
|
||||||
|
<Alert className="bg-red-500/10 border-red-500/30 text-red-400">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
{error.message}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Результаты */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<Card key={i} className="glass-card p-6">
|
||||||
|
<Skeleton className="h-8 w-48 mb-4 bg-white/10" />
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
{[1, 2, 3, 4].map((j) => (
|
||||||
|
<Skeleton key={j} className="h-16 bg-white/5" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : campaignStats.length > 0 ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Общая статистика по всем кампаниям */}
|
||||||
|
<Card className="glass-card p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||||
|
<TrendingUp className="h-5 w-5" />
|
||||||
|
Общая статистика ({campaignStats.length} кампаний)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4 mb-6">
|
||||||
|
{/* Показы */}
|
||||||
|
<div className="bg-white/5 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Eye className="h-4 w-4 text-purple-400" />
|
||||||
|
<span className="text-sm text-white/60">Показы</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-white">
|
||||||
|
{formatNumber(campaignStats.reduce((sum, stat) => sum + stat.views, 0))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Клики */}
|
||||||
|
<div className="bg-white/5 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<MousePointer className="h-4 w-4 text-cyan-400" />
|
||||||
|
<span className="text-sm text-white/60">Клики</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-white">
|
||||||
|
{formatNumber(campaignStats.reduce((sum, stat) => sum + stat.clicks, 0))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTR */}
|
||||||
|
<div className="bg-white/5 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Percent className="h-4 w-4 text-green-400" />
|
||||||
|
<span className="text-sm text-white/60">CTR</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-white">
|
||||||
|
{formatPercent(
|
||||||
|
campaignStats.reduce((sum, stat, _, arr) => {
|
||||||
|
const totalViews = arr.reduce((s, st) => s + st.views, 0)
|
||||||
|
const totalClicks = arr.reduce((s, st) => s + st.clicks, 0)
|
||||||
|
return totalViews > 0 ? (totalClicks / totalViews) * 100 : 0
|
||||||
|
}, 0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CPC */}
|
||||||
|
<div className="bg-white/5 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<DollarSign className="h-4 w-4 text-amber-400" />
|
||||||
|
<span className="text-sm text-white/60">CPC</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-white">
|
||||||
|
{formatCurrency(
|
||||||
|
campaignStats.reduce((sum, stat, _, arr) => {
|
||||||
|
const totalClicks = arr.reduce((s, st) => s + st.clicks, 0)
|
||||||
|
const totalSum = arr.reduce((s, st) => s + st.sum, 0)
|
||||||
|
return totalClicks > 0 ? totalSum / totalClicks : 0
|
||||||
|
}, 0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Затраты */}
|
||||||
|
<div className="bg-white/5 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<DollarSign className="h-4 w-4 text-red-400" />
|
||||||
|
<span className="text-sm text-white/60">Затраты</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-white">
|
||||||
|
{formatCurrency(campaignStats.reduce((sum, stat) => sum + stat.sum, 0))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Заказы */}
|
||||||
|
<div className="bg-white/5 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<ShoppingCart className="h-4 w-4 text-green-400" />
|
||||||
|
<span className="text-sm text-white/60">Заказы</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-white">
|
||||||
|
{formatNumber(campaignStats.reduce((sum, stat) => sum + stat.orders, 0))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CR */}
|
||||||
|
<div className="bg-white/5 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Percent className="h-4 w-4 text-blue-400" />
|
||||||
|
<span className="text-sm text-white/60">CR</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-white">
|
||||||
|
{formatPercent(
|
||||||
|
campaignStats.reduce((sum, stat, _, arr) => {
|
||||||
|
const totalClicks = arr.reduce((s, st) => s + st.clicks, 0)
|
||||||
|
const totalOrders = arr.reduce((s, st) => s + st.orders, 0)
|
||||||
|
return totalClicks > 0 ? (totalOrders / totalClicks) * 100 : 0
|
||||||
|
}, 0)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Выручка */}
|
||||||
|
<div className="bg-white/5 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<DollarSign className="h-4 w-4 text-emerald-400" />
|
||||||
|
<span className="text-sm text-white/60">Выручка</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold text-white">
|
||||||
|
{formatCurrency(campaignStats.reduce((sum, stat) => sum + stat.sum_price, 0))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* График */}
|
||||||
|
{chartData.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h4 className="text-md font-medium text-white mb-4">Динамика по дням</h4>
|
||||||
|
<div className="h-80">
|
||||||
|
<ChartContainer config={chartConfig}>
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fill: 'rgba(255,255,255,0.6)', fontSize: 12 }}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: 'rgba(255,255,255,0.6)', fontSize: 12 }}
|
||||||
|
axisLine={false}
|
||||||
|
/>
|
||||||
|
<ChartTooltip content={<ChartTooltipContent />} />
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="views"
|
||||||
|
stroke="#8b5cf6"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: '#8b5cf6', strokeWidth: 2, r: 4 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="clicks"
|
||||||
|
stroke="#06b6d4"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: '#06b6d4', strokeWidth: 2, r: 4 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="sum"
|
||||||
|
stroke="#f59e0b"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: '#f59e0b', strokeWidth: 2, r: 4 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="orders"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: '#10b981', strokeWidth: 2, r: 4 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Детальная статистика по каждой кампании */}
|
||||||
|
{campaignStats.map((campaign) => (
|
||||||
|
<Card key={campaign.advertId} className="glass-card p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-white">
|
||||||
|
Кампания #{campaign.advertId}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="outline" className="border-white/20 text-white">
|
||||||
|
{campaign.days.length} дней
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-4">
|
||||||
|
<div className="bg-white/5 rounded-lg p-3">
|
||||||
|
<div className="text-sm text-white/60 mb-1">Показы</div>
|
||||||
|
<div className="font-bold text-white">{formatNumber(campaign.views)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-lg p-3">
|
||||||
|
<div className="text-sm text-white/60 mb-1">Клики</div>
|
||||||
|
<div className="font-bold text-white">{formatNumber(campaign.clicks)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-lg p-3">
|
||||||
|
<div className="text-sm text-white/60 mb-1">CTR</div>
|
||||||
|
<div className="font-bold text-white">{formatPercent(campaign.ctr)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-lg p-3">
|
||||||
|
<div className="text-sm text-white/60 mb-1">CPC</div>
|
||||||
|
<div className="font-bold text-white">{formatCurrency(campaign.cpc)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-lg p-3">
|
||||||
|
<div className="text-sm text-white/60 mb-1">Затраты</div>
|
||||||
|
<div className="font-bold text-white">{formatCurrency(campaign.sum)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-lg p-3">
|
||||||
|
<div className="text-sm text-white/60 mb-1">Заказы</div>
|
||||||
|
<div className="font-bold text-white">{formatNumber(campaign.orders)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-lg p-3">
|
||||||
|
<div className="text-sm text-white/60 mb-1">CR</div>
|
||||||
|
<div className="font-bold text-white">{formatPercent(campaign.cr)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-lg p-3">
|
||||||
|
<div className="text-sm text-white/60 mb-1">Выручка</div>
|
||||||
|
<div className="font-bold text-white">{formatCurrency(campaign.sum_price)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="glass-card h-full overflow-hidden p-6">
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<TrendingUp className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Статистика рекламных кампаний</h3>
|
||||||
|
<p className="text-white/60 mb-4">Введите ID кампаний для получения детальной статистики</p>
|
||||||
|
<p className="text-white/40 text-sm">
|
||||||
|
Поддерживается API Wildberries /adv/v2/fullstats
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -1,13 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useQuery } from '@apollo/client'
|
import { useQuery } from '@apollo/client'
|
||||||
import { gql } from '@apollo/client'
|
import { gql } from '@apollo/client'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { TrendingUp } from 'lucide-react'
|
import { TrendingUp, Info, BarChart3 } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
ChartConfig,
|
ChartConfig,
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
@ -56,6 +56,8 @@ interface SalesTabProps {
|
|||||||
useCustomDates?: boolean
|
useCustomDates?: boolean
|
||||||
startDate?: string
|
startDate?: string
|
||||||
endDate?: string
|
endDate?: string
|
||||||
|
onPeriodChange?: (period: string) => void
|
||||||
|
onUseCustomDatesChange?: (useCustom: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock данные для графиков
|
// 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({
|
const [visibleMetrics, setVisibleMetrics] = useState({
|
||||||
sales: true,
|
sales: true,
|
||||||
orders: true,
|
orders: true,
|
||||||
advertising: true,
|
advertising: true, // Включаем, теперь используем двухосевой график
|
||||||
refusals: true,
|
refusals: true,
|
||||||
returns: true,
|
returns: true,
|
||||||
})
|
})
|
||||||
@ -194,15 +196,99 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
|
|||||||
if (wbData?.getWildberriesStatistics?.success && wbData.getWildberriesStatistics.data) {
|
if (wbData?.getWildberriesStatistics?.success && wbData.getWildberriesStatistics.data) {
|
||||||
const realData = wbData.getWildberriesStatistics.data
|
const realData = wbData.getWildberriesStatistics.data
|
||||||
|
|
||||||
// Обновляем данные для графика
|
// Улучшенная агрегация с более надежной обработкой дат
|
||||||
const newChartData = realData.map((item: {
|
const aggregateByDate = (data: Array<{
|
||||||
date: string;
|
date: string;
|
||||||
sales: number;
|
sales: number;
|
||||||
orders: number;
|
orders: number;
|
||||||
advertising: number;
|
advertising: number;
|
||||||
refusals: number;
|
refusals: number;
|
||||||
returns: number;
|
returns: number;
|
||||||
}) => ({
|
revenue: number;
|
||||||
|
buyoutPercentage: number;
|
||||||
|
}>) => {
|
||||||
|
const grouped = new Map<string, {
|
||||||
|
date: string;
|
||||||
|
sales: number;
|
||||||
|
orders: number;
|
||||||
|
advertising: number;
|
||||||
|
refusals: number;
|
||||||
|
returns: number;
|
||||||
|
revenue: number;
|
||||||
|
buyoutPercentages: number[];
|
||||||
|
}>()
|
||||||
|
|
||||||
|
data.forEach((item) => {
|
||||||
|
// Улучшенная нормализация даты - убираем время и часовой пояс
|
||||||
|
let normalizedDate: string
|
||||||
|
if (item.date.includes('T')) {
|
||||||
|
// Формат: 2025-07-19T03:00:00+03:00 или 2025-07-19T03:00:00
|
||||||
|
normalizedDate = item.date.split('T')[0]
|
||||||
|
} else if (item.date.includes(' ')) {
|
||||||
|
// Формат: 2025-07-19 03:00:00
|
||||||
|
normalizedDate = item.date.split(' ')[0]
|
||||||
|
} else {
|
||||||
|
// Формат: 2025-07-19
|
||||||
|
normalizedDate = item.date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дополнительная проверка на корректность формата YYYY-MM-DD
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalizedDate)) {
|
||||||
|
console.warn(`Invalid date format: ${item.date}, normalized: ${normalizedDate}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!grouped.has(normalizedDate)) {
|
||||||
|
grouped.set(normalizedDate, {
|
||||||
|
date: normalizedDate,
|
||||||
|
sales: 0,
|
||||||
|
orders: 0,
|
||||||
|
advertising: 0,
|
||||||
|
refusals: 0,
|
||||||
|
returns: 0,
|
||||||
|
revenue: 0,
|
||||||
|
buyoutPercentages: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = grouped.get(normalizedDate)!
|
||||||
|
group.sales += Number(item.sales) || 0
|
||||||
|
group.orders += Number(item.orders) || 0
|
||||||
|
group.advertising += Number(item.advertising) || 0
|
||||||
|
group.refusals += Number(item.refusals) || 0
|
||||||
|
group.returns += Number(item.returns) || 0
|
||||||
|
group.revenue += Number(item.revenue) || 0
|
||||||
|
|
||||||
|
// Собираем все процента выкупа для корректного усреднения
|
||||||
|
if (item.buyoutPercentage && item.buyoutPercentage > 0) {
|
||||||
|
group.buyoutPercentages.push(Number(item.buyoutPercentage))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Преобразуем в финальный формат
|
||||||
|
return Array.from(grouped.values()).map(group => ({
|
||||||
|
date: group.date,
|
||||||
|
sales: group.sales,
|
||||||
|
orders: group.orders,
|
||||||
|
advertising: group.advertising,
|
||||||
|
refusals: group.refusals,
|
||||||
|
returns: group.returns,
|
||||||
|
revenue: group.revenue,
|
||||||
|
buyoutPercentage: group.buyoutPercentages.length > 0
|
||||||
|
? Math.round(group.buyoutPercentages.reduce((a, b) => a + b, 0) / group.buyoutPercentages.length * 10) / 10
|
||||||
|
: group.orders > 0 ? Math.round((group.sales / group.orders) * 100 * 10) / 10 : 0
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregatedData = aggregateByDate(realData)
|
||||||
|
|
||||||
|
// Сортируем по дате (новые сверху)
|
||||||
|
const sortedData = aggregatedData.sort((a, b) =>
|
||||||
|
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Обновляем данные для графика
|
||||||
|
const newChartData = sortedData.map((item) => ({
|
||||||
date: new Date(item.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }),
|
date: new Date(item.date).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }),
|
||||||
sales: item.sales,
|
sales: item.sales,
|
||||||
orders: item.orders,
|
orders: item.orders,
|
||||||
@ -212,16 +298,7 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Обновляем данные для таблицы
|
// Обновляем данные для таблицы
|
||||||
const newTableData = realData.map((item: {
|
const newTableData = sortedData.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'),
|
date: new Date(item.date).toLocaleDateString('ru-RU'),
|
||||||
salesUnits: item.sales,
|
salesUnits: item.sales,
|
||||||
buyoutPercentage: item.buyoutPercentage,
|
buyoutPercentage: item.buyoutPercentage,
|
||||||
@ -231,9 +308,9 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
|
|||||||
returns: item.returns,
|
returns: item.returns,
|
||||||
revenue: Math.round(item.revenue)
|
revenue: Math.round(item.revenue)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
setChartData(newChartData)
|
setChartData(newChartData.reverse()) // Для графика - старые даты слева
|
||||||
setTableData(newTableData)
|
setTableData(newTableData) // Для таблицы - новые даты сверху
|
||||||
}
|
}
|
||||||
}, [wbData])
|
}, [wbData])
|
||||||
|
|
||||||
@ -248,25 +325,83 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
|
|||||||
// Проверяем состояние загрузки и данных
|
// Проверяем состояние загрузки и данных
|
||||||
const isLoading = loading || (useCustomDates && (!startDate || !endDate))
|
const isLoading = loading || (useCustomDates && (!startDate || !endDate))
|
||||||
const hasData = tableData.length > 0
|
const hasData = tableData.length > 0
|
||||||
|
|
||||||
|
// Состояние для сортировки
|
||||||
|
const [sortField, setSortField] = useState<string>('')
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
|
||||||
|
|
||||||
|
// Функция сортировки
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||||
|
} else {
|
||||||
|
setSortField(field)
|
||||||
|
setSortDirection('desc')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отсортированные данные таблицы
|
||||||
|
const sortedTableData = React.useMemo(() => {
|
||||||
|
if (!sortField) return tableData
|
||||||
|
|
||||||
|
return [...tableData].sort((a, b) => {
|
||||||
|
let aValue: string | number = a[sortField as keyof typeof a] as string | number
|
||||||
|
let bValue: string | number = b[sortField as keyof typeof b] as string | number
|
||||||
|
|
||||||
|
// Для даты используем особую логику
|
||||||
|
if (sortField === 'date') {
|
||||||
|
aValue = new Date(aValue).getTime()
|
||||||
|
bValue = new Date(bValue).getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return aValue > bValue ? 1 : -1
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : -1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [tableData, sortField, sortDirection])
|
||||||
|
|
||||||
|
// Вычисляем итоги
|
||||||
|
const totals = React.useMemo(() => {
|
||||||
|
if (!hasData) {
|
||||||
|
return {
|
||||||
|
salesUnits: 0,
|
||||||
|
buyoutPercentage: 0,
|
||||||
|
advertising: 0,
|
||||||
|
orders: 0,
|
||||||
|
refusals: 0,
|
||||||
|
returns: 0,
|
||||||
|
revenue: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSales = tableData.reduce((sum, row) => sum + row.salesUnits, 0)
|
||||||
|
const totalOrders = tableData.reduce((sum, row) => sum + row.orders, 0)
|
||||||
|
|
||||||
|
// ПРАВИЛЬНЫЙ расчёт % выкупа: общие продажи / общие заказы * 100
|
||||||
|
const correctBuyoutPercentage = totalOrders > 0
|
||||||
|
? Math.round((totalSales / totalOrders) * 100 * 10) / 10
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
salesUnits: totalSales,
|
||||||
|
buyoutPercentage: correctBuyoutPercentage,
|
||||||
|
advertising: tableData.reduce((sum, row) => sum + row.advertising, 0),
|
||||||
|
orders: totalOrders,
|
||||||
|
refusals: tableData.reduce((sum, row) => sum + row.refusals, 0),
|
||||||
|
returns: tableData.reduce((sum, row) => sum + row.returns, 0),
|
||||||
|
revenue: tableData.reduce((sum, row) => sum + row.revenue, 0)
|
||||||
|
}
|
||||||
|
}, [tableData, hasData])
|
||||||
const hasAnyActivity = hasData && tableData.some(row =>
|
const hasAnyActivity = hasData && tableData.some(row =>
|
||||||
row.salesUnits > 0 || row.orders > 0 || row.advertising > 0 || row.refusals > 0 || row.returns > 0
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col space-y-3">
|
<div className="h-full flex flex-col space-y-2">
|
||||||
<Card className="glass-card p-4 flex-shrink-0" style={{ height: '380px' }}>
|
<Card className="glass-card p-4 flex-shrink-0" style={{ height: '380px' }}>
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<Skeleton className="h-6 w-48 mb-4" />
|
<Skeleton className="h-6 w-48 mb-4" />
|
||||||
@ -318,21 +453,74 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col space-y-3">
|
<div className="h-full flex flex-col space-y-2">
|
||||||
{/* График с фильтрами */}
|
{/* График с фильтрами */}
|
||||||
<Card className="glass-card p-4 flex-shrink-0 overflow-hidden" style={{ height: '380px' }}>
|
<Card className="glass-card p-3 flex-shrink-0 overflow-hidden" style={{ height: '380px' }}>
|
||||||
<div className="h-full flex flex-col min-h-0">
|
<div className="h-full flex flex-col min-h-0">
|
||||||
{/* Компактный заголовок */}
|
{/* Заголовок с переключателями периода */}
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="text-white font-semibold">Динамика показателей</h3>
|
<h3 className="text-white font-semibold">Динамика показателей</h3>
|
||||||
{error && (
|
|
||||||
<div className="text-red-400 text-xs">Предупреждение: {error.message}</div>
|
{/* Переключатели периода */}
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex gap-1 bg-white/5 backdrop-blur border border-white/10 rounded-lg p-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onPeriodChange?.('week')
|
||||||
|
onUseCustomDatesChange?.(false)
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium transition-all duration-200 ${
|
||||||
|
selectedPeriod === 'week' && !useCustomDates
|
||||||
|
? 'bg-white/20 text-white shadow-sm'
|
||||||
|
: 'text-white/60 hover:bg-white/10 hover:text-white/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Неделя
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onPeriodChange?.('month')
|
||||||
|
onUseCustomDatesChange?.(false)
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium transition-all duration-200 ${
|
||||||
|
selectedPeriod === 'month' && !useCustomDates
|
||||||
|
? 'bg-white/20 text-white shadow-sm'
|
||||||
|
: 'text-white/60 hover:bg-white/10 hover:text-white/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Месяц
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onPeriodChange?.('quarter')
|
||||||
|
onUseCustomDatesChange?.(false)
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium transition-all duration-200 ${
|
||||||
|
selectedPeriod === 'quarter' && !useCustomDates
|
||||||
|
? 'bg-white/20 text-white shadow-sm'
|
||||||
|
: 'text-white/60 hover:bg-white/10 hover:text-white/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Квартал
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-400 text-xs">Ошибка: {error.message}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Компактные чекбоксы для фильтрации */}
|
{/* Компактные чекбоксы для фильтрации */}
|
||||||
<div className="mb-2 pb-2 border-b border-white/10">
|
<div className="mb-2 pb-2 border-b border-white/10">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs text-white/60">Показать на графике:</span>
|
||||||
|
<span className="text-xs text-blue-400 flex items-center gap-1">
|
||||||
|
<BarChart3 className="w-3 h-3" />
|
||||||
|
Реклама показана на правой оси
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
{Object.entries(chartConfig).map(([key, config]) => {
|
{Object.entries(chartConfig).map(([key, config]) => {
|
||||||
const isVisible = visibleMetrics[key as keyof typeof visibleMetrics]
|
const isVisible = visibleMetrics[key as keyof typeof visibleMetrics]
|
||||||
return (
|
return (
|
||||||
@ -382,47 +570,73 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickFormatter={(value) => value.slice(0, 5)}
|
tickFormatter={(value) => value.slice(0, 5)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Левая ось для основных метрик */}
|
||||||
|
<YAxis
|
||||||
|
yAxisId="left"
|
||||||
|
orientation="left"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
className="text-white/60 text-xs"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Правая ось для рекламы */}
|
||||||
|
<YAxis
|
||||||
|
yAxisId="right"
|
||||||
|
orientation="right"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
className="text-white/60 text-xs"
|
||||||
|
/>
|
||||||
|
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
cursor={false}
|
cursor={false}
|
||||||
content={<ChartTooltipContent indicator="dashed" />}
|
content={<ChartTooltipContent indicator="dashed" />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Условно рендерим бары в зависимости от чекбоксов */}
|
{/* Основные метрики на левой оси */}
|
||||||
{visibleMetrics.sales && (
|
{visibleMetrics.sales && (
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="sales"
|
yAxisId="left"
|
||||||
fill={chartConfig.sales.color}
|
dataKey="sales"
|
||||||
radius={4}
|
fill={chartConfig.sales.color}
|
||||||
/>
|
radius={4}
|
||||||
)}
|
/>
|
||||||
{visibleMetrics.orders && (
|
)}
|
||||||
<Bar
|
{visibleMetrics.orders && (
|
||||||
dataKey="orders"
|
<Bar
|
||||||
fill={chartConfig.orders.color}
|
yAxisId="left"
|
||||||
radius={4}
|
dataKey="orders"
|
||||||
/>
|
fill={chartConfig.orders.color}
|
||||||
)}
|
radius={4}
|
||||||
{visibleMetrics.advertising && (
|
/>
|
||||||
<Bar
|
)}
|
||||||
dataKey="advertising"
|
{visibleMetrics.refusals && (
|
||||||
fill={chartConfig.advertising.color}
|
<Bar
|
||||||
radius={4}
|
yAxisId="left"
|
||||||
/>
|
dataKey="refusals"
|
||||||
)}
|
fill={chartConfig.refusals.color}
|
||||||
{visibleMetrics.refusals && (
|
radius={4}
|
||||||
<Bar
|
/>
|
||||||
dataKey="refusals"
|
)}
|
||||||
fill={chartConfig.refusals.color}
|
{visibleMetrics.returns && (
|
||||||
radius={4}
|
<Bar
|
||||||
/>
|
yAxisId="left"
|
||||||
)}
|
dataKey="returns"
|
||||||
{visibleMetrics.returns && (
|
fill={chartConfig.returns.color}
|
||||||
<Bar
|
radius={4}
|
||||||
dataKey="returns"
|
/>
|
||||||
fill={chartConfig.returns.color}
|
)}
|
||||||
radius={4}
|
|
||||||
/>
|
{/* Реклама на правой оси */}
|
||||||
)}
|
{visibleMetrics.advertising && (
|
||||||
|
<Bar
|
||||||
|
yAxisId="right"
|
||||||
|
dataKey="advertising"
|
||||||
|
fill={chartConfig.advertising.color}
|
||||||
|
radius={4}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
</div>
|
</div>
|
||||||
@ -431,25 +645,162 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
|
|||||||
|
|
||||||
{/* Таблица данных */}
|
{/* Таблица данных */}
|
||||||
<Card className="glass-card flex-1 overflow-hidden">
|
<Card className="glass-card flex-1 overflow-hidden">
|
||||||
<div className="p-4 h-full flex flex-col">
|
<div className="p-3 h-full flex flex-col">
|
||||||
<h3 className="text-white font-semibold mb-3 text-sm">Детальная статистика</h3>
|
<h3 className="text-white font-semibold mb-2 text-sm">Детальная статистика</h3>
|
||||||
|
|
||||||
<div className="overflow-x-auto flex-1">
|
<div className="overflow-x-auto flex-1">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-white/20">
|
<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 className="text-left p-2 text-white font-semibold text-xs">Продажи, шт</th>
|
<button
|
||||||
<th className="text-left p-2 text-white font-semibold text-xs">% выкупов</th>
|
onClick={() => handleSort('date')}
|
||||||
<th className="text-left p-2 text-white font-semibold text-xs">Реклама, ₽</th>
|
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
|
||||||
<th className="text-left p-2 text-white font-semibold text-xs">Заказы</th>
|
title="Нажмите для сортировки"
|
||||||
<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>
|
<span className="text-xs opacity-50">
|
||||||
|
{sortField === 'date' ? (
|
||||||
|
sortDirection === 'asc' ? '↑' : '↓'
|
||||||
|
) : '⇅'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="text-left p-2 text-white font-semibold text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('salesUnits')}
|
||||||
|
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
|
||||||
|
title="Нажмите для сортировки"
|
||||||
|
>
|
||||||
|
Продажи, шт
|
||||||
|
<span className="text-xs opacity-50">
|
||||||
|
{sortField === 'salesUnits' ? (
|
||||||
|
sortDirection === 'asc' ? '↑' : '↓'
|
||||||
|
) : '⇅'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="text-left p-2 text-white font-semibold text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('buyoutPercentage')}
|
||||||
|
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
|
||||||
|
title="Нажмите для сортировки"
|
||||||
|
>
|
||||||
|
% выкупов
|
||||||
|
<span className="text-xs opacity-50">
|
||||||
|
{sortField === 'buyoutPercentage' ? (
|
||||||
|
sortDirection === 'asc' ? '↑' : '↓'
|
||||||
|
) : '⇅'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="text-left p-2 text-white font-semibold text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('advertising')}
|
||||||
|
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
|
||||||
|
title="Нажмите для сортировки"
|
||||||
|
>
|
||||||
|
Реклама, ₽
|
||||||
|
<span className="text-xs opacity-50">
|
||||||
|
{sortField === 'advertising' ? (
|
||||||
|
sortDirection === 'asc' ? '↑' : '↓'
|
||||||
|
) : '⇅'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="text-left p-2 text-white font-semibold text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('orders')}
|
||||||
|
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
|
||||||
|
title="Нажмите для сортировки"
|
||||||
|
>
|
||||||
|
Заказы
|
||||||
|
<span className="text-xs opacity-50">
|
||||||
|
{sortField === 'orders' ? (
|
||||||
|
sortDirection === 'asc' ? '↑' : '↓'
|
||||||
|
) : '⇅'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="text-left p-2 text-white font-semibold text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('refusals')}
|
||||||
|
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
|
||||||
|
title="Нажмите для сортировки"
|
||||||
|
>
|
||||||
|
Отказы
|
||||||
|
<span className="text-xs opacity-50">
|
||||||
|
{sortField === 'refusals' ? (
|
||||||
|
sortDirection === 'asc' ? '↑' : '↓'
|
||||||
|
) : '⇅'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="text-left p-2 text-white font-semibold text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('returns')}
|
||||||
|
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
|
||||||
|
title="Нажмите для сортировки"
|
||||||
|
>
|
||||||
|
Возвраты
|
||||||
|
<span className="text-xs opacity-50">
|
||||||
|
{sortField === 'returns' ? (
|
||||||
|
sortDirection === 'asc' ? '↑' : '↓'
|
||||||
|
) : '⇅'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th className="text-left p-2 text-white font-semibold text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSort('revenue')}
|
||||||
|
className="flex items-center gap-1 hover:text-white/80 hover:bg-white/10 px-1 py-0.5 rounded transition-all cursor-pointer"
|
||||||
|
title="Нажмите для сортировки"
|
||||||
|
>
|
||||||
|
Выручка, ₽
|
||||||
|
<span className="text-xs opacity-50">
|
||||||
|
{sortField === 'revenue' ? (
|
||||||
|
sortDirection === 'asc' ? '↑' : '↓'
|
||||||
|
) : '⇅'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{tableData.map((row, index) => (
|
{/* Итоговая строка сверху */}
|
||||||
|
<tr className="border-b-2 border-white/30 bg-white/10 font-semibold">
|
||||||
|
<td className="p-2 text-white text-xs font-bold">ИТОГО</td>
|
||||||
|
<td className="p-2 text-white text-xs font-bold">{totals.salesUnits}</td>
|
||||||
|
<td className="p-2 text-xs">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={`${
|
||||||
|
totals.buyoutPercentage >= 80
|
||||||
|
? 'bg-green-500/20 text-green-400'
|
||||||
|
: 'bg-yellow-500/20 text-yellow-400'
|
||||||
|
} font-bold`}
|
||||||
|
>
|
||||||
|
{totals.buyoutPercentage.toFixed(1)}%
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-white text-xs font-bold">{totals.advertising.toLocaleString('ru-RU')}</td>
|
||||||
|
<td className="p-2 text-white text-xs font-bold">{totals.orders}</td>
|
||||||
|
<td className="p-2 text-xs">
|
||||||
|
<Badge variant="secondary" className="bg-red-500/20 text-red-400 font-bold text-xs px-2 py-0.5">
|
||||||
|
{totals.refusals}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-xs">
|
||||||
|
<Badge variant="secondary" className="bg-orange-500/20 text-orange-400 font-bold text-xs px-2 py-0.5">
|
||||||
|
{totals.returns}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-white text-xs font-bold">
|
||||||
|
{totals.revenue.toLocaleString('ru-RU')} ₽
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{sortedTableData.map((row, index) => (
|
||||||
<tr key={index} className="border-b border-white/10 hover:bg-white/5 transition-colors">
|
<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/80 text-xs">{row.date}</td>
|
||||||
<td className="p-2 text-white text-xs font-medium">{row.salesUnits}</td>
|
<td className="p-2 text-white text-xs font-medium">{row.salesUnits}</td>
|
||||||
@ -482,44 +833,15 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate }:
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mt-2 text-xs text-white/50 flex items-center gap-1">
|
||||||
</Card>
|
<Info className="w-3 h-3" />
|
||||||
|
<span>Нажмите на заголовок столбца для сортировки</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
@ -6,6 +6,7 @@ import { Card } from '@/components/ui/card'
|
|||||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||||
import { useSidebar } from '@/hooks/useSidebar'
|
import { useSidebar } from '@/hooks/useSidebar'
|
||||||
import { SalesTab } from '@/components/seller-statistics/sales-tab'
|
import { SalesTab } from '@/components/seller-statistics/sales-tab'
|
||||||
|
import { AdvertisingTab } from '@/components/seller-statistics/advertising-tab'
|
||||||
import { DateRangePicker } from '@/components/ui/date-picker'
|
import { DateRangePicker } from '@/components/ui/date-picker'
|
||||||
import { BarChart3, PieChart, TrendingUp, Calendar } from 'lucide-react'
|
import { BarChart3, PieChart, TrendingUp, Calendar } from 'lucide-react'
|
||||||
|
|
||||||
@ -19,83 +20,8 @@ export function SellerStatisticsDashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-screen flex overflow-hidden">
|
<div className="h-screen flex overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className={`flex-1 ${getSidebarMargin()} px-4 py-3 overflow-hidden transition-all duration-300`}>
|
<main className={`flex-1 ${getSidebarMargin()} px-4 py-4 overflow-hidden transition-all duration-300`}>
|
||||||
<div className="h-full w-full flex flex-col">
|
<div className="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">
|
<div className="flex-1 overflow-hidden">
|
||||||
@ -132,19 +58,18 @@ export function SellerStatisticsDashboard() {
|
|||||||
useCustomDates={useCustomDates}
|
useCustomDates={useCustomDates}
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
endDate={endDate}
|
endDate={endDate}
|
||||||
|
onPeriodChange={setSelectedPeriod}
|
||||||
|
onUseCustomDatesChange={setUseCustomDates}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="advertising" className="h-full m-0 overflow-hidden">
|
<TabsContent value="advertising" className="h-full m-0 overflow-hidden">
|
||||||
<Card className="glass-card h-full overflow-hidden p-6">
|
<AdvertisingTab
|
||||||
<div className="flex items-center justify-center h-full">
|
selectedPeriod={selectedPeriod}
|
||||||
<div className="text-center">
|
useCustomDates={useCustomDates}
|
||||||
<TrendingUp className="h-12 w-12 text-white/40 mx-auto mb-4" />
|
startDate={startDate}
|
||||||
<h3 className="text-lg font-semibold text-white mb-2">Статистика рекламы</h3>
|
endDate={endDate}
|
||||||
<p className="text-white/60">Раздел в разработке</p>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="other" className="h-full m-0 overflow-hidden">
|
<TabsContent value="other" className="h-full m-0 overflow-hidden">
|
||||||
|
67
src/components/ui/calendar.tsx
Normal file
67
src/components/ui/calendar.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { DayPicker } from "react-day-picker"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
|
|
||||||
|
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: CalendarProps) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn("p-3", className)}
|
||||||
|
classNames={{
|
||||||
|
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||||
|
month: "space-y-4",
|
||||||
|
caption: "flex justify-center pt-1 relative items-center",
|
||||||
|
caption_label: "text-sm font-medium",
|
||||||
|
nav: "space-x-1 flex items-center",
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: "outline" }),
|
||||||
|
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||||
|
),
|
||||||
|
nav_button_previous: "absolute left-1",
|
||||||
|
nav_button_next: "absolute right-1",
|
||||||
|
table: "w-full border-collapse space-y-1",
|
||||||
|
head_row: "flex",
|
||||||
|
head_cell:
|
||||||
|
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||||
|
row: "flex w-full mt-2",
|
||||||
|
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||||
|
day: cn(
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
|
||||||
|
),
|
||||||
|
day_range_end: "day-range-end",
|
||||||
|
day_selected:
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||||
|
day_today: "bg-accent text-accent-foreground",
|
||||||
|
day_outside:
|
||||||
|
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
|
||||||
|
day_disabled: "text-muted-foreground opacity-50",
|
||||||
|
day_range_middle:
|
||||||
|
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||||
|
day_hidden: "invisible",
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Chevron: ({ orientation, ...props }) =>
|
||||||
|
orientation === "left" ?
|
||||||
|
<ChevronLeft className="h-4 w-4" /> :
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Calendar.displayName = "Calendar"
|
||||||
|
|
||||||
|
export { Calendar }
|
@ -1,8 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Calendar } from "lucide-react"
|
import { Calendar, CalendarIcon } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
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 {
|
interface DatePickerProps {
|
||||||
value?: string
|
value?: string
|
||||||
@ -50,6 +60,65 @@ interface DateRangePickerProps {
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DatePickerWithRange({
|
||||||
|
onDateChange,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
onDateChange?: (dates: { from: Date | undefined; to: Date | undefined }) => void
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const [date, setDate] = React.useState<DateRange | undefined>({
|
||||||
|
from: new Date(2025, 3, 1), // Апрель 2025
|
||||||
|
to: addDays(new Date(2025, 3, 1), 30),
|
||||||
|
})
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
onDateChange?.(date ? { from: date.from, to: date.to } : { from: undefined, to: undefined })
|
||||||
|
}, [date, onDateChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("grid gap-2", className)}>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
id="date"
|
||||||
|
variant={"outline"}
|
||||||
|
className={cn(
|
||||||
|
"w-[300px] justify-start text-left font-normal",
|
||||||
|
!date && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{date?.from ? (
|
||||||
|
date.to ? (
|
||||||
|
<>
|
||||||
|
{format(date.from, "dd.MM.yyyy", { locale: ru })} -{" "}
|
||||||
|
{format(date.to, "dd.MM.yyyy", { locale: ru })}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
format(date.from, "dd.MM.yyyy", { locale: ru })
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span>Выберите диапазон дат</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<CalendarComponent
|
||||||
|
initialFocus
|
||||||
|
mode="range"
|
||||||
|
defaultMonth={date?.from}
|
||||||
|
selected={date}
|
||||||
|
onSelect={setDate}
|
||||||
|
numberOfMonths={2}
|
||||||
|
locale={ru}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function DateRangePicker({
|
export function DateRangePicker({
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
30
src/components/ui/popover.tsx
Normal file
30
src/components/ui/popover.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent }
|
@ -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`
|
export const ADMIN_ME = gql`
|
||||||
query AdminMe {
|
query AdminMe {
|
||||||
|
@ -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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Добавляем админ запросы и мутации к основным резолверам
|
// Добавляем админ запросы и мутации к основным резолверам
|
||||||
|
@ -85,6 +85,11 @@ export const typeDefs = gql`
|
|||||||
|
|
||||||
# Отладка рекламы (временно)
|
# Отладка рекламы (временно)
|
||||||
debugWildberriesAdverts: DebugAdvertsResponse!
|
debugWildberriesAdverts: DebugAdvertsResponse!
|
||||||
|
|
||||||
|
# Статистика кампаний Wildberries
|
||||||
|
getWildberriesCampaignStats(
|
||||||
|
input: WildberriesCampaignStatsInput!
|
||||||
|
): WildberriesCampaignStatsResponse!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
@ -934,4 +939,57 @@ export const typeDefs = gql`
|
|||||||
status: Int!
|
status: Int!
|
||||||
type: 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!
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
@ -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 {
|
export interface WBAdvertStatsResponse {
|
||||||
dates: string[]
|
dates: string[]
|
||||||
views: number
|
views: number
|
||||||
@ -152,9 +170,45 @@ export interface WBAdvertStatsResponse {
|
|||||||
shks: number
|
shks: number
|
||||||
sum_price: 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
|
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 {
|
export interface WBStatisticsData {
|
||||||
date: string
|
date: string
|
||||||
sales: number
|
sales: number
|
||||||
@ -204,13 +258,145 @@ class WildberriesService {
|
|||||||
return this.makeRequest<WBOrdersData[]>(url)
|
return this.makeRequest<WBOrdersData[]>(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение рекламных кампаний
|
// Получение списка всех кампаний с группировкой
|
||||||
async getAdverts(status?: number, type?: number, limit = 100, offset = 0): Promise<WBAdvertData[]> {
|
async getCampaignsList(): Promise<WBCampaignsListResponse> {
|
||||||
let url = `${this.advertURL}/adv/v0/adverts?limit=${limit}&offset=${offset}`
|
const url = `${this.advertURL}/adv/v1/promotion/count`
|
||||||
if (status) url += `&status=${status}`
|
console.log(`WB API: Getting campaigns list from ${url}`)
|
||||||
if (type) url += `&type=${type}`
|
return this.makeRequest<WBCampaignsListResponse>(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение кампаний за определенный период по changeTime
|
||||||
|
async getCampaignsForPeriod(dateFrom: string, dateTo: string): Promise<number[]> {
|
||||||
|
const campaignsList = await this.getCampaignsList()
|
||||||
|
const fromDate = new Date(dateFrom)
|
||||||
|
const toDate = new Date(dateTo)
|
||||||
|
|
||||||
return this.makeRequest<WBAdvertData[]>(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<WBAdvertData[]> {
|
||||||
|
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<WBAdvertStatsResponse[]> {
|
||||||
|
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<WBAdvertStatsResponse[]>(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<WildberriesWarehouse[]> {
|
||||||
|
const url = `${this.baseURL}/api/v2/warehouses`
|
||||||
|
console.log(`WB API: Getting warehouses from ${url}`)
|
||||||
|
const response = await this.makeRequest<WildberriesWarehouse[]>(url)
|
||||||
|
return response || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение карточек товаров
|
||||||
|
async getCards(options: { limit?: number; offset?: number } = {}): Promise<WildberriesCard[]> {
|
||||||
|
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<WildberriesCard[]> {
|
||||||
|
// Для простоты пока используем тот же 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)
|
// Получение статистики всех рекламных кампаний (v2)
|
||||||
@ -261,13 +447,37 @@ class WildberriesService {
|
|||||||
|
|
||||||
console.log(`WB API: Got ${salesData.length} sales, ${ordersData.length} orders`)
|
console.log(`WB API: Got ${salesData.length} sales, ${ordersData.length} orders`)
|
||||||
|
|
||||||
// Получаем статистику рекламы напрямую через /adv/v2/fullstats
|
// Получаем статистику рекламы через правильный API
|
||||||
let advertStatsData: WBAdvertStatsResponse[] = []
|
let advertStatsData: WBAdvertStatsResponse[] = []
|
||||||
try {
|
try {
|
||||||
console.log(`WB API: Getting campaign stats for interval: ${dateFrom} to ${dateTo}`)
|
console.log(`WB API: Getting campaign stats for interval: ${dateFrom} to ${dateTo}`)
|
||||||
|
|
||||||
advertStatsData = await this.getAdvertStats(dateFrom, dateTo)
|
try {
|
||||||
console.log(`WB API: Got advertising stats for ${advertStatsData.length} campaigns`)
|
// Получаем 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 => {
|
advertStatsData.forEach(stat => {
|
||||||
@ -282,13 +492,21 @@ class WildberriesService {
|
|||||||
const statsMap = new Map<string, WBStatisticsData>()
|
const statsMap = new Map<string, WBStatisticsData>()
|
||||||
|
|
||||||
// Обрабатываем продажи
|
// Обрабатываем продажи
|
||||||
|
console.log(`WB API: Processing ${salesData.length} sales records`)
|
||||||
salesData.forEach(sale => {
|
salesData.forEach(sale => {
|
||||||
|
const originalDate = sale.date
|
||||||
const date = sale.date.split('T')[0] // Берем только дату без времени
|
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)) {
|
if (!statsMap.has(date)) {
|
||||||
|
console.log(`WB API: Creating new stats entry for date ${date}`)
|
||||||
statsMap.set(date, {
|
statsMap.set(date, {
|
||||||
date,
|
date,
|
||||||
sales: 0,
|
sales: 0,
|
||||||
@ -305,19 +523,29 @@ class WildberriesService {
|
|||||||
if (!sale.isCancel) {
|
if (!sale.isCancel) {
|
||||||
stats.sales += 1
|
stats.sales += 1
|
||||||
stats.revenue += sale.totalPrice * (1 - sale.discountPercent / 100)
|
stats.revenue += sale.totalPrice * (1 - sale.discountPercent / 100)
|
||||||
|
console.log(`WB API: Added sale to ${date}, total sales now: ${stats.sales}`)
|
||||||
} else {
|
} else {
|
||||||
stats.returns += 1
|
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 => {
|
ordersData.forEach(order => {
|
||||||
|
const originalDate = order.date
|
||||||
const date = order.date.split('T')[0]
|
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)) {
|
if (!statsMap.has(date)) {
|
||||||
|
console.log(`WB API: Creating new stats entry for date ${date} (from orders)`)
|
||||||
statsMap.set(date, {
|
statsMap.set(date, {
|
||||||
date,
|
date,
|
||||||
sales: 0,
|
sales: 0,
|
||||||
@ -333,8 +561,10 @@ class WildberriesService {
|
|||||||
const stats = statsMap.get(date)!
|
const stats = statsMap.get(date)!
|
||||||
if (!order.isCancel) {
|
if (!order.isCancel) {
|
||||||
stats.orders += 1
|
stats.orders += 1
|
||||||
|
console.log(`WB API: Added order to ${date}, total orders now: ${stats.orders}`)
|
||||||
} else {
|
} else {
|
||||||
stats.refusals += 1
|
stats.refusals += 1
|
||||||
|
console.log(`WB API: Added refusal to ${date}, total refusals now: ${stats.refusals}`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -390,7 +620,11 @@ class WildberriesService {
|
|||||||
// Получаем данные по рекламе (это более сложно, так как нужна отдельная статистика)
|
// Получаем данные по рекламе (это более сложно, так как нужна отдельная статистика)
|
||||||
// Пока используем заглушку, в реальности нужно вызвать отдельный API
|
// Пока используем заглушку, в реальности нужно вызвать отдельный 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) {
|
} catch (error) {
|
||||||
console.error('Error fetching WB statistics:', error)
|
console.error('Error fetching WB statistics:', error)
|
||||||
throw error
|
throw error
|
||||||
@ -443,6 +677,24 @@ class WildberriesService {
|
|||||||
|
|
||||||
return this.formatDate(date)
|
return this.formatDate(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Статический метод для получения складов с токеном
|
||||||
|
static async getWarehouses(apiKey: string): Promise<WildberriesWarehouse[]> {
|
||||||
|
const service = new WildberriesService(apiKey)
|
||||||
|
return service.getWarehouses()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статический метод для получения карточек с токеном
|
||||||
|
static async getAllCards(apiKey: string, limit = 100): Promise<WildberriesCard[]> {
|
||||||
|
const service = new WildberriesService(apiKey)
|
||||||
|
return service.getCards({ limit })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статический метод для поиска карточек с токеном
|
||||||
|
static async searchCards(apiKey: string, searchTerm: string, limit = 100): Promise<WildberriesCard[]> {
|
||||||
|
const service = new WildberriesService(apiKey)
|
||||||
|
return service.searchCards(searchTerm, limit)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { WildberriesService }
|
export { WildberriesService }
|
Reference in New Issue
Block a user