
КРИТИЧНЫЕ КОМПОНЕНТЫ ОПТИМИЗИРОВАНЫ: • AdminDashboard (346 kB) - добавлены React.memo, useCallback, useMemo • SellerStatisticsDashboard (329 kB) - мемоизация кэша и callback функций • CreateSupplyPage (276 kB) - оптимизированы вычисления и обработчики • EmployeesDashboard (268 kB) - мемоизация списков и функций • SalesTab + AdvertisingTab - React.memo обертка ТЕХНИЧЕСКИЕ УЛУЧШЕНИЯ: ✅ React.memo() для предотвращения лишних рендеров ✅ useMemo() для тяжелых вычислений ✅ useCallback() для стабильных ссылок на функции ✅ Мемоизация фильтрации и сортировки списков ✅ Оптимизация пропсов в компонентах-контейнерах РЕЗУЛЬТАТЫ: • Все компоненты успешно компилируются • Линтер проходит без критических ошибок • Сохранена вся функциональность • Улучшена производительность рендеринга • Снижена нагрузка на React дерево 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1638 lines
69 KiB
TypeScript
1638 lines
69 KiB
TypeScript
'use client'
|
||
|
||
import React, { useState, useEffect } from 'react'
|
||
import DatePicker from 'react-datepicker'
|
||
import { toast } from 'sonner'
|
||
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Card } from '@/components/ui/card'
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { PhoneInput } from '@/components/ui/phone-input'
|
||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||
import { formatPhoneInput, isValidPhone, formatNameInput } from '@/lib/input-masks'
|
||
|
||
import 'react-datepicker/dist/react-datepicker.css'
|
||
import {
|
||
Search,
|
||
Plus,
|
||
Calendar as CalendarIcon,
|
||
Package,
|
||
Check,
|
||
X,
|
||
User,
|
||
Phone,
|
||
MapPin,
|
||
Building,
|
||
Truck,
|
||
} 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,
|
||
GET_SUPPLY_SUPPLIERS,
|
||
} from '@/graphql/queries'
|
||
import { CREATE_WILDBERRIES_SUPPLY, CREATE_SUPPLY_SUPPLIER } from '@/graphql/mutations'
|
||
|
||
import { format } from 'date-fns'
|
||
import { ru } from 'date-fns/locale'
|
||
|
||
import { WildberriesCard } from '@/types/supplies'
|
||
|
||
// Добавляем CSS стили для line-clamp
|
||
const lineClampStyles = `
|
||
.line-clamp-2 {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
`
|
||
|
||
interface SupplyItem {
|
||
card: WildberriesCard
|
||
quantity: number
|
||
pricePerUnit: number
|
||
totalPrice: number
|
||
supplierId: string
|
||
priceType: 'perUnit' | 'total' // за штуку или за общее количество
|
||
}
|
||
|
||
interface Organization {
|
||
id: string
|
||
name?: string
|
||
fullName?: string
|
||
type: string
|
||
}
|
||
|
||
interface FulfillmentService {
|
||
id: string
|
||
name: string
|
||
description?: string
|
||
price: number
|
||
}
|
||
|
||
interface Supplier {
|
||
id: string
|
||
name: string
|
||
contactName: string
|
||
phone: string
|
||
market: string
|
||
address: string
|
||
place: string
|
||
telegram: string
|
||
}
|
||
|
||
interface DirectSupplyCreationProps {
|
||
onComplete: () => void
|
||
onCreateSupply: () => void
|
||
canCreateSupply: boolean
|
||
isCreatingSupply: boolean
|
||
onCanCreateSupplyChange?: (canCreate: boolean) => void
|
||
selectedFulfillmentId?: string
|
||
onServicesCostChange?: (cost: number) => void
|
||
onItemsPriceChange?: (totalPrice: number) => void
|
||
onItemsCountChange?: (hasItems: boolean) => void
|
||
onConsumablesCostChange?: (cost: number) => void
|
||
onVolumeChange?: (totalVolume: number) => void
|
||
onSuppliersChange?: (suppliers: unknown[]) => void
|
||
}
|
||
|
||
export function DirectSupplyCreation({
|
||
onComplete,
|
||
onCreateSupply,
|
||
canCreateSupply,
|
||
isCreatingSupply,
|
||
onCanCreateSupplyChange,
|
||
selectedFulfillmentId,
|
||
onServicesCostChange,
|
||
onItemsPriceChange,
|
||
onItemsCountChange,
|
||
onConsumablesCostChange,
|
||
onVolumeChange,
|
||
onSuppliersChange,
|
||
}: DirectSupplyCreationProps) {
|
||
const { user } = useAuth()
|
||
|
||
// Новые состояния для блока создания поставки
|
||
const [deliveryDate, setDeliveryDate] = useState<string>('')
|
||
const [selectedFulfillment, setSelectedFulfillment] = useState<string>('')
|
||
const [goodsQuantity, setGoodsQuantity] = useState<number>(1200)
|
||
const [goodsVolume, setGoodsVolume] = useState<number>(0)
|
||
const [cargoPlaces, setCargoPlaces] = useState<number>(0)
|
||
const [goodsPrice, setGoodsPrice] = useState<number>(0)
|
||
const [fulfillmentServicesPrice, setFulfillmentServicesPrice] = useState<number>(0)
|
||
const [logisticsPrice, setLogisticsPrice] = useState<number>(0)
|
||
|
||
// Оригинальные состояния для товаров
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [loading, setLoading] = useState(false)
|
||
const [wbCards, setWbCards] = useState<WildberriesCard[]>([])
|
||
const [supplyItems, setSupplyItems] = useState<SupplyItem[]>([])
|
||
|
||
// Общие настройки (оригинальные)
|
||
const [deliveryDateOriginal, setDeliveryDateOriginal] = useState<Date | undefined>(undefined)
|
||
const [selectedFulfillmentOrg, setSelectedFulfillmentOrg] = useState<string>('')
|
||
const [selectedServices, setSelectedServices] = useState<string[]>([])
|
||
const [selectedConsumables, setSelectedConsumables] = useState<string[]>([])
|
||
|
||
// Поставщики
|
||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||
const [showSupplierModal, setShowSupplierModal] = useState(false)
|
||
const [newSupplier, setNewSupplier] = useState({
|
||
name: '',
|
||
contactName: '',
|
||
phone: '',
|
||
market: '',
|
||
address: '',
|
||
place: '',
|
||
telegram: '',
|
||
})
|
||
const [supplierErrors, setSupplierErrors] = useState({
|
||
name: '',
|
||
contactName: '',
|
||
phone: '',
|
||
telegram: '',
|
||
})
|
||
|
||
// Данные для фулфилмента
|
||
const [organizationServices, setOrganizationServices] = useState<{
|
||
[orgId: string]: FulfillmentService[]
|
||
}>({})
|
||
const [organizationSupplies, setOrganizationSupplies] = useState<{
|
||
[orgId: string]: FulfillmentService[]
|
||
}>({})
|
||
|
||
// Загружаем контрагентов-фулфилментов
|
||
const { data: counterpartiesData } = useQuery(GET_MY_COUNTERPARTIES)
|
||
const { data: suppliersData, refetch: refetchSuppliers } = useQuery(GET_SUPPLY_SUPPLIERS)
|
||
|
||
// Мутации
|
||
const [createSupply, { loading: creatingSupply }] = useMutation(CREATE_WILDBERRIES_SUPPLY, {
|
||
onCompleted: (data) => {
|
||
if (data.createWildberriesSupply.success) {
|
||
toast.success(data.createWildberriesSupply.message)
|
||
onComplete()
|
||
} else {
|
||
toast.error(data.createWildberriesSupply.message)
|
||
}
|
||
},
|
||
onError: (error) => {
|
||
toast.error('Ошибка при создании поставки')
|
||
console.error('Error creating supply:', error)
|
||
},
|
||
})
|
||
|
||
const [createSupplierMutation, { loading: creatingSupplier }] = useMutation(CREATE_SUPPLY_SUPPLIER, {
|
||
onCompleted: (data) => {
|
||
if (data.createSupplySupplier.success) {
|
||
toast.success('Поставщик добавлен успешно!')
|
||
|
||
// Обновляем список поставщиков из БД
|
||
refetchSuppliers()
|
||
|
||
// Очищаем форму
|
||
setNewSupplier({
|
||
name: '',
|
||
contactName: '',
|
||
phone: '',
|
||
market: '',
|
||
address: '',
|
||
place: '',
|
||
telegram: '',
|
||
})
|
||
setSupplierErrors({
|
||
name: '',
|
||
contactName: '',
|
||
phone: '',
|
||
telegram: '',
|
||
})
|
||
setShowSupplierModal(false)
|
||
} else {
|
||
toast.error(data.createSupplySupplier.message || 'Ошибка при добавлении поставщика')
|
||
}
|
||
},
|
||
onError: (error) => {
|
||
toast.error('Ошибка при создании поставщика')
|
||
console.error('Error creating supplier:', error)
|
||
},
|
||
})
|
||
|
||
// Моковые данные товаров для демонстрации
|
||
const getMockCards = (): WildberriesCard[] => [
|
||
{
|
||
nmID: 123456789,
|
||
vendorCode: 'SKU001',
|
||
title: 'Платье летнее розовое',
|
||
description: 'Легкое летнее платье из натурального хлопка',
|
||
brand: 'Fashion',
|
||
object: 'Платья',
|
||
parent: 'Одежда',
|
||
countryProduction: 'Россия',
|
||
supplierVendorCode: 'SUPPLIER-001',
|
||
mediaFiles: ['/api/placeholder/400/400'],
|
||
dimensions: {
|
||
length: 30, // 30 см
|
||
width: 25, // 25 см
|
||
height: 5, // 5 см
|
||
weightBrutto: 0.3, // 300г
|
||
isValid: true,
|
||
},
|
||
sizes: [
|
||
{
|
||
chrtID: 123456,
|
||
techSize: 'M',
|
||
wbSize: 'M Розовый',
|
||
price: 2500,
|
||
discountedPrice: 2000,
|
||
quantity: 50,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
nmID: 987654321,
|
||
vendorCode: 'SKU002',
|
||
title: 'Платье черное вечернее',
|
||
description: 'Элегантное вечернее платье для особых случаев',
|
||
brand: 'Fashion',
|
||
object: 'Платья',
|
||
parent: 'Одежда',
|
||
countryProduction: 'Россия',
|
||
supplierVendorCode: 'SUPPLIER-002',
|
||
mediaFiles: ['/api/placeholder/400/403'],
|
||
dimensions: {
|
||
length: 35, // 35 см
|
||
width: 28, // 28 см
|
||
height: 6, // 6 см
|
||
weightBrutto: 0.4, // 400г
|
||
isValid: true,
|
||
},
|
||
sizes: [
|
||
{
|
||
chrtID: 987654,
|
||
techSize: 'M',
|
||
wbSize: 'M Черный',
|
||
price: 3500,
|
||
discountedPrice: 3000,
|
||
quantity: 30,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
nmID: 555666777,
|
||
vendorCode: 'SKU003',
|
||
title: 'Блузка белая офисная',
|
||
description: 'Классическая белая блузка для офиса',
|
||
brand: 'Office',
|
||
object: 'Блузки',
|
||
parent: 'Одежда',
|
||
countryProduction: 'Турция',
|
||
supplierVendorCode: 'SUPPLIER-003',
|
||
mediaFiles: ['/api/placeholder/400/405'],
|
||
sizes: [
|
||
{
|
||
chrtID: 555666,
|
||
techSize: 'L',
|
||
wbSize: 'L Белый',
|
||
price: 1800,
|
||
discountedPrice: 1500,
|
||
quantity: 40,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
nmID: 444333222,
|
||
vendorCode: 'SKU004',
|
||
title: 'Джинсы женские синие',
|
||
description: 'Классические женские джинсы прямого кроя',
|
||
brand: 'Denim',
|
||
object: 'Джинсы',
|
||
parent: 'Одежда',
|
||
countryProduction: 'Бангладеш',
|
||
supplierVendorCode: 'SUPPLIER-004',
|
||
mediaFiles: ['/api/placeholder/400/408'],
|
||
sizes: [
|
||
{
|
||
chrtID: 444333,
|
||
techSize: '30',
|
||
wbSize: '30 Синий',
|
||
price: 2800,
|
||
discountedPrice: 2300,
|
||
quantity: 25,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
nmID: 111222333,
|
||
vendorCode: 'SKU005',
|
||
title: 'Кроссовки женские белые',
|
||
description: 'Удобные женские кроссовки для повседневной носки',
|
||
brand: 'Sport',
|
||
object: 'Кроссовки',
|
||
parent: 'Обувь',
|
||
countryProduction: 'Вьетнам',
|
||
supplierVendorCode: 'SUPPLIER-005',
|
||
mediaFiles: ['/api/placeholder/400/410'],
|
||
sizes: [
|
||
{
|
||
chrtID: 111222,
|
||
techSize: '37',
|
||
wbSize: '37 Белый',
|
||
price: 3200,
|
||
discountedPrice: 2800,
|
||
quantity: 35,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
nmID: 777888999,
|
||
vendorCode: 'SKU006',
|
||
title: 'Сумка женская черная',
|
||
description: 'Стильная женская сумка из экокожи',
|
||
brand: 'Accessories',
|
||
object: 'Сумки',
|
||
parent: 'Аксессуары',
|
||
countryProduction: 'Китай',
|
||
supplierVendorCode: 'SUPPLIER-006',
|
||
mediaFiles: ['/api/placeholder/400/411'],
|
||
sizes: [
|
||
{
|
||
chrtID: 777888,
|
||
techSize: 'Универсальный',
|
||
wbSize: 'Черный',
|
||
price: 1500,
|
||
discountedPrice: 1200,
|
||
quantity: 60,
|
||
},
|
||
],
|
||
},
|
||
]
|
||
|
||
// Загружаем товары при инициализации
|
||
useEffect(() => {
|
||
loadCards()
|
||
}, [user])
|
||
|
||
// Загружаем услуги и расходники при выборе фулфилмента
|
||
useEffect(() => {
|
||
if (selectedFulfillmentId) {
|
||
console.warn('Загружаем услуги и расходники для фулфилмента:', selectedFulfillmentId)
|
||
loadOrganizationServices(selectedFulfillmentId)
|
||
loadOrganizationSupplies(selectedFulfillmentId)
|
||
}
|
||
}, [selectedFulfillmentId])
|
||
|
||
// Уведомляем об изменении стоимости услуг
|
||
useEffect(() => {
|
||
if (onServicesCostChange) {
|
||
const servicesCost = getServicesCost()
|
||
onServicesCostChange(servicesCost)
|
||
}
|
||
}, [selectedServices, selectedFulfillmentId, onServicesCostChange])
|
||
|
||
// Уведомляем об изменении общей стоимости товаров
|
||
useEffect(() => {
|
||
if (onItemsPriceChange) {
|
||
const totalItemsPrice = getTotalItemsCost()
|
||
onItemsPriceChange(totalItemsPrice)
|
||
}
|
||
}, [supplyItems, onItemsPriceChange])
|
||
|
||
// Уведомляем об изменении количества товаров
|
||
useEffect(() => {
|
||
if (onItemsCountChange) {
|
||
onItemsCountChange(supplyItems.length > 0)
|
||
}
|
||
}, [supplyItems.length, onItemsCountChange])
|
||
|
||
// Уведомляем об изменении стоимости расходников
|
||
useEffect(() => {
|
||
if (onConsumablesCostChange) {
|
||
const consumablesCost = getConsumablesCost()
|
||
onConsumablesCostChange(consumablesCost)
|
||
}
|
||
}, [selectedConsumables, selectedFulfillmentId, supplyItems.length, onConsumablesCostChange])
|
||
|
||
const loadCards = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const wbApiKey = user?.organization?.apiKeys?.find((key) => key.marketplace === 'WILDBERRIES')
|
||
|
||
if (wbApiKey?.isActive) {
|
||
const validationData = wbApiKey.validationData as Record<string, string>
|
||
const apiToken =
|
||
validationData?.token ||
|
||
validationData?.apiKey ||
|
||
validationData?.key ||
|
||
(wbApiKey as { apiKey?: string }).apiKey
|
||
|
||
if (apiToken) {
|
||
console.warn('Загружаем карточки из WB API...')
|
||
const cards = await WildberriesService.getAllCards(apiToken, 500)
|
||
|
||
// Логируем информацию о размерах товаров
|
||
cards.forEach((card) => {
|
||
if (card.dimensions) {
|
||
const volume =
|
||
(card.dimensions.length / 100) * (card.dimensions.width / 100) * (card.dimensions.height / 100)
|
||
console.warn(
|
||
`WB API: Карточка ${card.nmID} - размеры: ${card.dimensions.length}x${card.dimensions.width}x${
|
||
card.dimensions.height
|
||
} см, объем: ${volume.toFixed(6)} м³`,
|
||
)
|
||
} else {
|
||
console.warn(`WB API: Карточка ${card.nmID} - размеры отсутствуют`)
|
||
}
|
||
})
|
||
|
||
setWbCards(cards)
|
||
console.warn('Загружено карточек из WB API:', cards.length)
|
||
console.warn('Карточки с размерами:', cards.filter((card) => card.dimensions).length)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Если API ключ не настроен, показываем моковые данные
|
||
console.warn('API ключ WB не настроен, показываем моковые данные')
|
||
setWbCards(getMockCards())
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки карточек WB:', error)
|
||
// При ошибке API показываем моковые данные
|
||
setWbCards(getMockCards())
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const searchCards = async () => {
|
||
if (!searchTerm.trim()) {
|
||
loadCards()
|
||
return
|
||
}
|
||
|
||
setLoading(true)
|
||
try {
|
||
const wbApiKey = user?.organization?.apiKeys?.find((key) => key.marketplace === 'WILDBERRIES')
|
||
|
||
if (wbApiKey?.isActive) {
|
||
const validationData = wbApiKey.validationData as Record<string, string>
|
||
const apiToken =
|
||
validationData?.token ||
|
||
validationData?.apiKey ||
|
||
validationData?.key ||
|
||
(wbApiKey as { apiKey?: string }).apiKey
|
||
|
||
if (apiToken) {
|
||
console.warn('Поиск в WB API:', searchTerm)
|
||
const cards = await WildberriesService.searchCards(apiToken, searchTerm, 100)
|
||
|
||
// Логируем информацию о размерах найденных товаров
|
||
cards.forEach((card) => {
|
||
if (card.dimensions) {
|
||
const volume =
|
||
(card.dimensions.length / 100) * (card.dimensions.width / 100) * (card.dimensions.height / 100)
|
||
console.warn(
|
||
`WB API: Найденная карточка ${card.nmID} - размеры: ${
|
||
card.dimensions.length
|
||
}x${card.dimensions.width}x${card.dimensions.height} см, объем: ${volume.toFixed(6)} м³`,
|
||
)
|
||
} else {
|
||
console.warn(`WB API: Найденная карточка ${card.nmID} - размеры отсутствуют`)
|
||
}
|
||
})
|
||
|
||
setWbCards(cards)
|
||
console.warn('Найдено карточек в WB API:', cards.length)
|
||
console.warn('Найденные карточки с размерами:', cards.filter((card) => card.dimensions).length)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Если API ключ не настроен, ищем в моковых данных
|
||
console.warn('API ключ WB не настроен, поиск в моковых данных:', searchTerm)
|
||
const mockCards = getMockCards()
|
||
const filteredCards = mockCards.filter(
|
||
(card) =>
|
||
card.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
card.brand.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
card.nmID.toString().includes(searchTerm.toLowerCase()) ||
|
||
card.object?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||
)
|
||
setWbCards(filteredCards)
|
||
console.warn('Найдено моковых товаров:', 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.nmID.toString().includes(searchTerm.toLowerCase()) ||
|
||
card.object?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||
)
|
||
setWbCards(filteredCards)
|
||
console.warn('Найдено моковых товаров (fallback):', filteredCards.length)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
// Функции для работы с услугами и расходниками
|
||
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 addToSupply = (card: WildberriesCard) => {
|
||
const existingItem = supplyItems.find((item) => item.card.nmID === card.nmID)
|
||
if (existingItem) {
|
||
toast.info('Товар уже добавлен в поставку')
|
||
return
|
||
}
|
||
|
||
const newItem: SupplyItem = {
|
||
card,
|
||
quantity: 0,
|
||
pricePerUnit: 0,
|
||
totalPrice: 0,
|
||
supplierId: '',
|
||
priceType: 'perUnit',
|
||
}
|
||
|
||
setSupplyItems((prev) => [...prev, newItem])
|
||
toast.success('Товар добавлен в поставку')
|
||
}
|
||
|
||
const removeFromSupply = (nmID: number) => {
|
||
setSupplyItems((prev) => prev.filter((item) => item.card.nmID !== nmID))
|
||
}
|
||
|
||
const updateSupplyItem = (nmID: number, field: keyof SupplyItem, value: string | number) => {
|
||
setSupplyItems((prev) => {
|
||
const newItems = prev.map((item) => {
|
||
if (item.card.nmID === nmID) {
|
||
const updatedItem = { ...item, [field]: value }
|
||
|
||
// Пересчитываем totalPrice в зависимости от типа цены
|
||
if (field === 'quantity' || field === 'pricePerUnit' || field === 'priceType') {
|
||
if (updatedItem.priceType === 'perUnit') {
|
||
// Цена за штуку - умножаем на количество
|
||
updatedItem.totalPrice = updatedItem.quantity * updatedItem.pricePerUnit
|
||
} else {
|
||
// Цена за общее количество - pricePerUnit становится общей ценой
|
||
updatedItem.totalPrice = updatedItem.pricePerUnit
|
||
}
|
||
}
|
||
return updatedItem
|
||
}
|
||
return item
|
||
})
|
||
|
||
// Если изменился поставщик, уведомляем родительский компонент асинхронно
|
||
if (field === 'supplierId' && onSuppliersChange) {
|
||
// Создаем список поставщиков с информацией о выборе
|
||
const suppliersInfo = suppliers.map((supplier) => ({
|
||
...supplier,
|
||
selected: newItems.some((item) => item.supplierId === supplier.id),
|
||
}))
|
||
|
||
console.warn('Обновление поставщиков из updateSupplyItem:', suppliersInfo)
|
||
|
||
// Вызываем асинхронно чтобы не обновлять состояние во время рендера
|
||
setTimeout(() => {
|
||
onSuppliersChange(suppliersInfo)
|
||
}, 0)
|
||
}
|
||
|
||
return newItems
|
||
})
|
||
}
|
||
|
||
// Валидация полей поставщика
|
||
const validateSupplierField = (field: string, value: string) => {
|
||
let error = ''
|
||
switch (field) {
|
||
case 'name':
|
||
if (!value.trim()) error = 'Название обязательно'
|
||
else if (value.length < 2) error = 'Минимум 2 символа'
|
||
break
|
||
case 'contactName':
|
||
if (!value.trim()) error = 'Имя обязательно'
|
||
else if (value.length < 2) error = 'Минимум 2 символа'
|
||
break
|
||
case 'phone':
|
||
if (!value.trim()) error = 'Телефон обязателен'
|
||
else if (!isValidPhone(value)) error = 'Неверный формат телефона'
|
||
break
|
||
case 'telegram':
|
||
if (value && !value.match(/^@[a-zA-Z0-9_]{5,32}$/)) {
|
||
error = 'Формат: @username (5-32 символа)'
|
||
}
|
||
break
|
||
}
|
||
|
||
setSupplierErrors((prev) => ({ ...prev, [field]: error }))
|
||
return error === ''
|
||
}
|
||
|
||
const validateAllSupplierFields = () => {
|
||
const nameValid = validateSupplierField('name', newSupplier.name)
|
||
const contactNameValid = validateSupplierField('contactName', newSupplier.contactName)
|
||
const phoneValid = validateSupplierField('phone', newSupplier.phone)
|
||
const telegramValid = validateSupplierField('telegram', newSupplier.telegram)
|
||
return nameValid && contactNameValid && phoneValid && telegramValid
|
||
}
|
||
|
||
// Работа с поставщиками
|
||
const handleCreateSupplier = async () => {
|
||
if (!validateAllSupplierFields()) {
|
||
toast.error('Исправьте ошибки в форме')
|
||
return
|
||
}
|
||
|
||
try {
|
||
await createSupplierMutation({
|
||
variables: {
|
||
input: {
|
||
name: newSupplier.name,
|
||
contactName: newSupplier.contactName,
|
||
phone: newSupplier.phone,
|
||
market: newSupplier.market || null,
|
||
address: newSupplier.address || null,
|
||
place: newSupplier.place || null,
|
||
telegram: newSupplier.telegram || null,
|
||
},
|
||
},
|
||
})
|
||
} catch (error) {
|
||
// Ошибка обрабатывается в onError мутации
|
||
}
|
||
}
|
||
|
||
// Расчеты для нового блока
|
||
const getTotalSum = () => {
|
||
return goodsPrice + fulfillmentServicesPrice + logisticsPrice
|
||
}
|
||
|
||
// Оригинальные расчеты
|
||
const getTotalQuantity = () => {
|
||
return supplyItems.reduce((sum, item) => sum + item.quantity, 0)
|
||
}
|
||
|
||
// Функция для расчета объема одного товара в м³
|
||
const calculateItemVolume = (card: WildberriesCard): number => {
|
||
if (!card.dimensions) return 0
|
||
|
||
const { length, width, height } = card.dimensions
|
||
|
||
// Проверяем что все размеры указаны и больше 0
|
||
if (!length || !width || !height || length <= 0 || width <= 0 || height <= 0) {
|
||
return 0
|
||
}
|
||
|
||
// Переводим из сантиметров в метры и рассчитываем объем
|
||
const volumeInM3 = (length / 100) * (width / 100) * (height / 100)
|
||
|
||
return volumeInM3
|
||
}
|
||
|
||
// Функция для расчета общего объема всех товаров в поставке
|
||
const getTotalVolume = () => {
|
||
return supplyItems.reduce((totalVolume, item) => {
|
||
const itemVolume = calculateItemVolume(item.card)
|
||
return totalVolume + itemVolume * item.quantity
|
||
}, 0)
|
||
}
|
||
|
||
const getTotalItemsCost = () => {
|
||
return supplyItems.reduce((sum, item) => sum + item.totalPrice, 0)
|
||
}
|
||
|
||
const getServicesCost = () => {
|
||
if (!selectedFulfillmentId || selectedServices.length === 0) return 0
|
||
|
||
const services = organizationServices[selectedFulfillmentId] || []
|
||
return (
|
||
selectedServices.reduce((sum, serviceId) => {
|
||
const service = services.find((s) => s.id === serviceId)
|
||
return sum + (service ? service.price : 0)
|
||
}, 0) * getTotalQuantity()
|
||
)
|
||
}
|
||
|
||
const getConsumablesCost = () => {
|
||
if (!selectedFulfillmentId || selectedConsumables.length === 0) return 0
|
||
|
||
const supplies = organizationSupplies[selectedFulfillmentId] || []
|
||
return (
|
||
selectedConsumables.reduce((sum, supplyId) => {
|
||
const supply = supplies.find((s) => s.id === supplyId)
|
||
return sum + (supply ? supply.price : 0)
|
||
}, 0) * getTotalQuantity()
|
||
)
|
||
}
|
||
|
||
const formatCurrency = (amount: number) => {
|
||
return new Intl.NumberFormat('ru-RU', {
|
||
style: 'currency',
|
||
currency: 'RUB',
|
||
minimumFractionDigits: 0,
|
||
}).format(amount)
|
||
}
|
||
|
||
// Создание поставки
|
||
const handleCreateSupplyInternal = async () => {
|
||
if (supplyItems.length === 0) {
|
||
toast.error('Добавьте товары в поставку')
|
||
return
|
||
}
|
||
|
||
if (!deliveryDateOriginal) {
|
||
toast.error('Выберите дату поставки')
|
||
return
|
||
}
|
||
|
||
if (supplyItems.some((item) => item.quantity <= 0 || item.pricePerUnit <= 0)) {
|
||
toast.error('Укажите количество и цену для всех товаров')
|
||
return
|
||
}
|
||
|
||
try {
|
||
const supplyInput = {
|
||
deliveryDate: deliveryDateOriginal.toISOString().split('T')[0],
|
||
cards: supplyItems.map((item) => ({
|
||
nmId: item.card.nmID.toString(),
|
||
vendorCode: item.card.vendorCode,
|
||
title: item.card.title,
|
||
brand: item.card.brand,
|
||
selectedQuantity: item.quantity,
|
||
customPrice: item.totalPrice,
|
||
selectedFulfillmentOrg: selectedFulfillmentOrg,
|
||
selectedFulfillmentServices: selectedServices,
|
||
selectedConsumableOrg: selectedFulfillmentOrg,
|
||
selectedConsumableServices: selectedConsumables,
|
||
deliveryDate: deliveryDateOriginal.toISOString().split('T')[0],
|
||
mediaFiles: item.card.mediaFiles,
|
||
})),
|
||
}
|
||
|
||
await createSupply({ variables: { input: supplyInput } })
|
||
toast.success('Поставка успешно создана!')
|
||
onComplete()
|
||
} catch (error) {
|
||
console.error('Error creating supply:', error)
|
||
toast.error('Ошибка при создании поставки')
|
||
}
|
||
}
|
||
|
||
// Обработка внешнего вызова создания поставки
|
||
React.useEffect(() => {
|
||
if (isCreatingSupply) {
|
||
handleCreateSupplyInternal()
|
||
}
|
||
}, [isCreatingSupply])
|
||
|
||
// Уведомление об изменении объема товаров
|
||
React.useEffect(() => {
|
||
const totalVolume = getTotalVolume()
|
||
if (onVolumeChange) {
|
||
onVolumeChange(totalVolume)
|
||
}
|
||
}, [supplyItems, onVolumeChange])
|
||
|
||
// Загрузка поставщиков из правильного источника
|
||
React.useEffect(() => {
|
||
if (suppliersData?.supplySuppliers) {
|
||
console.warn('Загружаем поставщиков из БД:', suppliersData.supplySuppliers)
|
||
setSuppliers(suppliersData.supplySuppliers)
|
||
|
||
// Проверяем есть ли уже выбранные поставщики и уведомляем родителя
|
||
if (onSuppliersChange && supplyItems.length > 0) {
|
||
const suppliersInfo = suppliersData.supplySuppliers.map((supplier: { id: string; selected?: boolean }) => ({
|
||
...supplier,
|
||
selected: supplyItems.some((item) => item.supplierId === supplier.id),
|
||
}))
|
||
|
||
if (suppliersInfo.some((s: { selected?: boolean }) => s.selected)) {
|
||
console.warn('Найдены выбранные поставщики при загрузке:', suppliersInfo)
|
||
|
||
// Вызываем асинхронно чтобы не обновлять состояние во время рендера
|
||
setTimeout(() => {
|
||
onSuppliersChange(suppliersInfo)
|
||
}, 0)
|
||
}
|
||
}
|
||
}
|
||
}, [suppliersData])
|
||
|
||
// Обновление статуса возможности создания поставки
|
||
React.useEffect(() => {
|
||
const canCreate =
|
||
supplyItems.length > 0 &&
|
||
deliveryDateOriginal !== null &&
|
||
supplyItems.every((item) => item.quantity > 0 && item.pricePerUnit > 0)
|
||
|
||
if (onCanCreateSupplyChange) {
|
||
onCanCreateSupplyChange(canCreate)
|
||
}
|
||
}, [supplyItems, deliveryDateOriginal, onCanCreateSupplyChange])
|
||
|
||
const fulfillmentOrgs = (counterpartiesData?.myCounterparties || []).filter(
|
||
(org: Organization) => org.type === 'FULFILLMENT',
|
||
)
|
||
const markets = [
|
||
{ value: 'sadovod', label: 'Садовод' },
|
||
{ value: 'tyak-moscow', label: 'ТЯК Москва' },
|
||
]
|
||
|
||
return (
|
||
<>
|
||
<style>{lineClampStyles}</style>
|
||
<div className="flex flex-col h-full space-y-2 w-full min-h-0">
|
||
{/* Элегантный блок поиска и товаров */}
|
||
<div className="relative">
|
||
{/* Главная карточка с градиентом */}
|
||
<div className="bg-gradient-to-br from-white/15 via-white/10 to-white/5 backdrop-blur-xl border border-white/20 rounded-2xl p-4 shadow-2xl">
|
||
{/* Компактный заголовок с поиском */}
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="flex items-center space-x-3">
|
||
<div className="w-8 h-8 bg-gradient-to-r from-purple-500 to-blue-500 rounded-lg flex items-center justify-center shadow-lg">
|
||
<Search className="h-4 w-4 text-white" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-white font-semibold text-base">Каталог товаров</h3>
|
||
<p className="text-white/60 text-xs">Найдено: {wbCards.length}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Поиск в заголовке */}
|
||
<div className="flex items-center space-x-3 flex-1 max-w-md ml-4">
|
||
<div className="relative flex-1">
|
||
<Input
|
||
placeholder="Поиск товаров..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="pl-3 pr-16 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:bg-white/15 focus:border-white/40 text-sm h-8"
|
||
onKeyPress={(e) => e.key === 'Enter' && searchCards()}
|
||
/>
|
||
<Button
|
||
onClick={searchCards}
|
||
disabled={loading}
|
||
className="absolute right-1 top-1 h-6 px-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600 text-white border-0 rounded text-xs"
|
||
>
|
||
{loading ? (
|
||
<div className="animate-spin rounded-full h-3 w-3 border border-white/30 border-t-white"></div>
|
||
) : (
|
||
'Найти'
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Статистика в поставке */}
|
||
{supplyItems.length > 0 && (
|
||
<div className="bg-gradient-to-r from-purple-500/20 to-blue-500/20 backdrop-blur border border-purple-400/30 rounded-lg px-3 py-1 ml-3">
|
||
<div className="flex items-center space-x-2">
|
||
<div className="w-1.5 h-1.5 bg-purple-400 rounded-full animate-pulse"></div>
|
||
<span className="text-purple-200 font-medium text-xs">В поставке: {supplyItems.length}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Сетка товаров */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3">
|
||
{loading ? (
|
||
// Красивые skeleton-карточки
|
||
[...Array(16)].map((_, i) => (
|
||
<div key={i} className="group">
|
||
<div className="aspect-[3/4] bg-gradient-to-br from-white/10 to-white/5 rounded-xl animate-pulse">
|
||
<div className="w-full h-full bg-white/5 rounded-xl"></div>
|
||
</div>
|
||
<div className="mt-1 px-1">
|
||
<div className="h-3 bg-white/10 rounded animate-pulse"></div>
|
||
</div>
|
||
</div>
|
||
))
|
||
) : wbCards.length > 0 ? (
|
||
// Красивые карточки товаров
|
||
wbCards.map((card) => {
|
||
const isInSupply = supplyItems.some((item) => item.card.nmID === card.nmID)
|
||
return (
|
||
<div
|
||
key={card.nmID}
|
||
className={`group cursor-pointer transition-all duration-300 hover:scale-105 ${
|
||
isInSupply ? 'scale-105' : ''
|
||
}`}
|
||
onClick={() => addToSupply(card)}
|
||
>
|
||
{/* Карточка товара */}
|
||
<div
|
||
className={`relative aspect-[3/4] rounded-xl overflow-hidden shadow-lg transition-all duration-300 ${
|
||
isInSupply
|
||
? 'ring-2 ring-purple-400 shadow-purple-400/25 bg-gradient-to-br from-purple-500/20 to-blue-500/20'
|
||
: 'bg-white/10 hover:bg-white/15 hover:shadow-xl'
|
||
}`}
|
||
>
|
||
<img
|
||
src={WildberriesService.getCardImage(card, 'c516x688') || '/api/placeholder/200/267'}
|
||
alt={card.title}
|
||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||
loading="lazy"
|
||
/>
|
||
|
||
{/* Градиентный оверлей */}
|
||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||
|
||
{/* Информация при наведении */}
|
||
<div className="absolute bottom-0 left-0 right-0 p-3 transform translate-y-full group-hover:translate-y-0 transition-transform duration-300">
|
||
<h4 className="text-white font-medium text-sm line-clamp-2 mb-1">{card.title}</h4>
|
||
<p className="text-white/80 text-xs">WB: {card.nmID}</p>
|
||
</div>
|
||
|
||
{/* Индикаторы */}
|
||
{isInSupply ? (
|
||
<div className="absolute top-3 right-3 w-8 h-8 bg-gradient-to-r from-purple-500 to-blue-500 rounded-full flex items-center justify-center shadow-lg">
|
||
<svg className="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||
clipRule="evenodd"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
) : (
|
||
<div className="absolute top-3 right-3 w-8 h-8 bg-white/20 backdrop-blur rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||
<Plus className="w-4 h-4 text-white" />
|
||
</div>
|
||
)}
|
||
|
||
{/* Эффект при клике */}
|
||
<div className="absolute inset-0 bg-white/20 opacity-0 group-active:opacity-100 transition-opacity duration-150" />
|
||
</div>
|
||
|
||
{/* Название под карточкой */}
|
||
<div className="mt-1 px-1">
|
||
<h4 className="text-white/90 font-medium text-xs line-clamp-2 leading-tight">{card.title}</h4>
|
||
</div>
|
||
</div>
|
||
)
|
||
})
|
||
) : (
|
||
// Пустое состояние
|
||
<div className="col-span-full flex flex-col items-center justify-center py-8">
|
||
<div className="w-16 h-16 bg-gradient-to-r from-purple-500/20 to-blue-500/20 rounded-2xl flex items-center justify-center mb-3">
|
||
<Package className="w-8 h-8 text-white/40" />
|
||
</div>
|
||
<h3 className="text-white/80 font-medium text-base mb-1">
|
||
{searchTerm ? 'Товары не найдены' : 'Начните поиск товаров'}
|
||
</h3>
|
||
<p className="text-white/50 text-sm text-center max-w-md">
|
||
{searchTerm ? 'Попробуйте изменить поисковый запрос' : 'Введите название товара в поле поиска'}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Декоративные элементы */}
|
||
<div className="absolute -top-1 -left-1 w-4 h-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-full opacity-60 animate-pulse" />
|
||
<div className="absolute -bottom-1 -right-1 w-3 h-3 bg-gradient-to-r from-blue-500 to-purple-500 rounded-full opacity-40 animate-pulse delay-700" />
|
||
</div>
|
||
|
||
{/* Услуги и расходники в одной строке */}
|
||
{selectedFulfillmentOrg && (
|
||
<Card className="bg-white/10 backdrop-blur border-white/20 p-2">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
|
||
<div>
|
||
<div className="text-white/80 mb-1">Услуги фулфилмента:</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{organizationServices[selectedFulfillmentOrg] ? (
|
||
organizationServices[selectedFulfillmentOrg].map((service) => (
|
||
<label
|
||
key={service.id}
|
||
className="flex items-center space-x-1 cursor-pointer bg-white/5 rounded px-2 py-1 hover:bg-white/10"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedServices.includes(service.id)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setSelectedServices((prev) => [...prev, service.id])
|
||
} else {
|
||
setSelectedServices((prev) => prev.filter((id) => id !== service.id))
|
||
}
|
||
}}
|
||
className="w-3 h-3"
|
||
/>
|
||
<span className="text-white text-xs">
|
||
{service.name} ({service.price}₽)
|
||
</span>
|
||
</label>
|
||
))
|
||
) : (
|
||
<span className="text-white/60">Загрузка...</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="text-white/80 mb-1">Расходные материалы:</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{organizationSupplies[selectedFulfillmentOrg] ? (
|
||
organizationSupplies[selectedFulfillmentOrg].map((supply) => (
|
||
<label
|
||
key={supply.id}
|
||
className="flex items-center space-x-1 cursor-pointer bg-white/5 rounded px-2 py-1 hover:bg-white/10"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedConsumables.includes(supply.id)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setSelectedConsumables((prev) => [...prev, supply.id])
|
||
} else {
|
||
setSelectedConsumables((prev) => prev.filter((id) => id !== supply.id))
|
||
}
|
||
}}
|
||
className="w-3 h-3"
|
||
/>
|
||
<span className="text-white text-xs">
|
||
{supply.name} ({supply.price}₽)
|
||
</span>
|
||
</label>
|
||
))
|
||
) : (
|
||
<span className="text-white/60">Загрузка...</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Модуль товаров в поставке - растягивается до низа */}
|
||
<Card className="bg-white/10 backdrop-blur border-white/20 p-2 flex-1 flex flex-col min-h-0">
|
||
<div className="flex items-center justify-between mb-2 flex-shrink-0">
|
||
<span className="text-white font-medium text-sm">Товары в поставке</span>
|
||
{supplyItems.length > 0 && (
|
||
<span className="text-blue-400 text-xs font-medium bg-blue-500/20 px-2 py-1 rounded">
|
||
∑ {getTotalVolume().toFixed(4)} м³
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{supplyItems.length === 0 ? (
|
||
<div className="flex-1 flex items-center justify-center">
|
||
<div className="text-center">
|
||
<Package className="h-8 w-8 text-white/20 mx-auto mb-2" />
|
||
<p className="text-white/60 text-xs">Добавьте товары из карточек выше</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex-1 overflow-y-auto space-y-1">
|
||
{supplyItems.map((item) => (
|
||
<Card key={item.card.nmID} className="bg-white/5 border-white/10 p-1.5">
|
||
{/* Компактный заголовок товара */}
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="flex flex-col space-y-1 min-w-0 flex-1">
|
||
<div className="text-white font-medium text-xs line-clamp-1 truncate">{item.card.title}</div>
|
||
<div className="text-white/60 text-[10px] flex space-x-2">
|
||
<span>WB: {item.card.nmID}</span>
|
||
{calculateItemVolume(item.card) > 0 ? (
|
||
<span className="text-blue-400">
|
||
| {(calculateItemVolume(item.card) * item.quantity).toFixed(4)} м³
|
||
</span>
|
||
) : (
|
||
<span className="text-orange-400">| размеры не указаны</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
onClick={() => removeFromSupply(item.card.nmID)}
|
||
size="sm"
|
||
variant="ghost"
|
||
className="h-5 w-5 p-0 text-white/60 hover:text-red-400 flex-shrink-0"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Компактные названия блоков */}
|
||
<div className="grid grid-cols-8 gap-1 mb-1">
|
||
<div className="text-white/80 text-[9px] font-medium text-center">Товар</div>
|
||
<div className="text-white/80 text-[9px] font-medium text-center">Параметры</div>
|
||
<div className="text-white/80 text-[9px] font-medium text-center">Заказать</div>
|
||
<div className="text-white/80 text-[9px] font-medium text-center">Цена</div>
|
||
<div className="text-white/80 text-[9px] font-medium text-center">Услуги фулфилмента</div>
|
||
<div className="text-white/80 text-[9px] font-medium text-center">Поставщик</div>
|
||
<div className="text-white/80 text-[9px] font-medium text-center">Расходники фулфилмента</div>
|
||
<div className="text-white/80 text-[9px] font-medium text-center">Расходники</div>
|
||
</div>
|
||
|
||
{/* Компактная сетка блоков */}
|
||
<div className="grid grid-cols-8 gap-1">
|
||
{/* Блок 1: Картинка товара */}
|
||
<div className="bg-white/10 rounded-lg overflow-hidden relative h-20">
|
||
<img
|
||
src={WildberriesService.getCardImage(item.card, 'c246x328') || '/api/placeholder/60/60'}
|
||
alt={item.card.title}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
|
||
{/* Блок 2: Параметры */}
|
||
<div className="bg-white/10 rounded-lg p-3 flex flex-col justify-center h-20">
|
||
<div className="flex flex-wrap gap-1 justify-center items-center">
|
||
{/* Создаем массив валидных параметров */}
|
||
{(() => {
|
||
const params = []
|
||
|
||
// Бренд
|
||
if (item.card.brand && item.card.brand.trim() && item.card.brand !== '0') {
|
||
params.push({
|
||
value: item.card.brand,
|
||
color: 'bg-blue-500/80',
|
||
key: 'brand',
|
||
})
|
||
}
|
||
|
||
// Категория (объект)
|
||
if (item.card.object && item.card.object.trim() && item.card.object !== '0') {
|
||
params.push({
|
||
value: item.card.object,
|
||
color: 'bg-green-500/80',
|
||
key: 'object',
|
||
})
|
||
}
|
||
|
||
// Страна (только если не пустая и не 0)
|
||
if (
|
||
item.card.countryProduction &&
|
||
item.card.countryProduction.trim() &&
|
||
item.card.countryProduction !== '0'
|
||
) {
|
||
params.push({
|
||
value: item.card.countryProduction,
|
||
color: 'bg-purple-500/80',
|
||
key: 'country',
|
||
})
|
||
}
|
||
|
||
// Цена WB
|
||
if (item.card.sizes?.[0]?.price && item.card.sizes[0].price > 0) {
|
||
params.push({
|
||
value: formatCurrency(item.card.sizes[0].price),
|
||
color: 'bg-yellow-500/80',
|
||
key: 'price',
|
||
})
|
||
}
|
||
|
||
// Внутренний артикул
|
||
if (item.card.vendorCode && item.card.vendorCode.trim() && item.card.vendorCode !== '0') {
|
||
params.push({
|
||
value: item.card.vendorCode,
|
||
color: 'bg-gray-500/80',
|
||
key: 'vendor',
|
||
})
|
||
}
|
||
|
||
// НАМЕРЕННО НЕ ВКЛЮЧАЕМ techSize и wbSize так как они равны '0'
|
||
|
||
return params.map((param) => (
|
||
<span
|
||
key={param.key}
|
||
className={`${param.color} text-white text-[9px] px-2 py-1 rounded font-medium`}
|
||
>
|
||
{param.value}
|
||
</span>
|
||
))
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Блок 3: Заказать */}
|
||
<div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20">
|
||
<div className="text-white/60 text-xs mb-2 text-center">Количество</div>
|
||
<Input
|
||
type="number"
|
||
value={item.quantity}
|
||
onChange={(e) => updateSupplyItem(item.card.nmID, 'quantity', parseInt(e.target.value) || 0)}
|
||
className="bg-purple-500/20 border-purple-400/30 text-white text-center h-8 text-sm font-bold"
|
||
min="1"
|
||
/>
|
||
</div>
|
||
|
||
{/* Блок 4: Цена */}
|
||
<div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20">
|
||
{/* Переключатель типа цены */}
|
||
<div className="flex mb-1">
|
||
<button
|
||
onClick={() => updateSupplyItem(item.card.nmID, 'priceType', 'perUnit')}
|
||
className={`text-[9px] px-1 py-0.5 rounded-l ${
|
||
item.priceType === 'perUnit' ? 'bg-blue-500 text-white' : 'bg-white/20 text-white/60'
|
||
}`}
|
||
>
|
||
За шт
|
||
</button>
|
||
<button
|
||
onClick={() => updateSupplyItem(item.card.nmID, 'priceType', 'total')}
|
||
className={`text-[9px] px-1 py-0.5 rounded-r ${
|
||
item.priceType === 'total' ? 'bg-blue-500 text-white' : 'bg-white/20 text-white/60'
|
||
}`}
|
||
>
|
||
За все
|
||
</button>
|
||
</div>
|
||
|
||
<Input
|
||
type="number"
|
||
value={item.pricePerUnit || ''}
|
||
onChange={(e) =>
|
||
updateSupplyItem(item.card.nmID, 'pricePerUnit', parseFloat(e.target.value) || 0)
|
||
}
|
||
className="bg-white/20 border-white/20 text-white text-center h-7 text-xs"
|
||
placeholder="₽"
|
||
/>
|
||
<div className="text-white/80 text-xs font-medium text-center mt-1">
|
||
Итого: {formatCurrency(item.totalPrice).replace(' ₽', '₽')}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Блок 5: Услуги фулфилмента */}
|
||
<div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20">
|
||
<div className="space-y-1 max-h-16 overflow-y-auto">
|
||
{/* DEBUG */}
|
||
{console.warn('DEBUG SERVICES:', {
|
||
selectedFulfillmentId,
|
||
hasServices: !!organizationServices[selectedFulfillmentId],
|
||
servicesCount: organizationServices[selectedFulfillmentId]?.length || 0,
|
||
allOrganizationServices: Object.keys(organizationServices),
|
||
})}
|
||
{selectedFulfillmentId && organizationServices[selectedFulfillmentId] ? (
|
||
organizationServices[selectedFulfillmentId].slice(0, 3).map((service) => (
|
||
<label
|
||
key={service.id}
|
||
className="flex items-center justify-between cursor-pointer text-xs"
|
||
>
|
||
<div className="flex items-center space-x-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedServices.includes(service.id)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setSelectedServices((prev) => [...prev, service.id])
|
||
} else {
|
||
setSelectedServices((prev) => prev.filter((id) => id !== service.id))
|
||
}
|
||
}}
|
||
className="w-3 h-3"
|
||
/>
|
||
<span className="text-white text-[10px]">{service.name.substring(0, 10)}</span>
|
||
</div>
|
||
<span className="text-green-400 text-[10px] font-medium">
|
||
{service.price ? `${service.price}₽` : 'Бесплатно'}
|
||
</span>
|
||
</label>
|
||
))
|
||
) : (
|
||
<span className="text-white/60 text-xs text-center">
|
||
{selectedFulfillmentId ? 'Нет услуг' : 'Выберите фулфилмент'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Блок 6: Поставщик */}
|
||
<div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20">
|
||
<div className="space-y-1">
|
||
<Select
|
||
value={item.supplierId}
|
||
onValueChange={(value) => updateSupplyItem(item.card.nmID, 'supplierId', value)}
|
||
>
|
||
<SelectTrigger className="bg-white/20 border-white/20 text-white h-6 text-xs">
|
||
<SelectValue placeholder="Выбрать" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{suppliers.map((supplier) => (
|
||
<SelectItem key={supplier.id} value={supplier.id}>
|
||
{supplier.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
{/* Компактная информация о выбранном поставщике */}
|
||
{item.supplierId && suppliers.find((s) => s.id === item.supplierId) ? (
|
||
<div className="text-center">
|
||
<div className="text-white/80 text-[10px] font-medium truncate">
|
||
{suppliers.find((s) => s.id === item.supplierId)?.contactName}
|
||
</div>
|
||
<div className="text-white/60 text-[9px] truncate">
|
||
{suppliers.find((s) => s.id === item.supplierId)?.phone}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<Button
|
||
onClick={() => setShowSupplierModal(true)}
|
||
variant="outline"
|
||
size="sm"
|
||
className="bg-white/5 border-white/20 text-white hover:bg-white/10 h-5 px-2 text-[10px] w-full"
|
||
>
|
||
<Plus className="h-2 w-2 mr-1" />
|
||
Добавить
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Блок 7: Расходники фулфилмента */}
|
||
<div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20">
|
||
<div className="space-y-1 max-h-16 overflow-y-auto">
|
||
{/* DEBUG для расходников */}
|
||
{console.warn('DEBUG CONSUMABLES:', {
|
||
selectedFulfillmentId,
|
||
hasConsumables: !!organizationSupplies[selectedFulfillmentId],
|
||
consumablesCount: organizationSupplies[selectedFulfillmentId]?.length || 0,
|
||
allOrganizationSupplies: Object.keys(organizationSupplies),
|
||
})}
|
||
{selectedFulfillmentId && organizationSupplies[selectedFulfillmentId] ? (
|
||
organizationSupplies[selectedFulfillmentId].slice(0, 3).map((supply) => (
|
||
<label key={supply.id} className="flex items-center justify-between cursor-pointer text-xs">
|
||
<div className="flex items-center space-x-2">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedConsumables.includes(supply.id)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setSelectedConsumables((prev) => [...prev, supply.id])
|
||
} else {
|
||
setSelectedConsumables((prev) => prev.filter((id) => id !== supply.id))
|
||
}
|
||
}}
|
||
className="w-3 h-3"
|
||
/>
|
||
<span className="text-white text-[10px]">{supply.name.substring(0, 10)}</span>
|
||
</div>
|
||
<span className="text-orange-400 text-[10px] font-medium">
|
||
{supply.price ? `${supply.price}₽` : 'Бесплатно'}
|
||
</span>
|
||
</label>
|
||
))
|
||
) : (
|
||
<span className="text-white/60 text-xs text-center">
|
||
{selectedFulfillmentId ? 'Нет расходников' : 'Выберите фулфилмент'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Блок 8: Расходники селлера */}
|
||
<div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20">
|
||
<div className="space-y-2">
|
||
<label className="flex items-center space-x-2 cursor-pointer">
|
||
<input type="checkbox" className="w-3 h-3" />
|
||
<span className="text-white text-xs">Расходники</span>
|
||
</label>
|
||
<label className="flex items-center space-x-2 cursor-pointer">
|
||
<input type="checkbox" className="w-3 h-3" />
|
||
<span className="text-white text-xs">Этикетки</span>
|
||
</label>
|
||
<label className="flex items-center space-x-2 cursor-pointer">
|
||
<input type="checkbox" className="w-3 h-3" />
|
||
<span className="text-white text-xs">Пакеты</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* Модальное окно создания поставщика */}
|
||
<Dialog open={showSupplierModal} onOpenChange={setShowSupplierModal}>
|
||
<DialogContent className="glass-card border-white/10 max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white">Добавить поставщика</DialogTitle>
|
||
<p className="text-white/60 text-xs">Контактная информация поставщика для этой поставки</p>
|
||
</DialogHeader>
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label className="text-white/60 text-xs">Название *</Label>
|
||
<Input
|
||
value={newSupplier.name}
|
||
onChange={(e) => {
|
||
const value = formatNameInput(e.target.value)
|
||
setNewSupplier((prev) => ({
|
||
...prev,
|
||
name: value,
|
||
}))
|
||
validateSupplierField('name', value)
|
||
}}
|
||
className={`bg-white/10 border-white/20 text-white h-8 text-xs ${
|
||
supplierErrors.name ? 'border-red-400 focus:border-red-400' : ''
|
||
}`}
|
||
placeholder="Название"
|
||
/>
|
||
{supplierErrors.name && <p className="text-red-400 text-xs mt-1">{supplierErrors.name}</p>}
|
||
</div>
|
||
<div>
|
||
<Label className="text-white/60 text-xs">Имя *</Label>
|
||
<Input
|
||
value={newSupplier.contactName}
|
||
onChange={(e) => {
|
||
const value = formatNameInput(e.target.value)
|
||
setNewSupplier((prev) => ({
|
||
...prev,
|
||
contactName: value,
|
||
}))
|
||
validateSupplierField('contactName', value)
|
||
}}
|
||
className={`bg-white/10 border-white/20 text-white h-8 text-xs ${
|
||
supplierErrors.contactName ? 'border-red-400 focus:border-red-400' : ''
|
||
}`}
|
||
placeholder="Имя"
|
||
/>
|
||
{supplierErrors.contactName && (
|
||
<p className="text-red-400 text-xs mt-1">{supplierErrors.contactName}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label className="text-white/60 text-xs">Телефон *</Label>
|
||
<PhoneInput
|
||
value={newSupplier.phone}
|
||
onChange={(value) => {
|
||
setNewSupplier((prev) => ({
|
||
...prev,
|
||
phone: value,
|
||
}))
|
||
validateSupplierField('phone', value)
|
||
}}
|
||
className={`bg-white/10 border-white/20 text-white h-8 text-xs ${
|
||
supplierErrors.phone ? 'border-red-400 focus:border-red-400' : ''
|
||
}`}
|
||
placeholder="+7 (999) 123-45-67"
|
||
/>
|
||
{supplierErrors.phone && <p className="text-red-400 text-xs mt-1">{supplierErrors.phone}</p>}
|
||
</div>
|
||
<div>
|
||
<Label className="text-white/60 text-xs">Рынок</Label>
|
||
<Select
|
||
value={newSupplier.market}
|
||
onValueChange={(value) => setNewSupplier((prev) => ({ ...prev, market: value }))}
|
||
>
|
||
<SelectTrigger className="bg-white/10 border-white/20 text-white h-8 text-xs">
|
||
<SelectValue placeholder="Рынок" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{markets.map((market) => (
|
||
<SelectItem key={market.value} value={market.value}>
|
||
{market.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label className="text-white/60 text-xs">Адрес</Label>
|
||
<Input
|
||
value={newSupplier.address}
|
||
onChange={(e) =>
|
||
setNewSupplier((prev) => ({
|
||
...prev,
|
||
address: e.target.value,
|
||
}))
|
||
}
|
||
className="bg-white/10 border-white/20 text-white h-8 text-xs"
|
||
placeholder="Адрес"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-white/60 text-xs">Место</Label>
|
||
<Input
|
||
value={newSupplier.place}
|
||
onChange={(e) =>
|
||
setNewSupplier((prev) => ({
|
||
...prev,
|
||
place: e.target.value,
|
||
}))
|
||
}
|
||
className="bg-white/10 border-white/20 text-white h-8 text-xs"
|
||
placeholder="Павильон/место"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label className="text-white/60 text-xs">Телеграм</Label>
|
||
<Input
|
||
value={newSupplier.telegram}
|
||
onChange={(e) => {
|
||
const value = e.target.value
|
||
setNewSupplier((prev) => ({
|
||
...prev,
|
||
telegram: value,
|
||
}))
|
||
validateSupplierField('telegram', value)
|
||
}}
|
||
className={`bg-white/10 border-white/20 text-white h-8 text-xs ${
|
||
supplierErrors.telegram ? 'border-red-400 focus:border-red-400' : ''
|
||
}`}
|
||
placeholder="@username"
|
||
/>
|
||
{supplierErrors.telegram && <p className="text-red-400 text-xs mt-1">{supplierErrors.telegram}</p>}
|
||
</div>
|
||
|
||
<div className="flex space-x-2">
|
||
<Button
|
||
onClick={() => setShowSupplierModal(false)}
|
||
variant="outline"
|
||
className="flex-1 bg-white/5 border-white/20 text-white hover:bg-white/10 h-8 text-xs"
|
||
>
|
||
Отмена
|
||
</Button>
|
||
<Button
|
||
onClick={handleCreateSupplier}
|
||
disabled={
|
||
!newSupplier.name ||
|
||
!newSupplier.contactName ||
|
||
!newSupplier.phone ||
|
||
Object.values(supplierErrors).some((error) => error !== '') ||
|
||
creatingSupplier
|
||
}
|
||
className="flex-1 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 disabled:opacity-50 disabled:cursor-not-allowed h-8 text-xs"
|
||
>
|
||
{creatingSupplier ? (
|
||
<div className="flex items-center space-x-2">
|
||
<div className="animate-spin rounded-full h-3 w-3 border border-white/30 border-t-white"></div>
|
||
<span>Добавление...</span>
|
||
</div>
|
||
) : (
|
||
'Добавить'
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|