
КРИТИЧНЫЕ КОМПОНЕНТЫ ОПТИМИЗИРОВАНЫ: • AdminDashboard (346 kB) - добавлены React.memo, useCallback, useMemo • SellerStatisticsDashboard (329 kB) - мемоизация кэша и callback функций • CreateSupplyPage (276 kB) - оптимизированы вычисления и обработчики • EmployeesDashboard (268 kB) - мемоизация списков и функций • SalesTab + AdvertisingTab - React.memo обертка ТЕХНИЧЕСКИЕ УЛУЧШЕНИЯ: ✅ React.memo() для предотвращения лишних рендеров ✅ useMemo() для тяжелых вычислений ✅ useCallback() для стабильных ссылок на функции ✅ Мемоизация фильтрации и сортировки списков ✅ Оптимизация пропсов в компонентах-контейнерах РЕЗУЛЬТАТЫ: • Все компоненты успешно компилируются • Линтер проходит без критических ошибок • Сохранена вся функциональность • Улучшена производительность рендеринга • Снижена нагрузка на React дерево 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
386 lines
15 KiB
TypeScript
386 lines
15 KiB
TypeScript
'use client'
|
||
|
||
import { useQuery } from '@apollo/client'
|
||
import { formatDistanceToNow } from 'date-fns'
|
||
import { ru } from 'date-fns/locale'
|
||
import {
|
||
ArrowLeft,
|
||
Search,
|
||
Filter,
|
||
RefreshCw,
|
||
AlertTriangle,
|
||
Eye,
|
||
MessageSquare,
|
||
Clock,
|
||
CheckCircle,
|
||
XCircle,
|
||
Image as ImageIcon,
|
||
Play,
|
||
Calendar,
|
||
Package,
|
||
DollarSign,
|
||
Building2,
|
||
} from 'lucide-react'
|
||
import React, { useState } from 'react'
|
||
|
||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Card } from '@/components/ui/card'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from '@/components/ui/dialog'
|
||
import { Input } from '@/components/ui/input'
|
||
import { GET_WB_RETURN_CLAIMS } from '@/graphql/queries'
|
||
|
||
// Типы данных
|
||
interface WbReturnClaim {
|
||
id: string
|
||
claimType: number
|
||
status: number
|
||
statusEx: number
|
||
nmId: number
|
||
userComment: string
|
||
wbComment?: string
|
||
dt: string
|
||
imtName: string
|
||
orderDt: string
|
||
dtUpdate: string
|
||
photos: string[]
|
||
videoPaths: string[]
|
||
actions: string[]
|
||
price: number
|
||
currencyCode: string
|
||
srid: string
|
||
sellerOrganization: {
|
||
id: string
|
||
name: string
|
||
inn: string
|
||
}
|
||
}
|
||
|
||
interface WbReturnClaimsResponse {
|
||
claims: WbReturnClaim[]
|
||
total: number
|
||
}
|
||
|
||
// Функции для форматирования
|
||
const getStatusText = (status: number, statusEx: number) => {
|
||
const statusMap: { [key: number]: string } = {
|
||
1: 'Новая',
|
||
2: 'Рассматривается',
|
||
3: 'Одобрена',
|
||
4: 'Отклонена',
|
||
5: 'Возврат завершен',
|
||
}
|
||
return statusMap[status] || `Статус ${status}`
|
||
}
|
||
|
||
const getStatusColor = (status: number) => {
|
||
const colorMap: { [key: number]: string } = {
|
||
1: 'bg-blue-500/20 text-blue-300 border-blue-500/30',
|
||
2: 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30',
|
||
3: 'bg-green-500/20 text-green-300 border-green-500/30',
|
||
4: 'bg-red-500/20 text-red-300 border-red-500/30',
|
||
5: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30',
|
||
}
|
||
return colorMap[status] || 'bg-gray-500/20 text-gray-300 border-gray-500/30'
|
||
}
|
||
|
||
const formatPrice = (price: number) => {
|
||
return new Intl.NumberFormat('ru-RU').format(price)
|
||
}
|
||
|
||
interface WbReturnClaimsProps {
|
||
onBack: () => void
|
||
}
|
||
|
||
export function WbReturnClaims({ onBack }: WbReturnClaimsProps) {
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [isArchive, setIsArchive] = useState(false)
|
||
const [selectedClaim, setSelectedClaim] = useState<WbReturnClaim | null>(null)
|
||
|
||
const { data, loading, error, refetch } = useQuery<{
|
||
wbReturnClaims: WbReturnClaimsResponse
|
||
}>(GET_WB_RETURN_CLAIMS, {
|
||
variables: {
|
||
isArchive,
|
||
limit: 50,
|
||
offset: 0,
|
||
},
|
||
pollInterval: 30000, // Обновляем каждые 30 секунд
|
||
errorPolicy: 'all',
|
||
notifyOnNetworkStatusChange: true,
|
||
})
|
||
|
||
const claims = data?.wbReturnClaims?.claims || []
|
||
const total = data?.wbReturnClaims?.total || 0
|
||
|
||
// Отладочный вывод
|
||
console.warn('WB Claims Debug:', {
|
||
isArchive,
|
||
loading,
|
||
error: error?.message,
|
||
total,
|
||
claimsCount: claims.length,
|
||
hasData: !!data,
|
||
})
|
||
|
||
// Фильтрация заявок по поисковому запросу
|
||
const filteredClaims = claims.filter(
|
||
(claim) =>
|
||
claim.imtName.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
claim.sellerOrganization.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
claim.nmId.toString().includes(searchQuery) ||
|
||
claim.userComment.toLowerCase().includes(searchQuery.toLowerCase()),
|
||
)
|
||
|
||
return (
|
||
<div className="h-full flex flex-col">
|
||
{/* Заголовок */}
|
||
<div className="flex-shrink-0 p-6 border-b border-white/10">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-4">
|
||
<Button variant="ghost" size="sm" onClick={onBack} className="text-white/70 hover:text-white">
|
||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||
Назад
|
||
</Button>
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-white">Заявки покупателей на возврат</h2>
|
||
<p className="text-white/70">
|
||
Всего заявок: {total} | Показано: {filteredClaims.length} | Режим: {isArchive ? 'Архив' : 'Активные'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<Button variant="ghost" size="sm" onClick={() => refetch()} className="text-white/70 hover:text-white">
|
||
<RefreshCw className="h-4 w-4 mr-2" />
|
||
Обновить
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Фильтры и поиск */}
|
||
<div className="flex-shrink-0 p-6 border-b border-white/10">
|
||
<div className="flex items-center gap-4">
|
||
<div className="relative flex-1 max-w-md">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/40 h-4 w-4" />
|
||
<Input
|
||
placeholder="Поиск по товару, селлеру, артикулу..."
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="pl-10 bg-white/10 border-white/20 text-white placeholder:text-white/40"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant={!isArchive ? 'default' : 'ghost'}
|
||
size="sm"
|
||
onClick={() => setIsArchive(false)}
|
||
className={!isArchive ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white'}
|
||
>
|
||
Активные
|
||
</Button>
|
||
<Button
|
||
variant={isArchive ? 'default' : 'ghost'}
|
||
size="sm"
|
||
onClick={() => setIsArchive(true)}
|
||
className={isArchive ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white'}
|
||
>
|
||
Архив
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Список заявок */}
|
||
<div className="flex-1 overflow-auto p-6">
|
||
{error && (
|
||
<div className="mb-4 p-4 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||
<div className="flex items-center gap-2 text-red-300 mb-2">
|
||
<AlertTriangle className="h-4 w-4" />
|
||
<span className="font-medium">Ошибка загрузки данных</span>
|
||
</div>
|
||
<p className="text-red-200 text-sm">{error.message || 'Не удалось получить заявки от Wildberries API'}</p>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => refetch()}
|
||
className="mt-2 text-red-300 hover:text-red-200"
|
||
>
|
||
<RefreshCw className="h-3 w-3 mr-1" />
|
||
Повторить
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{loading ? (
|
||
<div className="flex items-center justify-center h-32">
|
||
<RefreshCw className="h-8 w-8 animate-spin text-white/50" />
|
||
<span className="ml-2 text-white/70">Загрузка заявок...</span>
|
||
</div>
|
||
) : filteredClaims.length === 0 ? (
|
||
<div className="text-center py-12">
|
||
<Package className="h-16 w-16 mx-auto text-white/30 mb-4" />
|
||
<h3 className="text-lg font-medium text-white mb-2">Заявки не найдены</h3>
|
||
<p className="text-white/60 mb-4">
|
||
{searchQuery ? 'Попробуйте изменить критерии поиска' : 'Новых заявок на возврат пока нет'}
|
||
</p>
|
||
{!searchQuery && total === 0 && (
|
||
<div className="bg-blue-500/20 border border-blue-500/30 rounded-lg p-4 max-w-md mx-auto">
|
||
<p className="text-blue-300 text-sm">
|
||
💡 Заявки отображаются только от партнеров-селлеров с настроенными Wildberries API ключами
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{filteredClaims.map((claim) => (
|
||
<Card
|
||
key={claim.id}
|
||
className="bg-white/10 border-white/20 p-4 hover:bg-white/15 transition-colors cursor-pointer"
|
||
onClick={() => setSelectedClaim(claim)}
|
||
>
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<Badge className={getStatusColor(claim.status)}>
|
||
{getStatusText(claim.status, claim.statusEx)}
|
||
</Badge>
|
||
<span className="text-sm text-white/60">№{claim.nmId}</span>
|
||
<span className="text-sm text-white/60">
|
||
{formatDistanceToNow(new Date(claim.dt), {
|
||
addSuffix: true,
|
||
locale: ru,
|
||
})}
|
||
</span>
|
||
</div>
|
||
|
||
<h3 className="text-white font-medium mb-2">{claim.imtName}</h3>
|
||
|
||
<div className="flex items-center gap-4 text-sm text-white/70 mb-2">
|
||
<span className="flex items-center gap-1">
|
||
<Building2 className="h-3 w-3" />
|
||
{claim.sellerOrganization.name}
|
||
</span>
|
||
<span className="flex items-center gap-1">
|
||
<DollarSign className="h-3 w-3" />
|
||
{formatPrice(claim.price)} ₽
|
||
</span>
|
||
</div>
|
||
|
||
<p className="text-white/60 text-sm line-clamp-2">{claim.userComment}</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 ml-4">
|
||
{claim.photos.length > 0 && (
|
||
<div className="flex items-center gap-1 text-xs text-white/60">
|
||
<ImageIcon className="h-3 w-3" />
|
||
{claim.photos.length}
|
||
</div>
|
||
)}
|
||
{claim.videoPaths.length > 0 && (
|
||
<div className="flex items-center gap-1 text-xs text-white/60">
|
||
<Play className="h-3 w-3" />
|
||
{claim.videoPaths.length}
|
||
</div>
|
||
)}
|
||
<Button variant="ghost" size="sm" className="text-white/70 hover:text-white">
|
||
<Eye className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Диалог детального просмотра */}
|
||
<Dialog open={selectedClaim !== null} onOpenChange={() => setSelectedClaim(null)}>
|
||
<DialogContent className="bg-gray-900/95 border-white/20 text-white max-w-2xl">
|
||
{selectedClaim && (
|
||
<>
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-3">
|
||
<Badge className={getStatusColor(selectedClaim.status)}>
|
||
{getStatusText(selectedClaim.status, selectedClaim.statusEx)}
|
||
</Badge>
|
||
<span>Заявка №{selectedClaim.nmId}</span>
|
||
</DialogTitle>
|
||
<DialogDescription className="text-white/70">{selectedClaim.imtName}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<span className="text-white/60">Дата заявки:</span>
|
||
<p className="text-white">{new Date(selectedClaim.dt).toLocaleString('ru-RU')}</p>
|
||
</div>
|
||
<div>
|
||
<span className="text-white/60">Дата заказа:</span>
|
||
<p className="text-white">{new Date(selectedClaim.orderDt).toLocaleString('ru-RU')}</p>
|
||
</div>
|
||
<div>
|
||
<span className="text-white/60">Стоимость:</span>
|
||
<p className="text-white">{formatPrice(selectedClaim.price)} ₽</p>
|
||
</div>
|
||
<div>
|
||
<span className="text-white/60">SRID:</span>
|
||
<p className="text-white">{selectedClaim.srid}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<span className="text-white/60 text-sm">Продавец:</span>
|
||
<p className="text-white">
|
||
{selectedClaim.sellerOrganization.name} (ИНН: {selectedClaim.sellerOrganization.inn})
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<span className="text-white/60 text-sm">Комментарий покупателя:</span>
|
||
<p className="text-white bg-white/5 p-3 rounded-lg mt-1">{selectedClaim.userComment}</p>
|
||
</div>
|
||
|
||
{selectedClaim.wbComment && (
|
||
<div>
|
||
<span className="text-white/60 text-sm">Комментарий WB:</span>
|
||
<p className="text-white bg-blue-500/20 p-3 rounded-lg mt-1">{selectedClaim.wbComment}</p>
|
||
</div>
|
||
)}
|
||
|
||
{(selectedClaim.photos.length > 0 || selectedClaim.videoPaths.length > 0) && (
|
||
<div>
|
||
<span className="text-white/60 text-sm">Медиафайлы:</span>
|
||
<div className="flex gap-2 mt-2">
|
||
{selectedClaim.photos.map((photo, index) => (
|
||
<div key={index} className="flex items-center gap-1 bg-white/10 px-2 py-1 rounded text-xs">
|
||
<ImageIcon className="h-3 w-3" />
|
||
Фото {index + 1}
|
||
</div>
|
||
))}
|
||
{selectedClaim.videoPaths.map((video, index) => (
|
||
<div key={index} className="flex items-center gap-1 bg-white/10 px-2 py-1 rounded text-xs">
|
||
<Play className="h-3 w-3" />
|
||
Видео {index + 1}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|