Files
sfera/src/components/supplies/wb-product-cards.tsx

1345 lines
62 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 { 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 { apolloClient } from '@/lib/apollo-client'
import { GET_MY_COUNTERPARTIES, GET_COUNTERPARTY_SERVICES, GET_COUNTERPARTY_SUPPLIES } from '@/graphql/queries'
import { CREATE_WILDBERRIES_SUPPLY } from '@/graphql/mutations'
import { toast } from 'sonner'
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
}
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 [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)
// Моковые товары для демонстрации
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)
// Автоматически загружаем услуги и расходники для уже выбранных организаций
useEffect(() => {
selectedCards.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)
}
})
// Моковые данные рынков
const markets = [
{ value: 'sadovod', label: 'Садовод' },
{ value: 'luzhniki', label: 'Лужники' },
{ value: 'tishinka', label: 'Тишинка' },
{ value: 'food-city', label: 'Фуд Сити' }
]
// Автоматически загружаем товары при открытии компонента
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,
customPrice: 0,
selectedFulfillmentOrg: '',
selectedFulfillmentServices: [],
selectedConsumableOrg: '',
selectedConsumableServices: [],
deliveryDate: '',
selectedMarket: '',
selectedPlace: '',
sellerName: '',
sellerPhone: '',
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)
}
// Функция для получения цены услуги по 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 selectedCards.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 selectedCards.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 && 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,
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 (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 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={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-4">
<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-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>
<label className="text-white/60 text-sm">Дата поставки:</label>
<Input
type="date"
value={sc.deliveryDate}
onChange={(e) => updateCardSelection(sc.card, 'deliveryDate', e.target.value)}
className="bg-white/5 border-white/20 text-white mt-1"
/>
</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="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)
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-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)}
/>
{/* Индикатор товара 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-purple-500 to-pink-500 text-white border-0 text-xs font-medium">
<ShoppingCart 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>
<p className="text-white/60 text-xs mb-2">Артикул: {card.vendorCode}</p>
</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 justify-center mb-1">
<span className="text-white/60 text-xs font-medium">Добавить в поставку:</span>
</div>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => updateCardSelection(card, 'selectedQuantity', Math.max(0, selectedQuantity - 1))}
disabled={selectedQuantity <= 0}
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10 border border-white/20 disabled:opacity-50"
>
<Minus className="h-3 w-3" />
</Button>
<div className="flex-1">
<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="h-8 w-full 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"
placeholder="0"
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => updateCardSelection(card, 'selectedQuantity', selectedQuantity + 1)}
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10 border border-white/20"
>
<Plus className="h-3 w-3" />
</Button>
</div>
{/* Указание что настройки в корзине */}
{selectedQuantity > 0 && (
<div className="bg-gradient-to-r from-blue-500/20 to-purple-500/20 border border-blue-500/30 rounded p-2 mt-2">
<div className="text-center">
<span className="text-blue-300 text-xs">В корзине: {selectedQuantity} шт</span>
<p className="text-blue-200 text-xs mt-1">Настройте цену и услуги в корзине</p>
</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-6xl w-[95vw] max-h-[95vh] bg-black/95 backdrop-blur-xl border border-white/20 p-0 overflow-hidden">
<DialogHeader className="sr-only">
<DialogTitle>Детальная информация о товаре</DialogTitle>
</DialogHeader>
{selectedCardForDetails && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0 h-full max-w-full">
{/* Изображения */}
<div className="p-4 lg:p-6 space-y-4 overflow-hidden">
<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-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-5 w-5" />
</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-5 w-5" />
</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} из {selectedCardForDetails.mediaFiles?.length || 0}
</div>
</>
)}
</div>
{/* Миниатюры изображений */}
{selectedCardForDetails.mediaFiles?.length > 1 && (
<div className="w-full overflow-hidden">
<div className="flex space-x-2 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent">
{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 transition-all ${
index === currentImageIndex ? 'ring-2 ring-purple-500' : 'opacity-60 hover:opacity-100'
}`}
onClick={() => setCurrentImageIndex(index)}
/>
))}
</div>
</div>
)}
</div>
{/* Информация о товаре */}
<div className="p-4 lg:p-6 overflow-y-auto max-h-[95vh] space-y-4 max-w-full">
<div>
<h2 className="text-xl font-bold text-white mb-2 leading-tight">{selectedCardForDetails.title}</h2>
<p className="text-white/60 mb-3 text-sm">Артикул: {selectedCardForDetails.vendorCode}</p>
<div className="grid grid-cols-1 gap-3 text-sm bg-white/5 rounded-lg p-4">
<div className="flex justify-between items-start gap-2">
<span className="text-white/60 flex-shrink-0">Бренд:</span>
<span className="text-white font-medium text-right break-words">{selectedCardForDetails.brand}</span>
</div>
<div className="flex justify-between items-start gap-2">
<span className="text-white/60 flex-shrink-0">Категория:</span>
<span className="text-white font-medium text-right break-words">{selectedCardForDetails.object}</span>
</div>
<div className="flex justify-between items-start gap-2">
<span className="text-white/60 flex-shrink-0">Родительская:</span>
<span className="text-white font-medium text-right break-words">{selectedCardForDetails.parent}</span>
</div>
<div className="flex justify-between items-start gap-2">
<span className="text-white/60 flex-shrink-0">Страна:</span>
<span className="text-white font-medium text-right break-words">{selectedCardForDetails.countryProduction}</span>
</div>
</div>
</div>
{selectedCardForDetails.description && (
<div>
<h3 className="text-white font-semibold mb-2 text-lg">Описание</h3>
<div className="bg-white/5 rounded-lg p-4 max-h-40 overflow-y-auto scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent">
<p className="text-white/80 text-sm leading-relaxed whitespace-pre-wrap break-words">
{selectedCardForDetails.description}
</p>
</div>
</div>
)}
{/* Размеры и цены */}
<div>
<h3 className="text-white font-semibold mb-3 text-lg">Доступные варианты</h3>
<div className="space-y-2">
{selectedCardForDetails.sizes.map((size) => (
<div key={size.chrtID} className="bg-white/5 border border-white/10 rounded-lg p-4">
<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/30 text-green-200' : size.quantity > 0 ? 'bg-yellow-500/30 text-yellow-200' : 'bg-red-500/30 text-red-200'} font-medium`}>
{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 text-base">{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 sticky bottom-0 bg-black/50 backdrop-blur">
<Button
className="flex-1 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white font-medium"
onClick={() => {
const currentQuantity = getSelectedQuantity(selectedCardForDetails)
updateCardSelection(selectedCardForDetails, 'selectedQuantity', currentQuantity + 1)
closeDetailsModal()
}}
>
<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>
)
}