diff --git a/src/components/supplies/create-suppliers/hooks/useProductCatalog.ts b/src/components/supplies/create-suppliers/hooks/useProductCatalog.ts new file mode 100644 index 0000000..17049a8 --- /dev/null +++ b/src/components/supplies/create-suppliers/hooks/useProductCatalog.ts @@ -0,0 +1,153 @@ +/** + * ХУКА ДЛЯ ЛОГИКИ КАТАЛОГА ТОВАРОВ + * + * Выделена из create-suppliers-supply-page.tsx + * Управляет загрузкой и фильтрацией товаров поставщика + */ + +import { useQuery } from '@apollo/client' +import { useState, useMemo } from 'react' + +import { GET_ORGANIZATION_PRODUCTS } from '@/graphql/queries' + +import type { GoodsSupplier, GoodsProduct } from '../types/supply-creation.types' + +interface UseProductCatalogProps { + selectedSupplier: GoodsSupplier | null +} + +export function useProductCatalog({ selectedSupplier }: UseProductCatalogProps) { + // Состояния + const [productSearchQuery] = useState('') + const [allSelectedProducts, setAllSelectedProducts] = useState>([]) + const [productQuantities, setProductQuantities] = useState>({}) + + // Загружаем каталог товаров согласно rules2.md 13.3 + const { + data: productsData, + loading: productsLoading, + error: productsError, + } = useQuery(GET_ORGANIZATION_PRODUCTS, { + variables: { + organizationId: selectedSupplier?.id || '', + search: productSearchQuery, + category: '', + type: 'PRODUCT', // КРИТИЧЕСКИ ВАЖНО: показываем только PRODUCT, не CONSUMABLE + }, + skip: !selectedSupplier || !selectedSupplier.id, + fetchPolicy: 'network-only', + errorPolicy: 'all', + onError: (error) => { + try { + console.warn('🚨 GET_ORGANIZATION_PRODUCTS ERROR:', { + errorMessage: error?.message || 'Unknown error', + hasGraphQLErrors: !!error?.graphQLErrors?.length, + hasNetworkError: !!error?.networkError, + variables: { + organizationId: selectedSupplier?.id || 'not_selected', + search: productSearchQuery || 'empty', + category: '', + type: 'PRODUCT', + }, + selectedSupplier: selectedSupplier + ? { + id: selectedSupplier.id, + name: selectedSupplier.name || selectedSupplier.fullName || 'Unknown', + } + : 'not_selected', + }) + } catch (logError) { + console.warn('❌ Error in error handler:', logError) + } + }, + }) + + // Получаем товары выбранного поставщика + const products = useMemo(() => { + return (productsData?.organizationProducts || []).filter((product: GoodsProduct) => { + try { + return product && product.id && product.name + } catch (error) { + console.warn('❌ Error filtering product:', error) + return false + } + }) + }, [productsData]) + + // Функции для работы с количествами товаров + const getProductQuantity = (productId: string): number => { + return productQuantities[productId] || 0 + } + + const setProductQuantity = (productId: string, quantity: number): void => { + setProductQuantities((prev) => ({ + ...prev, + [productId]: Math.max(0, quantity), + })) + } + + // Добавление товара в выбранные с количеством + const addProductToSelected = (product: GoodsProduct, quantity: number = 1) => { + const productWithQuantity = { + ...product, + selectedQuantity: quantity, + } + + setAllSelectedProducts((prev) => { + const existingIndex = prev.findIndex((p) => p.id === product.id) + if (existingIndex >= 0) { + // Обновляем количество существующего товара + const updated = [...prev] + updated[existingIndex] = { ...updated[existingIndex], selectedQuantity: quantity } + return updated + } else { + // Добавляем новый товар + return [...prev, productWithQuantity] + } + }) + + // Синхронизируем с productQuantities + setProductQuantity(product.id, quantity) + } + + // Удаление товара из выбранных + const removeProductFromSelected = (productId: string) => { + setAllSelectedProducts((prev) => prev.filter((p) => p.id !== productId)) + setProductQuantities((prev) => { + const updated = { ...prev } + delete updated[productId] + return updated + }) + } + + // Обновление количества выбранного товара + const updateSelectedProductQuantity = (productId: string, quantity: number) => { + if (quantity <= 0) { + removeProductFromSelected(productId) + return + } + + setAllSelectedProducts((prev) => prev.map((p) => (p.id === productId ? { ...p, selectedQuantity: quantity } : p))) + setProductQuantity(productId, quantity) + } + + return { + // Состояние товаров + products, + allSelectedProducts, + setAllSelectedProducts, + productQuantities, + setProductQuantities, + + // Статусы загрузки + loading: productsLoading, + error: productsError, + + // Функции управления товарами + getProductQuantity, + setProductQuantity, + addProductToSelected, + removeProductFromSelected, + updateSelectedProductQuantity, + } +} diff --git a/src/components/supplies/create-suppliers/hooks/useRecipeBuilder.ts b/src/components/supplies/create-suppliers/hooks/useRecipeBuilder.ts new file mode 100644 index 0000000..d2d5822 --- /dev/null +++ b/src/components/supplies/create-suppliers/hooks/useRecipeBuilder.ts @@ -0,0 +1,248 @@ +/** + * ХУКА ДЛЯ ЛОГИКИ ПОСТРОЕНИЯ РЕЦЕПТУР + * + * Выделена из create-suppliers-supply-page.tsx + * Управляет рецептурами товаров (услуги ФФ, расходники, WB карточки) + */ + +import { useQuery } from '@apollo/client' +import { useState, useMemo } from 'react' + +import { + GET_COUNTERPARTY_SERVICES, + GET_COUNTERPARTY_SUPPLIES, + GET_AVAILABLE_SUPPLIES_FOR_RECIPE, +} from '@/graphql/queries' + +import type { + ProductRecipe, + FulfillmentService, + FulfillmentConsumable, + SellerConsumable, + RecipeCostCalculation, +} from '../types/supply-creation.types' + +interface UseRecipeBuilderProps { + selectedFulfillment: string +} + +export function useRecipeBuilder({ selectedFulfillment }: UseRecipeBuilderProps) { + // Состояние рецептур + const [productRecipes, setProductRecipes] = useState>({}) + + // Загрузка услуг фулфилмента + const { data: servicesData } = useQuery(GET_COUNTERPARTY_SERVICES, { + variables: { counterpartyId: selectedFulfillment }, + skip: !selectedFulfillment, + }) + + // Загрузка расходников фулфилмента + const { data: ffConsumablesData } = useQuery(GET_COUNTERPARTY_SUPPLIES, { + variables: { + counterpartyId: selectedFulfillment, + type: 'CONSUMABLE', + }, + skip: !selectedFulfillment, + }) + + // Загрузка расходников селлера + const { data: sellerConsumablesData } = useQuery(GET_AVAILABLE_SUPPLIES_FOR_RECIPE, { + variables: { type: 'CONSUMABLE' }, + }) + + // Обработка данных + const fulfillmentServices: FulfillmentService[] = useMemo(() => { + try { + return servicesData?.counterpartyServices || [] + } catch (error) { + console.warn('❌ Error processing fulfillment services:', error) + return [] + } + }, [servicesData]) + + const fulfillmentConsumables: FulfillmentConsumable[] = useMemo(() => { + try { + return ffConsumablesData?.counterpartySupplies || [] + } catch (error) { + console.warn('❌ Error processing fulfillment consumables:', error) + return [] + } + }, [ffConsumablesData]) + + const sellerConsumables: SellerConsumable[] = useMemo(() => { + try { + return sellerConsumablesData?.availableSuppliesForRecipe || [] + } catch (error) { + console.warn('❌ Error processing seller consumables:', error) + return [] + } + }, [sellerConsumablesData]) + + // Инициализация рецептуры для товара + const initializeProductRecipe = (productId: string) => { + if (!productRecipes[productId]) { + setProductRecipes((prev) => ({ + ...prev, + [productId]: { + productId, + selectedServices: [], + selectedFFConsumables: [], + selectedSellerConsumables: [], + selectedWBCard: undefined, + }, + })) + } + } + + // Переключение услуги фулфилмента + const toggleService = (productId: string, serviceId: string) => { + setProductRecipes((prev) => { + const recipe = prev[productId] || { + productId, + selectedServices: [], + selectedFFConsumables: [], + selectedSellerConsumables: [], + } + + const isSelected = recipe.selectedServices.includes(serviceId) + + return { + ...prev, + [productId]: { + ...recipe, + selectedServices: isSelected + ? recipe.selectedServices.filter((id) => id !== serviceId) + : [...recipe.selectedServices, serviceId], + }, + } + }) + } + + // Переключение расходников ФФ + const toggleFFConsumable = (productId: string, consumableId: string) => { + setProductRecipes((prev) => { + const recipe = prev[productId] || { + productId, + selectedServices: [], + selectedFFConsumables: [], + selectedSellerConsumables: [], + } + + const isSelected = recipe.selectedFFConsumables.includes(consumableId) + + return { + ...prev, + [productId]: { + ...recipe, + selectedFFConsumables: isSelected + ? recipe.selectedFFConsumables.filter((id) => id !== consumableId) + : [...recipe.selectedFFConsumables, consumableId], + }, + } + }) + } + + // Переключение расходников селлера + const toggleSellerConsumable = (productId: string, consumableId: string) => { + setProductRecipes((prev) => { + const recipe = prev[productId] || { + productId, + selectedServices: [], + selectedFFConsumables: [], + selectedSellerConsumables: [], + } + + const isSelected = recipe.selectedSellerConsumables.includes(consumableId) + + return { + ...prev, + [productId]: { + ...recipe, + selectedSellerConsumables: isSelected + ? recipe.selectedSellerConsumables.filter((id) => id !== consumableId) + : [...recipe.selectedSellerConsumables, consumableId], + }, + } + }) + } + + // Установка WB карточки + const setWBCard = (productId: string, cardId: string) => { + setProductRecipes((prev) => ({ + ...prev, + [productId]: { + ...prev[productId], + selectedWBCard: cardId === 'none' ? undefined : cardId, + }, + })) + } + + // Расчет стоимости рецептуры + const calculateRecipeCost = (productId: string): RecipeCostCalculation => { + const recipe = productRecipes[productId] + if (!recipe) return { services: 0, consumables: 0, total: 0 } + + // Стоимость услуг + const servicesTotal = recipe.selectedServices.reduce((sum, serviceId) => { + const service = fulfillmentServices.find((s) => s.id === serviceId) + return sum + (service ? service.price : 0) + }, 0) + + // Стоимость расходников ФФ + const ffConsumablesTotal = recipe.selectedFFConsumables.reduce((sum, consumableId) => { + const consumable = fulfillmentConsumables.find((c) => c.id === consumableId) + return sum + (consumable ? consumable.price : 0) + }, 0) + + // Стоимость расходников селлера + const sellerConsumablesTotal = recipe.selectedSellerConsumables.reduce((sum, consumableId) => { + const consumable = sellerConsumables.find((c) => c.id === consumableId) + return sum + (consumable ? consumable.pricePerUnit : 0) + }, 0) + + const consumables = ffConsumablesTotal + sellerConsumablesTotal + const total = servicesTotal + consumables + + return { services: servicesTotal, consumables, total } + } + + // Получение рецептуры товара + const getProductRecipe = (productId: string): ProductRecipe | undefined => { + return productRecipes[productId] + } + + // Проверка наличия выбранных компонентов для товара + const hasSelectedComponents = (productId: string): boolean => { + const recipe = productRecipes[productId] + if (!recipe) return false + + return ( + recipe.selectedServices.length > 0 || + recipe.selectedFFConsumables.length > 0 || + recipe.selectedSellerConsumables.length > 0 + ) + } + + return { + // Состояние рецептур + productRecipes, + setProductRecipes, + + // Данные компонентов + fulfillmentServices, + fulfillmentConsumables, + sellerConsumables, + + // Функции управления рецептурами + initializeProductRecipe, + toggleService, + toggleFFConsumable, + toggleSellerConsumable, + setWBCard, + + // Функции расчетов и получения данных + calculateRecipeCost, + getProductRecipe, + hasSelectedComponents, + } +} diff --git a/src/components/supplies/create-suppliers/hooks/useSupplierSelection.ts b/src/components/supplies/create-suppliers/hooks/useSupplierSelection.ts new file mode 100644 index 0000000..9fde7f2 --- /dev/null +++ b/src/components/supplies/create-suppliers/hooks/useSupplierSelection.ts @@ -0,0 +1,125 @@ +/** + * ХУКА ДЛЯ ЛОГИКИ ВЫБОРА ПОСТАВЩИКОВ + * + * Выделена из create-suppliers-supply-page.tsx + * Управляет состоянием выбранного поставщика и поиском + */ + +import { useQuery } from '@apollo/client' +import { useState, useMemo } from 'react' + +import { GET_MY_COUNTERPARTIES } from '@/graphql/queries' + +import type { GoodsSupplier } from '../types/supply-creation.types' + +export function useSupplierSelection() { + // Состояния + const [selectedSupplier, setSelectedSupplier] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + + // Загружаем партнеров-поставщиков согласно rules2.md 13.3 + const { + data: counterpartiesData, + loading: counterpartiesLoading, + error: counterpartiesError, + } = useQuery(GET_MY_COUNTERPARTIES, { + errorPolicy: 'all', // Показываем все ошибки, но не прерываем работу + onError: (error) => { + try { + console.warn('🚨 GET_MY_COUNTERPARTIES ERROR:', { + errorMessage: error?.message || 'Unknown error', + hasGraphQLErrors: !!error?.graphQLErrors?.length, + hasNetworkError: !!error?.networkError, + }) + } catch (logError) { + console.warn('❌ Error in counterparties error handler:', logError) + } + }, + }) + + // Обработка данных контрагентов + const allCounterparties: GoodsSupplier[] = useMemo(() => { + try { + return counterpartiesData?.myCounterparties || [] + } catch (error) { + console.warn('❌ Error processing counterparties data:', error) + return [] + } + }, [counterpartiesData]) + + // Показываем только партнеров с типом WHOLESALE согласно rules2.md 13.3 + const wholesaleSuppliers = useMemo(() => { + return allCounterparties.filter((cp: GoodsSupplier) => { + try { + return cp && cp.type === 'WHOLESALE' + } catch (error) { + console.warn('❌ Error filtering wholesale supplier:', error) + return false + } + }) + }, [allCounterparties]) + + // Фильтрация поставщиков по поисковому запросу + const suppliers = useMemo(() => { + return wholesaleSuppliers.filter((cp: GoodsSupplier) => { + try { + if (!searchQuery.trim()) return true + const searchLower = searchQuery.toLowerCase() + const name = (cp.name || cp.fullName || '').toLowerCase() + const inn = (cp.inn || '').toLowerCase() + return name.includes(searchLower) || inn.includes(searchLower) + } catch (error) { + console.warn('❌ Error filtering supplier by search:', error) + return false + } + }) + }, [wholesaleSuppliers, searchQuery]) + + // Утилитарные функции для работы с рынками + const getMarketLabel = (market?: string) => { + switch (market) { + case 'wildberries': + return 'WB' + case 'ozon': + return 'OZON' + case 'yandexmarket': + return 'YM' + default: + return 'Универсальный' + } + } + + const getMarketBadgeStyle = (market?: string) => { + switch (market) { + case 'wildberries': + return 'bg-purple-500/20 text-purple-300 border-purple-500/30' + case 'ozon': + return 'bg-blue-500/20 text-blue-300 border-blue-500/30' + case 'yandexmarket': + return 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' + default: + return 'bg-gray-500/20 text-gray-300 border-gray-500/30' + } + } + + return { + // Состояние + selectedSupplier, + setSelectedSupplier, + searchQuery, + setSearchQuery, + + // Данные + suppliers, + allCounterparties, + wholesaleSuppliers, + + // Статусы загрузки + loading: counterpartiesLoading, + error: counterpartiesError, + + // Утилиты + getMarketLabel, + getMarketBadgeStyle, + } +} diff --git a/src/components/supplies/create-suppliers/hooks/useSupplyCart.ts b/src/components/supplies/create-suppliers/hooks/useSupplyCart.ts new file mode 100644 index 0000000..4b83c64 --- /dev/null +++ b/src/components/supplies/create-suppliers/hooks/useSupplyCart.ts @@ -0,0 +1,227 @@ +/** + * ХУКА ДЛЯ ЛОГИКИ КОРЗИНЫ ПОСТАВОК + * + * Выделена из create-suppliers-supply-page.tsx + * Управляет корзиной товаров и настройками поставки + */ + +import { useMutation } from '@apollo/client' +import { useRouter } from 'next/navigation' +import { useState, useMemo } from 'react' +import { toast } from 'sonner' + +import { CREATE_SUPPLY_ORDER } from '@/graphql/mutations' + +import type { + SelectedGoodsItem, + GoodsSupplier, + GoodsProduct, + ProductRecipe, + SupplyCreationFormData, +} from '../types/supply-creation.types' + +interface UseSupplyCartProps { + selectedSupplier: GoodsSupplier | null + allCounterparties: GoodsSupplier[] + productRecipes: Record +} + +export function useSupplyCart({ selectedSupplier, allCounterparties, productRecipes }: UseSupplyCartProps) { + const router = useRouter() + + // Состояния корзины и настроек + const [selectedGoods, setSelectedGoods] = useState([]) + const [deliveryDate, setDeliveryDate] = useState('') + const [selectedLogistics, setSelectedLogistics] = useState('auto') + const [selectedFulfillment, setSelectedFulfillment] = useState('') + const [isCreatingSupply, setIsCreatingSupply] = useState(false) + + // Мутация создания поставки + const [createSupplyOrder] = useMutation(CREATE_SUPPLY_ORDER) + + // Получаем логистические компании + const logisticsCompanies = useMemo(() => { + return allCounterparties?.filter((partner) => partner.type === 'LOGIST') || [] + }, [allCounterparties]) + + // Добавление товара в корзину + const addToCart = ( + product: GoodsProduct, + quantity: number, + additionalData?: { + completeness?: string + recipe?: string + specialRequirements?: string + parameters?: Array<{ name: string; value: string }> + }, + ) => { + if (!selectedSupplier) { + toast.error('Сначала выберите поставщика') + return + } + + if (quantity <= 0) { + toast.error('Укажите количество товара') + return + } + + const existingItemIndex = selectedGoods.findIndex((item) => item.id === product.id) + + if (existingItemIndex >= 0) { + // Обновляем существующий товар + setSelectedGoods((prev) => { + const updated = [...prev] + updated[existingItemIndex] = { + ...updated[existingItemIndex], + selectedQuantity: quantity, + ...additionalData, + } + return updated + }) + toast.success('Количество товара обновлено') + } else { + // Добавляем новый товар + const newItem: SelectedGoodsItem = { + id: product.id, + name: product.name, + sku: product.article, + price: product.price, + selectedQuantity: quantity, + unit: product.unit, + category: product.category?.name, + supplierId: selectedSupplier?.id || '', + supplierName: selectedSupplier?.name || selectedSupplier?.fullName || '', + completeness: additionalData?.completeness, + recipe: additionalData?.recipe, + specialRequirements: additionalData?.specialRequirements, + parameters: additionalData?.parameters, + } + + setSelectedGoods((prev) => [...prev, newItem]) + toast.success('Товар добавлен в корзину') + } + } + + // Удаление товара из корзины + const removeFromCart = (itemId: string) => { + setSelectedGoods((prev) => prev.filter((item) => item.id !== itemId)) + toast.success('Товар удален из корзины') + } + + // Функция расчета полной стоимости товара с рецептурой + const getProductTotalWithRecipe = (productId: string, quantity: number) => { + const product = selectedGoods.find((p) => p.id === productId) + if (!product) return 0 + + const baseTotal = product.price * quantity + const recipe = productRecipes[productId] + + if (!recipe) return baseTotal + + // Здесь будет логика расчета стоимости услуг и расходников + // Пока возвращаем базовую стоимость + return baseTotal + } + + // Расчеты для корзины + const totalGoodsAmount = useMemo(() => { + return selectedGoods.reduce((sum, item) => { + return sum + getProductTotalWithRecipe(item.id, item.selectedQuantity) + }, 0) + }, [selectedGoods, productRecipes]) + + const totalQuantity = useMemo(() => { + return selectedGoods.reduce((sum, item) => sum + item.selectedQuantity, 0) + }, [selectedGoods]) + + // Валидация формы + const hasRequiredServices = useMemo(() => { + return selectedGoods.every((item) => productRecipes[item.id]?.selectedServices?.length > 0) + }, [selectedGoods, productRecipes]) + + const isFormValid = useMemo(() => { + return selectedSupplier && selectedGoods.length > 0 && deliveryDate && selectedFulfillment && hasRequiredServices + }, [selectedSupplier, selectedGoods.length, deliveryDate, selectedFulfillment, hasRequiredServices]) + + // Создание поставки + const handleCreateSupply = async () => { + if (!isFormValid) { + if (!hasRequiredServices) { + toast.error('Каждый товар должен иметь минимум 1 услугу фулфилмента') + } else { + toast.error('Заполните все обязательные поля') + } + return + } + + if (!selectedSupplier) { + toast.error('Поставщик не выбран') + return + } + + setIsCreatingSupply(true) + + try { + await createSupplyOrder({ + variables: { + supplierId: selectedSupplier?.id || '', + fulfillmentCenterId: selectedFulfillment, + items: selectedGoods.map((item) => ({ + productId: item.id, + quantity: item.selectedQuantity, + recipe: productRecipes[item.id] || { + productId: item.id, + selectedServices: [], + selectedFFConsumables: [], + selectedSellerConsumables: [], + }, + })), + deliveryDate: deliveryDate, + logistics: selectedLogistics, + specialRequirements: selectedGoods + .map((item) => item.specialRequirements) + .filter(Boolean) + .join('; '), + } satisfies SupplyCreationFormData, + }) + + toast.success('Поставка успешно создана!') + router.push('/supplies') + } catch (error) { + console.error('❌ Ошибка создания поставки:', error) + toast.error('Ошибка при создании поставки') + } finally { + setIsCreatingSupply(false) + } + } + + return { + // Состояние корзины + selectedGoods, + setSelectedGoods, + deliveryDate, + setDeliveryDate, + selectedLogistics, + setSelectedLogistics, + selectedFulfillment, + setSelectedFulfillment, + isCreatingSupply, + + // Данные + logisticsCompanies, + + // Расчеты + totalGoodsAmount, + totalQuantity, + + // Валидация + hasRequiredServices, + isFormValid, + + // Функции управления корзиной + addToCart, + removeFromCart, + getProductTotalWithRecipe, + handleCreateSupply, + } +}