
КРИТИЧНЫЕ КОМПОНЕНТЫ ОПТИМИЗИРОВАНЫ: • 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>
413 lines
17 KiB
TypeScript
413 lines
17 KiB
TypeScript
'use client'
|
||
|
||
import { useMutation } from '@apollo/client'
|
||
import { Trash2, AlertTriangle, Package, Store, Minus, Plus } from 'lucide-react'
|
||
import Image from 'next/image'
|
||
import { useState } from 'react'
|
||
import { toast } from 'sonner'
|
||
|
||
import { OrganizationAvatar } from '@/components/market/organization-avatar'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { UPDATE_CART_ITEM, REMOVE_FROM_CART, CLEAR_CART } from '@/graphql/mutations'
|
||
import { GET_MY_CART } from '@/graphql/queries'
|
||
|
||
interface CartItem {
|
||
id: string
|
||
quantity: number
|
||
totalPrice: number
|
||
isAvailable: boolean
|
||
availableQuantity: number
|
||
product: {
|
||
id: string
|
||
name: string
|
||
article: string
|
||
price: number
|
||
quantity: number
|
||
images: string[]
|
||
mainImage?: string
|
||
organization: {
|
||
id: string
|
||
name?: string
|
||
fullName?: string
|
||
inn: string
|
||
}
|
||
}
|
||
}
|
||
|
||
interface Cart {
|
||
id: string
|
||
items: CartItem[]
|
||
totalPrice: number
|
||
totalItems: number
|
||
}
|
||
|
||
interface CartItemsProps {
|
||
cart: Cart
|
||
}
|
||
|
||
export function CartItems({ cart }: CartItemsProps) {
|
||
const [loadingItems, setLoadingItems] = useState<Set<string>>(new Set())
|
||
const [quantities, setQuantities] = useState<Record<string, number>>({})
|
||
|
||
const [updateCartItem] = useMutation(UPDATE_CART_ITEM, {
|
||
refetchQueries: [{ query: GET_MY_CART }],
|
||
onCompleted: (data) => {
|
||
if (data.updateCartItem.success) {
|
||
toast.success(data.updateCartItem.message)
|
||
} else {
|
||
toast.error(data.updateCartItem.message)
|
||
}
|
||
},
|
||
onError: (error) => {
|
||
toast.error('Ошибка при обновлении заявки')
|
||
console.error('Error updating cart item:', error)
|
||
},
|
||
})
|
||
|
||
const [removeFromCart] = useMutation(REMOVE_FROM_CART, {
|
||
refetchQueries: [{ query: GET_MY_CART }],
|
||
onCompleted: (data) => {
|
||
if (data.removeFromCart.success) {
|
||
toast.success(data.removeFromCart.message)
|
||
} else {
|
||
toast.error(data.removeFromCart.message)
|
||
}
|
||
},
|
||
onError: (error) => {
|
||
toast.error('Ошибка при удалении заявки')
|
||
console.error('Error removing from cart:', error)
|
||
},
|
||
})
|
||
|
||
const [clearCart] = useMutation(CLEAR_CART, {
|
||
refetchQueries: [{ query: GET_MY_CART }],
|
||
onCompleted: () => {
|
||
toast.success('Заявки очищены')
|
||
},
|
||
onError: (error) => {
|
||
toast.error('Ошибка при очистке заявок')
|
||
console.error('Error clearing cart:', error)
|
||
},
|
||
})
|
||
|
||
const getQuantity = (productId: string, defaultQuantity: number) => quantities[productId] || defaultQuantity
|
||
|
||
const setQuantity = (productId: string, quantity: number) => {
|
||
setQuantities((prev) => ({ ...prev, [productId]: quantity }))
|
||
}
|
||
|
||
const updateQuantity = async (productId: string, newQuantity: number) => {
|
||
if (newQuantity <= 0) return
|
||
|
||
setLoadingItems((prev) => new Set(prev).add(productId))
|
||
|
||
try {
|
||
await updateCartItem({
|
||
variables: {
|
||
productId,
|
||
quantity: newQuantity,
|
||
},
|
||
})
|
||
} finally {
|
||
setLoadingItems((prev) => {
|
||
const newSet = new Set(prev)
|
||
newSet.delete(productId)
|
||
return newSet
|
||
})
|
||
}
|
||
}
|
||
|
||
const removeItem = async (productId: string) => {
|
||
setLoadingItems((prev) => new Set(prev).add(productId))
|
||
|
||
try {
|
||
await removeFromCart({
|
||
variables: { productId },
|
||
})
|
||
} finally {
|
||
setLoadingItems((prev) => {
|
||
const newSet = new Set(prev)
|
||
newSet.delete(productId)
|
||
return newSet
|
||
})
|
||
}
|
||
}
|
||
|
||
const handleClearCart = async () => {
|
||
if (confirm('Вы уверены, что хотите очистить все заявки?')) {
|
||
await clearCart()
|
||
}
|
||
}
|
||
|
||
const formatPrice = (price: number) => {
|
||
return new Intl.NumberFormat('ru-RU', {
|
||
style: 'currency',
|
||
currency: 'RUB',
|
||
}).format(price)
|
||
}
|
||
|
||
const unavailableItems = cart.items.filter((item) => !item.isAvailable)
|
||
|
||
// Группировка товаров по поставщикам
|
||
const groupedItems = cart.items.reduce(
|
||
(groups, item) => {
|
||
const orgId = item.product.organization.id
|
||
if (!groups[orgId]) {
|
||
groups[orgId] = {
|
||
organization: item.product.organization,
|
||
items: [],
|
||
totalPrice: 0,
|
||
totalItems: 0,
|
||
}
|
||
}
|
||
groups[orgId].items.push(item)
|
||
groups[orgId].totalPrice += item.totalPrice
|
||
groups[orgId].totalItems += item.quantity
|
||
return groups
|
||
},
|
||
{} as Record<
|
||
string,
|
||
{
|
||
organization: CartItem['product']['organization']
|
||
items: CartItem[]
|
||
totalPrice: number
|
||
totalItems: number
|
||
}
|
||
>,
|
||
)
|
||
|
||
const supplierGroups = Object.values(groupedItems)
|
||
|
||
return (
|
||
<div className="p-6 h-full flex flex-col">
|
||
{/* Заголовок с кнопкой очистки */}
|
||
<div className="flex items-center justify-between mb-6">
|
||
<h2 className="text-xl font-semibold text-white">Заявки на товары</h2>
|
||
{cart.items.length > 0 && (
|
||
<Button
|
||
onClick={handleClearCart}
|
||
size="sm"
|
||
className="bg-gradient-to-r from-red-500/20 to-pink-500/20 hover:from-red-500/30 hover:to-pink-500/30 border border-red-500/30 text-red-400 hover:text-white transition-all"
|
||
>
|
||
<Trash2 className="h-4 w-4 mr-2" />
|
||
Очистить заявки
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Предупреждение о недоступных товарах */}
|
||
{unavailableItems.length > 0 && (
|
||
<div className="mb-6 p-4 bg-orange-500/10 border border-orange-500/20 rounded-lg">
|
||
<div className="flex items-center space-x-2 text-orange-400">
|
||
<AlertTriangle className="h-4 w-4" />
|
||
<span className="text-sm font-medium">{unavailableItems.length} заявок недоступно для оформления</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Группы поставщиков */}
|
||
<div className="flex-1 overflow-auto space-y-8">
|
||
{supplierGroups.map((group) => (
|
||
<div key={group.organization.id} className="space-y-4">
|
||
{/* Заголовок поставщика */}
|
||
<div className="bg-gradient-to-r from-purple-600/20 via-purple-500/10 to-transparent border border-purple-500/20 rounded-xl p-5">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center space-x-4">
|
||
<OrganizationAvatar organization={group.organization} size="md" />
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-white mb-1">
|
||
{group.organization.name || group.organization.fullName || `ИНН ${group.organization.inn}`}
|
||
</h3>
|
||
<div className="flex items-center space-x-3 text-sm text-white/60">
|
||
<span className="flex items-center space-x-1">
|
||
<Package className="h-4 w-4" />
|
||
<span>{group.totalItems} товаров</span>
|
||
</span>
|
||
<span>•</span>
|
||
<span className="font-medium text-purple-300">{formatPrice(group.totalPrice)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<Badge variant="secondary" className="bg-purple-500/20 text-purple-300 px-3 py-1 text-sm font-medium">
|
||
{group.items.length} заявок
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Товары этого поставщика */}
|
||
<div className="space-y-3">
|
||
{group.items.map((item) => {
|
||
const isLoading = loadingItems.has(item.product.id)
|
||
const mainImage = item.product.images?.[0] || item.product.mainImage
|
||
|
||
return (
|
||
<div
|
||
key={item.id}
|
||
className={`bg-white/5 backdrop-blur border border-white/10 rounded-xl transition-all hover:bg-white/8 hover:border-white/20 ${
|
||
!item.isAvailable ? 'opacity-60' : ''
|
||
}`}
|
||
>
|
||
{/* Информация о поставщике в карточке товара */}
|
||
<div className="px-4 py-2 bg-white/5 border-b border-white/10 rounded-t-xl">
|
||
<div className="flex items-center space-x-2 text-xs text-white/60">
|
||
<Store className="h-3 w-3" />
|
||
<span>Поставщик:</span>
|
||
<span className="text-white/80 font-medium">
|
||
{item.product.organization.name ||
|
||
item.product.organization.fullName ||
|
||
`ИНН ${item.product.organization.inn}`}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Основное содержимое карточки */}
|
||
<div className="p-4">
|
||
<div className="flex items-center space-x-4">
|
||
{/* Изображение товара */}
|
||
<div className="flex-shrink-0">
|
||
<div className="w-16 h-16 bg-white/5 rounded-lg overflow-hidden border border-white/10 shadow-lg">
|
||
{mainImage ? (
|
||
<Image
|
||
src={mainImage}
|
||
alt={item.product.name}
|
||
width={64}
|
||
height={64}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center">
|
||
<Package className="h-6 w-6 text-white/20" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Информация о товаре */}
|
||
<div className="flex-1 min-w-0">
|
||
{/* Название и артикул */}
|
||
<h4 className="text-sm font-semibold text-white mb-1 line-clamp-1">{item.product.name}</h4>
|
||
<p className="text-xs text-white/50 mb-2">Арт: {item.product.article}</p>
|
||
|
||
{/* Статус и наличие */}
|
||
<div className="flex items-center space-x-2">
|
||
<span className="text-xs text-white/60">{item.availableQuantity} шт.</span>
|
||
{item.isAvailable ? (
|
||
<Badge className="bg-green-500/20 text-green-300 text-xs border border-green-500/30 px-1 py-0">
|
||
В наличии
|
||
</Badge>
|
||
) : (
|
||
<Badge className="bg-red-500/20 text-red-300 text-xs border border-red-500/30 px-1 py-0">
|
||
Недоступно
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Управление количеством */}
|
||
<div className="flex-shrink-0">
|
||
<div className="flex items-center space-x-1 mb-2">
|
||
<Button
|
||
onClick={() => updateQuantity(item.product.id, item.quantity - 1)}
|
||
disabled={isLoading || !item.isAvailable || item.quantity <= 1}
|
||
size="sm"
|
||
className="h-7 w-7 p-0 bg-gradient-to-r from-purple-500/20 to-pink-500/20 hover:from-purple-500/30 hover:to-pink-500/30 border border-purple-500/30 text-purple-300 hover:text-white transition-all"
|
||
>
|
||
<Minus className="h-3 w-3" />
|
||
</Button>
|
||
|
||
<Input
|
||
type="text"
|
||
value={getQuantity(item.product.id, item.quantity)}
|
||
onChange={(e) => {
|
||
const value = e.target.value
|
||
|
||
// Разрешаем только цифры и пустое поле
|
||
if (value === '' || /^\d+$/.test(value)) {
|
||
const numValue = value === '' ? 0 : parseInt(value)
|
||
|
||
// Временно сохраняем даже если 0 или больше лимита для удобства ввода
|
||
if (value === '' || (numValue >= 0 && numValue <= 99999)) {
|
||
setQuantity(item.product.id, numValue || 1)
|
||
}
|
||
}
|
||
}}
|
||
onFocus={(e) => {
|
||
// При фокусе выделяем весь текст для удобного редактирования
|
||
e.target.select()
|
||
}}
|
||
onBlur={(e) => {
|
||
// При потере фокуса проверяем и корректируем значение, отправляем запрос
|
||
let value = parseInt(e.target.value)
|
||
if (isNaN(value) || value < 1) {
|
||
value = 1
|
||
} else if (value > item.availableQuantity) {
|
||
value = item.availableQuantity
|
||
}
|
||
setQuantity(item.product.id, value)
|
||
updateQuantity(item.product.id, value)
|
||
}}
|
||
onKeyDown={(e) => {
|
||
// Enter для быстрого обновления
|
||
if (e.key === 'Enter') {
|
||
let value = parseInt(e.currentTarget.value)
|
||
if (isNaN(value) || value < 1) {
|
||
value = 1
|
||
} else if (value > item.availableQuantity) {
|
||
value = item.availableQuantity
|
||
}
|
||
setQuantity(item.product.id, value)
|
||
updateQuantity(item.product.id, value)
|
||
e.currentTarget.blur()
|
||
}
|
||
}}
|
||
disabled={isLoading || !item.isAvailable}
|
||
className="w-16 h-7 text-xs text-center glass-input text-white border-white/20 bg-white/5"
|
||
placeholder="1"
|
||
/>
|
||
|
||
<Button
|
||
onClick={() => updateQuantity(item.product.id, item.quantity + 1)}
|
||
disabled={isLoading || !item.isAvailable || item.quantity >= item.availableQuantity}
|
||
size="sm"
|
||
className="h-7 w-7 p-0 bg-gradient-to-r from-purple-500/20 to-pink-500/20 hover:from-purple-500/30 hover:to-pink-500/30 border border-purple-500/30 text-purple-300 hover:text-white transition-all"
|
||
>
|
||
<Plus className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="text-xs text-white/40 text-center">до {item.availableQuantity}</div>
|
||
</div>
|
||
|
||
{/* Правая часть: цена и кнопка удаления */}
|
||
<div className="flex-shrink-0 text-right">
|
||
{/* Цена */}
|
||
<div className="mb-2">
|
||
<div className="text-base font-bold text-purple-300">{formatPrice(item.totalPrice)}</div>
|
||
<div className="text-xs text-white/50">{formatPrice(item.product.price)} за шт.</div>
|
||
</div>
|
||
|
||
{/* Кнопка удаления */}
|
||
<Button
|
||
onClick={() => removeItem(item.product.id)}
|
||
disabled={isLoading}
|
||
size="sm"
|
||
className="h-7 w-7 p-0 bg-gradient-to-r from-red-500/20 to-pink-500/20 hover:from-red-500/30 hover:to-pink-500/30 border border-red-500/30 text-red-400 hover:text-white transition-all"
|
||
>
|
||
<Trash2 className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|