From d3fb590c6e1396ee4f97f76e3f3ecd163ab1e808 Mon Sep 17 00:00:00 2001 From: Bivekich Date: Mon, 21 Jul 2025 13:51:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=8B=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D1=8B=20=D1=81=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B0=D0=BC=D0=B8=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=20Wildberries:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=84?= =?UTF-8?q?=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=B5=D0=BA,=20=D1=83=D0=BB=D1=83=D1=87=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D1=85=20=D0=B4=D0=B5=D1=82=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9.=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BF=D1=80=D0=BE=D0=B1=D0=BB=D0=B5=D0=BC=D1=8B?= =?UTF-8?q?=20=D1=81=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=D0=BC=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0?= =?UTF-8?q?=20=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B2=20GraphQL=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D1=8B=20=D1=81=20API=20=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B0=D0=BC=D0=B8.=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D0=B2=D0=B7=D0=B0=D0=B8=D0=BC=D0=BE=D0=B4=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=B8=D0=B8=20=D1=81=20API.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/placeholder/[...params]/route.ts | 50 ++ .../admin/ui-kit/navigation-demo.tsx | 2 +- .../materials-order-form.tsx | 2 +- .../supplies/create-supply-page.tsx | 47 ++ src/components/supplies/wb-product-cards.tsx | 641 +++++++++++++++--- src/graphql/mutations.ts | 1 + src/graphql/queries.ts | 4 + src/graphql/resolvers.ts | 166 ++--- src/graphql/typedefs.ts | 1 + src/services/wildberries-service.ts | 176 ++--- 10 files changed, 836 insertions(+), 254 deletions(-) create mode 100644 src/app/api/placeholder/[...params]/route.ts diff --git a/src/app/api/placeholder/[...params]/route.ts b/src/app/api/placeholder/[...params]/route.ts new file mode 100644 index 0000000..121c094 --- /dev/null +++ b/src/app/api/placeholder/[...params]/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ params: string[] }> } +) { + try { + const resolvedParams = await params + const [width, height] = resolvedParams.params[0]?.split('/') || ['400', '400'] + const searchParams = request.nextUrl.searchParams + const text = searchParams.get('text') || 'Image' + + // Создаем простое SVG изображение + const svg = ` + + + + ${text} ${width}x${height} + + + ` + + return new NextResponse(svg, { + headers: { + 'Content-Type': 'image/svg+xml', + 'Cache-Control': 'public, max-age=31536000' + } + }) + } catch (error) { + console.error('Placeholder API error:', error) + + // Возвращаем простое SVG в случае ошибки + const svg = ` + + + + No Image + + + ` + + return new NextResponse(svg, { + headers: { + 'Content-Type': 'image/svg+xml' + } + }) + } +} \ No newline at end of file diff --git a/src/components/admin/ui-kit/navigation-demo.tsx b/src/components/admin/ui-kit/navigation-demo.tsx index a43e4af..dd69dec 100644 --- a/src/components/admin/ui-kit/navigation-demo.tsx +++ b/src/components/admin/ui-kit/navigation-demo.tsx @@ -1447,7 +1447,7 @@ export function NavigationDemo() { {/* Load More Pattern */}
-

Паттерн "Загрузить еще"

+

Паттерн "Загрузить еще"

diff --git a/src/components/fulfillment-supplies/materials-supplies/materials-order-form.tsx b/src/components/fulfillment-supplies/materials-supplies/materials-order-form.tsx index d63343a..9277aa1 100644 --- a/src/components/fulfillment-supplies/materials-supplies/materials-order-form.tsx +++ b/src/components/fulfillment-supplies/materials-supplies/materials-order-form.tsx @@ -526,7 +526,7 @@ export function MaterialsOrderForm() { : "Партнеры не найдены"}

- Добавьте партнеров в разделе "Партнеры" + Добавьте партнеров в разделе "Партнеры"

diff --git a/src/components/supplies/create-supply-page.tsx b/src/components/supplies/create-supply-page.tsx index 9e150db..9a2fcf0 100644 --- a/src/components/supplies/create-supply-page.tsx +++ b/src/components/supplies/create-supply-page.tsx @@ -30,6 +30,7 @@ import { } from 'lucide-react' import { useRouter } from 'next/navigation' import Image from 'next/image' +import { WBProductCards } from './wb-product-cards' interface WholesalerForCreation { id: string @@ -72,6 +73,34 @@ interface SelectedProduct extends WholesalerProduct { wholesalerName: string } +interface WildberriesCard { + nmID: number + vendorCode: string + title: string + description: string + brand: string + mediaFiles: string[] + sizes: Array<{ + chrtID: number + techSize: string + wbSize: string + price: number + discountedPrice: number + quantity: number + }> +} + +interface SelectedCard { + card: WildberriesCard + selectedQuantity: number + selectedMarket: string + selectedPlace: string + sellerName: string + sellerPhone: string + deliveryDate: string + selectedServices: string[] +} + // Моковые данные оптовиков const mockWholesalers: WholesalerForCreation[] = [ { @@ -229,6 +258,7 @@ export function CreateSupplyPage() { const [selectedVariant, setSelectedVariant] = useState<'cards' | 'wholesaler' | null>(null) const [selectedWholesaler, setSelectedWholesaler] = useState(null) const [selectedProducts, setSelectedProducts] = useState([]) + const [selectedCards, setSelectedCards] = useState([]) const [showSummary, setShowSummary] = useState(false) const [searchQuery, setSearchQuery] = useState('') @@ -329,6 +359,13 @@ export function CreateSupplyPage() { return selectedProducts.reduce((sum, product) => sum + product.selectedQuantity, 0) } + const handleCardsComplete = (cards: SelectedCard[]) => { + setSelectedCards(cards) + console.log('Карточки товаров выбраны:', cards) + // TODO: Здесь будет создание поставки с данными карточек + router.push('/supplies') + } + const handleCreateSupply = () => { if (selectedVariant === 'cards') { console.log('Создание поставки с карточками Wildberries') @@ -706,6 +743,16 @@ export function CreateSupplyPage() { ) } + // Рендер карточек Wildberries + if (selectedVariant === 'cards') { + return ( + setSelectedVariant(null)} + onComplete={handleCardsComplete} + /> + ) + } + // Рендер выбора оптовиков if (selectedVariant === 'wholesaler') { return ( diff --git a/src/components/supplies/wb-product-cards.tsx b/src/components/supplies/wb-product-cards.tsx index 486675d..874743e 100644 --- a/src/components/supplies/wb-product-cards.tsx +++ b/src/components/supplies/wb-product-cards.tsx @@ -7,6 +7,7 @@ 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 { Search, Plus, @@ -20,7 +21,9 @@ import { Wrench, ArrowLeft, Check, - X + Eye, + ChevronLeft, + ChevronRight } from 'lucide-react' import { WildberriesService } from '@/services/wildberries-service' import { useAuth } from '@/hooks/useAuth' @@ -82,6 +85,176 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) { const [selectedCards, setSelectedCards] = useState([]) const [showSummary, setShowSummary] = useState(false) const [fulfillmentServices, setFulfillmentServices] = useState([]) + const [selectedCardForDetails, setSelectedCardForDetails] = useState(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) @@ -113,12 +286,19 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) { useEffect(() => { // Загружаем услуги фулфилмента из контрагентов if (counterpartiesData?.myCounterparties) { + interface Organization { + id: string + name?: string + fullName?: string + type: string + } + const fulfillmentOrganizations = counterpartiesData.myCounterparties.filter( - (org: any) => org.type === 'FULFILLMENT' + (org: Organization) => org.type === 'FULFILLMENT' ) // В реальном приложении здесь был бы запрос услуг для каждой организации - const mockServices: FulfillmentService[] = fulfillmentOrganizations.flatMap((org: any) => [ + const mockServices: FulfillmentService[] = fulfillmentOrganizations.flatMap((org: Organization) => [ { id: `${org.id}-packaging`, name: 'Упаковка товаров', @@ -146,83 +326,155 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) { } }, [counterpartiesData]) - const searchCards = async () => { - if (!searchTerm.trim()) return - + // Автоматически загружаем товары при открытии компонента + 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 + + // 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) { - throw new Error('API ключ Wildberries не настроен') - } - - const validationData = wbApiKey.validationData as Record - const apiToken = validationData?.token || validationData?.apiKey - if (!apiToken) { - throw new Error('API токен не найден') - } - - const cards = await WildberriesService.searchCards(apiToken, searchTerm) - setWbCards(cards) - } catch (error) { - console.error('Ошибка поиска карточек:', error) - // Для демо загрузим моковые данные - setWbCards([ - { - nmID: 123456789, - vendorCode: 'SKU001', - title: 'Смартфон Samsung Galaxy A54', - description: 'Современный смартфон с отличной камерой', - brand: 'Samsung', - object: 'Смартфоны', - parent: 'Электроника', - countryProduction: 'Корея', - supplierVendorCode: 'SUPPLIER-001', - mediaFiles: ['/api/placeholder/300/300'], - sizes: [ - { - chrtID: 123456, - techSize: '128GB', - wbSize: '128GB Черный', - price: 25990, - discountedPrice: 22990, - quantity: 10 - } - ] - }, - { - nmID: 987654321, - vendorCode: 'SKU002', - title: 'Наушники Apple AirPods Pro', - description: 'Беспроводные наушники с шумоподавлением', - brand: 'Apple', - object: 'Наушники', - parent: 'Электроника', - countryProduction: 'Китай', - supplierVendorCode: 'SUPPLIER-002', - mediaFiles: ['/api/placeholder/300/300'], - sizes: [ - { - chrtID: 987654, - techSize: 'Standart', - wbSize: 'Белый', - price: 24990, - discountedPrice: 19990, - quantity: 5 - } - ] + + if (wbApiKey?.isActive) { + // Попытка загрузить реальные данные из API Wildberries + const validationData = wbApiKey.validationData as Record + 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 updateCardSelection = (card: WildberriesCard, field: keyof SelectedCard, value: any) => { + 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 + 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' && value === 0) { + if (field === 'selectedQuantity' && typeof value === 'number' && value === 0) { return prev.filter(sc => sc.card.nmID !== card.nmID) } @@ -232,10 +484,10 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) { ? { ...sc, [field]: value } : sc ) - } else if (field === 'selectedQuantity' && value > 0) { + } else if (field === 'selectedQuantity' && typeof value === 'number' && value > 0) { const newSelectedCard: SelectedCard = { card, - selectedQuantity: value, + selectedQuantity: value as number, selectedMarket: '', selectedPlace: '', sellerName: '', @@ -284,6 +536,28 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) { ) } + 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 = { @@ -356,7 +630,7 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
{sc.card.title} @@ -504,8 +778,26 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
+ {/* Состояние загрузки */} + {loading && ( +
+ {[...Array(6)].map((_, i) => ( + +
+
+
+
+
+
+
+
+
+ ))} +
+ )} + {/* Карточки товаров */} - {wbCards.length > 0 && ( + {!loading && wbCards.length > 0 && (
{wbCards.map((card) => { const selectedQuantity = getSelectedQuantity(card) @@ -520,13 +812,28 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
{/* Изображение и основная информация */}
- {card.title} +
+ {card.title} handleCardClick(card)} + /> + {/* Кнопка "Подробнее" при наведении */} +
+ +
+
-

{card.title}

+

handleCardClick(card)}>{card.title}

{card.vendorCode}

{formatCurrency(price)} @@ -718,13 +1025,183 @@ export function WBProductCards({ onBack, onComplete }: WBProductCardsProps) {
-

Поиск товаров

-

- Введите запрос в поле поиска, чтобы найти товары в вашем каталоге Wildberries -

+

Карточки товаров Wildberries

+ {user?.organization?.apiKeys?.find(key => key.marketplace === 'WILDBERRIES')?.isActive ? ( + <> +

+ Введите запрос в поле поиска, чтобы найти товары в вашем каталоге Wildberries, или загрузите все доступные карточки +

+ + + ) : ( + <> +

+ Для работы с реальными карточками товаров необходимо настроить API ключ Wildberries в настройках организации +

+

+ Сейчас показаны демонстрационные товары. Для тестирования используйте поиск или загрузите все. +

+ + + )}
)} + + {/* Модальное окно с детальной информацией о товаре */} + !open && closeDetailsModal()}> + + + Детальная информация о товаре + + {selectedCardForDetails && ( +
+ {/* Изображения */} +
+
+ {selectedCardForDetails.title} + + {/* Навигация по изображениям */} + {selectedCardForDetails.mediaFiles?.length > 1 && ( + <> + + + +
+ {currentImageIndex + 1} из {selectedCardForDetails.mediaFiles?.length || 0} +
+ + )} +
+ + {/* Миниатюры изображений */} + {selectedCardForDetails.mediaFiles?.length > 1 && ( +
+ {selectedCardForDetails.mediaFiles?.map((image, index) => ( + {`${selectedCardForDetails.title} setCurrentImageIndex(index)} + /> + ))} +
+ )} +
+ + {/* Информация о товаре */} +
+
+

{selectedCardForDetails.title}

+

Артикул: {selectedCardForDetails.vendorCode}

+ +
+
+ Бренд: + {selectedCardForDetails.brand} +
+
+ Категория: + {selectedCardForDetails.object} +
+
+ Родительская категория: + {selectedCardForDetails.parent} +
+
+ Страна производства: + {selectedCardForDetails.countryProduction} +
+
+
+ + {selectedCardForDetails.description && ( +
+

Описание

+

{selectedCardForDetails.description}

+
+ )} + + {/* Размеры и цены */} +
+

Доступные варианты

+
+ {selectedCardForDetails.sizes.map((size) => ( +
+
+ {size.wbSize} + 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} шт. + +
+
+ Размер: {size.techSize} +
+
{formatCurrency(size.discountedPrice || size.price)}
+ {size.discountedPrice && size.discountedPrice < size.price && ( +
{formatCurrency(size.price)}
+ )} +
+
+
+ ))} +
+
+ + {/* Кнопки действий в модальном окне */} +
+ + +
+
+
+ )} +
+
) } \ No newline at end of file diff --git a/src/graphql/mutations.ts b/src/graphql/mutations.ts index 6aefb40..412d5d5 100644 --- a/src/graphql/mutations.ts +++ b/src/graphql/mutations.ts @@ -177,6 +177,7 @@ export const ADD_MARKETPLACE_API_KEY = gql` apiKey { id marketplace + apiKey isActive validationData } diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index 57d67a1..c6969b0 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -40,8 +40,11 @@ export const GET_ME = gql` apiKeys { id marketplace + apiKey isActive validationData + createdAt + updatedAt } } } @@ -253,6 +256,7 @@ export const GET_ORGANIZATION = gql` apiKeys { id marketplace + apiKey isActive validationData createdAt diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index 663f4ed..500bcd3 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -96,7 +96,7 @@ const generateToken = (payload: AuthTokenPayload): string => { const verifyToken = (token: string): AuthTokenPayload => { try { return jwt.verify(token, process.env.JWT_SECRET!) as AuthTokenPayload; - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { throw new GraphQLError("Недействительный токен", { extensions: { code: "UNAUTHENTICATED" }, @@ -168,7 +168,7 @@ function parseLiteral(ast: unknown): unknown { fields?: unknown[]; values?: unknown[]; }; - + switch (astNode.kind) { case Kind.STRING: case Kind.BOOLEAN: @@ -290,7 +290,7 @@ export const resolvers = { // Получаем исходящие заявки для добавления флага hasOutgoingRequest const outgoingRequests = await prisma.counterpartyRequest.findMany({ - where: { + where: { senderId: currentUser.organization.id, status: "PENDING", }, @@ -301,7 +301,7 @@ export const resolvers = { // Получаем входящие заявки для добавления флага hasIncomingRequest const incomingRequests = await prisma.counterpartyRequest.findMany({ - where: { + where: { receiverId: currentUser.organization.id, status: "PENDING", }, @@ -365,7 +365,7 @@ export const resolvers = { const counterparties = await prisma.counterparty.findMany({ where: { organizationId: currentUser.organization.id }, - include: { + include: { counterparty: { include: { users: true, @@ -396,7 +396,7 @@ export const resolvers = { } return await prisma.counterpartyRequest.findMany({ - where: { + where: { receiverId: currentUser.organization.id, status: "PENDING", }, @@ -436,7 +436,7 @@ export const resolvers = { } return await prisma.counterpartyRequest.findMany({ - where: { + where: { senderId: currentUser.organization.id, status: { in: ["PENDING", "REJECTED"] }, }, @@ -505,7 +505,7 @@ export const resolvers = { receiverOrganization: { include: { users: true, - }, + }, }, }, orderBy: { createdAt: "asc" }, @@ -670,7 +670,7 @@ export const resolvers = { return await prisma.product.findMany({ where: { organizationId: currentUser.organization.id }, - include: { + include: { category: true, organization: true, }, @@ -712,7 +712,7 @@ export const resolvers = { return await prisma.product.findMany({ where, - include: { + include: { category: true, organization: { include: { @@ -897,7 +897,7 @@ export const resolvers = { } const employee = await prisma.employee.findFirst({ - where: { + where: { id: args.id, organizationId: currentUser.organization.id, }, @@ -984,7 +984,7 @@ export const resolvers = { args.phone, args.code ); - + if (!verificationResult.success) { return { success: false, @@ -1043,7 +1043,7 @@ export const resolvers = { }; console.log("verifySmsCode - Returning result:", { - success: result.success, + success: result.success, hasToken: !!result.token, hasUser: !!result.user, message: result.message, @@ -1147,28 +1147,28 @@ export const resolvers = { addressFull: organizationData.addressFull, ogrn: organizationData.ogrn, ogrnDate: organizationData.ogrnDate, - + // Статус организации status: organizationData.status, actualityDate: organizationData.actualityDate, registrationDate: organizationData.registrationDate, liquidationDate: organizationData.liquidationDate, - + // Руководитель managementName: organizationData.managementName, managementPost: organizationData.managementPost, - + // ОПФ opfCode: organizationData.opfCode, opfFull: organizationData.opfFull, opfShort: organizationData.opfShort, - + // Коды статистики okato: organizationData.okato, oktmo: organizationData.oktmo, okpo: organizationData.okpo, okved: organizationData.okved, - + // Контакты phones: organizationData.phones ? JSON.parse(JSON.stringify(organizationData.phones)) @@ -1176,12 +1176,12 @@ export const resolvers = { emails: organizationData.emails ? JSON.parse(JSON.stringify(organizationData.emails)) : null, - + // Финансовые данные employeeCount: organizationData.employeeCount, revenue: organizationData.revenue, taxSystem: organizationData.taxSystem, - + type: type, dadataData: JSON.parse(JSON.stringify(organizationData.rawData)), }, @@ -1284,7 +1284,7 @@ export const resolvers = { const tradeMark = validationResults[0]?.data?.tradeMark; const sellerName = validationResults[0]?.data?.sellerName; const shopName = tradeMark || sellerName || "Магазин"; - + const organization = await prisma.organization.create({ data: { inn: @@ -1427,7 +1427,7 @@ export const resolvers = { where: { id: existingKey.id }, data: { apiKey, - validationData: JSON.parse(JSON.stringify(validationResult.data)), + validationData: JSON.parse(JSON.stringify(validationResult.data)), isActive: true, }, }); @@ -1453,7 +1453,7 @@ export const resolvers = { message: "API ключ успешно добавлен", apiKey: newKey, }; - } + } } catch (error) { console.error("Error adding marketplace API key:", error); return { @@ -1526,7 +1526,7 @@ export const resolvers = { const user = await prisma.user.findUnique({ where: { id: context.user.id }, - include: { + include: { organization: { include: { apiKeys: true, @@ -1541,7 +1541,7 @@ export const resolvers = { try { const { input } = args; - + // Обновляем данные пользователя (аватар, имя управляющего) const userUpdateData: { avatar?: string; managerName?: string } = {}; if (input.avatar) { @@ -1550,14 +1550,14 @@ export const resolvers = { if (input.managerName) { userUpdateData.managerName = input.managerName; } - + if (Object.keys(userUpdateData).length > 0) { await prisma.user.update({ where: { id: context.user.id }, data: userUpdateData, }); } - + // Подготавливаем данные для обновления организации const updateData: { phones?: object; @@ -1565,20 +1565,20 @@ export const resolvers = { managementName?: string; managementPost?: string; } = {}; - + // Название организации больше не обновляется через профиль // Для селлеров устанавливается при регистрации, для остальных - при смене ИНН - + // Обновляем контактные данные в JSON поле phones if (input.orgPhone) { updateData.phones = [{ value: input.orgPhone, type: "main" }]; } - - // Обновляем email в JSON поле emails + + // Обновляем email в JSON поле emails if (input.email) { updateData.emails = [{ value: input.email, type: "main" }]; } - + // Сохраняем дополнительные контакты в custom полях // Пока добавим их как дополнительные JSON поля const customContacts: { @@ -1592,13 +1592,13 @@ export const resolvers = { corrAccount?: string; }; } = {}; - + // managerName теперь сохраняется в поле пользователя, а не в JSON - + if (input.telegram) { customContacts.telegram = input.telegram; } - + if (input.whatsapp) { customContacts.whatsapp = input.whatsapp; } @@ -1616,7 +1616,7 @@ export const resolvers = { corrAccount: input.corrAccount, }; } - + // Если есть дополнительные контакты, сохраним их в поле managementPost временно // В идеале нужно добавить отдельную таблицу для контактов if (Object.keys(customContacts).length > 0) { @@ -1635,7 +1635,7 @@ export const resolvers = { // Получаем обновленного пользователя const updatedUser = await prisma.user.findUnique({ where: { id: context.user.id }, - include: { + include: { organization: { include: { apiKeys: true, @@ -1671,7 +1671,7 @@ export const resolvers = { const user = await prisma.user.findUnique({ where: { id: context.user.id }, - include: { + include: { organization: { include: { apiKeys: true, @@ -1769,7 +1769,7 @@ export const resolvers = { // Получаем обновленного пользователя const updatedUser = await prisma.user.findUnique({ where: { id: context.user.id }, - include: { + include: { organization: { include: { apiKeys: true, @@ -2930,22 +2930,22 @@ export const resolvers = { createProduct: async ( _: unknown, args: { - input: { - name: string; - article: string; - description?: string; - price: number; - quantity: number; - categoryId?: string; - brand?: string; - color?: string; - size?: string; - weight?: number; - dimensions?: string; - material?: string; - images?: string[]; - mainImage?: string; - isActive?: boolean; + input: { + name: string; + article: string; + description?: string; + price: number; + quantity: number; + categoryId?: string; + brand?: string; + color?: string; + size?: string; + weight?: number; + dimensions?: string; + material?: string; + images?: string[]; + mainImage?: string; + isActive?: boolean; }; }, context: Context @@ -3005,7 +3005,7 @@ export const resolvers = { isActive: args.input.isActive ?? true, organizationId: currentUser.organization.id, }, - include: { + include: { category: true, organization: true, }, @@ -3029,23 +3029,23 @@ export const resolvers = { updateProduct: async ( _: unknown, args: { - id: string; - input: { - name: string; - article: string; - description?: string; - price: number; - quantity: number; - categoryId?: string; - brand?: string; - color?: string; - size?: string; - weight?: number; - dimensions?: string; - material?: string; - images?: string[]; - mainImage?: string; - isActive?: boolean; + id: string; + input: { + name: string; + article: string; + description?: string; + price: number; + quantity: number; + categoryId?: string; + brand?: string; + color?: string; + size?: string; + weight?: number; + dimensions?: string; + material?: string; + images?: string[]; + mainImage?: string; + isActive?: boolean; }; }, context: Context @@ -3115,7 +3115,7 @@ export const resolvers = { mainImage: args.input.mainImage, isActive: args.input.isActive ?? true, }, - include: { + include: { category: true, organization: true, }, @@ -3400,7 +3400,7 @@ export const resolvers = { if (existingCartItem) { // Обновляем количество const newQuantity = existingCartItem.quantity + args.quantity; - + if (newQuantity > product.quantity) { return { success: false, @@ -3940,7 +3940,7 @@ export const resolvers = { try { const employee = await prisma.employee.update({ - where: { + where: { id: args.id, organizationId: currentUser.organization.id, }, @@ -4002,7 +4002,7 @@ export const resolvers = { try { await prisma.employee.delete({ - where: { + where: { id: args.id, organizationId: currentUser.organization.id, }, @@ -4150,7 +4150,7 @@ export const resolvers = { if (parent.users) { return parent.users; } - + // Иначе загружаем отдельно return await prisma.user.findMany({ where: { organizationId: parent.id }, @@ -4197,7 +4197,7 @@ export const resolvers = { if (parent.organization) { return parent.organization; } - + // Иначе загружаем отдельно если есть organizationId if (parent.organizationId) { return await prisma.organization.findUnique({ @@ -4514,14 +4514,14 @@ const adminQueries = { const limit = args.limit || 50; const offset = args.offset || 0; - + // Строим условие поиска const whereCondition: Prisma.UserWhereInput = args.search ? { OR: [ { phone: { contains: args.search, mode: "insensitive" } }, { managerName: { contains: args.search, mode: "insensitive" } }, - { + { organization: { OR: [ { name: { contains: args.search, mode: "insensitive" } }, @@ -4589,7 +4589,7 @@ const adminMutations = { args.password, admin.password ); - + if (!isPasswordValid) { return { success: false, @@ -4605,7 +4605,7 @@ const adminMutations = { // Создать токен const token = jwt.sign( - { + { adminId: admin.id, username: admin.username, type: "admin", diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index 854b3eb..a464a08 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -246,6 +246,7 @@ export const typeDefs = gql` type ApiKey { id: ID! marketplace: MarketplaceType! + apiKey: String! isActive: Boolean! validationData: JSON createdAt: DateTime! diff --git a/src/services/wildberries-service.ts b/src/services/wildberries-service.ts index 5c05712..e1d2696 100644 --- a/src/services/wildberries-service.ts +++ b/src/services/wildberries-service.ts @@ -43,7 +43,7 @@ interface WildberriesCardsResponse { } interface WildberriesCardFilter { - sort?: { + settings?: { cursor?: { limit?: number nmID?: number @@ -55,61 +55,55 @@ interface WildberriesCardFilter { objectIDs?: number[] tagIDs?: number[] brandIDs?: number[] + colorIDs?: number[] + sizeIDs?: number[] } } } export class WildberriesService { - private static baseUrl = 'https://marketplace-api.wildberries.ru' private static contentUrl = 'https://content-api.wildberries.ru' + private static publicUrl = 'https://public-api.wildberries.ru' + private static supplierUrl = 'https://suppliers-api.wildberries.ru' /** - * Получить список складов WB - */ - static async getWarehouses(apiKey: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v2/warehouses`, { - method: 'GET', - headers: { - 'Authorization': apiKey, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - throw new Error(`WB API Error: ${response.status} ${response.statusText}`) - } - - const data: WildberriesWarehousesResponse = await response.json() - return data.data || [] - } catch (error) { - console.error('Error fetching WB warehouses:', error) - throw new Error('Ошибка получения складов Wildberries') - } - } - - /** - * Получить карточки товаров + * Получение карточек товаров через Content API v2 */ static async getCards(apiKey: string, filter?: WildberriesCardFilter): Promise { try { - const response = await fetch(`${this.contentUrl}/content/v1/cards/cursor/list`, { + console.log('Calling WB Content API v2 with filter:', filter) + + const response = await fetch(`${this.contentUrl}/content/v2/get/cards/list`, { method: 'POST', headers: { 'Authorization': apiKey, 'Content-Type': 'application/json', }, body: JSON.stringify(filter || { - sort: { + settings: { cursor: { limit: 100 + }, + filter: { + withPhoto: -1 } } }) }) + console.log(`${this.contentUrl}/content/v2/get/cards/list`, response.status, response.statusText) + if (!response.ok) { - throw new Error(`WB API Error: ${response.status} ${response.statusText}`) + const errorText = await response.text() + let errorData + try { + errorData = JSON.parse(errorText) + } catch { + errorData = { message: errorText } + } + + console.log('WB API Error Response:', errorData) + throw new Error(`WB API Error: ${response.status} - ${response.statusText}`) } const data: WildberriesCardsResponse = await response.json() @@ -121,16 +115,17 @@ export class WildberriesService { } /** - * Поиск карточек товаров по тексту + * Поиск карточек товаров */ - static async searchCards(apiKey: string, searchText: string, limit: number = 100): Promise { + static async searchCards(apiKey: string, searchTerm: string, limit = 50): Promise { const filter: WildberriesCardFilter = { - sort: { + settings: { cursor: { limit }, filter: { - textSearch: searchText + textSearch: searchTerm, + withPhoto: -1 } } } @@ -139,63 +134,70 @@ export class WildberriesService { } /** - * Валидация API ключа WB + * Получение всех карточек товаров с пагинацией + */ + static async getAllCards(apiKey: string, limit = 100): Promise { + const filter: WildberriesCardFilter = { + settings: { + cursor: { + limit + }, + filter: { + withPhoto: -1 + } + } + } + + return this.getCards(apiKey, filter) + } + + /** + * Получение складов WB + */ + static async getWarehouses(apiKey: string): Promise { + try { + const response = await fetch(`${this.supplierUrl}/api/v3/warehouses`, { + headers: { + 'Authorization': apiKey, + } + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const data: WildberriesWarehousesResponse = await response.json() + return data.data || [] + } catch (error) { + console.error('Error fetching warehouses:', error) + return [] + } + } + + /** + * Проверка валидности API ключа */ static async validateApiKey(apiKey: string): Promise { try { - await this.getWarehouses(apiKey) - return true + const response = await fetch(`${this.contentUrl}/content/v2/get/cards/list`, { + method: 'POST', + headers: { + 'Authorization': apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + settings: { + cursor: { + limit: 1 + } + } + }) + }) + + return response.ok } catch (error) { - console.error('WB API key validation failed:', error) + console.error('Error validating API key:', error) return false } } - - /** - * Получить информацию о поставке - */ - static async getSupplyInfo(apiKey: string, supplyId: string): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v3/supplies/${supplyId}`, { - method: 'GET', - headers: { - 'Authorization': apiKey, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - throw new Error(`WB API Error: ${response.status} ${response.statusText}`) - } - - return await response.json() - } catch (error) { - console.error('Error fetching WB supply info:', error) - throw new Error('Ошибка получения информации о поставке') - } - } - - /** - * Получить список поставок - */ - static async getSupplies(apiKey: string, limit: number = 1000, next: number = 0): Promise { - try { - const response = await fetch(`${this.baseUrl}/api/v3/supplies?limit=${limit}&next=${next}`, { - method: 'GET', - headers: { - 'Authorization': apiKey, - 'Content-Type': 'application/json', - }, - }) - - if (!response.ok) { - throw new Error(`WB API Error: ${response.status} ${response.statusText}`) - } - - return await response.json() - } catch (error) { - console.error('Error fetching WB supplies:', error) - throw new Error('Ошибка получения списка поставок') - } - } } \ No newline at end of file