1139 lines
47 KiB
TypeScript
1139 lines
47 KiB
TypeScript
"use client"
|
||
|
||
import React, { useState, useEffect } from 'react'
|
||
import { Card } from '@/components/ui/card'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||
import { useSidebar } from '@/hooks/useSidebar'
|
||
import {
|
||
Search,
|
||
Plus,
|
||
Minus,
|
||
ShoppingCart,
|
||
Calendar,
|
||
Phone,
|
||
User,
|
||
MapPin,
|
||
Package,
|
||
Wrench,
|
||
ArrowLeft,
|
||
Check,
|
||
Eye,
|
||
ChevronLeft,
|
||
ChevronRight
|
||
} from 'lucide-react'
|
||
import { WildberriesService } from '@/services/wildberries-service'
|
||
import { useAuth } from '@/hooks/useAuth'
|
||
import { useQuery, useMutation } from '@apollo/client'
|
||
import { GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
||
import { CREATE_WILDBERRIES_SUPPLY } from '@/graphql/mutations'
|
||
import { toast } from 'sonner'
|
||
|
||
interface WildberriesCard {
|
||
nmID: number
|
||
vendorCode: string
|
||
sizes: Array<{
|
||
chrtID: number
|
||
techSize: string
|
||
wbSize: string
|
||
price: number
|
||
discountedPrice: number
|
||
quantity: number
|
||
}>
|
||
mediaFiles: string[]
|
||
object: string
|
||
parent: string
|
||
countryProduction: string
|
||
supplierVendorCode: string
|
||
brand: string
|
||
title: string
|
||
description: string
|
||
}
|
||
|
||
interface SelectedCard {
|
||
card: WildberriesCard
|
||
selectedQuantity: number
|
||
selectedMarket: string
|
||
selectedPlace: string
|
||
sellerName: string
|
||
sellerPhone: string
|
||
deliveryDate: string
|
||
selectedServices: string[]
|
||
}
|
||
|
||
interface FulfillmentService {
|
||
id: string
|
||
name: string
|
||
description?: string
|
||
price: number
|
||
organizationName: string
|
||
}
|
||
|
||
interface WBProductCardsProps {
|
||
onBack: () => void
|
||
onComplete: (selectedCards: SelectedCard[]) => void
|
||
}
|
||
|
||
export function WBProductCards({ onBack, onComplete }: 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 [showSummary, setShowSummary] = useState(false)
|
||
const [fulfillmentServices, setFulfillmentServices] = useState<FulfillmentService[]>([])
|
||
const [selectedCardForDetails, setSelectedCardForDetails] = useState<WildberriesCard | null>(null)
|
||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
||
|
||
// Моковые товары для демонстрации
|
||
const getMockCards = (): WildberriesCard[] => [
|
||
{
|
||
nmID: 123456789,
|
||
vendorCode: 'SKU001',
|
||
title: 'Смартфон Samsung Galaxy A54',
|
||
description: 'Современный смартфон с отличной камерой и долгим временем автономной работы',
|
||
brand: 'Samsung',
|
||
object: 'Смартфоны',
|
||
parent: 'Электроника',
|
||
countryProduction: 'Корея',
|
||
supplierVendorCode: 'SUPPLIER-001',
|
||
mediaFiles: ['/api/placeholder/400/400', '/api/placeholder/400/401', '/api/placeholder/400/402'],
|
||
sizes: [
|
||
{
|
||
chrtID: 123456,
|
||
techSize: '128GB',
|
||
wbSize: '128GB Черный',
|
||
price: 25990,
|
||
discountedPrice: 22990,
|
||
quantity: 15
|
||
}
|
||
]
|
||
},
|
||
{
|
||
nmID: 987654321,
|
||
vendorCode: 'SKU002',
|
||
title: 'Наушники Apple AirPods Pro',
|
||
description: 'Беспроводные наушники с активным шумоподавлением и пространственным звуком',
|
||
brand: 'Apple',
|
||
object: 'Наушники',
|
||
parent: 'Электроника',
|
||
countryProduction: 'Китай',
|
||
supplierVendorCode: 'SUPPLIER-002',
|
||
mediaFiles: ['/api/placeholder/400/403', '/api/placeholder/400/404'],
|
||
sizes: [
|
||
{
|
||
chrtID: 987654,
|
||
techSize: 'Standard',
|
||
wbSize: 'Белый',
|
||
price: 24990,
|
||
discountedPrice: 19990,
|
||
quantity: 8
|
||
}
|
||
]
|
||
},
|
||
{
|
||
nmID: 555666777,
|
||
vendorCode: 'SKU003',
|
||
title: 'Кроссовки Nike Air Max 270',
|
||
description: 'Спортивные кроссовки с современным дизайном и комфортной посадкой',
|
||
brand: 'Nike',
|
||
object: 'Кроссовки',
|
||
parent: 'Обувь',
|
||
countryProduction: 'Вьетнам',
|
||
supplierVendorCode: 'SUPPLIER-003',
|
||
mediaFiles: ['/api/placeholder/400/405', '/api/placeholder/400/406', '/api/placeholder/400/407'],
|
||
sizes: [
|
||
{
|
||
chrtID: 555666,
|
||
techSize: '42',
|
||
wbSize: '42 EU',
|
||
price: 12990,
|
||
discountedPrice: 9990,
|
||
quantity: 25
|
||
},
|
||
{
|
||
chrtID: 555667,
|
||
techSize: '43',
|
||
wbSize: '43 EU',
|
||
price: 12990,
|
||
discountedPrice: 9990,
|
||
quantity: 20
|
||
}
|
||
]
|
||
},
|
||
{
|
||
nmID: 444333222,
|
||
vendorCode: 'SKU004',
|
||
title: 'Футболка Adidas Originals',
|
||
description: 'Классическая футболка из органического хлопка с логотипом бренда',
|
||
brand: 'Adidas',
|
||
object: 'Футболки',
|
||
parent: 'Одежда',
|
||
countryProduction: 'Бангладеш',
|
||
supplierVendorCode: 'SUPPLIER-004',
|
||
mediaFiles: ['/api/placeholder/400/408', '/api/placeholder/400/409'],
|
||
sizes: [
|
||
{
|
||
chrtID: 444333,
|
||
techSize: 'M',
|
||
wbSize: 'M',
|
||
price: 2990,
|
||
discountedPrice: 2490,
|
||
quantity: 50
|
||
},
|
||
{
|
||
chrtID: 444334,
|
||
techSize: 'L',
|
||
wbSize: 'L',
|
||
price: 2990,
|
||
discountedPrice: 2490,
|
||
quantity: 45
|
||
},
|
||
{
|
||
chrtID: 444335,
|
||
techSize: 'XL',
|
||
wbSize: 'XL',
|
||
price: 2990,
|
||
discountedPrice: 2490,
|
||
quantity: 30
|
||
}
|
||
]
|
||
},
|
||
{
|
||
nmID: 111222333,
|
||
vendorCode: 'SKU005',
|
||
title: 'Рюкзак для ноутбука Xiaomi',
|
||
description: 'Стильный и функциональный рюкзак для ноутбука до 15.6 дюймов',
|
||
brand: 'Xiaomi',
|
||
object: 'Рюкзаки',
|
||
parent: 'Аксессуары',
|
||
countryProduction: 'Китай',
|
||
supplierVendorCode: 'SUPPLIER-005',
|
||
mediaFiles: ['/api/placeholder/400/410'],
|
||
sizes: [
|
||
{
|
||
chrtID: 111222,
|
||
techSize: '15.6"',
|
||
wbSize: 'Черный',
|
||
price: 4990,
|
||
discountedPrice: 3990,
|
||
quantity: 35
|
||
}
|
||
]
|
||
},
|
||
{
|
||
nmID: 777888999,
|
||
vendorCode: 'SKU006',
|
||
title: 'Умные часы Apple Watch Series 9',
|
||
description: 'Новейшие умные часы с передовыми функциями здоровья и фитнеса',
|
||
brand: 'Apple',
|
||
object: 'Умные часы',
|
||
parent: 'Электроника',
|
||
countryProduction: 'Китай',
|
||
supplierVendorCode: 'SUPPLIER-006',
|
||
mediaFiles: ['/api/placeholder/400/411', '/api/placeholder/400/412', '/api/placeholder/400/413'],
|
||
sizes: [
|
||
{
|
||
chrtID: 777888,
|
||
techSize: '41mm',
|
||
wbSize: '41mm GPS',
|
||
price: 39990,
|
||
discountedPrice: 35990,
|
||
quantity: 12
|
||
},
|
||
{
|
||
chrtID: 777889,
|
||
techSize: '45mm',
|
||
wbSize: '45mm GPS',
|
||
price: 42990,
|
||
discountedPrice: 38990,
|
||
quantity: 8
|
||
}
|
||
]
|
||
}
|
||
]
|
||
|
||
// Загружаем контрагентов-фулфилментов
|
||
const { data: counterpartiesData } = useQuery(GET_MY_COUNTERPARTIES)
|
||
|
||
// Мутация для создания поставки
|
||
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)
|
||
}
|
||
})
|
||
|
||
// Моковые данные рынков
|
||
const markets = [
|
||
{ value: 'sadovod', label: 'Садовод' },
|
||
{ value: 'luzhniki', label: 'Лужники' },
|
||
{ value: 'tishinka', label: 'Тишинка' },
|
||
{ value: 'food-city', label: 'Фуд Сити' }
|
||
]
|
||
|
||
useEffect(() => {
|
||
// Загружаем услуги фулфилмента из контрагентов
|
||
if (counterpartiesData?.myCounterparties) {
|
||
interface Organization {
|
||
id: string
|
||
name?: string
|
||
fullName?: string
|
||
type: string
|
||
}
|
||
|
||
const fulfillmentOrganizations = counterpartiesData.myCounterparties.filter(
|
||
(org: Organization) => org.type === 'FULFILLMENT'
|
||
)
|
||
|
||
// В реальном приложении здесь был бы запрос услуг для каждой организации
|
||
const mockServices: FulfillmentService[] = fulfillmentOrganizations.flatMap((org: Organization) => [
|
||
{
|
||
id: `${org.id}-packaging`,
|
||
name: 'Упаковка товаров',
|
||
description: 'Профессиональная упаковка товаров',
|
||
price: 50,
|
||
organizationName: org.name || org.fullName
|
||
},
|
||
{
|
||
id: `${org.id}-labeling`,
|
||
name: 'Маркировка товаров',
|
||
description: 'Нанесение этикеток и штрих-кодов',
|
||
price: 30,
|
||
organizationName: org.name || org.fullName
|
||
},
|
||
{
|
||
id: `${org.id}-quality-check`,
|
||
name: 'Контроль качества',
|
||
description: 'Проверка качества товаров',
|
||
price: 100,
|
||
organizationName: org.name || org.fullName
|
||
}
|
||
])
|
||
|
||
setFulfillmentServices(mockServices)
|
||
}
|
||
}, [counterpartiesData])
|
||
|
||
// Автоматически загружаем товары при открытии компонента
|
||
useEffect(() => {
|
||
const loadCards = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const wbApiKey = user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')
|
||
|
||
console.log('WB API Key found:', !!wbApiKey)
|
||
console.log('WB API Key active:', wbApiKey?.isActive)
|
||
console.log('WB API Key validationData:', wbApiKey?.validationData)
|
||
|
||
if (wbApiKey?.isActive) {
|
||
// Попытка загрузить реальные данные из API Wildberries
|
||
const validationData = wbApiKey.validationData as Record<string, string>
|
||
|
||
// API ключ может храниться в разных местах
|
||
const apiToken = validationData?.token ||
|
||
validationData?.apiKey ||
|
||
validationData?.key ||
|
||
(wbApiKey as { apiKey?: string }).apiKey // Прямое поле apiKey из базы
|
||
|
||
console.log('API Token extracted:', !!apiToken)
|
||
console.log('API Token length:', apiToken?.length)
|
||
|
||
if (apiToken) {
|
||
console.log('Загружаем карточки из WB API...')
|
||
const cards = await WildberriesService.getAllCards(apiToken, 50)
|
||
setWbCards(cards)
|
||
console.log('Загружено карточек из WB API:', cards.length)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Если API ключ не настроен, оставляем пустое состояние
|
||
console.log('API ключ WB не настроен, показываем пустое состояние')
|
||
setWbCards([])
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки карточек WB:', error)
|
||
// При ошибке API показываем пустое состояние
|
||
setWbCards([])
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
loadCards()
|
||
}, [user])
|
||
|
||
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.log('Загружаем все карточки из WB API...')
|
||
const cards = await WildberriesService.getAllCards(apiToken, 100)
|
||
setWbCards(cards)
|
||
console.log('Загружено карточек из WB API:', cards.length)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Если API ключ не настроен, загружаем моковые данные
|
||
console.log('API ключ WB не настроен, загружаем моковые данные')
|
||
const allCards = getMockCards()
|
||
setWbCards(allCards)
|
||
console.log('Загружены моковые товары:', allCards.length)
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки всех карточек WB:', error)
|
||
// При ошибке загружаем моковые данные
|
||
const allCards = getMockCards()
|
||
setWbCards(allCards)
|
||
console.log('Загружены моковые товары (fallback):', allCards.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.log('Поиск в WB API:', searchTerm)
|
||
const cards = await WildberriesService.searchCards(apiToken, searchTerm, 50)
|
||
setWbCards(cards)
|
||
console.log('Найдено карточек в WB API:', cards.length)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Если API ключ не настроен, ищем в моковых данных
|
||
console.log('API ключ WB не настроен, поиск в моковых данных:', searchTerm)
|
||
const mockCards = getMockCards()
|
||
|
||
// Фильтруем товары по поисковому запросу
|
||
const filteredCards = mockCards.filter(card =>
|
||
card.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
card.brand.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
card.vendorCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
card.object.toLowerCase().includes(searchTerm.toLowerCase())
|
||
)
|
||
|
||
setWbCards(filteredCards)
|
||
console.log('Найдено моковых товаров:', filteredCards.length)
|
||
} catch (error) {
|
||
console.error('Ошибка поиска карточек WB:', error)
|
||
// При ошибке ищем в моковых данных
|
||
const mockCards = getMockCards()
|
||
const filteredCards = mockCards.filter(card =>
|
||
card.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
card.brand.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
card.vendorCode.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
card.object.toLowerCase().includes(searchTerm.toLowerCase())
|
||
)
|
||
setWbCards(filteredCards)
|
||
console.log('Найдено моковых товаров (fallback):', filteredCards.length)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const updateCardSelection = (card: WildberriesCard, field: keyof SelectedCard, value: string | number | string[]) => {
|
||
setSelectedCards(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) {
|
||
return prev.map(sc =>
|
||
sc.card.nmID === card.nmID
|
||
? { ...sc, [field]: value }
|
||
: sc
|
||
)
|
||
} else if (field === 'selectedQuantity' && typeof value === 'number' && value > 0) {
|
||
const newSelectedCard: SelectedCard = {
|
||
card,
|
||
selectedQuantity: value as number,
|
||
selectedMarket: '',
|
||
selectedPlace: '',
|
||
sellerName: '',
|
||
sellerPhone: '',
|
||
deliveryDate: '',
|
||
selectedServices: []
|
||
}
|
||
return [...prev, newSelectedCard]
|
||
}
|
||
|
||
return prev
|
||
})
|
||
}
|
||
|
||
const getSelectedQuantity = (card: WildberriesCard): number => {
|
||
const selected = selectedCards.find(sc => sc.card.nmID === card.nmID)
|
||
return selected ? selected.selectedQuantity : 0
|
||
}
|
||
|
||
const formatCurrency = (amount: number) => {
|
||
return new Intl.NumberFormat('ru-RU', {
|
||
style: 'currency',
|
||
currency: 'RUB',
|
||
minimumFractionDigits: 0
|
||
}).format(amount)
|
||
}
|
||
|
||
const getTotalAmount = () => {
|
||
return selectedCards.reduce((sum, sc) => {
|
||
const cardPrice = sc.card.sizes[0]?.discountedPrice || sc.card.sizes[0]?.price || 0
|
||
const servicesPrice = sc.selectedServices.reduce((serviceSum, serviceId) => {
|
||
const service = fulfillmentServices.find(s => s.id === serviceId)
|
||
return serviceSum + (service?.price || 0)
|
||
}, 0)
|
||
return sum + (cardPrice + servicesPrice) * sc.selectedQuantity
|
||
}, 0)
|
||
}
|
||
|
||
const getTotalItems = () => {
|
||
return selectedCards.reduce((sum, sc) => sum + sc.selectedQuantity, 0)
|
||
}
|
||
|
||
const applyServicesToAll = (serviceIds: string[]) => {
|
||
setSelectedCards(prev =>
|
||
prev.map(sc => ({ ...sc, selectedServices: serviceIds }))
|
||
)
|
||
}
|
||
|
||
const handleCardClick = (card: WildberriesCard) => {
|
||
setSelectedCardForDetails(card)
|
||
setCurrentImageIndex(0)
|
||
}
|
||
|
||
const closeDetailsModal = () => {
|
||
setSelectedCardForDetails(null)
|
||
setCurrentImageIndex(0)
|
||
}
|
||
|
||
const nextImage = () => {
|
||
if (selectedCardForDetails && selectedCardForDetails.mediaFiles?.length > 1) {
|
||
setCurrentImageIndex((prev) => (prev + 1) % selectedCardForDetails.mediaFiles.length)
|
||
}
|
||
}
|
||
|
||
const prevImage = () => {
|
||
if (selectedCardForDetails && selectedCardForDetails.mediaFiles?.length > 1) {
|
||
setCurrentImageIndex((prev) => (prev - 1 + selectedCardForDetails.mediaFiles.length) % selectedCardForDetails.mediaFiles.length)
|
||
}
|
||
}
|
||
|
||
const handleCreateSupply = async () => {
|
||
try {
|
||
const supplyInput = {
|
||
deliveryDate: selectedCards[0]?.deliveryDate || null,
|
||
cards: selectedCards.map(sc => ({
|
||
nmId: sc.card.nmID.toString(),
|
||
vendorCode: sc.card.vendorCode,
|
||
title: sc.card.title,
|
||
brand: sc.card.brand,
|
||
price: sc.card.sizes[0]?.price || 0,
|
||
discountedPrice: sc.card.sizes[0]?.discountedPrice || null,
|
||
quantity: sc.card.sizes[0]?.quantity || 0,
|
||
selectedQuantity: sc.selectedQuantity,
|
||
selectedMarket: sc.selectedMarket,
|
||
selectedPlace: sc.selectedPlace,
|
||
sellerName: sc.sellerName,
|
||
sellerPhone: sc.sellerPhone,
|
||
deliveryDate: sc.deliveryDate || null,
|
||
mediaFiles: sc.card.mediaFiles,
|
||
selectedServices: sc.selectedServices
|
||
}))
|
||
}
|
||
|
||
await createSupply({ variables: { input: supplyInput } })
|
||
} catch (error) {
|
||
console.error('Error creating supply:', error)
|
||
}
|
||
}
|
||
|
||
if (showSummary) {
|
||
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={() => setShowSummary(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">Проверьте данные перед созданием поставки</p>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={onBack}
|
||
className="text-white/60 hover:text-white hover:bg-white/10"
|
||
>
|
||
Отмена
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
<div className="lg:col-span-2 space-y-4">
|
||
{selectedCards.map((sc) => {
|
||
const cardPrice = sc.card.sizes[0]?.discountedPrice || sc.card.sizes[0]?.price || 0
|
||
const servicesPrice = sc.selectedServices.reduce((sum, serviceId) => {
|
||
const service = fulfillmentServices.find(s => s.id === serviceId)
|
||
return sum + (service?.price || 0)
|
||
}, 0)
|
||
const totalPrice = (cardPrice + servicesPrice) * sc.selectedQuantity
|
||
|
||
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={sc.card.mediaFiles?.[0] || '/api/placeholder/120/120'}
|
||
alt={sc.card.title}
|
||
className="w-20 h-20 rounded-lg object-cover"
|
||
/>
|
||
<div className="flex-1 space-y-3">
|
||
<div>
|
||
<h3 className="text-white font-medium">{sc.card.title}</h3>
|
||
<p className="text-white/60 text-sm">{sc.card.vendorCode}</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
|
||
<div>
|
||
<span className="text-white/60">Количество:</span>
|
||
<span className="text-white ml-2">{sc.selectedQuantity}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-white/60">Рынок:</span>
|
||
<span className="text-white ml-2">{markets.find(m => m.value === sc.selectedMarket)?.label || 'Не выбран'}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-white/60">Место:</span>
|
||
<span className="text-white ml-2">{sc.selectedPlace || 'Не указано'}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-white/60">Продавец:</span>
|
||
<span className="text-white ml-2">{sc.sellerName || 'Не указан'}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-white/60">Телефон:</span>
|
||
<span className="text-white ml-2">{sc.sellerPhone || 'Не указан'}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-white/60">Дата поставки:</span>
|
||
<span className="text-white ml-2">{sc.deliveryDate || 'Не выбрана'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{sc.selectedServices.length > 0 && (
|
||
<div>
|
||
<p className="text-white/60 text-sm mb-2">Услуги:</p>
|
||
<div className="flex flex-wrap gap-1">
|
||
{sc.selectedServices.map(serviceId => {
|
||
const service = fulfillmentServices.find(s => s.id === serviceId)
|
||
return service ? (
|
||
<Badge key={serviceId} className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs">
|
||
{service.name} ({formatCurrency(service.price)})
|
||
</Badge>
|
||
) : null
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="text-right">
|
||
<span className="text-white font-bold text-lg">{formatCurrency(totalPrice)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="lg:col-span-1">
|
||
<Card className="bg-white/10 backdrop-blur border-white/20 p-6 sticky top-4">
|
||
<h3 className="text-white font-semibold text-lg mb-4">Итого</h3>
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between">
|
||
<span className="text-white/60">Товаров:</span>
|
||
<span className="text-white">{getTotalItems()}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-white/60">Карточек:</span>
|
||
<span className="text-white">{selectedCards.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-xl">{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="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={onBack}
|
||
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">Карточки товаров Wildberries</h2>
|
||
<p className="text-white/60">Найдите и выберите товары для поставки</p>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedCards.length > 0 && (
|
||
<Button
|
||
onClick={() => setShowSummary(true)}
|
||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white"
|
||
>
|
||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||
Корзина ({getTotalItems()})
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Поиск */}
|
||
<Card className="bg-white/10 backdrop-blur border-white/20 p-4">
|
||
<div className="flex space-x-3">
|
||
<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"
|
||
onKeyPress={(e) => e.key === 'Enter' && searchCards()}
|
||
/>
|
||
</div>
|
||
<Button
|
||
onClick={searchCards}
|
||
disabled={loading || !searchTerm.trim()}
|
||
className="bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600"
|
||
>
|
||
<Search className="h-4 w-4 mr-2" />
|
||
{loading ? 'Поиск...' : 'Найти'}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Состояние загрузки */}
|
||
{loading && (
|
||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||
{[...Array(12)].map((_, i) => (
|
||
<Card key={i} className="bg-white/10 backdrop-blur border-white/20 p-3 animate-pulse">
|
||
<div className="space-y-3">
|
||
<div className="bg-white/20 rounded-lg aspect-square w-full"></div>
|
||
<div className="space-y-2">
|
||
<div className="bg-white/20 rounded h-3 w-3/4"></div>
|
||
<div className="bg-white/20 rounded h-3 w-1/2"></div>
|
||
<div className="bg-white/20 rounded h-4 w-2/3"></div>
|
||
</div>
|
||
<div className="bg-white/20 rounded h-7 w-full"></div>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Карточки товаров */}
|
||
{!loading && 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 = selectedCards.find(sc => sc.card.nmID === card.nmID)
|
||
const mainSize = card.sizes[0]
|
||
const maxQuantity = mainSize?.quantity || 0
|
||
const price = mainSize?.discountedPrice || mainSize?.price || 0
|
||
|
||
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' : ''}`}>
|
||
<div className="p-3 space-y-3">
|
||
{/* Изображение и основная информация */}
|
||
<div className="space-y-2">
|
||
<div className="relative">
|
||
<div className="aspect-square relative bg-white/5 overflow-hidden rounded-lg">
|
||
<img
|
||
src={card.mediaFiles?.[0] || '/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)}
|
||
/>
|
||
|
||
{/* Количество в наличии */}
|
||
<div className="absolute top-2 right-2">
|
||
<Badge className={`${maxQuantity > 10 ? 'bg-green-500/80' : maxQuantity > 0 ? 'bg-yellow-500/80' : 'bg-red-500/80'} text-white border-0 backdrop-blur text-xs`}>
|
||
{maxQuantity}
|
||
</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" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{/* Заголовок и бренд */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-1">
|
||
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30 text-xs">
|
||
{card.brand}
|
||
</Badge>
|
||
</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>
|
||
<p className="text-white/60 text-xs mb-1">{card.vendorCode}</p>
|
||
</div>
|
||
|
||
{/* Цена */}
|
||
<div className="pt-1 border-t border-white/10">
|
||
<div className="text-white font-bold text-base">
|
||
{formatCurrency(price)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Управление количеством */}
|
||
<div className="space-y-2">
|
||
<div className="flex items-center space-x-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => updateCardSelection(card, 'selectedQuantity', Math.max(0, selectedQuantity - 1))}
|
||
disabled={selectedQuantity <= 0}
|
||
className="h-7 w-7 p-0 text-white/60 hover:text-white hover:bg-white/10 border border-white/20"
|
||
>
|
||
<Minus className="h-3 w-3" />
|
||
</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, Math.min(maxQuantity, parseInt(value) || 0))
|
||
updateCardSelection(card, 'selectedQuantity', numValue)
|
||
}}
|
||
onFocus={(e) => e.target.select()}
|
||
className="h-7 w-12 text-center bg-white/10 border border-white/20 text-white text-sm rounded focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => updateCardSelection(card, 'selectedQuantity', Math.min(maxQuantity, selectedQuantity + 1))}
|
||
disabled={selectedQuantity >= maxQuantity}
|
||
className="h-7 w-7 p-0 text-white/60 hover:text-white hover:bg-white/10 border border-white/20"
|
||
>
|
||
<Plus className="h-3 w-3" />
|
||
</Button>
|
||
|
||
{selectedQuantity > 0 && (
|
||
<Badge className="bg-gradient-to-r from-purple-500 to-pink-500 text-white border-0 text-xs ml-auto">
|
||
<ShoppingCart className="h-3 w-3 mr-1" />
|
||
{selectedQuantity}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
|
||
{/* Сумма для выбранного товара */}
|
||
{selectedQuantity > 0 && (
|
||
<div className="bg-gradient-to-r from-green-500/20 to-emerald-500/20 border border-green-500/30 rounded p-1">
|
||
<div className="text-green-300 text-xs font-medium text-center">
|
||
{formatCurrency(price * selectedQuantity)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
|
||
</div>
|
||
</Card>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Плавающая корзина */}
|
||
{selectedCards.length > 0 && !showSummary && (
|
||
<div className="fixed bottom-6 right-6 z-50">
|
||
<Button
|
||
onClick={() => setShowSummary(true)}
|
||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white shadow-lg h-12 px-6"
|
||
>
|
||
<ShoppingCart className="h-5 w-5 mr-2" />
|
||
Корзина ({getTotalItems()}) • {formatCurrency(getTotalAmount())}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{wbCards.length === 0 && !loading && (
|
||
<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">Карточки товаров Wildberries</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">
|
||
Для работы с реальными карточками необходимо настроить 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="max-w-4xl max-h-[90vh] bg-black/90 backdrop-blur-xl border border-white/20 p-0">
|
||
<DialogHeader className="sr-only">
|
||
<DialogTitle>Детальная информация о товаре</DialogTitle>
|
||
</DialogHeader>
|
||
{selectedCardForDetails && (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 p-6">
|
||
{/* Изображения */}
|
||
<div className="space-y-4">
|
||
<div className="relative">
|
||
<img
|
||
src={selectedCardForDetails.mediaFiles?.[currentImageIndex] || '/api/placeholder/400/400'}
|
||
alt={selectedCardForDetails.title}
|
||
className="w-full aspect-square rounded-lg object-cover"
|
||
/>
|
||
|
||
{/* Навигация по изображениям */}
|
||
{selectedCardForDetails.mediaFiles?.length > 1 && (
|
||
<>
|
||
<button
|
||
onClick={prevImage}
|
||
className="absolute left-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-3 rounded-full"
|
||
>
|
||
<ChevronLeft className="h-6 w-6" />
|
||
</button>
|
||
<button
|
||
onClick={nextImage}
|
||
className="absolute right-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-3 rounded-full"
|
||
>
|
||
<ChevronRight className="h-6 w-6" />
|
||
</button>
|
||
|
||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-black/50 px-3 py-1 rounded-full text-white text-sm">
|
||
{currentImageIndex + 1} из {selectedCardForDetails.mediaFiles?.length || 0}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Миниатюры изображений */}
|
||
{selectedCardForDetails.mediaFiles?.length > 1 && (
|
||
<div className="flex space-x-2 overflow-x-auto">
|
||
{selectedCardForDetails.mediaFiles?.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 ${
|
||
index === currentImageIndex ? 'ring-2 ring-purple-500' : 'opacity-60 hover:opacity-100'
|
||
}`}
|
||
onClick={() => setCurrentImageIndex(index)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Информация о товаре */}
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-white mb-2">{selectedCardForDetails.title}</h2>
|
||
<p className="text-white/60 mb-4">Артикул: {selectedCardForDetails.vendorCode}</p>
|
||
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<span className="text-white/60">Бренд:</span>
|
||
<span className="text-white ml-2">{selectedCardForDetails.brand}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-white/60">Категория:</span>
|
||
<span className="text-white ml-2">{selectedCardForDetails.object}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-white/60">Родительская категория:</span>
|
||
<span className="text-white ml-2">{selectedCardForDetails.parent}</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-white/60">Страна производства:</span>
|
||
<span className="text-white ml-2">{selectedCardForDetails.countryProduction}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedCardForDetails.description && (
|
||
<div>
|
||
<h3 className="text-white font-semibold mb-2">Описание</h3>
|
||
<p className="text-white/80 text-sm leading-relaxed">{selectedCardForDetails.description}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Размеры и цены */}
|
||
<div>
|
||
<h3 className="text-white font-semibold mb-3">Доступные варианты</h3>
|
||
<div className="space-y-2">
|
||
{selectedCardForDetails.sizes.map((size) => (
|
||
<div key={size.chrtID} className="bg-white/5 rounded-lg p-3">
|
||
<div className="flex justify-between items-center mb-2">
|
||
<span className="text-white font-medium">{size.wbSize}</span>
|
||
<Badge className={`${size.quantity > 10 ? 'bg-green-500/20 text-green-300' : size.quantity > 0 ? 'bg-yellow-500/20 text-yellow-300' : 'bg-red-500/20 text-red-300'}`}>
|
||
{size.quantity} шт.
|
||
</Badge>
|
||
</div>
|
||
<div className="flex justify-between items-center text-sm">
|
||
<span className="text-white/60">Размер: {size.techSize}</span>
|
||
<div className="text-right">
|
||
<div className="text-white font-bold">{formatCurrency(size.discountedPrice || size.price)}</div>
|
||
{size.discountedPrice && size.discountedPrice < size.price && (
|
||
<div className="text-white/40 line-through text-xs">{formatCurrency(size.price)}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Кнопки действий в модальном окне */}
|
||
<div className="flex gap-3 pt-4 border-t border-white/20">
|
||
<Button
|
||
className="flex-1 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white"
|
||
onClick={() => {
|
||
const currentQuantity = getSelectedQuantity(selectedCardForDetails)
|
||
updateCardSelection(selectedCardForDetails, 'selectedQuantity', currentQuantity + 1)
|
||
}}
|
||
>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
Добавить в корзину
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
className="border-white/20 text-white hover:bg-white/10"
|
||
onClick={closeDetailsModal}
|
||
>
|
||
Закрыть
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|