Files
sfera-new/src/components/fulfillment-warehouse/wb-return-claims.tsx
Veronika Smirnova bf27f3ba29 Оптимизирована производительность React компонентов с помощью мемоизации
КРИТИЧНЫЕ КОМПОНЕНТЫ ОПТИМИЗИРОВАНЫ:
• 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>
2025-08-06 13:18:45 +03:00

386 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}