
КРИТИЧНЫЕ КОМПОНЕНТЫ ОПТИМИЗИРОВАНЫ: • 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>
496 lines
19 KiB
TypeScript
496 lines
19 KiB
TypeScript
'use client'
|
||
|
||
import { useMutation } from '@apollo/client'
|
||
import {
|
||
Calendar,
|
||
Package,
|
||
Truck,
|
||
User,
|
||
CheckCircle,
|
||
Clock,
|
||
XCircle,
|
||
MapPin,
|
||
Phone,
|
||
Mail,
|
||
Building,
|
||
Hash,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
MessageCircle,
|
||
Loader2,
|
||
} from 'lucide-react'
|
||
import { useState } from 'react'
|
||
import { toast } from 'sonner'
|
||
|
||
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, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||
import { Textarea } from '@/components/ui/textarea'
|
||
import { SUPPLIER_APPROVE_ORDER, SUPPLIER_REJECT_ORDER, SUPPLIER_SHIP_ORDER } from '@/graphql/mutations'
|
||
import { GET_SUPPLY_ORDERS } from '@/graphql/queries'
|
||
|
||
interface SupplierOrderCardProps {
|
||
order: {
|
||
id: string
|
||
organizationId: string
|
||
partnerId: string
|
||
deliveryDate: string
|
||
status:
|
||
| 'PENDING'
|
||
| 'SUPPLIER_APPROVED'
|
||
| 'CONFIRMED'
|
||
| 'LOGISTICS_CONFIRMED'
|
||
| 'SHIPPED'
|
||
| 'IN_TRANSIT'
|
||
| 'DELIVERED'
|
||
| 'CANCELLED'
|
||
totalAmount: number
|
||
totalItems: number
|
||
createdAt: string
|
||
organization: {
|
||
id: string
|
||
name?: string
|
||
fullName?: string
|
||
type: string
|
||
inn?: string
|
||
}
|
||
fulfillmentCenter?: {
|
||
id: string
|
||
name?: string
|
||
fullName?: string
|
||
type: string
|
||
}
|
||
logisticsPartner?: {
|
||
id: string
|
||
name?: string
|
||
fullName?: string
|
||
type: string
|
||
}
|
||
items: Array<{
|
||
id: string
|
||
quantity: number
|
||
price: number
|
||
totalPrice: number
|
||
product: {
|
||
id: string
|
||
name: string
|
||
article: string
|
||
description?: string
|
||
category?: {
|
||
id: string
|
||
name: string
|
||
}
|
||
}
|
||
}>
|
||
}
|
||
}
|
||
|
||
export function SupplierOrderCard({ order }: SupplierOrderCardProps) {
|
||
const [isExpanded, setIsExpanded] = useState(false)
|
||
const [showRejectModal, setShowRejectModal] = useState(false)
|
||
const [rejectReason, setRejectReason] = useState('')
|
||
|
||
// Мутации для действий поставщика
|
||
const [supplierApproveOrder, { loading: approving }] = useMutation(SUPPLIER_APPROVE_ORDER, {
|
||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
||
onCompleted: (data) => {
|
||
if (data.supplierApproveOrder.success) {
|
||
toast.success(data.supplierApproveOrder.message)
|
||
} else {
|
||
toast.error(data.supplierApproveOrder.message)
|
||
}
|
||
},
|
||
onError: (error) => {
|
||
console.error('Error approving order:', error)
|
||
toast.error('Ошибка при одобрении заказа')
|
||
},
|
||
})
|
||
|
||
const [supplierRejectOrder, { loading: rejecting }] = useMutation(SUPPLIER_REJECT_ORDER, {
|
||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
||
onCompleted: (data) => {
|
||
if (data.supplierRejectOrder.success) {
|
||
toast.success(data.supplierRejectOrder.message)
|
||
} else {
|
||
toast.error(data.supplierRejectOrder.message)
|
||
}
|
||
setShowRejectModal(false)
|
||
setRejectReason('')
|
||
},
|
||
onError: (error) => {
|
||
console.error('Error rejecting order:', error)
|
||
toast.error('Ошибка при отклонении заказа')
|
||
},
|
||
})
|
||
|
||
const [supplierShipOrder, { loading: shipping }] = useMutation(SUPPLIER_SHIP_ORDER, {
|
||
refetchQueries: [{ query: GET_SUPPLY_ORDERS }],
|
||
onCompleted: (data) => {
|
||
if (data.supplierShipOrder.success) {
|
||
toast.success(data.supplierShipOrder.message)
|
||
} else {
|
||
toast.error(data.supplierShipOrder.message)
|
||
}
|
||
},
|
||
onError: (error) => {
|
||
console.error('Error shipping order:', error)
|
||
toast.error('Ошибка при отправке заказа')
|
||
},
|
||
})
|
||
|
||
const handleApproveOrder = async () => {
|
||
try {
|
||
await supplierApproveOrder({
|
||
variables: { id: order.id },
|
||
})
|
||
} catch (error) {
|
||
console.error('Error in handleApproveOrder:', error)
|
||
}
|
||
}
|
||
|
||
const handleRejectOrder = async () => {
|
||
if (!rejectReason.trim()) {
|
||
toast.error('Укажите причину отклонения заявки')
|
||
return
|
||
}
|
||
|
||
try {
|
||
await supplierRejectOrder({
|
||
variables: {
|
||
id: order.id,
|
||
reason: rejectReason,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
console.error('Error in handleRejectOrder:', error)
|
||
}
|
||
}
|
||
|
||
const handleShipOrder = async () => {
|
||
try {
|
||
await supplierShipOrder({
|
||
variables: { id: order.id },
|
||
})
|
||
} catch (error) {
|
||
console.error('Error in handleShipOrder:', error)
|
||
}
|
||
}
|
||
|
||
const getStatusBadge = (status: string) => {
|
||
switch (status) {
|
||
case 'PENDING':
|
||
return <Badge className="bg-yellow-500/20 text-yellow-300 border-yellow-400/30">🟡 ОЖИДАЕТ</Badge>
|
||
case 'SUPPLIER_APPROVED':
|
||
return <Badge className="bg-green-500/20 text-green-300 border-green-400/30">🟢 ОДОБРЕНО</Badge>
|
||
case 'CONFIRMED':
|
||
case 'LOGISTICS_CONFIRMED':
|
||
return <Badge className="bg-blue-500/20 text-blue-300 border-blue-400/30">🔵 В РАБОТЕ</Badge>
|
||
case 'SHIPPED':
|
||
case 'IN_TRANSIT':
|
||
return <Badge className="bg-orange-500/20 text-orange-300 border-orange-400/30">🟠 В ПУТИ</Badge>
|
||
case 'DELIVERED':
|
||
return <Badge className="bg-emerald-500/20 text-emerald-300 border-emerald-400/30">✅ ДОСТАВЛЕНО</Badge>
|
||
default:
|
||
return <Badge className="bg-white/20 text-white/70 border-white/30">{status}</Badge>
|
||
}
|
||
}
|
||
|
||
const formatDate = (dateString: string) => {
|
||
return new Date(dateString).toLocaleDateString('ru-RU', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
})
|
||
}
|
||
|
||
const getInitials = (name: string) => {
|
||
return name
|
||
.split(' ')
|
||
.map((word) => word[0])
|
||
.join('')
|
||
.toUpperCase()
|
||
.slice(0, 2)
|
||
}
|
||
|
||
const calculateVolume = () => {
|
||
// Примерный расчет объема - можно улучшить на основе реальных данных о товарах
|
||
return (order.totalItems * 0.02).toFixed(1) // 0.02 м³ на единицу товара
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Card className="glass-card border-white/10 hover:border-white/20 transition-all">
|
||
{/* Основная информация - структура согласно правилам */}
|
||
<div className="p-4">
|
||
{/* Шапка заявки */}
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div className="flex items-center space-x-4">
|
||
<div className="flex items-center space-x-2">
|
||
<Hash className="h-4 w-4 text-white/60" />
|
||
<span className="text-white font-semibold">СФ-{order.id.slice(-8)}</span>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<Calendar className="h-4 w-4 text-blue-400" />
|
||
<span className="text-white/70 text-sm">{formatDate(order.createdAt)}</span>
|
||
</div>
|
||
{getStatusBadge(order.status)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Информация об участниках */}
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||
{/* Заказчик */}
|
||
<div>
|
||
<div className="flex items-center space-x-2 mb-2">
|
||
<User className="h-4 w-4 text-white/60" />
|
||
<span className="text-white/60 text-sm">Заказчик:</span>
|
||
</div>
|
||
<div className="flex items-center space-x-3">
|
||
<Avatar className="w-8 h-8">
|
||
<AvatarFallback className="bg-blue-500 text-white text-sm">
|
||
{getInitials(order.organization.name || order.organization.fullName || 'ОРГ')}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<div>
|
||
<p className="text-white font-medium text-sm">
|
||
{order.organization.name || order.organization.fullName}
|
||
</p>
|
||
{order.organization.inn && <p className="text-white/60 text-xs">ИНН: {order.organization.inn}</p>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Фулфилмент */}
|
||
{order.fulfillmentCenter && (
|
||
<div>
|
||
<div className="flex items-center space-x-2 mb-2">
|
||
<Building className="h-4 w-4 text-white/60" />
|
||
<span className="text-white/60 text-sm">Фулфилмент:</span>
|
||
</div>
|
||
<p className="text-white font-medium text-sm">
|
||
{order.fulfillmentCenter.name || order.fulfillmentCenter.fullName}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Логистика */}
|
||
{order.logisticsPartner && (
|
||
<div>
|
||
<div className="flex items-center space-x-2 mb-2">
|
||
<Truck className="h-4 w-4 text-white/60" />
|
||
<span className="text-white/60 text-sm">Логистика:</span>
|
||
</div>
|
||
<p className="text-white font-medium text-sm">
|
||
{order.logisticsPartner.name || order.logisticsPartner.fullName}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Краткая информация о заказе */}
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center space-x-6">
|
||
<div className="flex items-center space-x-2">
|
||
<Package className="h-4 w-4 text-green-400" />
|
||
<span className="text-white text-sm">
|
||
{order.items.length} вид
|
||
{order.items.length === 1 ? '' : order.items.length < 5 ? 'а' : 'ов'} товаров
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<span className="text-white text-sm">{order.totalItems} единиц</span>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<span className="text-white text-sm">📏 {calculateVolume()} м³</span>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<span className="text-white font-semibold">💰 {order.totalAmount.toLocaleString()}₽</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Кнопки действий */}
|
||
<div className="flex items-center space-x-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setIsExpanded(!isExpanded)}
|
||
className="text-white/70 hover:text-white"
|
||
>
|
||
Подробности
|
||
{isExpanded ? <ChevronUp className="h-4 w-4 ml-1" /> : <ChevronDown className="h-4 w-4 ml-1" />}
|
||
</Button>
|
||
|
||
{/* Действия для PENDING */}
|
||
{order.status === 'PENDING' && (
|
||
<>
|
||
<Button
|
||
size="sm"
|
||
onClick={handleApproveOrder}
|
||
disabled={approving}
|
||
className="glass-button bg-green-500/20 hover:bg-green-500/30 text-green-300 border border-green-500/30"
|
||
>
|
||
{approving ? (
|
||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||
) : (
|
||
<CheckCircle className="h-3 w-3 mr-1" />
|
||
)}
|
||
Одобрить
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => setShowRejectModal(true)}
|
||
disabled={rejecting}
|
||
className="glass-secondary bg-red-500/20 hover:bg-red-500/30 text-red-300 border border-red-500/30"
|
||
>
|
||
<XCircle className="h-3 w-3 mr-1" />
|
||
Отклонить
|
||
</Button>
|
||
</>
|
||
)}
|
||
|
||
{/* Действие для LOGISTICS_CONFIRMED */}
|
||
{order.status === 'LOGISTICS_CONFIRMED' && (
|
||
<Button
|
||
size="sm"
|
||
onClick={handleShipOrder}
|
||
disabled={shipping}
|
||
className="glass-button bg-orange-500/20 hover:bg-orange-500/30 text-orange-300 border border-orange-500/30"
|
||
>
|
||
{shipping ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Truck className="h-3 w-3 mr-1" />}
|
||
Отгрузить
|
||
</Button>
|
||
)}
|
||
|
||
{/* Кнопка связаться всегда доступна */}
|
||
<Button size="sm" variant="ghost" className="glass-secondary text-blue-300 hover:text-blue-200">
|
||
<MessageCircle className="h-3 w-3 mr-1" />
|
||
Связаться
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Срок доставки */}
|
||
<div className="mt-3 pt-3 border-t border-white/10">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center space-x-2">
|
||
<MapPin className="h-4 w-4 text-white/60" />
|
||
<span className="text-white/60 text-sm">Доставка:</span>
|
||
<span className="text-white text-sm">Склад фулфилмента</span>
|
||
</div>
|
||
<div className="flex items-center space-x-2">
|
||
<Clock className="h-4 w-4 text-white/60" />
|
||
<span className="text-white/60 text-sm">Срок:</span>
|
||
<span className="text-white text-sm">{formatDate(order.deliveryDate)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Расширенная детализация */}
|
||
{isExpanded && (
|
||
<div className="border-t border-white/10 p-4">
|
||
<h4 className="text-white font-semibold mb-3">📋 ДЕТАЛИ ЗАЯВКИ #{order.id.slice(-8)}</h4>
|
||
|
||
{/* Товары в заявке */}
|
||
<div className="mb-4">
|
||
<h5 className="text-white/80 font-medium mb-2">📦 ТОВАРЫ В ЗАЯВКЕ:</h5>
|
||
<div className="space-y-2">
|
||
{order.items.map((item) => (
|
||
<div key={item.id} className="flex items-center justify-between p-2 bg-white/5 rounded">
|
||
<div className="flex-1">
|
||
<span className="text-white text-sm">
|
||
{item.product.name} • {item.quantity} шт • {item.price}
|
||
₽/шт = {item.totalPrice.toLocaleString()}₽
|
||
</span>
|
||
<div className="text-white/60 text-xs">
|
||
Артикул: {item.product.article}
|
||
{item.product.category && ` • ${item.product.category.name}`}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
<div className="pt-2 border-t border-white/10">
|
||
<span className="text-white font-semibold">
|
||
Общая стоимость: {order.totalAmount.toLocaleString()}₽
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Логистическая информация */}
|
||
<div className="mb-4">
|
||
<h5 className="text-white/80 font-medium mb-2">📍 ЛОГИСТИЧЕСКАЯ ИНФОРМАЦИЯ:</h5>
|
||
<div className="space-y-1 text-sm">
|
||
<div className="text-white/70">• Объем груза: {calculateVolume()} м³</div>
|
||
<div className="text-white/70">
|
||
• Предварительная стоимость доставки: ~
|
||
{Math.round(parseFloat(calculateVolume()) * 3500).toLocaleString()}₽
|
||
</div>
|
||
<div className="text-white/70">
|
||
• Маршрут: Склад поставщика → {order.fulfillmentCenter?.name || 'Фулфилмент-центр'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Контактная информация */}
|
||
<div>
|
||
<h5 className="text-white/80 font-medium mb-2">📞 КОНТАКТЫ:</h5>
|
||
<div className="space-y-1 text-sm">
|
||
<div className="text-white/70">
|
||
• Заказчик: {order.organization.name || order.organization.fullName}
|
||
{order.organization.inn && ` (ИНН: ${order.organization.inn})`}
|
||
</div>
|
||
{order.fulfillmentCenter && (
|
||
<div className="text-white/70">
|
||
• Фулфилмент: {order.fulfillmentCenter.name || order.fulfillmentCenter.fullName}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* Модал отклонения заявки */}
|
||
<Dialog open={showRejectModal} onOpenChange={setShowRejectModal}>
|
||
<DialogContent className="glass-card border-white/20">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white">Отклонить заявку</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="text-white/90 text-sm mb-2 block">Причина отклонения заявки:</label>
|
||
<Textarea
|
||
value={rejectReason}
|
||
onChange={(e) => setRejectReason(e.target.value)}
|
||
placeholder="Укажите причину отклонения..."
|
||
className="glass-input text-white placeholder:text-white/50"
|
||
rows={3}
|
||
/>
|
||
</div>
|
||
<div className="flex justify-end space-x-2">
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => setShowRejectModal(false)}
|
||
className="text-white/70 hover:text-white"
|
||
>
|
||
Отмена
|
||
</Button>
|
||
<Button
|
||
onClick={handleRejectOrder}
|
||
disabled={rejecting || !rejectReason.trim()}
|
||
className="bg-red-500/20 hover:bg-red-500/30 text-red-300 border border-red-500/30"
|
||
>
|
||
{rejecting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <XCircle className="h-4 w-4 mr-2" />}
|
||
Отклонить заявку
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
)
|
||
}
|