Files
sfera/src/components/supplies/wb-product-cards.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

1324 lines
63 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 React, { useState, useEffect } from 'react'
import DatePicker from 'react-datepicker'
import { toast } from 'sonner'
import { Sidebar } from '@/components/dashboard/sidebar'
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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ProductCardSkeletonGrid } from '@/components/ui/product-card-skeleton'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import 'react-datepicker/dist/react-datepicker.css'
import { CREATE_WILDBERRIES_SUPPLY } from '@/graphql/mutations'
import { GET_MY_COUNTERPARTIES, GET_COUNTERPARTY_SERVICES, GET_COUNTERPARTY_SUPPLIES } from '@/graphql/queries'
import { useAuth } from '@/hooks/useAuth'
import { useSidebar } from '@/hooks/useSidebar'
import {
Search,
Plus,
Minus,
ShoppingCart,
Calendar as CalendarIcon,
Phone,
User,
MapPin,
Package,
Wrench,
ArrowLeft,
Check,
Eye,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import { apolloClient } from '@/lib/apollo-client'
import { WildberriesService } from '@/services/wildberries-service'
import { useQuery, useMutation } from '@apollo/client'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import { SelectedCard, FulfillmentService, ConsumableService, WildberriesCard } from '@/types/supplies'
interface Organization {
id: string
name?: string
fullName?: string
type: string
}
interface WBProductCardsProps {
onBack: () => void
onComplete: (selectedCards: SelectedCard[]) => void
showSummary?: boolean
setShowSummary?: (show: boolean) => void
selectedCards?: SelectedCard[]
setSelectedCards?: (cards: SelectedCard[]) => void
}
export function WBProductCards({
onBack,
onComplete,
showSummary: externalShowSummary,
setShowSummary: externalSetShowSummary,
selectedCards: externalSelectedCards,
setSelectedCards: externalSetSelectedCards,
}: WBProductCardsProps) {
const { user } = useAuth()
const { getSidebarMargin } = useSidebar()
const [searchTerm, setSearchTerm] = useState('')
const [loading, setLoading] = useState(false)
const [wbCards, setWbCards] = useState<WildberriesCard[]>([])
const [selectedCards, setSelectedCards] = useState<SelectedCard[]>([]) // Товары в корзине
// Используем внешнее состояние если передано
const actualSelectedCards = externalSelectedCards !== undefined ? externalSelectedCards : selectedCards
const actualSetSelectedCards = externalSetSelectedCards || setSelectedCards
const [preparingCards, setPreparingCards] = useState<SelectedCard[]>([]) // Товары, готовящиеся к добавлению
const [showSummary, setShowSummary] = useState(false)
// Используем внешнее состояние если передано
const actualShowSummary = externalShowSummary !== undefined ? externalShowSummary : showSummary
const actualSetShowSummary = externalSetShowSummary || setShowSummary
const [globalDeliveryDate, setGlobalDeliveryDate] = useState<Date | undefined>(undefined)
const [fulfillmentServices, setFulfillmentServices] = useState<FulfillmentService[]>([])
const [organizationServices, setOrganizationServices] = useState<{
[orgId: string]: Array<{ id: string; name: string; description?: string; price: number }>
}>({})
const [organizationSupplies, setOrganizationSupplies] = useState<{
[orgId: string]: Array<{ id: string; name: string; description?: string; price: number }>
}>({})
const [selectedCardForDetails, setSelectedCardForDetails] = useState<WildberriesCard | null>(null)
const [currentImageIndex, setCurrentImageIndex] = useState(0)
// Загружаем реальные карточки WB
const { data: wbCardsData, loading: wbCardsLoading } = useQuery(GET_MY_WILDBERRIES_SUPPLIES, {
errorPolicy: 'all',
})
// Используем реальные данные из GraphQL запроса
const realWbCards: WildberriesCard[] = (wbCardsData?.myWildberriesSupplies || [])
.flatMap((supply: any) => supply.cards || [])
.map((card: any) => ({
nmID: card.nmId || card.nmID,
vendorCode: card.vendorCode || '',
title: card.title || 'Без названия',
description: card.description || '',
brand: card.brand || '',
object: card.object || '',
parent: card.parent || '',
countryProduction: card.countryProduction || '',
supplierVendorCode: card.supplierVendorCode || '',
mediaFiles: card.mediaFiles || [],
sizes: card.sizes || [],
}))
// Загружаем контрагентов-фулфилментов
const { data: counterpartiesData } = useQuery(GET_MY_COUNTERPARTIES)
// Автоматически загружаем услуги и расходники для уже выбранных организаций
useEffect(() => {
actualSelectedCards.forEach((sc) => {
if (sc.selectedFulfillmentOrg && !organizationServices[sc.selectedFulfillmentOrg]) {
loadOrganizationServices(sc.selectedFulfillmentOrg)
}
if (sc.selectedConsumableOrg && !organizationSupplies[sc.selectedConsumableOrg]) {
loadOrganizationSupplies(sc.selectedConsumableOrg)
}
})
}, [selectedCards])
// Функция для загрузки услуг организации
const loadOrganizationServices = async (organizationId: string) => {
if (organizationServices[organizationId]) return // Уже загружены
try {
const response = await apolloClient.query({
query: GET_COUNTERPARTY_SERVICES,
variables: { organizationId },
})
if (response.data?.counterpartyServices) {
setOrganizationServices((prev) => ({
...prev,
[organizationId]: response.data.counterpartyServices,
}))
}
} catch (error) {
console.error('Ошибка загрузки услуг организации:', error)
}
}
// Функция для загрузки расходников организации
const loadOrganizationSupplies = async (organizationId: string) => {
if (organizationSupplies[organizationId]) return // Уже загружены
try {
const response = await apolloClient.query({
query: GET_COUNTERPARTY_SUPPLIES,
variables: { organizationId },
})
if (response.data?.counterpartySupplies) {
setOrganizationSupplies((prev) => ({
...prev,
[organizationId]: response.data.counterpartySupplies,
}))
}
} catch (error) {
console.error('Ошибка загрузки расходников организации:', error)
}
}
// Мутация для создания поставки
const [createSupply, { loading: creatingSupply }] = useMutation(CREATE_WILDBERRIES_SUPPLY, {
onCompleted: (data) => {
if (data.createWildberriesSupply.success) {
toast.success(data.createWildberriesSupply.message)
onComplete(selectedCards)
} else {
toast.error(data.createWildberriesSupply.message)
}
},
onError: (error) => {
toast.error('Ошибка при создании поставки')
console.error('Error creating supply:', error)
},
})
// Данные рынков можно будет загружать через GraphQL в будущем
const markets = [
{ value: 'sadovod', label: 'Садовод' },
{ value: 'luzhniki', label: 'Лужники' },
{ value: 'tishinka', label: 'Тишинка' },
{ value: 'food-city', label: 'Фуд Сити' },
]
// Загружаем карточки из GraphQL запроса
useEffect(() => {
if (!wbCardsLoading && wbCardsData) {
setWbCards(realWbCards)
console.warn('Загружено карточек из GraphQL:', realWbCards.length)
}
}, [wbCardsData, wbCardsLoading, realWbCards])
const loadAllCards = async () => {
setLoading(true)
try {
const wbApiKey = user?.organization?.apiKeys?.find((key) => key.marketplace === 'WILDBERRIES')
if (wbApiKey?.isActive) {
// Попытка загрузить реальные данные из API Wildberries
const validationData = wbApiKey.validationData as Record<string, string>
const apiToken =
validationData?.token ||
validationData?.apiKey ||
validationData?.key ||
(wbApiKey as { apiKey?: string }).apiKey
if (apiToken) {
console.warn('Загружаем все карточки из WB API...')
const cards = await WildberriesService.getAllCards(apiToken, 100)
setWbCards(cards)
console.warn('Загружено карточек из WB API:', cards.length)
return
}
}
// Если API ключ не настроен, используем данные из GraphQL
console.warn('API ключ WB не настроен, используем данные из GraphQL')
setWbCards(realWbCards)
console.warn('Используются данные из GraphQL:', realWbCards.length)
} catch (error) {
console.error('Ошибка загрузки всех карточек WB:', error)
// При ошибке используем данные из GraphQL
setWbCards(realWbCards)
console.warn('Используются данные из GraphQL (fallback):', realWbCards.length)
} finally {
setLoading(false)
}
}
const searchCards = async () => {
if (!searchTerm.trim()) {
loadAllCards()
return
}
setLoading(true)
try {
const wbApiKey = user?.organization?.apiKeys?.find((key) => key.marketplace === 'WILDBERRIES')
if (wbApiKey?.isActive) {
// Попытка поиска в реальном API Wildberries
const validationData = wbApiKey.validationData as Record<string, string>
const apiToken =
validationData?.token ||
validationData?.apiKey ||
validationData?.key ||
(wbApiKey as { apiKey?: string }).apiKey
if (apiToken) {
console.warn('Поиск в WB API:', searchTerm)
const cards = await WildberriesService.searchCards(apiToken, searchTerm, 50)
setWbCards(cards)
console.warn('Найдено карточек в WB API:', cards.length)
return
}
}
// Если API ключ не настроен, ищем в данных из GraphQL
console.warn('API ключ WB не настроен, поиск в данных GraphQL:', searchTerm)
// Фильтруем товары по поисковому запросу
const filteredCards = realWbCards.filter(
(card) =>
card.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.brand.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.nmID.toString().includes(searchTerm.toLowerCase()) ||
card.object?.toLowerCase().includes(searchTerm.toLowerCase()),
)
setWbCards(filteredCards)
console.warn('Найдено товаров в GraphQL данных:', filteredCards.length)
} catch (error) {
console.error('Ошибка поиска карточек WB:', error)
// При ошибке ищем в данных из GraphQL
const filteredCards = realWbCards.filter(
(card) =>
card.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.brand.toLowerCase().includes(searchTerm.toLowerCase()) ||
card.nmID.toString().includes(searchTerm.toLowerCase()) ||
card.object?.toLowerCase().includes(searchTerm.toLowerCase()),
)
setWbCards(filteredCards)
console.warn('Найдено товаров в GraphQL данных (fallback):', filteredCards.length)
} finally {
setLoading(false)
}
}
const updateCardSelection = (card: WildberriesCard, field: keyof SelectedCard, value: string | number | string[]) => {
setPreparingCards((prev) => {
const existing = prev.find((sc) => sc.card.nmID === card.nmID)
if (field === 'selectedQuantity' && typeof value === 'number' && value === 0) {
return prev.filter((sc) => sc.card.nmID !== card.nmID)
}
if (existing) {
const updatedCard = { ...existing, [field]: value }
// При изменении количества сбрасываем цену, чтобы пользователь ввел новую
if (field === 'selectedQuantity' && typeof value === 'number' && existing.customPrice > 0) {
updatedCard.customPrice = 0
}
return prev.map((sc) => (sc.card.nmID === card.nmID ? updatedCard : sc))
} else if (field === 'selectedQuantity' && typeof value === 'number' && value > 0) {
const newSelectedCard: SelectedCard = {
card,
selectedQuantity: value as number,
customPrice: 0,
selectedFulfillmentOrg: '',
selectedFulfillmentServices: [],
selectedConsumableOrg: '',
selectedConsumableServices: [],
deliveryDate: '',
selectedMarket: '',
selectedPlace: '',
sellerName: '',
sellerPhone: '',
selectedServices: [],
}
return [...prev, newSelectedCard]
}
return prev
})
}
// Функция для получения цены за единицу товара
const getSelectedUnitPrice = (card: WildberriesCard): number => {
const selected = preparingCards.find((sc) => sc.card.nmID === card.nmID)
if (!selected || selected.selectedQuantity === 0) return 0
return selected.customPrice / selected.selectedQuantity
}
// Функция для получения общей стоимости товара
const getSelectedTotalPrice = (card: WildberriesCard): number => {
const selected = preparingCards.find((sc) => sc.card.nmID === card.nmID)
return selected ? selected.customPrice : 0
}
const getSelectedQuantity = (card: WildberriesCard): number => {
const selected = preparingCards.find((sc) => sc.card.nmID === card.nmID)
return selected ? selected.selectedQuantity : 0
}
// Функция для добавления подготовленных товаров в корзину
const addToCart = () => {
const validCards = preparingCards.filter((card) => card.selectedQuantity > 0 && card.customPrice > 0)
if (validCards.length === 0) {
toast.error('Выберите товары и укажите цены')
return
}
if (!globalDeliveryDate) {
toast.error('Выберите дату поставки')
return
}
const newCards = [...actualSelectedCards]
validCards.forEach((prepCard) => {
const cardWithDate = {
...prepCard,
deliveryDate: globalDeliveryDate.toISOString().split('T')[0],
}
const existingIndex = newCards.findIndex((sc) => sc.card.nmID === prepCard.card.nmID)
if (existingIndex >= 0) {
// Обновляем существующий товар
newCards[existingIndex] = cardWithDate
} else {
// Добавляем новый товар
newCards.push(cardWithDate)
}
})
actualSetSelectedCards(newCards)
// Очищаем подготовленные товары
setPreparingCards([])
toast.success(`Добавлено ${validCards.length} товар(ов) в корзину`)
}
// Функции подсчета для подготовленных товаров
const getPreparingTotalItems = () => {
return preparingCards.reduce((sum, card) => sum + card.selectedQuantity, 0)
}
const getPreparingTotalAmount = () => {
return preparingCards.reduce((sum, card) => sum + card.customPrice, 0)
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
}).format(amount)
}
// Функция для получения цены услуги по ID
const getServicePrice = (orgId: string, serviceId: string): number => {
const services = organizationServices[orgId]
if (!services) return 0
const service = services.find((s) => s.id === serviceId)
return service ? service.price : 0
}
// Функция для получения цены расходника по ID
const getSupplyPrice = (orgId: string, supplyId: string): number => {
const supplies = organizationSupplies[orgId]
if (!supplies) return 0
const supply = supplies.find((s) => s.id === supplyId)
return supply ? supply.price : 0
}
// Функция для расчета стоимости услуг и расходников за 1 штуку
const calculateAdditionalCostPerUnit = (sc: SelectedCard): number => {
let servicesCost = 0
let suppliesCost = 0
// Стоимость услуг фулфилмента
if (sc.selectedFulfillmentOrg && sc.selectedFulfillmentServices.length > 0) {
servicesCost = sc.selectedFulfillmentServices.reduce((sum, serviceId) => {
return sum + getServicePrice(sc.selectedFulfillmentOrg, serviceId)
}, 0)
}
// Стоимость расходных материалов
if (sc.selectedConsumableOrg && sc.selectedConsumableServices.length > 0) {
suppliesCost = sc.selectedConsumableServices.reduce((sum, supplyId) => {
return sum + getSupplyPrice(sc.selectedConsumableOrg, supplyId)
}, 0)
}
return servicesCost + suppliesCost
}
const getTotalAmount = () => {
return actualSelectedCards.reduce((sum, sc) => {
const additionalCostPerUnit = calculateAdditionalCostPerUnit(sc)
const totalCostPerUnit = sc.customPrice / sc.selectedQuantity + additionalCostPerUnit
const totalCostForAllItems = totalCostPerUnit * sc.selectedQuantity
return sum + totalCostForAllItems
}, 0)
}
const getTotalItems = () => {
return actualSelectedCards.reduce((sum, sc) => sum + sc.selectedQuantity, 0)
}
// Функция больше не нужна, так как услуги выбираются индивидуально
const handleCardClick = (card: WildberriesCard) => {
setSelectedCardForDetails(card)
setCurrentImageIndex(0)
}
const closeDetailsModal = () => {
setSelectedCardForDetails(null)
setCurrentImageIndex(0)
}
const nextImage = () => {
if (selectedCardForDetails) {
const images = WildberriesService.getCardImages(selectedCardForDetails)
if (images.length > 1) {
setCurrentImageIndex((prev) => (prev + 1) % images.length)
}
}
}
const prevImage = () => {
if (selectedCardForDetails) {
const images = WildberriesService.getCardImages(selectedCardForDetails)
if (images.length > 1) {
setCurrentImageIndex((prev) => (prev - 1 + images.length) % images.length)
}
}
}
const handleCreateSupply = async () => {
try {
const supplyInput = {
deliveryDate: selectedCards[0]?.deliveryDate || null,
cards: actualSelectedCards.map((sc) => ({
nmId: sc.card.nmID.toString(),
vendorCode: sc.card.vendorCode,
title: sc.card.title,
brand: sc.card.brand,
selectedQuantity: sc.selectedQuantity,
customPrice: sc.customPrice,
selectedFulfillmentOrg: sc.selectedFulfillmentOrg,
selectedFulfillmentServices: sc.selectedFulfillmentServices,
selectedConsumableOrg: sc.selectedConsumableOrg,
selectedConsumableServices: sc.selectedConsumableServices,
deliveryDate: sc.deliveryDate || null,
mediaFiles: sc.card.mediaFiles,
})),
}
await createSupply({ variables: { input: supplyInput } })
} catch (error) {
console.error('Error creating supply:', error)
}
}
if (actualShowSummary) {
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main className={`flex-1 ${getSidebarMargin()} overflow-auto transition-all duration-300`}>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Button
variant="ghost"
size="sm"
onClick={() => actualSetShowSummary(false)}
className="text-white/60 hover:text-white hover:bg-white/10"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Назад
</Button>
<div>
<h2 className="text-2xl font-bold text-white mb-1">Корзина</h2>
<p className="text-white/60">{actualSelectedCards.length} карточек товаров</p>
</div>
</div>
</div>
{/* Массовое назначение поставщиков */}
<Card className="bg-blue-500/10 backdrop-blur border-blue-500/20 p-4 mb-6">
<h3 className="text-white font-semibold mb-4">Быстрое назначение</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="text-white/60 text-sm mb-2 block">Поставщик услуг для всех товаров:</label>
<Select
onValueChange={(value) => {
if (value && value !== 'none') {
// Загружаем услуги для выбранной организации
loadOrganizationServices(value)
actualSelectedCards.forEach((sc) => {
updateCardSelection(sc.card, 'selectedFulfillmentOrg', value)
// Сбрасываем выбранные услуги при смене организации
updateCardSelection(sc.card, 'selectedFulfillmentServices', [])
})
}
}}
>
<SelectTrigger className="bg-white/5 border-white/20 text-white">
<SelectValue placeholder="Выберите поставщика" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Не выбран</SelectItem>
{(counterpartiesData?.myCounterparties || [])
.filter((org: Organization) => org.type === 'FULFILLMENT')
.map((org: Organization) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-white/60 text-sm mb-2 block">Поставщик расходников для всех:</label>
<Select
onValueChange={(value) => {
if (value && value !== 'none') {
// Загружаем расходники для выбранной организации
loadOrganizationSupplies(value)
actualSelectedCards.forEach((sc) => {
updateCardSelection(sc.card, 'selectedConsumableOrg', value)
// Сбрасываем выбранные расходники при смене организации
updateCardSelection(sc.card, 'selectedConsumableServices', [])
})
}
}}
>
<SelectTrigger className="bg-white/5 border-white/20 text-white">
<SelectValue placeholder="Выберите поставщика" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Не выбран</SelectItem>
{(counterpartiesData?.myCounterparties || [])
.filter((org: Organization) => org.type === 'FULFILLMENT')
.map((org: Organization) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-white/60 text-sm mb-2 block">Дата поставки для всех:</label>
<Popover>
<PopoverTrigger asChild>
<button className="w-full bg-white/5 border border-white/20 text-white hover:bg-white/10 justify-start text-left font-normal h-10 px-3 py-2 rounded-md flex items-center transition-colors">
<CalendarIcon className="mr-2 h-4 w-4" />
{globalDeliveryDate ? format(globalDeliveryDate, 'dd.MM.yyyy') : 'Выберите дату'}
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<DatePicker
selected={globalDeliveryDate}
onChange={(date: Date | null) => {
setGlobalDeliveryDate(date || undefined)
if (date) {
const dateString = date.toISOString().split('T')[0]
actualSelectedCards.forEach((sc) => {
updateCardSelection(sc.card, 'deliveryDate', dateString)
})
}
}}
minDate={new Date()}
inline
locale="ru"
/>
</PopoverContent>
</Popover>
</div>
</div>
</Card>
<div className="grid grid-cols-1 xl:grid-cols-4 gap-6">
<div className="xl:col-span-3 space-y-4">
{actualSelectedCards.map((sc) => {
const fulfillmentOrgs = (counterpartiesData?.myCounterparties || []).filter(
(org: Organization) => org.type === 'FULFILLMENT',
)
const consumableOrgs = (counterpartiesData?.myCounterparties || []).filter(
(org: Organization) => org.type === 'FULFILLMENT',
)
return (
<Card key={sc.card.nmID} className="bg-white/10 backdrop-blur border-white/20 p-4">
<div className="flex space-x-4">
<img
src={WildberriesService.getCardImage(sc.card, 'c246x328') || '/api/placeholder/120/120'}
alt={sc.card.title}
className="w-20 h-20 rounded-lg object-cover"
/>
<div className="flex-1 space-y-4">
<div>
<h3 className="text-white font-medium">{sc.card.title}</h3>
<p className="text-white/60 text-sm">WB: {sc.card.nmID}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Количество и цена */}
<div className="space-y-3">
<div>
<label className="text-white/60 text-sm">Количество:</label>
<Input
type="number"
value={sc.selectedQuantity}
onChange={(e) =>
updateCardSelection(sc.card, 'selectedQuantity', parseInt(e.target.value) || 0)
}
className="bg-white/5 border-white/20 text-white mt-1"
min="1"
/>
</div>
<div>
<label className="text-white/60 text-sm">Цена за единицу:</label>
<Input
type="number"
value={sc.customPrice === 0 ? '' : (sc.customPrice / sc.selectedQuantity).toFixed(2)}
onChange={(e) => {
const pricePerUnit = e.target.value === '' ? 0 : parseFloat(e.target.value) || 0
const totalPrice = pricePerUnit * sc.selectedQuantity
updateCardSelection(sc.card, 'customPrice', totalPrice)
}}
className="bg-white/5 border-white/20 text-white mt-1"
placeholder="Введите цену за 1 штуку"
/>
{/* Показываем расчет дополнительных расходов */}
{(() => {
const additionalCost = calculateAdditionalCostPerUnit(sc)
if (additionalCost > 0) {
return (
<div className="mt-2 p-2 bg-blue-500/20 border border-blue-500/30 rounded text-xs">
<div className="text-blue-300 font-medium">Дополнительные расходы за 1 шт:</div>
{sc.selectedFulfillmentServices.length > 0 && (
<div className="text-blue-200">
Услуги:{' '}
{sc.selectedFulfillmentServices
.map((serviceId) => {
const price = getServicePrice(sc.selectedFulfillmentOrg, serviceId)
const services = organizationServices[sc.selectedFulfillmentOrg]
const service = services?.find((s) => s.id === serviceId)
return service ? `${service.name} (${price}₽)` : ''
})
.join(', ')}
</div>
)}
{sc.selectedConsumableServices.length > 0 && (
<div className="text-blue-200">
Расходники:{' '}
{sc.selectedConsumableServices
.map((supplyId) => {
const price = getSupplyPrice(sc.selectedConsumableOrg, supplyId)
const supplies = organizationSupplies[sc.selectedConsumableOrg]
const supply = supplies?.find((s) => s.id === supplyId)
return supply ? `${supply.name} (${price}₽)` : ''
})
.join(', ')}
</div>
)}
<div className="text-blue-300 font-medium mt-1">
Итого доп. расходы: {formatCurrency(additionalCost)}
</div>
<div className="text-green-300 font-medium">
Полная стоимость за 1 шт:{' '}
{formatCurrency(sc.customPrice / sc.selectedQuantity + additionalCost)}
</div>
</div>
)
}
return null
})()}
</div>
</div>
{/* Услуги */}
<div className="space-y-3">
<div>
<label className="text-white/60 text-sm">Фулфилмент организация:</label>
<Select
value={sc.selectedFulfillmentOrg}
onValueChange={(value) => {
updateCardSelection(sc.card, 'selectedFulfillmentOrg', value)
updateCardSelection(sc.card, 'selectedFulfillmentServices', []) // Сбрасываем услуги
if (value) {
loadOrganizationServices(value) // Автоматически загружаем услуги
}
}}
>
<SelectTrigger className="bg-white/5 border-white/20 text-white mt-1">
<SelectValue placeholder="Выберите фулфилмент" />
</SelectTrigger>
<SelectContent>
{fulfillmentOrgs.map((org: Organization) => (
<SelectItem key={org.id} value={org.id}>
{org.name || org.fullName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{sc.selectedFulfillmentOrg && (
<div>
<label className="text-white/60 text-sm">Услуги фулфилмента:</label>
<div className="mt-2 space-y-2 max-h-32 overflow-y-auto bg-white/5 border border-white/20 rounded p-2">
{organizationServices[sc.selectedFulfillmentOrg] ? (
organizationServices[sc.selectedFulfillmentOrg].length > 0 ? (
organizationServices[sc.selectedFulfillmentOrg].map((service) => {
const isSelected = sc.selectedFulfillmentServices.includes(service.id)
return (
<label
key={service.id}
className="flex items-center space-x-2 cursor-pointer hover:bg-white/5 p-1 rounded"
>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
const newServices = e.target.checked
? [...sc.selectedFulfillmentServices, service.id]
: sc.selectedFulfillmentServices.filter((id) => id !== service.id)
updateCardSelection(
sc.card,
'selectedFulfillmentServices',
newServices,
)
}}
className="rounded border-white/20 bg-white/10 text-purple-500 focus:ring-purple-500"
/>
<span className="text-white text-sm">
{service.name} - {service.price}
</span>
</label>
)
})
) : (
<div className="text-white/60 text-sm text-center py-2">
У данной организации нет услуг
</div>
)
) : (
<div className="text-white/60 text-sm text-center py-2">Загрузка услуг...</div>
)}
</div>
</div>
)}
<div>
<label className="text-white/60 text-sm">Поставщик расходников:</label>
<Select
value={sc.selectedConsumableOrg}
onValueChange={(value) => {
updateCardSelection(sc.card, 'selectedConsumableOrg', value)
updateCardSelection(sc.card, 'selectedConsumableServices', []) // Сбрасываем услуги
if (value) {
loadOrganizationSupplies(value) // Автоматически загружаем расходники
}
}}
>
<SelectTrigger className="bg-white/5 border-white/20 text-white mt-1">
<SelectValue placeholder="Выберите поставщика" />
</SelectTrigger>
<SelectContent>
{consumableOrgs.map((org: Organization) => (
<SelectItem key={org.id} value={org.id}>
{org.name || org.fullName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{sc.selectedConsumableOrg && (
<div>
<label className="text-white/60 text-sm">Расходные материалы:</label>
<div className="mt-2 space-y-2 max-h-32 overflow-y-auto bg-white/5 border border-white/20 rounded p-2">
{organizationSupplies[sc.selectedConsumableOrg] ? (
organizationSupplies[sc.selectedConsumableOrg].length > 0 ? (
organizationSupplies[sc.selectedConsumableOrg].map((supply) => {
const isSelected = sc.selectedConsumableServices.includes(supply.id)
return (
<label
key={supply.id}
className="flex items-center space-x-2 cursor-pointer hover:bg-white/5 p-1 rounded"
>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => {
const newSupplies = e.target.checked
? [...sc.selectedConsumableServices, supply.id]
: sc.selectedConsumableServices.filter((id) => id !== supply.id)
updateCardSelection(
sc.card,
'selectedConsumableServices',
newSupplies,
)
}}
className="rounded border-white/20 bg-white/10 text-green-500 focus:ring-green-500"
/>
<span className="text-white text-sm">
{supply.name} - {supply.price}
</span>
</label>
)
})
) : (
<div className="text-white/60 text-sm text-center py-2">
У данной организации нет расходников
</div>
)
) : (
<div className="text-white/60 text-sm text-center py-2">
Загрузка расходников...
</div>
)}
</div>
</div>
)}
</div>
</div>
<div className="text-right">
<span className="text-white font-bold text-lg">{formatCurrency(sc.customPrice)}</span>
{sc.selectedQuantity > 0 && sc.customPrice > 0 && (
<p className="text-white/60 text-sm">
~{formatCurrency(sc.customPrice / sc.selectedQuantity)} за шт.
</p>
)}
</div>
</div>
</div>
</Card>
)
})}
</div>
<div className="xl:col-span-1">
<Card className="bg-white/10 backdrop-blur border-white/20 p-4 sticky top-4">
<h3 className="text-white font-semibold text-lg mb-4">Итого</h3>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-white/60">Товаров:</span>
<span className="text-white">{getTotalItems()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-white/60">Карточек:</span>
<span className="text-white">{actualSelectedCards.length}</span>
</div>
<div className="border-t border-white/20 pt-3 flex justify-between">
<span className="text-white font-semibold">Общая сумма:</span>
<span className="text-white font-bold text-lg">{formatCurrency(getTotalAmount())}</span>
</div>
<Button
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white"
onClick={handleCreateSupply}
disabled={creatingSupply}
>
<Check className="h-4 w-4 mr-2" />
{creatingSupply ? 'Создание...' : 'Создать поставку'}
</Button>
</div>
</Card>
</div>
</div>
</div>
</main>
</div>
)
}
return (
<div className="space-y-4">
{/* Поиск */}
{/* Поиск товаров и выбор даты поставки */}
<Card className="bg-white/10 backdrop-blur border-white/20 p-3">
<div className="flex space-x-2 items-center">
{/* Поиск */}
<div className="flex-1">
<Input
placeholder="Поиск товаров..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="bg-white/5 border-white/20 text-white placeholder-white/50 h-9"
onKeyPress={(e) => e.key === 'Enter' && searchCards()}
/>
</div>
{/* Выбор даты поставки */}
<div className="w-44">
<Popover>
<PopoverTrigger asChild>
<button className="w-full justify-start text-left font-normal bg-white/5 border border-white/20 text-white hover:bg-white/10 h-9 text-xs px-3 py-2 rounded-md flex items-center transition-colors">
<CalendarIcon className="mr-1 h-3 w-3" />
{globalDeliveryDate ? (
format(globalDeliveryDate, 'dd.MM.yy', { locale: ru })
) : (
<span className="text-white/50">Дата поставки</span>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<DatePicker
selected={globalDeliveryDate}
onChange={(date: Date | null) => setGlobalDeliveryDate(date || undefined)}
minDate={new Date()}
inline
locale="ru"
/>
</PopoverContent>
</Popover>
</div>
{/* Кнопка поиска */}
<Button
onClick={searchCards}
disabled={loading || !searchTerm.trim()}
variant="glass"
size="sm"
className="h-9 px-3"
>
<Search className="h-3 w-3" />
</Button>
</div>
</Card>
{/* Состояние загрузки с красивыми скелетонами */}
{(loading || wbCardsLoading) && <ProductCardSkeletonGrid count={12} />}
{/* Карточки товаров */}
{!loading && !wbCardsLoading && wbCards.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
{wbCards.map((card) => {
const selectedQuantity = getSelectedQuantity(card)
const isSelected = selectedQuantity > 0
const selectedCard = actualSelectedCards.find((sc) => sc.card.nmID === card.nmID)
return (
<Card
key={card.nmID}
className={`bg-white/10 backdrop-blur border-white/20 transition-all hover:scale-105 hover:shadow-2xl group ${isSelected ? 'ring-2 ring-purple-500/50 bg-purple-500/10' : ''} relative overflow-hidden`}
>
<div className="p-2 space-y-2">
{/* Изображение и основная информация */}
<div className="space-y-2">
<div className="relative">
<div className="aspect-square relative bg-white/5 overflow-hidden rounded-lg">
<img
src={WildberriesService.getCardImage(card, 'c516x688') || '/api/placeholder/300/300'}
alt={card.title}
className="w-full h-full object-cover cursor-pointer group-hover:scale-110 transition-transform duration-500"
onClick={() => handleCardClick(card)}
/>
{/* Индикатор товара WB */}
<div className="absolute top-2 right-2">
<Badge className="bg-blue-500/90 text-white border-0 backdrop-blur text-xs font-medium px-2 py-1">
WB
</Badge>
</div>
{/* Индикатор выбранного товара */}
{isSelected && (
<div className="absolute top-2 left-2">
<Badge className="bg-gradient-to-r from-orange-500 to-amber-500 text-white border-0 text-xs font-medium">
<Package className="h-3 w-3 mr-1" />
Подготовлен
</Badge>
</div>
)}
{/* Overlay с кнопкой */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<Button
size="sm"
variant="secondary"
onClick={() => handleCardClick(card)}
className="bg-white/20 backdrop-blur text-white border-white/30 hover:bg-white/30"
>
<Eye className="h-4 w-4 mr-2" />
Подробнее
</Button>
</div>
</div>
</div>
<div className="space-y-2">
{/* Заголовок и бренд */}
<div>
<div className="flex items-center justify-between mb-2">
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 text-xs font-medium">
{card.brand}
</Badge>
<span className="text-white/40 text-xs">{card.nmID}</span>
</div>
<h3
className="text-white font-semibold text-sm mb-1 line-clamp-2 leading-tight cursor-pointer hover:text-purple-300 transition-colors"
onClick={() => handleCardClick(card)}
>
{card.title}
</h3>
</div>
{/* Информация о товаре */}
<div className="pt-2 border-t border-white/10">
<div className="text-center">
<span className="text-white/60 text-xs">Добавьте в поставку для настройки</span>
</div>
</div>
</div>
</div>
{/* Компактное управление */}
<div className="space-y-2 bg-white/5 rounded-lg p-2">
{/* Количество - компактно */}
<div className="flex items-center space-x-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
const newQuantity = Math.max(0, selectedQuantity - 1)
updateCardSelection(card, 'selectedQuantity', newQuantity)
}}
disabled={selectedQuantity <= 0}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10 border border-white/20 disabled:opacity-50 flex-shrink-0"
>
<Minus className="h-2.5 w-2.5" />
</Button>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={selectedQuantity}
onChange={(e) => {
const value = e.target.value.replace(/[^0-9]/g, '')
const numValue = Math.max(0, parseInt(value) || 0)
updateCardSelection(card, 'selectedQuantity', numValue)
}}
onFocus={(e) => e.target.select()}
className="flex-1 h-6 text-center bg-white/10 border border-white/20 text-white text-xs rounded focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-transparent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none min-w-0"
placeholder="0"
/>
<Button
variant="ghost"
size="sm"
onClick={() => {
const newQuantity = selectedQuantity + 1
updateCardSelection(card, 'selectedQuantity', newQuantity)
}}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10 border border-white/20 flex-shrink-0"
>
<Plus className="h-2.5 w-2.5" />
</Button>
</div>
{/* Цена - компактно, показывается только если есть количество */}
{selectedQuantity > 0 && (
<input
type="text"
inputMode="decimal"
value={getSelectedTotalPrice(card) || ''}
onChange={(e) => {
const value = e.target.value.replace(/[^0-9.,]/g, '').replace(',', '.')
const totalPrice = parseFloat(value) || 0
updateCardSelection(card, 'customPrice', totalPrice)
}}
onFocus={(e) => e.target.select()}
className="w-full h-6 text-center bg-white/10 border border-white/20 text-white text-xs rounded focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-transparent"
placeholder={`Цена за ${selectedQuantity} шт`}
/>
)}
{/* Результат - очень компактно */}
{selectedQuantity > 0 && getSelectedTotalPrice(card) > 0 && (
<div className="bg-gradient-to-r from-green-500/20 to-emerald-500/20 border border-green-500/30 rounded p-1.5">
<div className="text-center space-y-0.5">
<div className="text-green-300 text-xs font-medium">
{formatCurrency(getSelectedTotalPrice(card))}
</div>
<div className="text-green-200 text-[10px]">
~{formatCurrency(getSelectedUnitPrice(card))}/шт
</div>
</div>
</div>
)}
{/* Индикатор подготовки к добавлению */}
{selectedQuantity > 0 && (
<div className="flex items-center justify-center">
<Badge className="bg-orange-500/20 text-orange-300 border-orange-500/30 text-[10px] px-2 py-0.5">
<Package className="h-2.5 w-2.5 mr-1" />
Подготовлен
</Badge>
</div>
)}
</div>
</div>
</Card>
)
})}
</div>
)}
{/* Плавающая кнопка "В корзину" для подготовленных товаров */}
{preparingCards.length > 0 && getPreparingTotalItems() > 0 && (
<div className="fixed bottom-6 right-6 z-50">
<Button
onClick={addToCart}
className="bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white shadow-lg h-12 px-6"
>
<ShoppingCart className="h-5 w-5 mr-2" />В корзину ({getPreparingTotalItems()}) {' '}
{formatCurrency(getPreparingTotalAmount())}
</Button>
</div>
)}
{wbCards.length === 0 && !loading && !wbCardsLoading && (
<Card className="bg-white/10 backdrop-blur border-white/20 p-8">
<div className="text-center max-w-md mx-auto">
<Package className="h-12 w-12 text-white/20 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">Нет товаров</h3>
{user?.organization?.apiKeys?.find((key) => key.marketplace === 'WILDBERRIES')?.isActive ? (
<>
<p className="text-white/60 mb-4 text-sm">
Введите запрос в поле поиска, чтобы найти товары в вашем каталоге Wildberries, или загрузите все
доступные карточки
</p>
<Button
onClick={loadAllCards}
className="bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 text-white"
>
<Package className="h-4 w-4 mr-2" />
Загрузить из WB API
</Button>
</>
) : (
<>
<p className="text-white/60 mb-3 text-sm">
Для работы с полным функционалом WB API необходимо настроить API ключ Wildberries
</p>
<p className="text-white/40 text-xs mb-4">Загружены товары из вашего склада</p>
<Button
onClick={loadAllCards}
className="bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 text-white"
>
<Package className="h-4 w-4 mr-2" />
Загрузить товары
</Button>
</>
)}
</div>
</Card>
)}
{/* Модальное окно с детальной информацией о товаре */}
<Dialog open={!!selectedCardForDetails} onOpenChange={(open) => !open && closeDetailsModal()}>
<DialogContent className="glass-card border-white/10 max-w-2xl">
<DialogHeader>
<DialogTitle className="text-white">Информация о товаре</DialogTitle>
</DialogHeader>
{selectedCardForDetails && (
<div className="space-y-4">
{/* Изображение */}
<div className="relative">
<img
src={
WildberriesService.getCardImages(selectedCardForDetails)[currentImageIndex] ||
'/api/placeholder/400/400'
}
alt={selectedCardForDetails.title}
className="w-full aspect-video rounded-lg object-cover"
/>
{/* Навигация по изображениям */}
{WildberriesService.getCardImages(selectedCardForDetails).length > 1 && (
<>
<button
onClick={prevImage}
className="absolute left-3 top-1/2 -translate-y-1/2 bg-black/70 hover:bg-black/90 text-white p-2 rounded-full transition-all"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
onClick={nextImage}
className="absolute right-3 top-1/2 -translate-y-1/2 bg-black/70 hover:bg-black/90 text-white p-2 rounded-full transition-all"
>
<ChevronRight className="h-4 w-4" />
</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">
{currentImageIndex + 1} из {WildberriesService.getCardImages(selectedCardForDetails).length}
</div>
</>
)}
</div>
{/* Основная информация */}
<div className="space-y-3">
<div>
<h3 className="text-white font-semibold text-lg">{selectedCardForDetails.title}</h3>
<p className="text-white/60 text-sm">Артикул WB: {selectedCardForDetails.nmID}</p>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex justify-between">
<span className="text-white/60">Бренд:</span>
<span className="text-white font-medium">{selectedCardForDetails.brand}</span>
</div>
<div className="flex justify-between">
<span className="text-white/60">Категория:</span>
<span className="text-white font-medium">{selectedCardForDetails.object}</span>
</div>
</div>
{selectedCardForDetails.description && (
<div>
<h4 className="text-white font-medium mb-2">Описание</h4>
<p className="text-white/70 text-sm leading-relaxed max-h-32 overflow-y-auto">
{selectedCardForDetails.description}
</p>
</div>
)}
</div>
{/* Миниатюры изображений */}
{WildberriesService.getCardImages(selectedCardForDetails).length > 1 && (
<div className="flex space-x-2 overflow-x-auto pb-2">
{WildberriesService.getCardImages(selectedCardForDetails).map((image, index) => (
<img
key={index}
src={image}
alt={`${selectedCardForDetails.title} ${index + 1}`}
className={`w-16 h-16 rounded-lg object-cover cursor-pointer flex-shrink-0 transition-all ${
index === currentImageIndex ? 'ring-2 ring-purple-500' : 'opacity-60 hover:opacity-100'
}`}
onClick={() => setCurrentImageIndex(index)}
/>
))}
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}