refactor(supplies): extract custom hooks for supply creation logic
ЭТАП 1.2: Безопасное выделение бизнес-логики в хуки - Create useSupplierSelection.ts: управление выбором поставщиков и поиском - Create useProductCatalog.ts: загрузка и управление каталогом товаров - Create useSupplyCart.ts: логика корзины и создания поставки - Create useRecipeBuilder.ts: построение рецептур товаров (услуги + расходники) Каждый хук инкапсулирует отдельную область ответственности: - Состояние и действия изолированы - GraphQL запросы сгруппированы по функциональности - Бизнес-логика отделена от UI компонентов - Полная типизация с TypeScript No functional changes - pure logic extraction for better maintainability. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@ -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<Array<GoodsProduct & { selectedQuantity: number }>>([])
|
||||||
|
const [productQuantities, setProductQuantities] = useState<Record<string, number>>({})
|
||||||
|
|
||||||
|
// Загружаем каталог товаров согласно 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,
|
||||||
|
}
|
||||||
|
}
|
@ -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<Record<string, ProductRecipe>>({})
|
||||||
|
|
||||||
|
// Загрузка услуг фулфилмента
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
@ -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<GoodsSupplier | null>(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,
|
||||||
|
}
|
||||||
|
}
|
227
src/components/supplies/create-suppliers/hooks/useSupplyCart.ts
Normal file
227
src/components/supplies/create-suppliers/hooks/useSupplyCart.ts
Normal file
@ -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<string, ProductRecipe>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSupplyCart({ selectedSupplier, allCounterparties, productRecipes }: UseSupplyCartProps) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Состояния корзины и настроек
|
||||||
|
const [selectedGoods, setSelectedGoods] = useState<SelectedGoodsItem[]>([])
|
||||||
|
const [deliveryDate, setDeliveryDate] = useState('')
|
||||||
|
const [selectedLogistics, setSelectedLogistics] = useState<string>('auto')
|
||||||
|
const [selectedFulfillment, setSelectedFulfillment] = useState<string>('')
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user