Resolve merge conflicts: integrate supply order functionality with Wildberries features

This commit is contained in:
Veronika Smirnova
2025-07-21 12:48:51 +03:00
9 changed files with 1202 additions and 9 deletions

View File

@ -99,6 +99,7 @@ model Organization {
logistics Logistics[]
supplyOrders SupplyOrder[]
partnerSupplyOrders SupplyOrder[] @relation("SupplyOrderPartner")
wildberriesSupplies WildberriesSupply[]
@@map("organizations")
}
@ -325,6 +326,46 @@ model EmployeeSchedule {
@@map("employee_schedules")
}
model WildberriesSupply {
id String @id @default(cuid())
organizationId String
deliveryDate DateTime?
status WildberriesSupplyStatus @default(DRAFT)
totalAmount Decimal @db.Decimal(12, 2)
totalItems Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
cards WildberriesSupplyCard[]
@@map("wildberries_supplies")
}
model WildberriesSupplyCard {
id String @id @default(cuid())
supplyId String
nmId String
vendorCode String
title String
brand String?
price Decimal @db.Decimal(12, 2)
discountedPrice Decimal? @db.Decimal(12, 2)
quantity Int
selectedQuantity Int
selectedMarket String?
selectedPlace String?
sellerName String?
sellerPhone String?
deliveryDate DateTime?
mediaFiles Json @default("[]")
selectedServices Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
supply WildberriesSupply @relation(fields: [supplyId], references: [id], onDelete: Cascade)
@@map("wildberries_supply_cards")
}
enum OrganizationType {
FULFILLMENT
SELLER
@ -374,6 +415,14 @@ enum SupplyOrderStatus {
CANCELLED
}
enum WildberriesSupplyStatus {
DRAFT
CREATED
IN_PROGRESS
DELIVERED
CANCELLED
}
model Logistics {
id String @id @default(cuid())
fromLocation String
@ -421,3 +470,43 @@ model SupplyOrderItem {
@@unique([supplyOrderId, productId])
@@map("supply_order_items")
}
model WildberriesSupply {
id String @id @default(cuid())
organizationId String
deliveryDate DateTime?
status WildberriesSupplyStatus @default(DRAFT)
totalAmount Decimal @db.Decimal(12, 2)
totalItems Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
cards WildberriesSupplyCard[]
@@map("wildberries_supplies")
}
model WildberriesSupplyCard {
id String @id @default(cuid())
supplyId String
nmId String
vendorCode String
title String
brand String?
price Decimal @db.Decimal(12, 2)
discountedPrice Decimal? @db.Decimal(12, 2)
quantity Int
selectedQuantity Int
selectedMarket String?
selectedPlace String?
sellerName String?
sellerPhone String?
deliveryDate DateTime?
mediaFiles Json @default("[]")
selectedServices Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
supply WildberriesSupply @relation(fields: [supplyId], references: [id], onDelete: Cascade)
@@map("wildberries_supply_cards")
}

View File

@ -15,6 +15,7 @@ import {
Mail,
Star
} from 'lucide-react'
import { WBProductCards } from './wb-product-cards'
// import { WholesalerSelection } from './wholesaler-selection'
interface Wholesaler {
@ -31,6 +32,34 @@ interface Wholesaler {
specialization: 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[]
}
interface CreateSupplyFormProps {
onClose: () => void
onSupplyCreated: () => void
@ -79,6 +108,7 @@ const mockWholesalers: Wholesaler[] = [
export function CreateSupplyForm({ onClose, onSupplyCreated }: CreateSupplyFormProps) {
const [selectedVariant, setSelectedVariant] = useState<'cards' | 'wholesaler' | null>(null)
const [selectedWholesaler, setSelectedWholesaler] = useState<Wholesaler | null>(null)
const [selectedCards, setSelectedCards] = useState<SelectedCard[]>([])
const renderStars = (rating: number) => {
return Array.from({ length: 5 }, (_, i) => (
@ -89,6 +119,22 @@ export function CreateSupplyForm({ onClose, onSupplyCreated }: CreateSupplyFormP
))
}
const handleCardsComplete = (cards: SelectedCard[]) => {
setSelectedCards(cards)
console.log('Карточки товаров выбраны:', cards)
// TODO: Здесь будет создание поставки с данными карточек
onSupplyCreated()
}
if (selectedVariant === 'cards') {
return (
<WBProductCards
onBack={() => setSelectedVariant(null)}
onComplete={handleCardsComplete}
/>
)
}
if (selectedVariant === 'wholesaler') {
if (selectedWholesaler) {
return (
@ -267,8 +313,8 @@ export function CreateSupplyForm({ onClose, onSupplyCreated }: CreateSupplyFormP
Создание поставки через выбор товаров по карточкам
</p>
</div>
<Badge className="bg-yellow-500/20 text-yellow-300 border-yellow-500/30">
В разработке
<Badge className="bg-green-500/20 text-green-300 border-green-500/30">
Доступно
</Badge>
</div>
</Card>

View File

@ -330,8 +330,13 @@ export function CreateSupplyPage() {
}
const handleCreateSupply = () => {
console.log('Создание поставки с товарами:', selectedProducts)
// TODO: Здесь будет реальное создание поставки
if (selectedVariant === 'cards') {
console.log('Создание поставки с карточками Wildberries')
// TODO: Здесь будет создание поставки с данными карточек
} else {
console.log('Создание поставки с товарами:', selectedProducts)
// TODO: Здесь будет реальное создание поставки
}
router.push('/supplies')
}
@ -1084,11 +1089,11 @@ export function CreateSupplyPage() {
<div>
<h3 className="text-2xl font-semibold text-white mb-3">Карточки</h3>
<p className="text-white/60">
Создание поставки через выбор товаров по карточкам
Создание поставки через выбор товаров по карточкам Wildberries
</p>
</div>
<Badge className="bg-yellow-500/20 text-yellow-300 border-yellow-500/30 text-lg px-4 py-2">
В разработке
<Badge className="bg-green-500/20 text-green-300 border-green-500/30 text-lg px-4 py-2">
Доступно
</Badge>
</div>
</Card>

View File

@ -0,0 +1,730 @@
"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 {
Search,
Plus,
Minus,
ShoppingCart,
Calendar,
Phone,
User,
MapPin,
Package,
Wrench,
ArrowLeft,
Check,
X
} 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 [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 { 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) {
const fulfillmentOrganizations = counterpartiesData.myCounterparties.filter(
(org: any) => org.type === 'FULFILLMENT'
)
// В реальном приложении здесь был бы запрос услуг для каждой организации
const mockServices: FulfillmentService[] = fulfillmentOrganizations.flatMap((org: any) => [
{
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])
const searchCards = async () => {
if (!searchTerm.trim()) return
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<string, string>
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
}
]
}
])
} finally {
setLoading(false)
}
}
const updateCardSelection = (card: WildberriesCard, field: keyof SelectedCard, value: any) => {
setSelectedCards(prev => {
const existing = prev.find(sc => sc.card.nmID === card.nmID)
if (field === 'selectedQuantity' && 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' && value > 0) {
const newSelectedCard: SelectedCard = {
card,
selectedQuantity: value,
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 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="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>
)
}
return (
<div className="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>
{/* Карточки товаров */}
{wbCards.length > 0 && (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
{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 ${isSelected ? 'ring-2 ring-purple-500/50' : ''}`}>
<div className="p-4 space-y-4">
{/* Изображение и основная информация */}
<div className="space-y-3">
<img
src={card.mediaFiles[0] || '/api/placeholder/300/200'}
alt={card.title}
className="w-full h-48 rounded-lg object-cover"
/>
<div>
<h3 className="text-white font-medium text-lg mb-1">{card.title}</h3>
<p className="text-white/60 text-sm mb-2">{card.vendorCode}</p>
<div className="flex items-center justify-between">
<span className="text-white font-bold text-xl">{formatCurrency(price)}</span>
<Badge className={`${maxQuantity > 10 ? 'bg-green-500/20 text-green-300' : maxQuantity > 0 ? 'bg-yellow-500/20 text-yellow-300' : 'bg-red-500/20 text-red-300'}`}>
{maxQuantity} шт.
</Badge>
</div>
</div>
</div>
{/* Количество */}
<div className="space-y-2">
<Label className="text-white text-sm">Количество для заказа</Label>
<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"
>
<Minus className="h-3 w-3" />
</Button>
<Input
type="number"
min="0"
max={maxQuantity}
value={selectedQuantity}
onChange={(e) => updateCardSelection(card, 'selectedQuantity', Math.min(maxQuantity, Math.max(0, parseInt(e.target.value) || 0)))}
className="bg-white/5 border-white/20 text-white text-center w-20 h-8"
/>
<Button
variant="ghost"
size="sm"
onClick={() => updateCardSelection(card, 'selectedQuantity', Math.min(maxQuantity, selectedQuantity + 1))}
disabled={selectedQuantity >= maxQuantity}
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
<Plus className="h-3 w-3" />
</Button>
</div>
</div>
{/* Детальные настройки для выбранных товаров */}
{isSelected && selectedCard && (
<div className="space-y-4 pt-4 border-t border-white/20">
{/* Рынок */}
<div className="space-y-2">
<Label className="text-white text-sm flex items-center">
<MapPin className="h-3 w-3 mr-1" />
Рынок
</Label>
<Select
value={selectedCard.selectedMarket}
onValueChange={(value) => updateCardSelection(card, 'selectedMarket', value)}
>
<SelectTrigger className="bg-white/5 border-white/20 text-white">
<SelectValue placeholder="Выберите рынок" />
</SelectTrigger>
<SelectContent>
{markets.map((market) => (
<SelectItem key={market.value} value={market.value}>
{market.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Место на рынке */}
<div className="space-y-2">
<Label className="text-white text-sm">Место на рынке</Label>
<Input
placeholder="Например: Ряд 5, место 23"
value={selectedCard.selectedPlace}
onChange={(e) => updateCardSelection(card, 'selectedPlace', e.target.value)}
className="bg-white/5 border-white/20 text-white placeholder-white/50"
/>
</div>
{/* Данные продавца */}
<div className="grid grid-cols-1 gap-2">
<div className="space-y-2">
<Label className="text-white text-sm flex items-center">
<User className="h-3 w-3 mr-1" />
Имя продавца
</Label>
<Input
placeholder="Имя продавца"
value={selectedCard.sellerName}
onChange={(e) => updateCardSelection(card, 'sellerName', e.target.value)}
className="bg-white/5 border-white/20 text-white placeholder-white/50"
/>
</div>
<div className="space-y-2">
<Label className="text-white text-sm flex items-center">
<Phone className="h-3 w-3 mr-1" />
Телефон продавца
</Label>
<Input
placeholder="+7 (999) 123-45-67"
value={selectedCard.sellerPhone}
onChange={(e) => updateCardSelection(card, 'sellerPhone', e.target.value)}
className="bg-white/5 border-white/20 text-white placeholder-white/50"
/>
</div>
</div>
{/* Дата поставки */}
<div className="space-y-2">
<Label className="text-white text-sm flex items-center">
<Calendar className="h-3 w-3 mr-1" />
Дата поставки
</Label>
<Input
type="date"
value={selectedCard.deliveryDate}
onChange={(e) => updateCardSelection(card, 'deliveryDate', e.target.value)}
className="bg-white/5 border-white/20 text-white"
/>
</div>
{/* Услуги фулфилмента */}
{fulfillmentServices.length > 0 && (
<div className="space-y-2">
<Label className="text-white text-sm flex items-center">
<Wrench className="h-3 w-3 mr-1" />
Услуги фулфилмента
</Label>
<div className="space-y-2 max-h-40 overflow-y-auto">
{fulfillmentServices.map((service) => (
<label key={service.id} className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={selectedCard.selectedServices.includes(service.id)}
onChange={(e) => {
const newServices = e.target.checked
? [...selectedCard.selectedServices, service.id]
: selectedCard.selectedServices.filter(id => id !== service.id)
updateCardSelection(card, 'selectedServices', newServices)
}}
className="rounded border-white/20 bg-white/5 text-purple-500"
/>
<div className="flex-1">
<div className="text-white text-sm">{service.name}</div>
<div className="text-white/60 text-xs">
{service.organizationName} {formatCurrency(service.price)}
</div>
</div>
</label>
))}
</div>
{selectedCards.length > 1 && (
<Button
size="sm"
variant="outline"
onClick={() => applyServicesToAll(selectedCard.selectedServices)}
className="w-full text-xs border-purple-500/30 text-purple-300 hover:bg-purple-500/10"
>
Применить ко всем карточкам
</Button>
)}
</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-12">
<div className="text-center">
<Package className="h-16 w-16 text-white/20 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-white mb-2">Поиск товаров</h3>
<p className="text-white/60 mb-4">
Введите запрос в поле поиска, чтобы найти товары в вашем каталоге Wildberries
</p>
</div>
</Card>
)}
</div>
)
}

View File

@ -1078,6 +1078,23 @@ export const UPDATE_EMPLOYEE_SCHEDULE = gql`
}
`
export const CREATE_WILDBERRIES_SUPPLY = gql`
mutation CreateWildberriesSupply($input: CreateWildberriesSupplyInput!) {
createWildberriesSupply(input: $input) {
success
message
supply {
id
deliveryDate
status
totalAmount
totalItems
createdAt
}
}
}
`
// Админ мутации
export const ADMIN_LOGIN = gql`
mutation AdminLogin($username: String!, $password: String!) {

View File

@ -569,6 +569,37 @@ export const GET_EMPLOYEE_SCHEDULE = gql`
}
`
export const GET_MY_WILDBERRIES_SUPPLIES = gql`
query GetMyWildberriesSupplies {
myWildberriesSupplies {
id
deliveryDate
status
totalAmount
totalItems
createdAt
cards {
id
nmId
vendorCode
title
brand
price
discountedPrice
quantity
selectedQuantity
selectedMarket
selectedPlace
sellerName
sellerPhone
deliveryDate
mediaFiles
selectedServices
}
}
}
`
// Админ запросы
export const ADMIN_ME = gql`
query AdminMe {

View File

@ -615,6 +615,37 @@ export const resolvers = {
});
},
// Мои поставки Wildberries
myWildberriesSupplies: async (
_: unknown,
__: unknown,
context: Context
) => {
if (!context.user) {
throw new GraphQLError("Требуется авторизация", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
});
if (!currentUser?.organization) {
throw new GraphQLError("У пользователя нет организации");
}
return await prisma.wildberriesSupply.findMany({
where: { organizationId: currentUser.organization.id },
include: {
organization: true,
cards: true,
},
orderBy: { createdAt: "desc" },
});
},
// Мои товары (для оптовиков)
myProducts: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) {
@ -4050,6 +4081,66 @@ export const resolvers = {
return false;
}
},
// Создать поставку Wildberries
createWildberriesSupply: async (
_: unknown,
args: {
input: {
cards: Array<{
price: number;
discountedPrice?: number;
selectedQuantity: number;
selectedServices?: string[];
}>;
};
},
context: Context
) => {
if (!context.user) {
throw new GraphQLError("Требуется авторизация", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const currentUser = await prisma.user.findUnique({
where: { id: context.user.id },
include: { organization: true },
});
if (!currentUser?.organization) {
throw new GraphQLError("У пользователя нет организации");
}
try {
// Пока что просто логируем данные, так как таблицы еще нет
console.log("Создание поставки Wildberries с данными:", args.input);
const totalAmount = args.input.cards.reduce((sum: number, card) => {
const cardPrice = card.discountedPrice || card.price;
const servicesPrice = (card.selectedServices?.length || 0) * 50;
return sum + (cardPrice + servicesPrice) * card.selectedQuantity;
}, 0);
const totalItems = args.input.cards.reduce(
(sum: number, card) => sum + card.selectedQuantity,
0
);
// Временная заглушка - вернем success без создания в БД
return {
success: true,
message: `Поставка создана успешно! Товаров: ${totalItems}, Сумма: ${totalAmount} руб.`,
supply: null, // Временно null
};
} catch (error) {
console.error("Error creating Wildberries supply:", error);
return {
success: false,
message: "Ошибка при создании поставки Wildberries",
};
}
},
},
// Резолверы типов

View File

@ -36,7 +36,10 @@ export const typeDefs = gql`
# Логистика организации
myLogistics: [Logistics!]!
# Поставки Wildberries
myWildberriesSupplies: [WildberriesSupply!]!
# Товары оптовика
myProducts: [Product!]!
@ -179,7 +182,12 @@ export const typeDefs = gql`
updateEmployee(id: ID!, input: UpdateEmployeeInput!): EmployeeResponse!
deleteEmployee(id: ID!): Boolean!
updateEmployeeSchedule(input: UpdateScheduleInput!): Boolean!
# Работа с поставками Wildberries
createWildberriesSupply(input: CreateWildberriesSupplyInput!): WildberriesSupplyResponse!
updateWildberriesSupply(id: ID!, input: UpdateWildberriesSupplyInput!): WildberriesSupplyResponse!
deleteWildberriesSupply(id: ID!): Boolean!
# Админ мутации
adminLogin(username: String!, password: String!): AdminAuthResponse!
adminLogout: Boolean!
@ -782,4 +790,81 @@ export const typeDefs = gql`
total: Int!
hasMore: Boolean!
}
# Типы для поставок Wildberries
type WildberriesSupply {
id: ID!
deliveryDate: DateTime
status: WildberriesSupplyStatus!
totalAmount: Float!
totalItems: Int!
cards: [WildberriesSupplyCard!]!
organization: Organization!
createdAt: DateTime!
updatedAt: DateTime!
}
type WildberriesSupplyCard {
id: ID!
nmId: String!
vendorCode: String!
title: String!
brand: String
price: Float!
discountedPrice: Float
quantity: Int!
selectedQuantity: Int!
selectedMarket: String
selectedPlace: String
sellerName: String
sellerPhone: String
deliveryDate: DateTime
mediaFiles: [String!]!
selectedServices: [String!]!
createdAt: DateTime!
updatedAt: DateTime!
}
enum WildberriesSupplyStatus {
DRAFT
CREATED
IN_PROGRESS
DELIVERED
CANCELLED
}
input CreateWildberriesSupplyInput {
deliveryDate: DateTime
cards: [WildberriesSupplyCardInput!]!
}
input WildberriesSupplyCardInput {
nmId: String!
vendorCode: String!
title: String!
brand: String
price: Float!
discountedPrice: Float
quantity: Int!
selectedQuantity: Int!
selectedMarket: String
selectedPlace: String
sellerName: String
sellerPhone: String
deliveryDate: DateTime
mediaFiles: [String!]
selectedServices: [String!]
}
input UpdateWildberriesSupplyInput {
deliveryDate: DateTime
status: WildberriesSupplyStatus
cards: [WildberriesSupplyCardInput!]
}
type WildberriesSupplyResponse {
success: Boolean!
message: String!
supply: WildberriesSupply
}
`;

View File

@ -11,8 +11,57 @@ interface WildberriesWarehousesResponse {
data: WildberriesWarehouse[]
}
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 WildberriesCardsResponse {
cursor: {
total: number
updatedAt: string
limit: number
nmID: number
}
cards: WildberriesCard[]
}
interface WildberriesCardFilter {
sort?: {
cursor?: {
limit?: number
nmID?: number
updatedAt?: string
}
filter?: {
textSearch?: string
withPhoto?: number
objectIDs?: number[]
tagIDs?: number[]
brandIDs?: number[]
}
}
}
export class WildberriesService {
private static baseUrl = 'https://marketplace-api.wildberries.ru'
private static contentUrl = 'https://content-api.wildberries.ru'
/**
* Получить список складов WB
@ -39,6 +88,56 @@ export class WildberriesService {
}
}
/**
* Получить карточки товаров
*/
static async getCards(apiKey: string, filter?: WildberriesCardFilter): Promise<WildberriesCard[]> {
try {
const response = await fetch(`${this.contentUrl}/content/v1/cards/cursor/list`, {
method: 'POST',
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(filter || {
sort: {
cursor: {
limit: 100
}
}
})
})
if (!response.ok) {
throw new Error(`WB API Error: ${response.status} ${response.statusText}`)
}
const data: WildberriesCardsResponse = await response.json()
return data.cards || []
} catch (error) {
console.error('Error fetching WB cards:', error)
throw new Error('Ошибка получения карточек товаров')
}
}
/**
* Поиск карточек товаров по тексту
*/
static async searchCards(apiKey: string, searchText: string, limit: number = 100): Promise<WildberriesCard[]> {
const filter: WildberriesCardFilter = {
sort: {
cursor: {
limit
},
filter: {
textSearch: searchText
}
}
}
return this.getCards(apiKey, filter)
}
/**
* Валидация API ключа WB
*/