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:
Veronika Smirnova
2025-08-12 19:52:44 +03:00
parent a198293861
commit 9988e55126
4 changed files with 753 additions and 0 deletions

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View 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,
}
}