This commit is contained in:
Veronika Smirnova
2025-07-22 15:51:17 +03:00
5 changed files with 566 additions and 103 deletions

View File

@ -1,15 +1,27 @@
"use client" "use client"
import React from 'react' import React, { useState, useMemo } from 'react'
import { useQuery, useMutation } from '@apollo/client' import { useQuery, useMutation } from '@apollo/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { GlassInput } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { import {
Users, Users,
ArrowUpCircle, ArrowUpCircle,
ArrowDownCircle ArrowDownCircle,
Search,
Filter,
SortAsc,
SortDesc,
Calendar,
Building,
Phone,
Mail,
MapPin,
X
} from 'lucide-react' } from 'lucide-react'
import { OrganizationCard } from './organization-card' import { OrganizationCard } from './organization-card'
import { OrganizationAvatar } from './organization-avatar' import { OrganizationAvatar } from './organization-avatar'
@ -38,7 +50,15 @@ interface CounterpartyRequest {
receiver: Organization receiver: Organization
} }
type SortField = 'name' | 'date' | 'inn' | 'type'
type SortOrder = 'asc' | 'desc'
export function MarketCounterparties() { export function MarketCounterparties() {
const [searchQuery, setSearchQuery] = useState('')
const [typeFilter, setTypeFilter] = useState<string>('all')
const [sortField, setSortField] = useState<SortField>('name')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES) const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES)
const { data: incomingData, loading: incomingLoading } = useQuery(GET_INCOMING_REQUESTS) const { data: incomingData, loading: incomingLoading } = useQuery(GET_INCOMING_REQUESTS)
const { data: outgoingData, loading: outgoingLoading } = useQuery(GET_OUTGOING_REQUESTS) const { data: outgoingData, loading: outgoingLoading } = useQuery(GET_OUTGOING_REQUESTS)
@ -77,6 +97,56 @@ export function MarketCounterparties() {
awaitRefetchQueries: true awaitRefetchQueries: true
}) })
// Фильтрация и сортировка контрагентов
const filteredAndSortedCounterparties = useMemo(() => {
const filtered = (counterpartiesData?.myCounterparties || []).filter((org: Organization) => {
const matchesSearch = !searchQuery ||
(org.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
org.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
org.inn.includes(searchQuery) ||
org.address?.toLowerCase().includes(searchQuery.toLowerCase()))
const matchesType = typeFilter === 'all' || org.type === typeFilter
return matchesSearch && matchesType
})
// Сортировка
filtered.sort((a: Organization, b: Organization) => {
let aValue: string | number
let bValue: string | number
switch (sortField) {
case 'name':
aValue = (a.name || a.fullName || '').toLowerCase()
bValue = (b.name || b.fullName || '').toLowerCase()
break
case 'date':
aValue = new Date(a.createdAt).getTime()
bValue = new Date(b.createdAt).getTime()
break
case 'inn':
aValue = a.inn
bValue = b.inn
break
case 'type':
aValue = a.type
bValue = b.type
break
default:
return 0
}
if (sortOrder === 'asc') {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0
} else {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0
}
})
return filtered
}, [counterpartiesData?.myCounterparties, searchQuery, typeFilter, sortField, sortOrder])
const handleAcceptRequest = async (requestId: string) => { const handleAcceptRequest = async (requestId: string) => {
try { try {
await respondToRequest({ await respondToRequest({
@ -146,20 +216,41 @@ export function MarketCounterparties() {
} }
} }
const clearFilters = () => {
setSearchQuery('')
setTypeFilter('all')
setSortField('name')
setSortOrder('asc')
}
const hasActiveFilters = searchQuery || typeFilter !== 'all' || sortField !== 'name' || sortOrder !== 'asc'
const counterparties = counterpartiesData?.myCounterparties || [] const counterparties = counterpartiesData?.myCounterparties || []
const incomingRequests = incomingData?.incomingRequests || [] const incomingRequests = incomingData?.incomingRequests || []
const outgoingRequests = outgoingData?.outgoingRequests || [] const outgoingRequests = outgoingData?.outgoingRequests || []
const getTypeLabel = (type: string) => {
switch (type) {
case 'FULFILLMENT': return 'Фулфилмент'
case 'SELLER': return 'Селлер'
case 'LOGIST': return 'Логистика'
case 'WHOLESALE': return 'Оптовик'
default: return type
}
}
const getTypeBadgeStyles = (type: string) => {
switch (type) {
case 'FULFILLMENT': return 'bg-blue-500/20 text-blue-300 border-blue-500/30'
case 'SELLER': return 'bg-green-500/20 text-green-300 border-green-500/30'
case 'LOGIST': return 'bg-orange-500/20 text-orange-300 border-orange-500/30'
case 'WHOLESALE': return 'bg-purple-500/20 text-purple-300 border-purple-500/30'
default: return 'bg-gray-500/20 text-gray-300 border-gray-500/30'
}
}
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="flex items-center space-x-3 mb-6">
<Users className="h-6 w-6 text-blue-400" />
<div>
<h3 className="text-lg font-semibold text-white">Мои контрагенты</h3>
<p className="text-white/60 text-sm">Управление контрагентами и заявками</p>
</div>
</div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<Tabs defaultValue="counterparties" className="h-full flex flex-col"> <Tabs defaultValue="counterparties" className="h-full flex flex-col">
<TabsList className="grid w-full grid-cols-3 bg-white/5 border-white/10"> <TabsList className="grid w-full grid-cols-3 bg-white/5 border-white/10">
@ -180,33 +271,200 @@ export function MarketCounterparties() {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="counterparties" className="flex-1 overflow-auto mt-4"> <TabsContent value="counterparties" className="flex-1 overflow-hidden mt-3 flex flex-col">
{counterpartiesLoading ? ( {/* Компактная панель фильтров */}
<div className="flex items-center justify-center p-8"> <div className="glass-card p-3 mb-3 space-y-3">
<div className="text-white/60">Загрузка...</div> <div className="flex flex-col xl:flex-row gap-3">
</div> {/* Поиск */}
) : counterparties.length === 0 ? ( <div className="flex-1 min-w-0">
<div className="glass-card p-8"> <div className="relative">
<div className="text-center"> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-white/40" />
<Users className="h-12 w-12 text-white/20 mx-auto mb-4" /> <GlassInput
<p className="text-white/60">У вас пока нет контрагентов</p> placeholder="Поиск..."
<p className="text-white/40 text-sm mt-2"> value={searchQuery}
Перейдите на другие вкладки, чтобы найти партнеров onChange={(e) => setSearchQuery(e.target.value)}
</p> className="pl-10 h-9"
/>
</div>
</div>
{/* Фильтры и сортировка */}
<div className="flex gap-2">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="glass-input text-white border-white/20 h-9 min-w-[120px]">
<Filter className="h-3 w-3 mr-1" />
<SelectValue placeholder="Тип" />
</SelectTrigger>
<SelectContent className="glass-card border-white/20">
<SelectItem value="all">Все</SelectItem>
<SelectItem value="FULFILLMENT">Фулфилмент</SelectItem>
<SelectItem value="SELLER">Селлер</SelectItem>
<SelectItem value="LOGIST">Логистика</SelectItem>
<SelectItem value="WHOLESALE">Оптовик</SelectItem>
</SelectContent>
</Select>
<Select value={sortField} onValueChange={(value) => setSortField(value as SortField)}>
<SelectTrigger className="glass-input text-white border-white/20 h-9 min-w-[100px]">
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-card border-white/20">
<SelectItem value="name">Название</SelectItem>
<SelectItem value="date">Дата</SelectItem>
<SelectItem value="inn">ИНН</SelectItem>
<SelectItem value="type">Тип</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
className="glass-input border-white/20 text-white hover:bg-white/10 h-9 w-9 p-0"
>
{sortOrder === 'asc' ? <SortAsc className="h-3 w-3" /> : <SortDesc className="h-3 w-3" />}
</Button>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="text-white/60 hover:text-white hover:bg-white/10 h-9 w-9 p-0"
>
<X className="h-3 w-3" />
</Button>
)}
</div> </div>
</div> </div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> {/* Статистика и быстрые фильтры */}
{counterparties.map((organization: Organization) => ( <div className="flex items-center justify-between text-xs">
<OrganizationCard <div className="text-white/60">
key={organization.id} {filteredAndSortedCounterparties.length} из {counterparties.length}
organization={organization} </div>
onRemove={handleRemoveCounterparty}
showRemoveButton={true} <div className="flex gap-1">
/> {['FULFILLMENT', 'SELLER', 'LOGIST', 'WHOLESALE'].map((type) => {
))} const count = counterparties.filter((org: Organization) => org.type === type).length
if (count === 0) return null
return (
<Button
key={type}
variant="ghost"
size="sm"
onClick={() => setTypeFilter(typeFilter === type ? 'all' : type)}
className={`h-6 px-2 text-xs ${
typeFilter === type
? getTypeBadgeStyles(type) + ' border'
: 'text-white/50 hover:text-white hover:bg-white/10'
}`}
>
{getTypeLabel(type)} ({count})
</Button>
)
})}
</div>
</div> </div>
)} </div>
{/* Список контрагентов */}
<div className="flex-1 overflow-auto">
{counterpartiesLoading ? (
<div className="flex items-center justify-center p-8">
<div className="text-white/60">Загрузка...</div>
</div>
) : filteredAndSortedCounterparties.length === 0 ? (
<div className="glass-card p-8">
<div className="text-center">
{counterparties.length === 0 ? (
<>
<Users className="h-12 w-12 text-white/20 mx-auto mb-4" />
<p className="text-white/60">У вас пока нет контрагентов</p>
<p className="text-white/40 text-sm mt-2">
Перейдите на другие вкладки, чтобы найти партнеров
</p>
</>
) : (
<>
<Search className="h-12 w-12 text-white/20 mx-auto mb-4" />
<p className="text-white/60">Ничего не найдено</p>
<p className="text-white/40 text-sm mt-2">
Попробуйте изменить параметры поиска или фильтрации
</p>
</>
)}
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredAndSortedCounterparties.map((organization: Organization) => (
<div key={organization.id} className="glass-card p-4 w-full hover:bg-white/5 transition-colors">
<div className="flex flex-col space-y-4">
<div className="flex items-start space-x-3">
<OrganizationAvatar organization={organization} size="md" />
<div className="flex-1 min-w-0">
<div className="flex flex-col space-y-2 mb-3">
<h4 className="text-white font-medium text-lg leading-tight">
{organization.name || organization.fullName}
</h4>
<div className="flex items-center space-x-2">
<Badge className={getTypeBadgeStyles(organization.type)}>
{getTypeLabel(organization.type)}
</Badge>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center text-white/60 text-sm">
<Building className="h-3 w-3 mr-2 flex-shrink-0" />
<span>ИНН: {organization.inn}</span>
</div>
{organization.address && (
<div className="flex items-start text-white/60 text-sm">
<MapPin className="h-3 w-3 mr-2 mt-0.5 flex-shrink-0" />
<span className="line-clamp-2">{organization.address}</span>
</div>
)}
{organization.phones && organization.phones.length > 0 && (
<div className="flex items-center text-white/60 text-sm">
<Phone className="h-3 w-3 mr-2 flex-shrink-0" />
<span>{organization.phones[0].value}</span>
</div>
)}
{organization.emails && organization.emails.length > 0 && (
<div className="flex items-center text-white/60 text-sm">
<Mail className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate">{organization.emails[0].value}</span>
</div>
)}
<div className="flex items-center text-white/40 text-xs pt-2">
<Calendar className="h-3 w-3 mr-2 flex-shrink-0" />
<span>Добавлен {formatDate(organization.createdAt)}</span>
</div>
</div>
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => handleRemoveCounterparty(organization.id)}
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border-red-500/30 cursor-pointer w-full"
>
Удалить из контрагентов
</Button>
</div>
</div>
))}
</div>
)}
</div>
</TabsContent> </TabsContent>
<TabsContent value="incoming" className="flex-1 overflow-auto mt-4"> <TabsContent value="incoming" className="flex-1 overflow-auto mt-4">
@ -234,18 +492,8 @@ export function MarketCounterparties() {
{request.sender.name || request.sender.fullName} {request.sender.name || request.sender.fullName}
</h4> </h4>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Badge className={ <Badge className={getTypeBadgeStyles(request.sender.type)}>
request.sender.type === 'FULFILLMENT' ? 'bg-blue-500/20 text-blue-300 border-blue-500/30' : {getTypeLabel(request.sender.type)}
request.sender.type === 'SELLER' ? 'bg-green-500/20 text-green-300 border-green-500/30' :
request.sender.type === 'LOGIST' ? 'bg-orange-500/20 text-orange-300 border-orange-500/30' :
request.sender.type === 'WHOLESALE' ? 'bg-purple-500/20 text-purple-300 border-purple-500/30' :
'bg-gray-500/20 text-gray-300 border-gray-500/30'
}>
{request.sender.type === 'FULFILLMENT' ? 'Фулфилмент' :
request.sender.type === 'SELLER' ? 'Селлер' :
request.sender.type === 'LOGIST' ? 'Логистика' :
request.sender.type === 'WHOLESALE' ? 'Оптовик' :
request.sender.type}
</Badge> </Badge>
</div> </div>
</div> </div>
@ -318,18 +566,8 @@ export function MarketCounterparties() {
{request.receiver.name || request.receiver.fullName} {request.receiver.name || request.receiver.fullName}
</h4> </h4>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Badge className={ <Badge className={getTypeBadgeStyles(request.receiver.type)}>
request.receiver.type === 'FULFILLMENT' ? 'bg-blue-500/20 text-blue-300 border-blue-500/30' : {getTypeLabel(request.receiver.type)}
request.receiver.type === 'SELLER' ? 'bg-green-500/20 text-green-300 border-green-500/30' :
request.receiver.type === 'LOGIST' ? 'bg-orange-500/20 text-orange-300 border-orange-500/30' :
request.receiver.type === 'WHOLESALE' ? 'bg-purple-500/20 text-purple-300 border-purple-500/30' :
'bg-gray-500/20 text-gray-300 border-gray-500/30'
}>
{request.receiver.type === 'FULFILLMENT' ? 'Фулфилмент' :
request.receiver.type === 'SELLER' ? 'Селлер' :
request.receiver.type === 'LOGIST' ? 'Логистика' :
request.receiver.type === 'WHOLESALE' ? 'Оптовик' :
request.receiver.type}
</Badge> </Badge>
<Badge className={ <Badge className={
request.status === 'PENDING' ? 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' : request.status === 'PENDING' ? 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' :

View File

@ -7,7 +7,7 @@ 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, Info, BarChart3 } from 'lucide-react' import { TrendingUp, Info, BarChart3, ChevronDown, ChevronUp } from 'lucide-react'
import { import {
ChartConfig, ChartConfig,
ChartContainer, ChartContainer,
@ -330,6 +330,9 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate, o
const [sortField, setSortField] = useState<string>('') const [sortField, setSortField] = useState<string>('')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc') const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
// Состояние для сворачивания графика
const [isChartCollapsed, setIsChartCollapsed] = useState(false)
// Функция сортировки // Функция сортировки
const handleSort = (field: string) => { const handleSort = (field: string) => {
if (sortField === field) { if (sortField === field) {
@ -455,11 +458,27 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate, o
return ( return (
<div className="h-full flex flex-col space-y-2"> <div className="h-full flex flex-col space-y-2">
{/* График с фильтрами */} {/* График с фильтрами */}
<Card className="glass-card p-3 flex-shrink-0 overflow-hidden" style={{ height: '380px' }}> <Card
className="glass-card p-3 flex-shrink-0 overflow-hidden transition-all duration-300"
style={{ height: isChartCollapsed ? 'auto' : '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> <div className="flex items-center gap-2">
<h3 className="text-white font-semibold">Динамика показателей</h3>
<button
onClick={() => setIsChartCollapsed(!isChartCollapsed)}
className="text-white/60 hover:text-white/80 hover:bg-white/10 p-1 rounded transition-all"
title={isChartCollapsed ? "Развернуть график" : "Свернуть график"}
>
{isChartCollapsed ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronUp className="w-4 h-4" />
)}
</button>
</div>
{/* Переключатели периода */} {/* Переключатели периода */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -511,8 +530,11 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate, o
</div> </div>
</div> </div>
{/* Компактные чекбоксы для фильтрации */} {/* Контент графика - показывается только если не свернут */}
<div className="mb-2 pb-2 border-b border-white/10"> {!isChartCollapsed && (
<>
{/* Компактные чекбоксы для фильтрации */}
<div className="mb-2 pb-2 border-b border-white/10">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className="text-xs text-white/60">Показать на графике:</span> <span className="text-xs text-white/60">Показать на графике:</span>
<span className="text-xs text-blue-400 flex items-center gap-1"> <span className="text-xs text-blue-400 flex items-center gap-1">
@ -640,6 +662,8 @@ export function SalesTab({ selectedPeriod, useCustomDates, startDate, endDate, o
</BarChart> </BarChart>
</ChartContainer> </ChartContainer>
</div> </div>
</>
)}
</div> </div>
</Card> </Card>

View File

@ -437,7 +437,7 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
card.title.toLowerCase().includes(searchTerm.toLowerCase()) || card.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.brand.toLowerCase().includes(searchTerm.toLowerCase()) || card.brand.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.vendorCode.toLowerCase().includes(searchTerm.toLowerCase()) || card.vendorCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.object.toLowerCase().includes(searchTerm.toLowerCase()) card.object?.toLowerCase().includes(searchTerm.toLowerCase())
) )
setWbCards(filteredCards) setWbCards(filteredCards)
@ -450,7 +450,7 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
card.title.toLowerCase().includes(searchTerm.toLowerCase()) || card.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.brand.toLowerCase().includes(searchTerm.toLowerCase()) || card.brand.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.vendorCode.toLowerCase().includes(searchTerm.toLowerCase()) || card.vendorCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.object.toLowerCase().includes(searchTerm.toLowerCase()) card.object?.toLowerCase().includes(searchTerm.toLowerCase())
) )
setWbCards(filteredCards) setWbCards(filteredCards)
console.log('Найдено моковых товаров (fallback):', filteredCards.length) console.log('Найдено моковых товаров (fallback):', filteredCards.length)
@ -573,14 +573,20 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
} }
const nextImage = () => { const nextImage = () => {
if (selectedCardForDetails && selectedCardForDetails.mediaFiles?.length > 1) { if (selectedCardForDetails) {
setCurrentImageIndex((prev) => (prev + 1) % selectedCardForDetails.mediaFiles.length) const images = WildberriesService.getCardImages(selectedCardForDetails)
if (images.length > 1) {
setCurrentImageIndex((prev) => (prev + 1) % images.length)
}
} }
} }
const prevImage = () => { const prevImage = () => {
if (selectedCardForDetails && selectedCardForDetails.mediaFiles?.length > 1) { if (selectedCardForDetails) {
setCurrentImageIndex((prev) => (prev - 1 + selectedCardForDetails.mediaFiles.length) % selectedCardForDetails.mediaFiles.length) const images = WildberriesService.getCardImages(selectedCardForDetails)
if (images.length > 1) {
setCurrentImageIndex((prev) => (prev - 1 + images.length) % images.length)
}
} }
} }
@ -652,7 +658,7 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
<Card key={sc.card.nmID} className="bg-white/10 backdrop-blur border-white/20 p-4"> <Card key={sc.card.nmID} className="bg-white/10 backdrop-blur border-white/20 p-4">
<div className="flex space-x-4"> <div className="flex space-x-4">
<img <img
src={sc.card.mediaFiles?.[0] || '/api/placeholder/120/120'} src={WildberriesService.getCardImage(sc.card, 'c246x328') || '/api/placeholder/120/120'}
alt={sc.card.title} alt={sc.card.title}
className="w-20 h-20 rounded-lg object-cover" className="w-20 h-20 rounded-lg object-cover"
/> />
@ -1017,7 +1023,7 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
<div className="relative"> <div className="relative">
<div className="aspect-square relative bg-white/5 overflow-hidden rounded-lg"> <div className="aspect-square relative bg-white/5 overflow-hidden rounded-lg">
<img <img
src={card.mediaFiles?.[0] || '/api/placeholder/300/300'} src={WildberriesService.getCardImage(card, 'c516x688') || '/api/placeholder/300/300'}
alt={card.title} alt={card.title}
className="w-full h-full object-cover cursor-pointer group-hover:scale-110 transition-transform duration-500" className="w-full h-full object-cover cursor-pointer group-hover:scale-110 transition-transform duration-500"
onClick={() => handleCardClick(card)} onClick={() => handleCardClick(card)}
@ -1201,13 +1207,13 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
<div className="p-4 lg:p-6 space-y-4 overflow-hidden"> <div className="p-4 lg:p-6 space-y-4 overflow-hidden">
<div className="relative"> <div className="relative">
<img <img
src={selectedCardForDetails.mediaFiles?.[currentImageIndex] || '/api/placeholder/400/400'} src={WildberriesService.getCardImages(selectedCardForDetails)[currentImageIndex] || '/api/placeholder/400/400'}
alt={selectedCardForDetails.title} alt={selectedCardForDetails.title}
className="w-full aspect-square rounded-lg object-cover" className="w-full aspect-square rounded-lg object-cover"
/> />
{/* Навигация по изображениям */} {/* Навигация по изображениям */}
{selectedCardForDetails.mediaFiles?.length > 1 && ( {WildberriesService.getCardImages(selectedCardForDetails).length > 1 && (
<> <>
<button <button
onClick={prevImage} onClick={prevImage}
@ -1223,17 +1229,17 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
</button> </button>
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 bg-black/70 px-3 py-1 rounded-full text-white text-sm"> <div className="absolute bottom-3 left-1/2 -translate-x-1/2 bg-black/70 px-3 py-1 rounded-full text-white text-sm">
{currentImageIndex + 1} из {selectedCardForDetails.mediaFiles?.length || 0} {currentImageIndex + 1} из {WildberriesService.getCardImages(selectedCardForDetails).length}
</div> </div>
</> </>
)} )}
</div> </div>
{/* Миниатюры изображений */} {/* Миниатюры изображений */}
{selectedCardForDetails.mediaFiles?.length > 1 && ( {WildberriesService.getCardImages(selectedCardForDetails).length > 1 && (
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
<div className="flex space-x-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent"> <div className="flex space-x-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent">
{selectedCardForDetails.mediaFiles?.map((image, index) => ( {WildberriesService.getCardImages(selectedCardForDetails).map((image, index) => (
<img <img
key={index} key={index}
src={image} src={image}

View File

@ -13,23 +13,58 @@ interface WildberriesWarehousesResponse {
interface WildberriesCard { interface WildberriesCard {
nmID: number nmID: number
imtID?: number
nmUUID?: string
subjectID?: number
subjectName?: string
vendorCode: string vendorCode: string
brand: string
title: string
description: string
needKiz?: boolean
photos?: Array<{
big: string
c246x328: string
c516x688: string
square: string
tm: string
}>
video?: string
dimensions?: {
length: number
width: number
height: number
weightBrutto: number
isValid: boolean
}
characteristics?: Array<{
id: number
name: string
value: string[]
}>
sizes: Array<{ sizes: Array<{
chrtID: number chrtID: number
techSize: string techSize: string
skus?: string[]
// Legacy fields for backward compatibility
wbSize: string wbSize: string
price: number price: number
discountedPrice: number discountedPrice: number
quantity: number quantity: number
}> }>
tags?: Array<{
id: number
name: string
color: string
}>
createdAt?: string
updatedAt?: string
// Legacy fields for backward compatibility
mediaFiles: string[] mediaFiles: string[]
object: string object?: string
parent: string parent?: string
countryProduction: string countryProduction?: string
supplierVendorCode: string supplierVendorCode?: string
brand: string
title: string
description: string
} }
interface WildberriesCardsResponse { interface WildberriesCardsResponse {
@ -224,6 +259,7 @@ class WildberriesService {
private apiKey: string private apiKey: string
private baseURL = 'https://statistics-api.wildberries.ru' private baseURL = 'https://statistics-api.wildberries.ru'
private advertURL = 'https://advert-api.wildberries.ru' private advertURL = 'https://advert-api.wildberries.ru'
private contentURL = 'https://content-api.wildberries.ru'
constructor(apiKey: string) { constructor(apiKey: string) {
this.apiKey = apiKey this.apiKey = apiKey
@ -366,32 +402,93 @@ class WildberriesService {
} }
// Получение карточек товаров // Получение карточек товаров
async getCards(options: { limit?: number; offset?: number } = {}): Promise<WildberriesCard[]> { async getCards(options: { limit?: number; cursor?: { updatedAt?: string; nmID?: number } } = {}): Promise<WildberriesCardsResponse> {
const { limit = 100, offset = 0 } = options const { limit = 100, cursor } = options
const url = `${this.baseURL}/content/v1/cards/cursor/list?sort=updateAt&limit=${limit}&cursor=${offset}` const url = `${this.contentURL}/content/v2/get/cards/list`
console.log(`WB API: Getting cards from ${url}`)
const body = {
settings: {
cursor: {
limit,
...(cursor?.updatedAt && { updatedAt: cursor.updatedAt }),
...(cursor?.nmID && { nmID: cursor.nmID })
},
filter: {
withPhoto: -1
}
}
}
console.log(`WB API: Getting cards from ${url}`, body)
try { try {
const response = await this.makeRequest<{ cards: WildberriesCard[] }>(url) const response = await this.makeRequest<WildberriesCardsResponse>(url, {
return response?.cards || [] method: 'POST',
body: JSON.stringify(body)
})
// Преобразуем карточки для обратной совместимости
const processedCards = response.cards.map(this.processCard)
return {
...response,
cards: processedCards
}
} catch (error) { } catch (error) {
console.error(`WB API: Error getting cards:`, error) console.error(`WB API: Error getting cards:`, error)
return [] return { cards: [], cursor: { total: 0, updatedAt: '', limit: 0, nmID: 0 } }
}
}
// Обработка карточки для обратной совместимости
private processCard(card: WildberriesCard): WildberriesCard {
// Создаем массив URL изображений для совместимости с mediaFiles
const mediaFiles: string[] = []
if (card.photos && card.photos.length > 0) {
card.photos.forEach(photo => {
// Добавляем разные размеры изображений, приоритет большим размерам
if (photo.big) mediaFiles.push(photo.big)
if (photo.c516x688) mediaFiles.push(photo.c516x688)
if (photo.c246x328) mediaFiles.push(photo.c246x328)
})
}
// Заполняем размеры с ценами и количеством для совместимости
const processedSizes = card.sizes.map(size => ({
...size,
wbSize: size.wbSize || size.techSize || '',
price: size.price || 0,
discountedPrice: size.discountedPrice || size.price || 0,
quantity: size.quantity || 0
}))
return {
...card,
// Добавляем mediaFiles для обратной совместимости
mediaFiles,
// Заполняем legacy поля если они отсутствуют
object: card.object || card.subjectName || '',
parent: card.parent || '',
countryProduction: card.countryProduction || '',
supplierVendorCode: card.supplierVendorCode || card.vendorCode,
// Обработанные размеры
sizes: processedSizes
} }
} }
// Поиск карточек товаров // Поиск карточек товаров
async searchCards(searchTerm: string, limit = 100): Promise<WildberriesCard[]> { async searchCards(searchTerm: string, limit = 100): Promise<WildberriesCard[]> {
// Для простоты пока используем тот же API что и getCards // Сначала получаем все карточки
// В реальности может потребоваться другой endpoint для поиска const response = await this.getCards({ limit })
const cards = await this.getCards({ limit })
// Фильтруем результаты по поисковому запросу // Фильтруем результаты по поисковому запросу
const filteredCards = cards.filter(card => { const filteredCards = response.cards.filter((card: WildberriesCard) => {
const searchLower = searchTerm.toLowerCase() const searchLower = searchTerm.toLowerCase()
return ( return (
card.vendorCode?.toLowerCase().includes(searchLower) || card.vendorCode?.toLowerCase().includes(searchLower) ||
card.object?.toLowerCase().includes(searchLower) || card.object?.toLowerCase().includes(searchLower) ||
card.brand?.toLowerCase().includes(searchLower) card.brand?.toLowerCase().includes(searchLower) ||
card.title?.toLowerCase().includes(searchLower)
) )
}) })
@ -684,10 +781,50 @@ class WildberriesService {
return service.getWarehouses() return service.getWarehouses()
} }
// Получение всех карточек с пагинацией
async getAllCardsWithPagination(maxCards = 1000): Promise<WildberriesCard[]> {
const allCards: WildberriesCard[] = []
let cursor: { updatedAt?: string; nmID?: number } | undefined
while (allCards.length < maxCards) {
const response = await this.getCards({
limit: Math.min(100, maxCards - allCards.length),
cursor
})
if (!response.cards || response.cards.length === 0) {
break
}
allCards.push(...response.cards)
// Если получили меньше чем запрашивали, значит это последняя страница
if (response.cards.length < 100) {
break
}
// Обновляем курсор для следующего запроса
const lastCard = response.cards[response.cards.length - 1]
cursor = {
updatedAt: response.cursor.updatedAt,
nmID: lastCard.nmID
}
}
return allCards
}
// Статический метод для получения карточек с токеном // Статический метод для получения карточек с токеном
static async getAllCards(apiKey: string, limit = 100): Promise<WildberriesCard[]> { static async getAllCards(apiKey: string, limit = 100): Promise<WildberriesCard[]> {
const service = new WildberriesService(apiKey) const service = new WildberriesService(apiKey)
return service.getCards({ limit })
// Если запрашивается больше 100 карточек, используем пагинацию
if (limit > 100) {
return service.getAllCardsWithPagination(limit)
}
const response = await service.getCards({ limit })
return response.cards
} }
// Статический метод для поиска карточек с токеном // Статический метод для поиска карточек с токеном
@ -695,6 +832,29 @@ class WildberriesService {
const service = new WildberriesService(apiKey) const service = new WildberriesService(apiKey)
return service.searchCards(searchTerm, limit) return service.searchCards(searchTerm, limit)
} }
// Утилитные методы для работы с изображениями
static getCardImage(card: WildberriesCard, size: 'big' | 'c516x688' | 'c246x328' | 'square' | 'tm' = 'c516x688'): string {
if (card.photos && card.photos.length > 0) {
return card.photos[0][size] || card.photos[0].big || ''
}
// Fallback на mediaFiles для старых данных
if (card.mediaFiles && card.mediaFiles.length > 0) {
return card.mediaFiles[0]
}
return ''
}
static getCardImages(card: WildberriesCard): string[] {
if (card.photos && card.photos.length > 0) {
return card.photos.map(photo => photo.big || photo.c516x688 || photo.c246x328)
}
// Fallback на mediaFiles для старых данных
return card.mediaFiles || []
}
} }
export { WildberriesService } export { WildberriesService }

View File

@ -1,22 +1,57 @@
export interface WildberriesCard { export interface WildberriesCard {
nmID: number nmID: number
imtID?: number
nmUUID?: string
subjectID?: number
subjectName?: string
vendorCode: string vendorCode: string
brand: string
title: string
description: string
needKiz?: boolean
photos?: Array<{
big: string
c246x328: string
c516x688: string
square: string
tm: string
}>
video?: string
dimensions?: {
length: number
width: number
height: number
weightBrutto: number
isValid: boolean
}
characteristics?: Array<{
id: number
name: string
value: string[]
}>
sizes: Array<{ sizes: Array<{
chrtID: number chrtID: number
techSize: string techSize: string
skus?: string[]
// Legacy fields for backward compatibility
wbSize: string wbSize: string
price: number price: number
discountedPrice: number discountedPrice: number
quantity: number quantity: number
}> }>
tags?: Array<{
id: number
name: string
color: string
}>
createdAt?: string
updatedAt?: string
// Legacy fields for backward compatibility
mediaFiles: string[] mediaFiles: string[]
object: string object?: string
parent: string parent?: string
countryProduction: string countryProduction?: string
supplierVendorCode: string supplierVendorCode?: string
brand: string
title: string
description: string
} }
export interface SelectedCard { export interface SelectedCard {