feat(components): добавить модульную V2 систему создания поставок расходников
Создана новая модульная архитектура компонентов для создания поставок расходников фулфилмента с улучшенной организацией кода и разделением ответственности. ESLint warnings исправим в отдельном коммите для cleaner history. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@ -0,0 +1,307 @@
|
||||
// =============================================================================
|
||||
// 📦 БЛОК РАСХОДНИКОВ
|
||||
// =============================================================================
|
||||
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в монолитной версии!
|
||||
// Все gradients, glassmorphism, анимации, индикаторы остатков сохранены
|
||||
|
||||
import { Search, Wrench, Package, Plus, Minus } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import type { FulfillmentConsumableProduct } from '../types'
|
||||
|
||||
import type { ConsumablesBlockProps } from './types'
|
||||
|
||||
export function ConsumablesBlock({
|
||||
selectedSupplier,
|
||||
products,
|
||||
productsLoading,
|
||||
productSearchQuery,
|
||||
getSelectedQuantity,
|
||||
onProductSearchChange,
|
||||
onUpdateQuantity,
|
||||
formatCurrency,
|
||||
}: ConsumablesBlockProps) {
|
||||
return (
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 flex-1 min-h-0 flex flex-col">
|
||||
<div className="p-3 border-b border-white/10 flex-shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center">
|
||||
<Wrench className="h-4 w-4 mr-2" />
|
||||
Расходники для фулфилмента
|
||||
{selectedSupplier && (
|
||||
<span className="text-white/60 text-xs font-normal ml-2 truncate">
|
||||
- {selectedSupplier.name || selectedSupplier.fullName}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
{selectedSupplier && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 text-white/40 h-3 w-3" />
|
||||
<Input
|
||||
placeholder="Поиск расходников..."
|
||||
value={productSearchQuery}
|
||||
onChange={(e) => onProductSearchChange(e.target.value)}
|
||||
className="bg-white/10 border-white/20 text-white placeholder-white/40 pl-7 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 flex-1 overflow-y-auto">
|
||||
{!selectedSupplier ? (
|
||||
<div className="text-center py-8">
|
||||
<Wrench className="h-8 w-8 text-white/40 mx-auto mb-3" />
|
||||
<p className="text-white/60 text-sm">Выберите поставщика для просмотра расходников</p>
|
||||
</div>
|
||||
) : productsLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-white border-t-transparent mx-auto mb-2"></div>
|
||||
<p className="text-white/60 text-sm">Загрузка...</p>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-8 w-8 text-white/40 mx-auto mb-3" />
|
||||
<p className="text-white/60 text-sm">Нет доступных расходников</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7 gap-3">
|
||||
{products.map((product: FulfillmentConsumableProduct, index: number) => {
|
||||
const selectedQuantity = getSelectedQuantity(product.id)
|
||||
return (
|
||||
<Card
|
||||
key={product.id}
|
||||
className={`relative bg-gradient-to-br from-white/10 via-white/5 to-white/10 backdrop-blur border border-white/20 p-3 rounded-xl overflow-hidden group hover:shadow-xl transition-all duration-300 ${
|
||||
selectedQuantity > 0
|
||||
? 'ring-2 ring-green-400/50 bg-gradient-to-br from-green-500/20 via-green-400/10 to-green-500/20'
|
||||
: 'hover:from-white/20 hover:via-white/10 hover:to-white/20 hover:border-white/40'
|
||||
}`}
|
||||
style={{
|
||||
animationDelay: `${index * 50}ms`,
|
||||
minHeight: '200px',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2 h-full flex flex-col">
|
||||
{/* Изображение товара */}
|
||||
<div className="aspect-square bg-white/5 rounded-lg overflow-hidden relative flex-shrink-0">
|
||||
{/* 🚫 ОВЕРЛЕЙ НЕДОСТУПНОСТИ */}
|
||||
{(() => {
|
||||
const totalStock = product.stock || (product as any).quantity || 0
|
||||
const orderedStock = (product as any).ordered || 0
|
||||
const availableStock = totalStock - orderedStock
|
||||
|
||||
if (availableStock <= 0) {
|
||||
return (
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-10">
|
||||
<div className="text-center">
|
||||
<div className="text-red-400 font-bold text-xs">НЕТ В НАЛИЧИИ</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
{product.images && product.images.length > 0 && product.images[0] ? (
|
||||
<Image
|
||||
src={product.images[0]}
|
||||
alt={product.name}
|
||||
width={100}
|
||||
height={100}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
|
||||
/>
|
||||
) : product.mainImage ? (
|
||||
<Image
|
||||
src={product.mainImage}
|
||||
alt={product.name}
|
||||
width={100}
|
||||
height={100}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Wrench className="h-8 w-8 text-white/40" />
|
||||
</div>
|
||||
)}
|
||||
{selectedQuantity > 0 && (
|
||||
<div className="absolute top-2 right-2 bg-gradient-to-r from-green-400 to-green-500 rounded-full w-6 h-6 flex items-center justify-center shadow-lg animate-pulse">
|
||||
<span className="text-white text-xs font-bold">
|
||||
{selectedQuantity > 999 ? '999+' : selectedQuantity}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Информация о товаре */}
|
||||
<div className="space-y-1 flex-grow">
|
||||
<h3 className="text-white font-medium text-sm leading-tight line-clamp-2 group-hover:text-purple-200 transition-colors duration-300">
|
||||
{product.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{product.category && (
|
||||
<Badge className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs px-2 py-1">
|
||||
{product.category.name.slice(0, 10)}
|
||||
</Badge>
|
||||
)}
|
||||
{/* 🚨 ИНДИКАТОР НИЗКИХ ОСТАТКОВ согласно правилам (раздел 6.3) */}
|
||||
{(() => {
|
||||
const totalStock = product.stock || product.quantity || 0
|
||||
const orderedStock = product.ordered || 0
|
||||
const availableStock = totalStock - orderedStock
|
||||
|
||||
if (availableStock <= 0) {
|
||||
return (
|
||||
<Badge className="bg-red-500/30 text-red-300 border-red-500/50 text-xs px-2 py-1 animate-pulse">
|
||||
Нет в наличии
|
||||
</Badge>
|
||||
)
|
||||
} else if (availableStock <= 10) {
|
||||
return (
|
||||
<Badge className="bg-yellow-500/30 text-yellow-300 border-yellow-500/50 text-xs px-2 py-1">
|
||||
Мало остатков
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-green-400 font-semibold text-sm">
|
||||
{formatCurrency(product.price)}
|
||||
</span>
|
||||
{/* 📊 АКТУАЛЬНЫЙ ОСТАТОК согласно правилам (раздел 6.4.2) */}
|
||||
<div className="text-right">
|
||||
{(() => {
|
||||
const totalStock = product.stock || product.quantity || 0
|
||||
const orderedStock = product.ordered || 0
|
||||
const availableStock = totalStock - orderedStock
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end">
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
availableStock <= 0
|
||||
? 'text-red-400'
|
||||
: availableStock <= 10
|
||||
? 'text-yellow-400'
|
||||
: 'text-white/80'
|
||||
}`}
|
||||
>
|
||||
Доступно: {availableStock}
|
||||
</span>
|
||||
{orderedStock > 0 && (
|
||||
<span className="text-white/40 text-xs">Заказано: {orderedStock}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Управление количеством */}
|
||||
<div className="flex flex-col items-center space-y-2 mt-auto">
|
||||
{(() => {
|
||||
const totalStock = product.stock || (product as any).quantity || 0
|
||||
const orderedStock = (product as any).ordered || 0
|
||||
const availableStock = totalStock - orderedStock
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onUpdateQuantity(product.id, Math.max(0, selectedQuantity - 1))
|
||||
}
|
||||
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/20 rounded-full transition-all duration-300"
|
||||
disabled={selectedQuantity === 0}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={selectedQuantity === 0 ? '' : selectedQuantity.toString()}
|
||||
onChange={(e) => {
|
||||
let inputValue = e.target.value
|
||||
|
||||
// Удаляем все нецифровые символы
|
||||
inputValue = inputValue.replace(/[^0-9]/g, '')
|
||||
|
||||
// Удаляем ведущие нули
|
||||
inputValue = inputValue.replace(/^0+/, '')
|
||||
|
||||
// Если строка пустая после удаления нулей, устанавливаем 0
|
||||
const numericValue = inputValue === '' ? 0 : parseInt(inputValue)
|
||||
|
||||
// Ограничиваем значение максимумом доступного остатка
|
||||
const clampedValue = Math.min(numericValue, availableStock, 99999)
|
||||
|
||||
onUpdateQuantity(product.id, clampedValue)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// При потере фокуса, если поле пустое, устанавливаем 0
|
||||
if (e.target.value === '') {
|
||||
onUpdateQuantity(product.id, 0)
|
||||
}
|
||||
}}
|
||||
className="w-16 h-7 text-center text-sm bg-white/10 border-white/20 text-white rounded px-1 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50"
|
||||
placeholder="0"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onUpdateQuantity(
|
||||
product.id,
|
||||
Math.min(selectedQuantity + 1, availableStock, 99999),
|
||||
)
|
||||
}
|
||||
className={`h-6 w-6 p-0 rounded-full transition-all duration-300 ${
|
||||
selectedQuantity >= availableStock || availableStock <= 0
|
||||
? 'text-white/30 cursor-not-allowed'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/20'
|
||||
}`}
|
||||
disabled={selectedQuantity >= availableStock || availableStock <= 0}
|
||||
title={
|
||||
availableStock <= 0
|
||||
? 'Товар отсутствует на складе'
|
||||
: selectedQuantity >= availableStock
|
||||
? `Максимум доступно: ${availableStock}`
|
||||
: 'Увеличить количество'
|
||||
}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{selectedQuantity > 0 && (
|
||||
<div className="text-center">
|
||||
<span className="text-green-400 font-bold text-sm bg-green-500/10 px-3 py-1 rounded-full">
|
||||
{formatCurrency(product.price * selectedQuantity)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover эффект */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/0 to-pink-500/0 group-hover:from-purple-500/5 group-hover:to-pink-500/5 transition-all duration-300 pointer-events-none rounded-xl"></div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
// =============================================================================
|
||||
// 📄 БЛОК ЗАГОЛОВКА СТРАНИЦЫ
|
||||
// =============================================================================
|
||||
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в монолитной версии!
|
||||
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import type { PageHeaderProps } from './types'
|
||||
|
||||
export function PageHeader({ onBack }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-3 flex-shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Создание поставки расходников фулфилмента</h1>
|
||||
<p className="text-white/60 text-sm">
|
||||
Выберите поставщика и добавьте расходники в заказ для вашего фулфилмент-центра
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onBack}
|
||||
className="text-white/60 hover:text-white hover:bg-white/10 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Назад
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,143 @@
|
||||
// =============================================================================
|
||||
// 🛒 БЛОК КОРЗИНЫ
|
||||
// =============================================================================
|
||||
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в монолитной версии!
|
||||
// Все gradients, glassmorphism, анимации сохранены
|
||||
|
||||
import { ShoppingCart } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import type { ShoppingCartBlockProps } from './types'
|
||||
|
||||
export function ShoppingCartBlock({
|
||||
selectedConsumables,
|
||||
deliveryDate,
|
||||
notes,
|
||||
selectedLogistics,
|
||||
logisticsPartners,
|
||||
isCreatingSupply,
|
||||
getTotalAmount,
|
||||
getTotalItems,
|
||||
formatCurrency,
|
||||
onUpdateQuantity,
|
||||
onSetDeliveryDate,
|
||||
onSetNotes,
|
||||
onSetLogistics,
|
||||
onCreateSupply,
|
||||
}: ShoppingCartBlockProps) {
|
||||
return (
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-3 sticky top-0">
|
||||
<h3 className="text-white font-semibold mb-3 flex items-center text-sm">
|
||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||
Корзина ({getTotalItems()} шт)
|
||||
</h3>
|
||||
|
||||
{selectedConsumables.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<div className="bg-gradient-to-br from-purple-500/20 to-pink-500/20 rounded-full p-4 w-fit mx-auto mb-3">
|
||||
<ShoppingCart className="h-8 w-8 text-purple-300" />
|
||||
</div>
|
||||
<p className="text-white/60 text-sm font-medium mb-2">Корзина пуста</p>
|
||||
<p className="text-white/40 text-xs mb-3">Добавьте расходники для создания поставки</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 mb-3 max-h-48 overflow-y-auto">
|
||||
{selectedConsumables.map((consumable) => (
|
||||
<div key={consumable.id} className="flex items-center justify-between p-2 bg-white/5 rounded-lg">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-xs font-medium truncate">{consumable.name}</p>
|
||||
<p className="text-white/60 text-xs">
|
||||
{formatCurrency(consumable.price)} × {consumable.selectedQuantity}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-green-400 font-medium text-xs">
|
||||
{formatCurrency(consumable.price * consumable.selectedQuantity)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onUpdateQuantity(consumable.id, 0)}
|
||||
className="h-5 w-5 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-white/20 pt-3">
|
||||
<div className="mb-3">
|
||||
<label className="text-white/60 text-xs mb-1 block">Дата поставки:</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={deliveryDate}
|
||||
onChange={(e) => onSetDeliveryDate(e.target.value)}
|
||||
className="bg-white/10 border-white/20 text-white h-8 text-sm"
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Выбор логистики */}
|
||||
<div className="mb-3">
|
||||
<label className="text-white/60 text-xs mb-1 block">Логистика (опционально):</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedLogistics?.id || ''}
|
||||
onChange={(e) => {
|
||||
const logisticsId = e.target.value
|
||||
const logistics = logisticsPartners.find((p: any) => p.id === logisticsId)
|
||||
onSetLogistics(logistics || null)
|
||||
}}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-md px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-transparent appearance-none"
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
Выберите логистику
|
||||
</option>
|
||||
{logisticsPartners.map((partner: any) => (
|
||||
<option key={partner.id} value={partner.id} className="bg-gray-800 text-white">
|
||||
{partner.name || partner.fullName || partner.inn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Заметки */}
|
||||
<div className="mb-3">
|
||||
<label className="text-white/60 text-xs mb-1 block">Заметки (необязательно):</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => onSetNotes(e.target.value)}
|
||||
placeholder="Дополнительная информация о поставке"
|
||||
rows={3}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-md px-3 py-2 text-white text-sm placeholder-white/40 focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-white font-semibold text-sm">Итого:</span>
|
||||
<span className="text-green-400 font-bold text-lg">{formatCurrency(getTotalAmount())}</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={onCreateSupply}
|
||||
disabled={isCreatingSupply || !deliveryDate || selectedConsumables.length === 0}
|
||||
className="w-full bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white disabled:opacity-50 h-8 text-sm"
|
||||
>
|
||||
{isCreatingSupply ? 'Создание...' : 'Создать поставку'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
@ -0,0 +1,143 @@
|
||||
// =============================================================================
|
||||
// 🏢 БЛОК ПОСТАВЩИКОВ
|
||||
// =============================================================================
|
||||
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в монолитной версии!
|
||||
// Все gradients, glassmorphism, анимации сохранены
|
||||
|
||||
import { Building2, Search } from 'lucide-react'
|
||||
|
||||
import { OrganizationAvatar } from '@/components/market/organization-avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import type { FulfillmentConsumableSupplier } from '../types'
|
||||
|
||||
import type { SuppliersBlockProps } from './types'
|
||||
|
||||
export function SuppliersBlock({
|
||||
suppliers,
|
||||
filteredSuppliers,
|
||||
selectedSupplier,
|
||||
searchQuery,
|
||||
loading,
|
||||
onSelectSupplier,
|
||||
onSearchChange,
|
||||
}: SuppliersBlockProps) {
|
||||
return (
|
||||
<Card className="bg-gradient-to-r from-white/15 via-white/10 to-white/15 backdrop-blur-xl border border-white/30 shadow-2xl flex-shrink-0 sticky top-0 z-10 rounded-xl overflow-hidden">
|
||||
<div className="p-3 bg-gradient-to-r from-purple-500/10 to-pink-500/10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-bold flex items-center flex-shrink-0 bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
|
||||
<Building2 className="h-5 w-5 mr-3 text-purple-400" />
|
||||
Поставщики расходников
|
||||
</h2>
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-purple-300 h-4 w-4 z-10" />
|
||||
<Input
|
||||
placeholder="Найти поставщика..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="bg-white/20 backdrop-blur border-white/30 text-white placeholder-white/50 pl-10 h-8 text-sm rounded-full shadow-inner focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50 transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
{selectedSupplier && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSelectSupplier(null)}
|
||||
className="text-white/70 hover:text-white hover:bg-white/20 text-sm h-8 px-3 flex-shrink-0 rounded-full transition-all duration-300 hover:scale-105"
|
||||
>
|
||||
✕ Сбросить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-3 h-24 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-purple-400 border-t-transparent mx-auto mb-2"></div>
|
||||
<p className="text-white/70 text-sm font-medium">Загружаем поставщиков...</p>
|
||||
</div>
|
||||
) : filteredSuppliers.length === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="bg-gradient-to-br from-purple-500/20 to-pink-500/20 rounded-full p-3 w-fit mx-auto mb-2">
|
||||
<Building2 className="h-6 w-6 text-purple-300" />
|
||||
</div>
|
||||
<p className="text-white/70 text-sm font-medium">
|
||||
{searchQuery ? 'Поставщики не найдены' : 'Добавьте поставщиков'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 h-full pt-1">
|
||||
{filteredSuppliers.slice(0, 7).map((supplier: FulfillmentConsumableSupplier, index: number) => (
|
||||
<Card
|
||||
key={supplier.id}
|
||||
className={`relative cursor-pointer transition-all duration-300 border flex-shrink-0 rounded-xl overflow-hidden group hover:scale-105 hover:shadow-xl ${
|
||||
selectedSupplier?.id === supplier.id
|
||||
? 'bg-gradient-to-br from-orange-500/30 via-orange-400/20 to-orange-500/30 border-orange-400/60 shadow-lg shadow-orange-500/25'
|
||||
: 'bg-gradient-to-br from-white/10 via-white/5 to-white/10 border-white/20 hover:from-white/20 hover:via-white/10 hover:to-white/20 hover:border-white/40'
|
||||
}`}
|
||||
style={{
|
||||
width: 'calc((100% - 48px) / 7)', // 48px = 6 gaps * 8px each
|
||||
animationDelay: `${index * 100}ms`,
|
||||
}}
|
||||
onClick={() => onSelectSupplier(supplier)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center h-full p-2 space-y-1">
|
||||
<div className="relative">
|
||||
<OrganizationAvatar
|
||||
organization={{
|
||||
id: supplier.id,
|
||||
name: supplier.name || supplier.fullName || 'Поставщик',
|
||||
fullName: supplier.fullName,
|
||||
users: (supplier.users || []).map((user) => ({
|
||||
id: user.id,
|
||||
avatar: user.avatar,
|
||||
})),
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
{selectedSupplier?.id === supplier.id && (
|
||||
<div className="absolute -top-1 -right-1 bg-gradient-to-r from-orange-400 to-orange-500 rounded-full w-4 h-4 flex items-center justify-center shadow-lg animate-pulse">
|
||||
<span className="text-white text-xs font-bold">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center w-full space-y-0.5">
|
||||
<h3 className="text-white font-semibold text-xs truncate leading-tight group-hover:text-purple-200 transition-colors duration-300">
|
||||
{(supplier.name || supplier.fullName || 'Поставщик').slice(0, 10)}
|
||||
</h3>
|
||||
<div className="flex items-center justify-center space-x-1">
|
||||
<span className="text-yellow-400 text-sm animate-pulse">★</span>
|
||||
<span className="text-white/80 text-xs font-medium">4.5</span>
|
||||
</div>
|
||||
<div className="w-full bg-white/10 rounded-full h-1 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-purple-400 to-pink-400 h-full rounded-full animate-pulse"
|
||||
style={{ width: '90%' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover эффект */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/0 to-pink-500/0 group-hover:from-purple-500/10 group-hover:to-pink-500/10 transition-all duration-300 pointer-events-none"></div>
|
||||
</Card>
|
||||
))}
|
||||
{filteredSuppliers.length > 7 && (
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col items-center justify-center bg-gradient-to-br from-white/10 to-white/5 rounded-xl border border-white/20 text-white/70 hover:text-white transition-all duration-300 hover:scale-105"
|
||||
style={{ width: 'calc((100% - 48px) / 7)' }}
|
||||
>
|
||||
<div className="text-lg font-bold text-purple-300">+{filteredSuppliers.length - 7}</div>
|
||||
<div className="text-xs">ещё</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
// =============================================================================
|
||||
// 🧩 UI BLOCKS ДЛЯ СИСТЕМЫ СОЗДАНИЯ ПОСТАВОК РАСХОДНИКОВ ФУЛФИЛМЕНТА V2
|
||||
// =============================================================================
|
||||
// Эти блоки содержат весь UI, экстрагированный из монолитного компонента
|
||||
// с сохранением ТОЧНО ТАКОГО ЖЕ визуала в соответствии с модульной архитектурой
|
||||
|
||||
export { PageHeader } from './PageHeader'
|
||||
export { SuppliersBlock } from './SuppliersBlock'
|
||||
export { ConsumablesBlock } from './ConsumablesBlock'
|
||||
export { ShoppingCartBlock } from './ShoppingCartBlock'
|
||||
|
||||
// 🎯 Экспорт типов для блоков
|
||||
export type {
|
||||
PageHeaderProps,
|
||||
SuppliersBlockProps,
|
||||
ConsumablesBlockProps,
|
||||
ShoppingCartBlockProps,
|
||||
} from './types'
|
@ -0,0 +1,168 @@
|
||||
// =============================================================================
|
||||
// 🛒 БЛОК КОРЗИНЫ ДЛЯ СЕЛЛЕРА
|
||||
// =============================================================================
|
||||
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в оригинале!
|
||||
// Отличие - показываем выбранный фулфилмент-центр и другие лейблы
|
||||
|
||||
import { ShoppingCart, Building2 } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import type { SellerShoppingCartBlockProps } from '../types/seller-types'
|
||||
|
||||
export function SellerShoppingCartBlock({
|
||||
selectedConsumables,
|
||||
selectedFulfillment,
|
||||
deliveryDate,
|
||||
notes,
|
||||
selectedLogistics,
|
||||
logisticsPartners,
|
||||
isCreatingSupply,
|
||||
getTotalAmount,
|
||||
getTotalItems,
|
||||
formatCurrency,
|
||||
onUpdateQuantity,
|
||||
onSetDeliveryDate,
|
||||
onSetNotes,
|
||||
onSetLogistics,
|
||||
onCreateSupply,
|
||||
}: SellerShoppingCartBlockProps) {
|
||||
return (
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-3 sticky top-0">
|
||||
<h3 className="text-white font-semibold mb-3 flex items-center text-sm">
|
||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||
Заказ поставки ({getTotalItems()} шт)
|
||||
</h3>
|
||||
|
||||
{/* 🏢 ИНФОРМАЦИЯ О ВЫБРАННОМ ФУЛФИЛМЕНТ-ЦЕНТРЕ */}
|
||||
{selectedFulfillment && (
|
||||
<div className="mb-3 p-2 bg-white/5 rounded-lg border border-green-400/20">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Building2 className="h-4 w-4 text-green-400" />
|
||||
<div>
|
||||
<p className="text-green-400 text-xs font-medium">Доставка в:</p>
|
||||
<p className="text-white text-xs">
|
||||
{selectedFulfillment.name || selectedFulfillment.fullName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedConsumables.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<div className="bg-gradient-to-br from-purple-500/20 to-pink-500/20 rounded-full p-4 w-fit mx-auto mb-3">
|
||||
<ShoppingCart className="h-8 w-8 text-purple-300" />
|
||||
</div>
|
||||
<p className="text-white/60 text-sm font-medium mb-2">Заказ пуст</p>
|
||||
<p className="text-white/40 text-xs mb-1">Выберите поставщика и</p>
|
||||
<p className="text-white/40 text-xs">добавьте расходники для заказа</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 mb-3 max-h-48 overflow-y-auto">
|
||||
{selectedConsumables.map((consumable) => (
|
||||
<div key={consumable.id} className="flex items-center justify-between p-2 bg-white/5 rounded-lg">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-xs font-medium truncate">{consumable.name}</p>
|
||||
<p className="text-white/60 text-xs">
|
||||
{formatCurrency(consumable.price)} × {consumable.selectedQuantity}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-green-400 font-medium text-xs">
|
||||
{formatCurrency(consumable.price * consumable.selectedQuantity)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onUpdateQuantity(consumable.id, 0)}
|
||||
className="h-5 w-5 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-white/20 pt-3">
|
||||
<div className="mb-3">
|
||||
<label className="text-white/60 text-xs mb-1 block">Дата доставки в фулфилмент:</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={deliveryDate}
|
||||
onChange={(e) => onSetDeliveryDate(e.target.value)}
|
||||
className="bg-white/10 border-white/20 text-white h-8 text-sm"
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Выбор логистики */}
|
||||
<div className="mb-3">
|
||||
<label className="text-white/60 text-xs mb-1 block">Логистика (опционально):</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedLogistics?.id || ''}
|
||||
onChange={(e) => {
|
||||
const logisticsId = e.target.value
|
||||
const logistics = logisticsPartners.find((p: any) => p.id === logisticsId)
|
||||
onSetLogistics(logistics || null)
|
||||
}}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-md px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-transparent appearance-none"
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
Выберите логистику
|
||||
</option>
|
||||
{logisticsPartners.map((partner: any) => (
|
||||
<option key={partner.id} value={partner.id} className="bg-gray-800 text-white">
|
||||
{partner.name || partner.fullName || partner.inn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Заметки */}
|
||||
<div className="mb-3">
|
||||
<label className="text-white/60 text-xs mb-1 block">Заметки для поставки (необязательно):</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => onSetNotes(e.target.value)}
|
||||
placeholder="Дополнительная информация о заказе для поставщика и фулфилмента"
|
||||
rows={3}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-md px-3 py-2 text-white text-sm placeholder-white/40 focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-white font-semibold text-sm">Итого:</span>
|
||||
<span className="text-green-400 font-bold text-lg">{formatCurrency(getTotalAmount())}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onCreateSupply}
|
||||
disabled={isCreatingSupply || !deliveryDate || selectedConsumables.length === 0 || !selectedFulfillment}
|
||||
className="w-full bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white disabled:opacity-50 h-8 text-sm"
|
||||
>
|
||||
{isCreatingSupply ? 'Создание заказа...' : 'Заказать поставку'}
|
||||
</Button>
|
||||
|
||||
{/* Подсказка для селлера */}
|
||||
{selectedConsumables.length > 0 && !selectedFulfillment && (
|
||||
<p className="text-amber-400 text-xs mt-2 text-center">
|
||||
Выберите фулфилмент-центр для доставки
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
@ -0,0 +1,181 @@
|
||||
// =============================================================================
|
||||
// 🏪 БЛОК ВЫБОРА ПОСТАВЩИКОВ И ФУЛФИЛМЕНТ-ЦЕНТРОВ ДЛЯ СЕЛЛЕРА
|
||||
// =============================================================================
|
||||
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в оригинале!
|
||||
// Отличие только в логике - селлер выбирает и поставщика, и фулфилмент-центр
|
||||
|
||||
import { Building2, Search } from 'lucide-react'
|
||||
|
||||
import { OrganizationAvatar } from '@/components/market/organization-avatar'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import type { SellerSuppliersBlockProps } from '../types/seller-types'
|
||||
|
||||
export function SellerSuppliersBlock({
|
||||
suppliers,
|
||||
fulfillmentCenters,
|
||||
filteredSuppliers,
|
||||
filteredFulfillmentCenters,
|
||||
selectedSupplier,
|
||||
selectedFulfillment,
|
||||
searchQuery,
|
||||
loading,
|
||||
onSelectSupplier,
|
||||
onSelectFulfillment,
|
||||
onSearchChange,
|
||||
}: SellerSuppliersBlockProps) {
|
||||
|
||||
return (
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-3 flex-shrink-0">
|
||||
<h3 className="text-white font-semibold mb-3 flex items-center text-sm">
|
||||
<Building2 className="h-4 w-4 mr-2" />
|
||||
Партнеры ({suppliers.length + fulfillmentCenters.length})
|
||||
</h3>
|
||||
|
||||
{/* 🔍 Поиск партнеров */}
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/40 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Поиск поставщиков и фулфилмент-центров..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-10 bg-white/10 border-white/20 text-white placeholder-white/40 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="animate-pulse text-white/60 text-sm">Загрузка партнеров...</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
|
||||
{/* 🏪 СЕКЦИЯ ПОСТАВЩИКОВ */}
|
||||
<div>
|
||||
<h4 className="text-white/80 font-medium text-xs mb-2 uppercase tracking-wide">
|
||||
Поставщики расходников ({filteredSuppliers.length})
|
||||
</h4>
|
||||
|
||||
<div className="grid gap-2 max-h-48 overflow-y-auto">
|
||||
{filteredSuppliers.length === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<Building2 className="h-8 w-8 text-white/20 mx-auto mb-2" />
|
||||
<p className="text-white/40 text-xs">
|
||||
{searchQuery ? 'Поставщики не найдены' : 'Нет поставщиков'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredSuppliers.map((supplier, index) => (
|
||||
<div
|
||||
key={supplier.id}
|
||||
onClick={() => onSelectSupplier(supplier)}
|
||||
className={`
|
||||
p-3 rounded-lg cursor-pointer transition-all duration-200 border
|
||||
${
|
||||
selectedSupplier?.id === supplier.id
|
||||
? 'bg-gradient-to-br from-blue-500/30 to-purple-500/30 border-blue-400/50 shadow-lg'
|
||||
: 'bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
animationDelay: `${index * 50}ms`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<OrganizationAvatar
|
||||
organization={supplier}
|
||||
size="sm"
|
||||
className={
|
||||
selectedSupplier?.id === supplier.id
|
||||
? 'ring-2 ring-blue-400/50'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium text-sm truncate">
|
||||
{supplier.name || supplier.fullName || supplier.inn}
|
||||
</p>
|
||||
<p className="text-white/60 text-xs">ИНН: {supplier.inn}</p>
|
||||
{supplier.address && (
|
||||
<p className="text-white/40 text-xs truncate">{supplier.address}</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedSupplier?.id === supplier.id && (
|
||||
<div className="bg-blue-500/20 rounded-full p-1">
|
||||
<div className="h-2 w-2 bg-blue-400 rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🏢 СЕКЦИЯ ФУЛФИЛМЕНТ-ЦЕНТРОВ */}
|
||||
<div>
|
||||
<h4 className="text-white/80 font-medium text-xs mb-2 uppercase tracking-wide">
|
||||
Фулфилмент-центры ({filteredFulfillmentCenters.length})
|
||||
</h4>
|
||||
|
||||
<div className="grid gap-2 max-h-48 overflow-y-auto">
|
||||
{filteredFulfillmentCenters.length === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<Building2 className="h-8 w-8 text-white/20 mx-auto mb-2" />
|
||||
<p className="text-white/40 text-xs">
|
||||
{searchQuery ? 'Фулфилмент-центры не найдены' : 'Нет фулфилмент-центров'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredFulfillmentCenters.map((fulfillment, index) => (
|
||||
<div
|
||||
key={fulfillment.id}
|
||||
onClick={() => onSelectFulfillment(fulfillment)}
|
||||
className={`
|
||||
p-3 rounded-lg cursor-pointer transition-all duration-200 border
|
||||
${
|
||||
selectedFulfillment?.id === fulfillment.id
|
||||
? 'bg-gradient-to-br from-green-500/30 to-emerald-500/30 border-green-400/50 shadow-lg'
|
||||
: 'bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
animationDelay: `${(index + filteredSuppliers.length) * 50}ms`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<OrganizationAvatar
|
||||
organization={fulfillment}
|
||||
size="sm"
|
||||
className={
|
||||
selectedFulfillment?.id === fulfillment.id
|
||||
? 'ring-2 ring-green-400/50'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium text-sm truncate">
|
||||
{fulfillment.name || fulfillment.fullName || fulfillment.inn}
|
||||
</p>
|
||||
<p className="text-white/60 text-xs">ИНН: {fulfillment.inn}</p>
|
||||
{fulfillment.address && (
|
||||
<p className="text-white/40 text-xs truncate">{fulfillment.address}</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedFulfillment?.id === fulfillment.id && (
|
||||
<div className="bg-green-500/20 rounded-full p-1">
|
||||
<div className="h-2 w-2 bg-green-400 rounded-full"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
// =============================================================================
|
||||
// 📦 ЭКСПОРТ СЕЛЛЕРСКИХ UI БЛОКОВ
|
||||
// =============================================================================
|
||||
|
||||
export { SellerSuppliersBlock } from './SellerSuppliersBlock'
|
||||
export { SellerShoppingCartBlock } from './SellerShoppingCartBlock'
|
@ -0,0 +1,55 @@
|
||||
// =============================================================================
|
||||
// 🧩 ТИПЫ ДЛЯ UI BLOCKS
|
||||
// =============================================================================
|
||||
|
||||
import type {
|
||||
FulfillmentConsumableSupplier,
|
||||
FulfillmentConsumableProduct,
|
||||
SelectedFulfillmentConsumable,
|
||||
} from '../types'
|
||||
|
||||
// 📄 ТИПЫ ДЛЯ ЗАГОЛОВКА СТРАНИЦЫ
|
||||
export interface PageHeaderProps {
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
// 🏢 ТИПЫ ДЛЯ БЛОКА ПОСТАВЩИКОВ
|
||||
export interface SuppliersBlockProps {
|
||||
suppliers: FulfillmentConsumableSupplier[]
|
||||
filteredSuppliers: FulfillmentConsumableSupplier[]
|
||||
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||
searchQuery: string
|
||||
loading: boolean
|
||||
onSelectSupplier: (supplier: FulfillmentConsumableSupplier | null) => void
|
||||
onSearchChange: (query: string) => void
|
||||
}
|
||||
|
||||
// 📦 ТИПЫ ДЛЯ БЛОКА РАСХОДНИКОВ
|
||||
export interface ConsumablesBlockProps {
|
||||
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||
products: FulfillmentConsumableProduct[]
|
||||
productsLoading: boolean
|
||||
productSearchQuery: string
|
||||
getSelectedQuantity: (productId: string) => number
|
||||
onProductSearchChange: (query: string) => void
|
||||
onUpdateQuantity: (productId: string, quantity: number) => void
|
||||
formatCurrency: (amount: number) => string
|
||||
}
|
||||
|
||||
// 🛒 ТИПЫ ДЛЯ БЛОКА КОРЗИНЫ
|
||||
export interface ShoppingCartBlockProps {
|
||||
selectedConsumables: SelectedFulfillmentConsumable[]
|
||||
deliveryDate: string
|
||||
notes: string
|
||||
selectedLogistics: FulfillmentConsumableSupplier | null
|
||||
logisticsPartners: FulfillmentConsumableSupplier[]
|
||||
isCreatingSupply: boolean
|
||||
getTotalAmount: () => number
|
||||
getTotalItems: () => number
|
||||
formatCurrency: (amount: number) => string
|
||||
onUpdateQuantity: (productId: string, quantity: number) => void
|
||||
onSetDeliveryDate: (date: string) => void
|
||||
onSetNotes: (notes: string) => void
|
||||
onSetLogistics: (logistics: FulfillmentConsumableSupplier | null) => void
|
||||
onCreateSupply: () => void
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
// =============================================================================
|
||||
// 🔧 BUSINESS HOOKS ДЛЯ СИСТЕМЫ СОЗДАНИЯ ПОСТАВОК РАСХОДНИКОВ ФУЛФИЛМЕНТА V2
|
||||
// =============================================================================
|
||||
// Эти хуки содержат всю бизнес-логику, экстрагированную из монолитного компонента
|
||||
// в соответствии с правилами модульной архитектуры MODULAR_ARCHITECTURE_PATTERN.md
|
||||
|
||||
export { useSupplierData } from './useSupplierData'
|
||||
export { useProductData } from './useProductData'
|
||||
export { useSupplyForm } from './useSupplyForm'
|
||||
export { useQuantityManagement } from './useQuantityManagement'
|
||||
export { useSupplyCreation } from './useSupplyCreation'
|
||||
export { useCurrencyFormatting } from './useCurrencyFormatting'
|
||||
export { useStockValidation } from './useStockValidation'
|
||||
|
||||
// 🎯 Экспорт типов для хуков
|
||||
export type {
|
||||
UseSupplierDataReturn,
|
||||
UseProductDataReturn,
|
||||
UseSupplyFormReturn,
|
||||
UseQuantityManagementReturn,
|
||||
UseSupplyCreationReturn,
|
||||
} from './types'
|
@ -0,0 +1,7 @@
|
||||
// =============================================================================
|
||||
// 📦 ЭКСПОРТ СЕЛЛЕРСКИХ БИЗНЕС ХУКОВ
|
||||
// =============================================================================
|
||||
|
||||
export { useSellerSupplyCreation } from './useSellerSupplyCreation'
|
||||
export { useSellerSupplyForm } from './useSellerSupplyForm'
|
||||
export { useSellerSupplierData } from './useSellerSupplierData'
|
@ -0,0 +1,90 @@
|
||||
// =============================================================================
|
||||
// 🏢 ХУК ДЛЯ ПОЛУЧЕНИЯ ДАННЫХ ПОСТАВЩИКОВ И ФУЛФИЛМЕНТ-ЦЕНТРОВ (ДЛЯ СЕЛЛЕРА)
|
||||
// =============================================================================
|
||||
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
||||
|
||||
import type {
|
||||
CounterpartiesData,
|
||||
SellerConsumableSupplier,
|
||||
UseSupplierDataReturn,
|
||||
} from '../types'
|
||||
|
||||
interface UseSellerSupplierDataProps {
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
interface UseSellerSupplierDataReturn {
|
||||
// Поставщики (WHOLESALE)
|
||||
suppliers: SellerConsumableSupplier[]
|
||||
filteredSuppliers: SellerConsumableSupplier[]
|
||||
|
||||
// Фулфилмент-центры (FULFILLMENT)
|
||||
fulfillmentCenters: SellerConsumableSupplier[]
|
||||
filteredFulfillmentCenters: SellerConsumableSupplier[]
|
||||
|
||||
// Логистические партнеры (LOGIST)
|
||||
logisticsPartners: SellerConsumableSupplier[]
|
||||
|
||||
// Состояние загрузки и ошибки
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export function useSellerSupplierData({
|
||||
searchQuery,
|
||||
}: UseSellerSupplierDataProps): UseSellerSupplierDataReturn {
|
||||
|
||||
// 🔄 ЗАГРУЗКА КОНТРАГЕНТОВ
|
||||
const { data, loading, error } = useQuery<CounterpartiesData>(GET_MY_COUNTERPARTIES)
|
||||
|
||||
// 📊 МЕМОИЗИРОВАННАЯ ОБРАБОТКА ДАННЫХ
|
||||
const processedData = useMemo(() => {
|
||||
const allPartners = data?.myCounterparties || []
|
||||
|
||||
// 🏪 ПОСТАВЩИКИ (где селлер заказывает товары)
|
||||
const suppliers = allPartners.filter(partner => partner.type === 'WHOLESALE')
|
||||
|
||||
// 🏢 ФУЛФИЛМЕНТ-ЦЕНТРЫ (куда селлер доставляет товары)
|
||||
const fulfillmentCenters = allPartners.filter(partner => partner.type === 'FULFILLMENT')
|
||||
|
||||
// 🚚 ЛОГИСТИЧЕСКИЕ ПАРТНЕРЫ (кто доставляет)
|
||||
const logisticsPartners = allPartners.filter(partner => partner.type === 'LOGIST')
|
||||
|
||||
// 🔍 ФИЛЬТРАЦИЯ ПО ПОИСКУ
|
||||
const searchLower = searchQuery.toLowerCase().trim()
|
||||
|
||||
const filteredSuppliers = searchLower
|
||||
? suppliers.filter(supplier =>
|
||||
supplier.name?.toLowerCase().includes(searchLower) ||
|
||||
supplier.fullName?.toLowerCase().includes(searchLower) ||
|
||||
supplier.inn.includes(searchLower),
|
||||
)
|
||||
: suppliers
|
||||
|
||||
const filteredFulfillmentCenters = searchLower
|
||||
? fulfillmentCenters.filter(ff =>
|
||||
ff.name?.toLowerCase().includes(searchLower) ||
|
||||
ff.fullName?.toLowerCase().includes(searchLower) ||
|
||||
ff.inn.includes(searchLower),
|
||||
)
|
||||
: fulfillmentCenters
|
||||
|
||||
return {
|
||||
suppliers,
|
||||
fulfillmentCenters,
|
||||
logisticsPartners,
|
||||
filteredSuppliers,
|
||||
filteredFulfillmentCenters,
|
||||
}
|
||||
}, [data?.myCounterparties, searchQuery])
|
||||
|
||||
return {
|
||||
...processedData,
|
||||
loading,
|
||||
error: error || null,
|
||||
}
|
||||
}
|
@ -0,0 +1,124 @@
|
||||
// =============================================================================
|
||||
// 🚀 ХУК ДЛЯ СОЗДАНИЯ ПОСТАВКИ СЕЛЛЕРА
|
||||
// =============================================================================
|
||||
|
||||
import { useMutation } from '@apollo/client'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
CREATE_SELLER_CONSUMABLE_SUPPLY,
|
||||
GET_MY_SELLER_CONSUMABLE_SUPPLIES,
|
||||
} from '@/graphql/queries/seller-consumables-v2'
|
||||
|
||||
import type {
|
||||
SellerConsumableSupplier,
|
||||
SelectedSellerConsumable,
|
||||
SellerSupplyCreationInput,
|
||||
CreateSellerSupplyMutationResponse,
|
||||
UseSellerSupplyCreationReturn,
|
||||
UseSellerSupplyCreationProps,
|
||||
} from '../types/seller-types'
|
||||
|
||||
export function useSellerSupplyCreation({
|
||||
selectedSupplier,
|
||||
selectedFulfillment,
|
||||
selectedConsumables,
|
||||
deliveryDate,
|
||||
notes,
|
||||
resetForm,
|
||||
}: UseSellerSupplyCreationProps): UseSellerSupplyCreationReturn {
|
||||
const router = useRouter()
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Мутация для создания заказа поставки расходников селлера
|
||||
const [createSupplyMutation] = useMutation<CreateSellerSupplyMutationResponse>(CREATE_SELLER_CONSUMABLE_SUPPLY)
|
||||
|
||||
// Функция создания поставки
|
||||
const createSupply = useCallback(async () => {
|
||||
// 🔍 ВАЛИДАЦИЯ ОБЯЗАТЕЛЬНЫХ ПОЛЕЙ ДЛЯ СЕЛЛЕРА
|
||||
if (!selectedSupplier || !selectedFulfillment || selectedConsumables.length === 0 || !deliveryDate) {
|
||||
const errorMessage = 'Заполните все обязательные поля: поставщик, фулфилмент-центр, расходники и дата доставки'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreating(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// 📊 ПОДГОТОВКА ДАННЫХ ДЛЯ СОЗДАНИЯ ПОСТАВКИ
|
||||
const input: SellerSupplyCreationInput = {
|
||||
fulfillmentCenterId: selectedFulfillment.id, // 🏢 Куда доставлять
|
||||
supplierId: selectedSupplier.id, // 🏪 От кого заказывать
|
||||
requestedDeliveryDate: deliveryDate, // 📅 Когда нужно
|
||||
items: selectedConsumables.map((consumable) => ({
|
||||
productId: consumable.id,
|
||||
requestedQuantity: consumable.selectedQuantity,
|
||||
})),
|
||||
notes: notes || undefined,
|
||||
}
|
||||
|
||||
console.log('🚀 Создание поставки селлера с данными:', input)
|
||||
|
||||
// 🔄 ВЫПОЛНЕНИЕ МУТАЦИИ
|
||||
const result = await createSupplyMutation({
|
||||
variables: { input },
|
||||
refetchQueries: [
|
||||
{
|
||||
query: GET_MY_SELLER_CONSUMABLE_SUPPLIES,
|
||||
},
|
||||
],
|
||||
awaitRefetchQueries: true,
|
||||
})
|
||||
|
||||
const response = result.data?.createSellerConsumableSupply
|
||||
|
||||
if (response?.success) {
|
||||
// ✅ УСПЕШНОЕ СОЗДАНИЕ
|
||||
toast.success(response.message || 'Поставка успешно создана')
|
||||
|
||||
// Очищаем форму
|
||||
resetForm()
|
||||
|
||||
// Переходим к списку поставок селлера
|
||||
// TODO: Создать маршрут для списка поставок селлера
|
||||
// router.push('/seller-supplies')
|
||||
|
||||
console.log('✅ Поставка селлера создана:', response.supplyOrder)
|
||||
} else {
|
||||
// ❌ ОШИБКА ИЗ СЕРВЕРА
|
||||
const errorMessage = response?.message || 'Ошибка создания поставки'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
} catch (err: any) {
|
||||
// ❌ ОШИБКА ВЫПОЛНЕНИЯ
|
||||
console.error('❌ Ошибка создания поставки селлера:', err)
|
||||
|
||||
const errorMessage = err?.message || err?.graphQLErrors?.[0]?.message || 'Неизвестная ошибка при создании поставки'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}, [
|
||||
selectedSupplier,
|
||||
selectedFulfillment,
|
||||
selectedConsumables,
|
||||
deliveryDate,
|
||||
notes,
|
||||
createSupplyMutation,
|
||||
resetForm,
|
||||
router,
|
||||
])
|
||||
|
||||
return {
|
||||
createSupply,
|
||||
isCreating,
|
||||
error,
|
||||
}
|
||||
}
|
@ -0,0 +1,93 @@
|
||||
// =============================================================================
|
||||
// 📋 ХУК ДЛЯ УПРАВЛЕНИЯ ФОРМОЙ ПОСТАВКИ СЕЛЛЕРА
|
||||
// =============================================================================
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
import type {
|
||||
SellerConsumableSupplier,
|
||||
SelectedSellerConsumable,
|
||||
UseSellerSupplyFormReturn,
|
||||
} from '../types/seller-types'
|
||||
|
||||
export function useSellerSupplyForm(): UseSellerSupplyFormReturn {
|
||||
// 🏪 СОСТОЯНИЕ ПОСТАВЩИКА
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<SellerConsumableSupplier | null>(null)
|
||||
|
||||
// 🏢 СОСТОЯНИЕ ФУЛФИЛМЕНТ-ЦЕНТРА (специфично для селлера)
|
||||
const [selectedFulfillment, setSelectedFulfillment] = useState<SellerConsumableSupplier | null>(null)
|
||||
|
||||
// 🚚 СОСТОЯНИЕ ЛОГИСТИКИ
|
||||
const [selectedLogistics, setSelectedLogistics] = useState<SellerConsumableSupplier | null>(null)
|
||||
|
||||
// 📦 СОСТОЯНИЕ ВЫБРАННЫХ РАСХОДНИКОВ
|
||||
const [selectedConsumables, setSelectedConsumables] = useState<SelectedSellerConsumable[]>([])
|
||||
|
||||
// 🔍 СОСТОЯНИЕ ПОИСКА
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [productSearchQuery, setProductSearchQuery] = useState('')
|
||||
|
||||
// 📅 СОСТОЯНИЕ ДАТЫ ДОСТАВКИ
|
||||
const [deliveryDate, setDeliveryDate] = useState('')
|
||||
|
||||
// 📝 СОСТОЯНИЕ ЗАМЕТОК
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
// 🔄 ФУНКЦИЯ СБРОСА ФОРМЫ
|
||||
const resetForm = useCallback(() => {
|
||||
setSelectedSupplier(null)
|
||||
setSelectedFulfillment(null)
|
||||
setSelectedLogistics(null)
|
||||
setSelectedConsumables([])
|
||||
setSearchQuery('')
|
||||
setProductSearchQuery('')
|
||||
setDeliveryDate('')
|
||||
setNotes('')
|
||||
}, [])
|
||||
|
||||
// 🔧 СПЕЦИАЛЬНЫЕ СЕТТЕРЫ С ОЧИСТКОЙ ЗАВИСИМЫХ ПОЛЕЙ
|
||||
|
||||
const handleSetSelectedSupplier = useCallback((supplier: SellerConsumableSupplier | null) => {
|
||||
setSelectedSupplier(supplier)
|
||||
|
||||
// При смене поставщика очищаем выбранные товары
|
||||
setSelectedConsumables([])
|
||||
setProductSearchQuery('')
|
||||
}, [])
|
||||
|
||||
const handleSetSelectedFulfillment = useCallback((fulfillment: SellerConsumableSupplier | null) => {
|
||||
setSelectedFulfillment(fulfillment)
|
||||
|
||||
// При смене фулфилмента можем очистить логистику если нужно
|
||||
// setSelectedLogistics(null)
|
||||
}, [])
|
||||
|
||||
const handleSetSelectedLogistics = useCallback((logistics: SellerConsumableSupplier | null) => {
|
||||
setSelectedLogistics(logistics)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Состояние
|
||||
selectedSupplier,
|
||||
selectedFulfillment,
|
||||
selectedLogistics,
|
||||
selectedConsumables,
|
||||
searchQuery,
|
||||
productSearchQuery,
|
||||
deliveryDate,
|
||||
notes,
|
||||
|
||||
// Сеттеры
|
||||
setSelectedSupplier: handleSetSelectedSupplier,
|
||||
setSelectedFulfillment: handleSetSelectedFulfillment,
|
||||
setSelectedLogistics: handleSetSelectedLogistics,
|
||||
setSelectedConsumables,
|
||||
setSearchQuery,
|
||||
setProductSearchQuery,
|
||||
setDeliveryDate,
|
||||
setNotes,
|
||||
|
||||
// Утилиты
|
||||
resetForm,
|
||||
}
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
// =============================================================================
|
||||
// 🔧 ТИПЫ ДЛЯ BUSINESS HOOKS
|
||||
// =============================================================================
|
||||
|
||||
import type {
|
||||
FulfillmentConsumableSupplier,
|
||||
FulfillmentConsumableProduct,
|
||||
SelectedFulfillmentConsumable,
|
||||
} from '../types'
|
||||
|
||||
// 🏢 ТИПЫ ДЛЯ ХУКА ПОСТАВЩИКОВ
|
||||
export interface UseSupplierDataReturn {
|
||||
suppliers: FulfillmentConsumableSupplier[]
|
||||
logisticsPartners: FulfillmentConsumableSupplier[]
|
||||
filteredSuppliers: FulfillmentConsumableSupplier[]
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
// 📦 ТИПЫ ДЛЯ ХУКА ТОВАРОВ
|
||||
export interface UseProductDataReturn {
|
||||
products: FulfillmentConsumableProduct[]
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
refetch: () => void
|
||||
}
|
||||
|
||||
// 📋 ТИПЫ ДЛЯ ХУКА ФОРМЫ
|
||||
export interface UseSupplyFormReturn {
|
||||
// Состояние формы
|
||||
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||
selectedLogistics: FulfillmentConsumableSupplier | null
|
||||
selectedConsumables: SelectedFulfillmentConsumable[]
|
||||
searchQuery: string
|
||||
productSearchQuery: string
|
||||
deliveryDate: string
|
||||
notes: string
|
||||
|
||||
// Действия формы
|
||||
setSelectedSupplier: (supplier: FulfillmentConsumableSupplier | null) => void
|
||||
setSelectedLogistics: (logistics: FulfillmentConsumableSupplier | null) => void
|
||||
setSelectedConsumables: (consumables: SelectedFulfillmentConsumable[]) => void
|
||||
setSearchQuery: (query: string) => void
|
||||
setProductSearchQuery: (query: string) => void
|
||||
setDeliveryDate: (date: string) => void
|
||||
setNotes: (notes: string) => void
|
||||
|
||||
// Сброс формы
|
||||
resetForm: () => void
|
||||
}
|
||||
|
||||
// 📊 ТИПЫ ДЛЯ ХУКА УПРАВЛЕНИЯ КОЛИЧЕСТВОМ
|
||||
export interface UseQuantityManagementReturn {
|
||||
updateConsumableQuantity: (productId: string, quantity: number) => void
|
||||
getSelectedQuantity: (productId: string) => number
|
||||
getTotalAmount: () => number
|
||||
getTotalItems: () => number
|
||||
}
|
||||
|
||||
// 🚀 ТИПЫ ДЛЯ ХУКА СОЗДАНИЯ ПОСТАВКИ
|
||||
export interface UseSupplyCreationReturn {
|
||||
createSupply: () => Promise<void>
|
||||
isCreating: boolean
|
||||
error: string | null
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
// =============================================================================
|
||||
// 💰 ХУК ДЛЯ ФОРМАТИРОВАНИЯ ВАЛЮТЫ
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export function useCurrencyFormatting() {
|
||||
// Форматирование валюты в рублях
|
||||
const formatCurrency = useCallback((amount: number): string => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
formatCurrency,
|
||||
}
|
||||
}
|
@ -0,0 +1,65 @@
|
||||
// =============================================================================
|
||||
// 📦 ХУК ДЛЯ УПРАВЛЕНИЯ ДАННЫМИ ТОВАРОВ
|
||||
// =============================================================================
|
||||
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { GET_ORGANIZATION_PRODUCTS } from '@/graphql/queries'
|
||||
|
||||
import type {
|
||||
FulfillmentConsumableSupplier,
|
||||
ProductsData,
|
||||
UseProductDataReturn,
|
||||
GraphQLQueryVariables,
|
||||
} from '../types'
|
||||
|
||||
interface UseProductDataProps {
|
||||
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||
productSearchQuery: string
|
||||
}
|
||||
|
||||
export function useProductData({
|
||||
selectedSupplier,
|
||||
productSearchQuery,
|
||||
}: UseProductDataProps): UseProductDataReturn {
|
||||
// Стабилизируем переменные для useQuery
|
||||
const queryVariables = useMemo((): GraphQLQueryVariables => {
|
||||
return {
|
||||
organizationId: selectedSupplier?.id || '',
|
||||
search: productSearchQuery || null,
|
||||
category: null,
|
||||
type: 'CONSUMABLE' as const, // Фильтруем только расходники согласно GraphQL правилам
|
||||
}
|
||||
}, [selectedSupplier?.id, productSearchQuery])
|
||||
|
||||
// Загружаем товары для выбранного поставщика с фильтрацией по типу CONSUMABLE
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<ProductsData>(GET_ORGANIZATION_PRODUCTS, {
|
||||
skip: !selectedSupplier?.id, // Не запрашиваем без выбранного поставщика
|
||||
variables: queryVariables,
|
||||
onCompleted: (data) => {
|
||||
// Логируем только количество загруженных товаров
|
||||
console.warn(`📦 Загружено товаров: ${data?.organizationProducts?.length || 0}`)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('❌ GET_ORGANIZATION_PRODUCTS ERROR:', error)
|
||||
},
|
||||
})
|
||||
|
||||
// Получаем товары поставщика (уже отфильтрованы в GraphQL запросе по типу CONSUMABLE)
|
||||
const products = useMemo(() => {
|
||||
return data?.organizationProducts || []
|
||||
}, [data?.organizationProducts])
|
||||
|
||||
return {
|
||||
products,
|
||||
loading,
|
||||
error: error || null,
|
||||
refetch,
|
||||
}
|
||||
}
|
@ -0,0 +1,96 @@
|
||||
// =============================================================================
|
||||
// 📊 ХУК ДЛЯ УПРАВЛЕНИЯ КОЛИЧЕСТВОМ ТОВАРОВ
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type {
|
||||
FulfillmentConsumableSupplier,
|
||||
FulfillmentConsumableProduct,
|
||||
SelectedFulfillmentConsumable,
|
||||
UseQuantityManagementReturn,
|
||||
} from '../types'
|
||||
|
||||
interface UseQuantityManagementProps {
|
||||
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||
selectedConsumables: SelectedFulfillmentConsumable[]
|
||||
products: FulfillmentConsumableProduct[]
|
||||
setSelectedConsumables: (updater: SelectedFulfillmentConsumable[] | ((prev: SelectedFulfillmentConsumable[]) => SelectedFulfillmentConsumable[])) => void
|
||||
}
|
||||
|
||||
export function useQuantityManagement({
|
||||
selectedSupplier,
|
||||
selectedConsumables,
|
||||
products,
|
||||
setSelectedConsumables,
|
||||
}: UseQuantityManagementProps): UseQuantityManagementReturn {
|
||||
|
||||
// Обновление количества выбранного расходника
|
||||
const updateConsumableQuantity = useCallback((productId: string, quantity: number) => {
|
||||
const product = products.find((p: FulfillmentConsumableProduct) => p.id === productId)
|
||||
if (!product || !selectedSupplier) return
|
||||
|
||||
// 🔒 ВАЛИДАЦИЯ ОСТАТКОВ согласно бизнес-правилам (раздел 6.2)
|
||||
if (quantity > 0) {
|
||||
const availableStock = (product.stock || product.quantity || 0) - (product.ordered || 0)
|
||||
|
||||
if (quantity > availableStock) {
|
||||
toast.error(`❌ Недостаточно остатков!\nДоступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedConsumables((prev: SelectedFulfillmentConsumable[]) => {
|
||||
const existing = prev.find((p: SelectedFulfillmentConsumable) => p.id === productId)
|
||||
|
||||
if (quantity === 0) {
|
||||
// Удаляем расходник если количество 0
|
||||
return prev.filter((p: SelectedFulfillmentConsumable) => p.id !== productId)
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
// Обновляем количество существующего расходника
|
||||
return prev.map((p: SelectedFulfillmentConsumable) => (p.id === productId ? { ...p, selectedQuantity: quantity } : p))
|
||||
} else {
|
||||
// Добавляем новый расходник
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
selectedQuantity: quantity,
|
||||
unit: product.unit || 'шт',
|
||||
category: product.category?.name || 'Расходники',
|
||||
supplierId: selectedSupplier.id,
|
||||
supplierName: selectedSupplier.name || selectedSupplier.fullName || 'Поставщик',
|
||||
},
|
||||
]
|
||||
}
|
||||
})
|
||||
}, [products, selectedSupplier, setSelectedConsumables])
|
||||
|
||||
// Получение выбранного количества для товара
|
||||
const getSelectedQuantity = useCallback((productId: string): number => {
|
||||
const selected = selectedConsumables.find((p) => p.id === productId)
|
||||
return selected ? selected.selectedQuantity : 0
|
||||
}, [selectedConsumables])
|
||||
|
||||
// Расчет общей стоимости
|
||||
const getTotalAmount = useCallback(() => {
|
||||
return selectedConsumables.reduce((sum, consumable) => sum + consumable.price * consumable.selectedQuantity, 0)
|
||||
}, [selectedConsumables])
|
||||
|
||||
// Расчет общего количества товаров
|
||||
const getTotalItems = useCallback(() => {
|
||||
return selectedConsumables.reduce((sum, consumable) => sum + consumable.selectedQuantity, 0)
|
||||
}, [selectedConsumables])
|
||||
|
||||
return {
|
||||
updateConsumableQuantity,
|
||||
getSelectedQuantity,
|
||||
getTotalAmount,
|
||||
getTotalItems,
|
||||
}
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
// =============================================================================
|
||||
// 📊 ХУК ДЛЯ ВАЛИДАЦИИ ОСТАТКОВ
|
||||
// =============================================================================
|
||||
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import type {
|
||||
FulfillmentConsumableProduct,
|
||||
StockCalculation,
|
||||
StockValidationResult,
|
||||
} from '../types'
|
||||
|
||||
export function useStockValidation() {
|
||||
// Расчет остатков товара
|
||||
const calculateStock = useCallback((product: FulfillmentConsumableProduct): StockCalculation => {
|
||||
const totalStock = product.stock || product.quantity || 0
|
||||
const orderedStock = product.ordered || 0
|
||||
const availableStock = totalStock - orderedStock
|
||||
|
||||
return {
|
||||
totalStock,
|
||||
orderedStock,
|
||||
availableStock,
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Валидация запрашиваемого количества
|
||||
const validateQuantity = useCallback((
|
||||
product: FulfillmentConsumableProduct,
|
||||
requestedQuantity: number,
|
||||
): StockValidationResult => {
|
||||
const { availableStock } = calculateStock(product)
|
||||
|
||||
if (requestedQuantity <= 0) {
|
||||
return {
|
||||
isValid: true,
|
||||
availableStock,
|
||||
requestedQuantity,
|
||||
}
|
||||
}
|
||||
|
||||
if (requestedQuantity > availableStock) {
|
||||
return {
|
||||
isValid: false,
|
||||
availableStock,
|
||||
requestedQuantity,
|
||||
error: `❌ Недостаточно остатков!\nДоступно: ${availableStock} шт.\nЗапрашивается: ${requestedQuantity} шт.`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
availableStock,
|
||||
requestedQuantity,
|
||||
}
|
||||
}, [calculateStock])
|
||||
|
||||
// Определение статуса остатков для UI
|
||||
const getStockStatus = useCallback((product: FulfillmentConsumableProduct): 'out_of_stock' | 'low_stock' | 'in_stock' => {
|
||||
const { availableStock } = calculateStock(product)
|
||||
|
||||
if (availableStock <= 0) {
|
||||
return 'out_of_stock'
|
||||
} else if (availableStock <= 10) {
|
||||
return 'low_stock'
|
||||
}
|
||||
|
||||
return 'in_stock'
|
||||
}, [calculateStock])
|
||||
|
||||
return {
|
||||
calculateStock,
|
||||
validateQuantity,
|
||||
getStockStatus,
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
// =============================================================================
|
||||
// 🏢 ХУК ДЛЯ УПРАВЛЕНИЯ ДАННЫМИ ПОСТАВЩИКОВ
|
||||
// =============================================================================
|
||||
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { GET_MY_COUNTERPARTIES } from '@/graphql/queries'
|
||||
|
||||
import type {
|
||||
FulfillmentConsumableSupplier,
|
||||
CounterpartiesData,
|
||||
UseSupplierDataReturn,
|
||||
} from '../types'
|
||||
|
||||
interface UseSupplierDataProps {
|
||||
searchQuery: string
|
||||
}
|
||||
|
||||
export function useSupplierData({ searchQuery }: UseSupplierDataProps): UseSupplierDataReturn {
|
||||
// Загружаем контрагентов-поставщиков расходников
|
||||
const { data, loading, error } = useQuery<CounterpartiesData>(GET_MY_COUNTERPARTIES)
|
||||
|
||||
// Фильтруем только поставщиков расходников (WHOLESALE)
|
||||
const suppliers = useMemo(() => {
|
||||
return (data?.myCounterparties || []).filter(
|
||||
(org: FulfillmentConsumableSupplier) => org.type === 'WHOLESALE',
|
||||
)
|
||||
}, [data?.myCounterparties])
|
||||
|
||||
// Фильтруем только логистические компании (LOGIST)
|
||||
const logisticsPartners = useMemo(() => {
|
||||
return (data?.myCounterparties || []).filter(
|
||||
(org: FulfillmentConsumableSupplier) => org.type === 'LOGIST',
|
||||
)
|
||||
}, [data?.myCounterparties])
|
||||
|
||||
// Фильтруем поставщиков по поисковому запросу
|
||||
const filteredSuppliers = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return suppliers
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
return suppliers.filter((supplier: FulfillmentConsumableSupplier) =>
|
||||
supplier.name?.toLowerCase().includes(query) ||
|
||||
supplier.fullName?.toLowerCase().includes(query) ||
|
||||
supplier.inn?.toLowerCase().includes(query),
|
||||
)
|
||||
}, [suppliers, searchQuery])
|
||||
|
||||
return {
|
||||
suppliers,
|
||||
logisticsPartners,
|
||||
filteredSuppliers,
|
||||
loading,
|
||||
error: error || null,
|
||||
}
|
||||
}
|
@ -0,0 +1,112 @@
|
||||
// =============================================================================
|
||||
// 🚀 ХУК ДЛЯ СОЗДАНИЯ ПОСТАВКИ
|
||||
// =============================================================================
|
||||
|
||||
import { useMutation } from '@apollo/client'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import {
|
||||
CREATE_FULFILLMENT_CONSUMABLE_SUPPLY,
|
||||
GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES,
|
||||
} from '@/graphql/queries/fulfillment-consumables-v2'
|
||||
|
||||
import type {
|
||||
FulfillmentConsumableSupplier,
|
||||
SelectedFulfillmentConsumable,
|
||||
SupplyCreationInput,
|
||||
CreateSupplyMutationResponse,
|
||||
UseSupplyCreationReturn,
|
||||
} from '../types'
|
||||
|
||||
interface UseSupplyCreationProps {
|
||||
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||
selectedConsumables: SelectedFulfillmentConsumable[]
|
||||
selectedLogistics: any | null // Добавляем логистику
|
||||
deliveryDate: string
|
||||
notes: string
|
||||
resetForm: () => void
|
||||
}
|
||||
|
||||
export function useSupplyCreation({
|
||||
selectedSupplier,
|
||||
selectedConsumables,
|
||||
selectedLogistics,
|
||||
deliveryDate,
|
||||
notes,
|
||||
resetForm,
|
||||
}: UseSupplyCreationProps): UseSupplyCreationReturn {
|
||||
const router = useRouter()
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Мутация для создания заказа поставки расходников v2
|
||||
const [createSupplyMutation] = useMutation<CreateSupplyMutationResponse>(CREATE_FULFILLMENT_CONSUMABLE_SUPPLY)
|
||||
|
||||
// Функция создания поставки
|
||||
const createSupply = useCallback(async () => {
|
||||
// Валидация обязательных полей
|
||||
if (!selectedSupplier || selectedConsumables.length === 0 || !deliveryDate) {
|
||||
const errorMessage = 'Заполните все обязательные поля: поставщик, расходники и дата доставки'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreating(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Формируем input для системы v2
|
||||
const input: SupplyCreationInput = {
|
||||
supplierId: selectedSupplier.id,
|
||||
logisticsPartnerId: selectedLogistics?.id || undefined, // Добавляем логистику
|
||||
requestedDeliveryDate: deliveryDate,
|
||||
items: selectedConsumables.map((consumable) => ({
|
||||
productId: consumable.id,
|
||||
requestedQuantity: consumable.selectedQuantity,
|
||||
})),
|
||||
notes: notes || undefined,
|
||||
}
|
||||
|
||||
console.warn('🚀 СОЗДАНИЕ ПОСТАВКИ v2 - INPUT:', input)
|
||||
|
||||
const result = await createSupplyMutation({
|
||||
variables: { input },
|
||||
refetchQueries: [
|
||||
{ query: GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES }, // Обновляем новый v2 запрос
|
||||
],
|
||||
})
|
||||
|
||||
console.warn('🎯 РЕЗУЛЬТАТ СОЗДАНИЯ ПОСТАВКИ v2:', result)
|
||||
|
||||
if (result.data?.createFulfillmentConsumableSupply?.success) {
|
||||
toast.success('Поставка расходников создана успешно!')
|
||||
|
||||
// Очищаем форму
|
||||
resetForm()
|
||||
|
||||
// Перенаправляем на страницу детальных поставок
|
||||
router.push('/fulfillment-supplies/detailed-supplies')
|
||||
} else {
|
||||
const errorMessage = result.data?.createFulfillmentConsumableSupply?.message || 'Ошибка при создании поставки'
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = 'Ошибка при создании поставки расходников'
|
||||
console.error('Error creating fulfillment consumables supply v2:', error)
|
||||
setError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}, [selectedSupplier, selectedConsumables, selectedLogistics, deliveryDate, notes, resetForm, router, createSupplyMutation])
|
||||
|
||||
return {
|
||||
createSupply,
|
||||
isCreating,
|
||||
error,
|
||||
}
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
// =============================================================================
|
||||
// 📋 ХУК ДЛЯ УПРАВЛЕНИЯ ФОРМОЙ СОЗДАНИЯ ПОСТАВКИ
|
||||
// =============================================================================
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
import type {
|
||||
FulfillmentConsumableSupplier,
|
||||
SelectedFulfillmentConsumable,
|
||||
UseSupplyFormReturn,
|
||||
} from '../types'
|
||||
|
||||
export function useSupplyForm(): UseSupplyFormReturn {
|
||||
// Состояние формы
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<FulfillmentConsumableSupplier | null>(null)
|
||||
const [selectedLogistics, setSelectedLogistics] = useState<FulfillmentConsumableSupplier | null>(null)
|
||||
const [selectedConsumables, setSelectedConsumables] = useState<SelectedFulfillmentConsumable[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [productSearchQuery, setProductSearchQuery] = useState('')
|
||||
const [deliveryDate, setDeliveryDate] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
|
||||
// Функция сброса формы
|
||||
const resetForm = useCallback(() => {
|
||||
setSelectedSupplier(null)
|
||||
setSelectedLogistics(null)
|
||||
setSelectedConsumables([])
|
||||
setSearchQuery('')
|
||||
setProductSearchQuery('')
|
||||
setDeliveryDate('')
|
||||
setNotes('')
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Состояние формы
|
||||
selectedSupplier,
|
||||
selectedLogistics,
|
||||
selectedConsumables,
|
||||
searchQuery,
|
||||
productSearchQuery,
|
||||
deliveryDate,
|
||||
notes,
|
||||
|
||||
// Действия формы
|
||||
setSelectedSupplier,
|
||||
setSelectedLogistics,
|
||||
setSelectedConsumables,
|
||||
setSearchQuery,
|
||||
setProductSearchQuery,
|
||||
setDeliveryDate,
|
||||
setNotes,
|
||||
|
||||
// Сброс формы
|
||||
resetForm,
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
// =============================================================================
|
||||
// 🔄 СИСТЕМА ПЕРЕКЛЮЧЕНИЯ ПО ТИПУ ОРГАНИЗАЦИИ - УНИВЕРСАЛЬНАЯ ПОСТАВКА
|
||||
// =============================================================================
|
||||
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
|
||||
import { ModularVersion } from './modular-version' // Фулфилмент версия
|
||||
import { MonolithicVersion } from './monolithic-version' // Фолбэк версия
|
||||
import { SellerModularVersion } from './seller-modular-version' // 🆕 Селлер версия
|
||||
|
||||
// ⚙️ КОНФИГУРАЦИЯ ВЕРСИЙ
|
||||
const USE_MODULAR_ARCHITECTURE = true // 👈 ПЕРЕКЛЮЧАТЕЛЬ: true = модульная, false = монолитная
|
||||
|
||||
export default function CreateConsumablesSupplyV2Page() {
|
||||
const { user } = useAuth()
|
||||
|
||||
// 🔄 Выбор версии по типу организации
|
||||
if (USE_MODULAR_ARCHITECTURE && user?.organization) {
|
||||
const organizationType = user.organization.type
|
||||
|
||||
// 🏪 СЕЛЛЕР - заказывает расходники у поставщика для доставки в ФФ
|
||||
if (organizationType === 'SELLER') {
|
||||
return <SellerModularVersion />
|
||||
}
|
||||
|
||||
// 🏢 ФУЛФИЛМЕНТ - заказывает расходники для собственного использования
|
||||
if (organizationType === 'FULFILLMENT') {
|
||||
return <ModularVersion />
|
||||
}
|
||||
|
||||
// 🚚 ДРУГИЕ ТИПЫ - пока используют фулфилмент версию
|
||||
// TODO: Добавить специальные версии для WHOLESALE и LOGIST если нужно
|
||||
return <ModularVersion />
|
||||
}
|
||||
|
||||
// ✅ ФОЛБЭК НА МОНОЛИТНУЮ ВЕРСИЮ
|
||||
return <MonolithicVersion />
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 📋 ИНСТРУКЦИИ ПО ПЕРЕКЛЮЧЕНИЮ:
|
||||
//
|
||||
// 🔄 Переключение на модульную архитектуру:
|
||||
// 1. Установить USE_MODULAR_ARCHITECTURE = true
|
||||
// 2. Импорт ModularVersion уже активен
|
||||
// 3. return <ModularVersion /> уже активен
|
||||
//
|
||||
// ⬅️ ОТКАТ к монолитной версии:
|
||||
// 1. Установить USE_MODULAR_ARCHITECTURE = false
|
||||
// 2. Импорт ModularVersion можно оставить
|
||||
// 3. return <ModularVersion /> автоматически отключится
|
||||
//
|
||||
// ⚡ ГОРЯЧИЕ КЛАВИШИ ДЛЯ ПЕРЕКЛЮЧЕНИЯ:
|
||||
// - Ctrl+F → "USE_MODULAR_ARCHITECTURE"
|
||||
// - Изменить true/false
|
||||
// - Сохранить файл
|
||||
// =============================================================================
|
@ -0,0 +1,169 @@
|
||||
// =============================================================================
|
||||
// 🧩 МОДУЛЬНАЯ ВЕРСИЯ - СОЗДАНИЕ ПОСТАВОК РАСХОДНИКОВ ФУЛФИЛМЕНТА V2
|
||||
// =============================================================================
|
||||
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в монолитной версии!
|
||||
// Рефакторинг касается только архитектуры - разделение на модули
|
||||
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React from 'react'
|
||||
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
|
||||
// 📦 Импорт модульных компонентов
|
||||
import {
|
||||
PageHeader,
|
||||
SuppliersBlock,
|
||||
ConsumablesBlock,
|
||||
ShoppingCartBlock,
|
||||
} from './blocks'
|
||||
|
||||
// 🔧 Импорт бизнес-хуков
|
||||
import {
|
||||
useSupplierData,
|
||||
useProductData,
|
||||
useSupplyForm,
|
||||
useQuantityManagement,
|
||||
useSupplyCreation,
|
||||
useCurrencyFormatting,
|
||||
} from './hooks'
|
||||
|
||||
export function ModularVersion() {
|
||||
const router = useRouter()
|
||||
const { getSidebarMargin } = useSidebar()
|
||||
const { user: _user } = useAuth()
|
||||
|
||||
// 📋 Управление состоянием формы
|
||||
const {
|
||||
selectedSupplier,
|
||||
selectedLogistics,
|
||||
selectedConsumables,
|
||||
searchQuery,
|
||||
productSearchQuery,
|
||||
deliveryDate,
|
||||
notes,
|
||||
setSelectedSupplier,
|
||||
setSelectedLogistics,
|
||||
setSelectedConsumables,
|
||||
setSearchQuery,
|
||||
setProductSearchQuery,
|
||||
setDeliveryDate,
|
||||
setNotes,
|
||||
resetForm,
|
||||
} = useSupplyForm()
|
||||
|
||||
// 🏢 Данные поставщиков
|
||||
const {
|
||||
suppliers,
|
||||
logisticsPartners,
|
||||
filteredSuppliers,
|
||||
loading: suppliersLoading,
|
||||
} = useSupplierData({ searchQuery })
|
||||
|
||||
// 📦 Данные товаров
|
||||
const {
|
||||
products,
|
||||
loading: productsLoading,
|
||||
} = useProductData({
|
||||
selectedSupplier,
|
||||
productSearchQuery,
|
||||
})
|
||||
|
||||
// 📊 Управление количеством
|
||||
const {
|
||||
updateConsumableQuantity,
|
||||
getSelectedQuantity,
|
||||
getTotalAmount,
|
||||
getTotalItems,
|
||||
} = useQuantityManagement({
|
||||
selectedSupplier,
|
||||
selectedConsumables,
|
||||
products,
|
||||
setSelectedConsumables,
|
||||
})
|
||||
|
||||
// 🚀 Создание поставки
|
||||
const {
|
||||
createSupply,
|
||||
isCreating: isCreatingSupply,
|
||||
} = useSupplyCreation({
|
||||
selectedSupplier,
|
||||
selectedConsumables,
|
||||
selectedLogistics,
|
||||
deliveryDate,
|
||||
notes,
|
||||
resetForm,
|
||||
})
|
||||
|
||||
// 💰 Форматирование валюты
|
||||
const { formatCurrency } = useCurrencyFormatting()
|
||||
|
||||
// 🔄 Обработчики событий
|
||||
const handleBack = () => {
|
||||
router.push('/fulfillment-supplies/detailed-supplies')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className={`flex-1 ${getSidebarMargin()} overflow-auto transition-all duration-300`}>
|
||||
<div className="min-h-full w-full flex flex-col px-3 py-2">
|
||||
{/* 📄 Заголовок страницы */}
|
||||
<PageHeader onBack={handleBack} />
|
||||
|
||||
{/* 🧩 Основной контент с двумя блоками */}
|
||||
<div className="flex-1 flex gap-3 min-h-0">
|
||||
{/* 📦 Левая колонка - Поставщики и Расходники */}
|
||||
<div className="flex-1 flex flex-col gap-3 min-h-0">
|
||||
{/* 🏢 Блок поставщиков */}
|
||||
<SuppliersBlock
|
||||
suppliers={suppliers}
|
||||
filteredSuppliers={filteredSuppliers}
|
||||
selectedSupplier={selectedSupplier}
|
||||
searchQuery={searchQuery}
|
||||
loading={suppliersLoading}
|
||||
onSelectSupplier={setSelectedSupplier}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
|
||||
{/* 📦 Блок расходников */}
|
||||
<ConsumablesBlock
|
||||
selectedSupplier={selectedSupplier}
|
||||
products={products}
|
||||
productsLoading={productsLoading}
|
||||
productSearchQuery={productSearchQuery}
|
||||
getSelectedQuantity={getSelectedQuantity}
|
||||
onProductSearchChange={setProductSearchQuery}
|
||||
onUpdateQuantity={updateConsumableQuantity}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 🛒 Правая колонка - Корзина */}
|
||||
<div className="w-72 flex-shrink-0">
|
||||
<ShoppingCartBlock
|
||||
selectedConsumables={selectedConsumables}
|
||||
deliveryDate={deliveryDate}
|
||||
notes={notes}
|
||||
selectedLogistics={selectedLogistics}
|
||||
logisticsPartners={logisticsPartners}
|
||||
isCreatingSupply={isCreatingSupply}
|
||||
getTotalAmount={getTotalAmount}
|
||||
getTotalItems={getTotalItems}
|
||||
formatCurrency={formatCurrency}
|
||||
onUpdateQuantity={updateConsumableQuantity}
|
||||
onSetDeliveryDate={setDeliveryDate}
|
||||
onSetNotes={setNotes}
|
||||
onSetLogistics={setSelectedLogistics}
|
||||
onCreateSupply={createSupply}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,820 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery, useMutation } from '@apollo/client'
|
||||
import { ArrowLeft, Building2, Search, Package, Plus, Minus, ShoppingCart, Wrench } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { OrganizationAvatar } from '@/components/market/organization-avatar'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { GET_MY_COUNTERPARTIES, GET_ORGANIZATION_PRODUCTS } from '@/graphql/queries'
|
||||
import {
|
||||
CREATE_FULFILLMENT_CONSUMABLE_SUPPLY,
|
||||
GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES,
|
||||
} from '@/graphql/queries/fulfillment-consumables-v2'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
|
||||
interface FulfillmentConsumableSupplier {
|
||||
id: string
|
||||
inn: string
|
||||
name?: string
|
||||
fullName?: string
|
||||
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||||
address?: string
|
||||
phones?: Array<{ value: string }>
|
||||
emails?: Array<{ value: string }>
|
||||
users?: Array<{ id: string; avatar?: string; managerName?: string }>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface FulfillmentConsumableProduct {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
price: number
|
||||
type?: 'PRODUCT' | 'CONSUMABLE'
|
||||
category?: { name: string }
|
||||
images: string[]
|
||||
mainImage?: string
|
||||
organization: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
stock?: number
|
||||
unit?: string
|
||||
quantity?: number
|
||||
ordered?: number
|
||||
}
|
||||
|
||||
interface SelectedFulfillmentConsumable {
|
||||
id: string
|
||||
name: string
|
||||
price: number
|
||||
selectedQuantity: number
|
||||
unit?: string
|
||||
category?: string
|
||||
supplierId: string
|
||||
supplierName: string
|
||||
}
|
||||
|
||||
export function MonolithicVersion() {
|
||||
const router = useRouter()
|
||||
const { getSidebarMargin } = useSidebar()
|
||||
const { user: _user } = useAuth()
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<FulfillmentConsumableSupplier | null>(null)
|
||||
const [selectedLogistics, setSelectedLogistics] = useState<FulfillmentConsumableSupplier | null>(null)
|
||||
const [selectedConsumables, setSelectedConsumables] = useState<SelectedFulfillmentConsumable[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [productSearchQuery, setProductSearchQuery] = useState('')
|
||||
const [deliveryDate, setDeliveryDate] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [isCreatingSupply, setIsCreatingSupply] = useState(false)
|
||||
|
||||
// Загружаем контрагентов-поставщиков расходников
|
||||
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES)
|
||||
|
||||
// Убираем избыточное логирование для предотвращения визуального "бесконечного цикла"
|
||||
|
||||
// Стабилизируем переменные для useQuery
|
||||
const queryVariables = useMemo(() => {
|
||||
return {
|
||||
organizationId: selectedSupplier?.id || '', // Всегда возвращаем объект, но с пустым ID если нет поставщика
|
||||
search: productSearchQuery || null,
|
||||
category: null,
|
||||
type: 'CONSUMABLE' as const, // Фильтруем только расходники согласно rules2.md
|
||||
}
|
||||
}, [selectedSupplier?.id, productSearchQuery])
|
||||
|
||||
// Загружаем товары для выбранного поставщика с фильтрацией по типу CONSUMABLE
|
||||
const {
|
||||
data: productsData,
|
||||
loading: productsLoading,
|
||||
error: _productsError,
|
||||
} = useQuery(GET_ORGANIZATION_PRODUCTS, {
|
||||
skip: !selectedSupplier?.id, // Используем стабильное условие вместо !queryVariables
|
||||
variables: queryVariables,
|
||||
onCompleted: (data) => {
|
||||
// Логируем только количество загруженных товаров
|
||||
console.warn(`📦 Загружено товаров: ${data?.organizationProducts?.length || 0}`)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('❌ GET_ORGANIZATION_PRODUCTS ERROR:', error)
|
||||
},
|
||||
})
|
||||
|
||||
// Мутация для создания заказа поставки расходников v2
|
||||
const [createSupply] = useMutation(CREATE_FULFILLMENT_CONSUMABLE_SUPPLY)
|
||||
|
||||
// Фильтруем только поставщиков расходников (поставщиков)
|
||||
const consumableSuppliers = (counterpartiesData?.myCounterparties || []).filter(
|
||||
(org: FulfillmentConsumableSupplier) => org.type === 'WHOLESALE',
|
||||
)
|
||||
|
||||
// Фильтруем только логистические компании
|
||||
const logisticsPartners = (counterpartiesData?.myCounterparties || []).filter(
|
||||
(org: FulfillmentConsumableSupplier) => org.type === 'LOGIST',
|
||||
)
|
||||
|
||||
// Фильтруем поставщиков по поисковому запросу
|
||||
const filteredSuppliers = consumableSuppliers.filter(
|
||||
(supplier: FulfillmentConsumableSupplier) =>
|
||||
supplier.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
supplier.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
supplier.inn?.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
// Фильтруем товары по выбранному поставщику
|
||||
// 📦 Получаем товары поставщика (уже отфильтрованы в GraphQL запросе по типу CONSUMABLE)
|
||||
const supplierProducts = productsData?.organizationProducts || []
|
||||
|
||||
// Отладочное логирование только при смене поставщика
|
||||
React.useEffect(() => {
|
||||
if (selectedSupplier) {
|
||||
console.warn('🔄 ПОСТАВЩИК ВЫБРАН:', {
|
||||
id: selectedSupplier.id,
|
||||
name: selectedSupplier.name || selectedSupplier.fullName,
|
||||
type: selectedSupplier.type,
|
||||
})
|
||||
}
|
||||
}, [selectedSupplier]) // Включаем весь объект поставщика для корректной работы
|
||||
|
||||
// Логируем результат загрузки товаров только при получении данных
|
||||
React.useEffect(() => {
|
||||
if (productsData && !productsLoading) {
|
||||
console.warn('📦 ТОВАРЫ ЗАГРУЖЕНЫ:', {
|
||||
organizationProductsCount: productsData?.organizationProducts?.length || 0,
|
||||
supplierProductsCount: supplierProducts.length,
|
||||
})
|
||||
}
|
||||
}, [productsData, productsLoading, supplierProducts.length]) // Включаем все зависимости для корректной работы
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const updateConsumableQuantity = (productId: string, quantity: number) => {
|
||||
const product = supplierProducts.find((p: FulfillmentConsumableProduct) => p.id === productId)
|
||||
if (!product || !selectedSupplier) return
|
||||
|
||||
// 🔒 ВАЛИДАЦИЯ ОСТАТКОВ согласно правилам (раздел 6.2)
|
||||
if (quantity > 0) {
|
||||
const availableStock = (product.stock || product.quantity || 0) - (product.ordered || 0)
|
||||
|
||||
if (quantity > availableStock) {
|
||||
toast.error(`❌ Недостаточно остатков!\nДоступно: ${availableStock} шт.\nЗапрашивается: ${quantity} шт.`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedConsumables((prev) => {
|
||||
const existing = prev.find((p) => p.id === productId)
|
||||
|
||||
if (quantity === 0) {
|
||||
// Удаляем расходник если количество 0
|
||||
return prev.filter((p) => p.id !== productId)
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
// Обновляем количество существующего расходника
|
||||
return prev.map((p) => (p.id === productId ? { ...p, selectedQuantity: quantity } : p))
|
||||
} else {
|
||||
// Добавляем новый расходник
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
selectedQuantity: quantity,
|
||||
unit: product.unit || 'шт',
|
||||
category: product.category?.name || 'Расходники',
|
||||
supplierId: selectedSupplier.id,
|
||||
supplierName: selectedSupplier.name || selectedSupplier.fullName || 'Поставщик',
|
||||
},
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getSelectedQuantity = (productId: string): number => {
|
||||
const selected = selectedConsumables.find((p) => p.id === productId)
|
||||
return selected ? selected.selectedQuantity : 0
|
||||
}
|
||||
|
||||
const getTotalAmount = () => {
|
||||
return selectedConsumables.reduce((sum, consumable) => sum + consumable.price * consumable.selectedQuantity, 0)
|
||||
}
|
||||
|
||||
const getTotalItems = () => {
|
||||
return selectedConsumables.reduce((sum, consumable) => sum + consumable.selectedQuantity, 0)
|
||||
}
|
||||
|
||||
const handleCreateSupply = async () => {
|
||||
if (!selectedSupplier || selectedConsumables.length === 0 || !deliveryDate) {
|
||||
toast.error('Заполните все обязательные поля: поставщик, расходники и дата доставки')
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreatingSupply(true)
|
||||
|
||||
try {
|
||||
// Новый формат для системы v2
|
||||
const input = {
|
||||
supplierId: selectedSupplier.id,
|
||||
logisticsPartnerId: selectedLogistics?.id || undefined, // Добавляем логистического партнера
|
||||
requestedDeliveryDate: deliveryDate,
|
||||
items: selectedConsumables.map((consumable) => ({
|
||||
productId: consumable.id,
|
||||
requestedQuantity: consumable.selectedQuantity,
|
||||
})),
|
||||
notes: notes || undefined,
|
||||
}
|
||||
|
||||
console.warn('🚀 СОЗДАНИЕ ПОСТАВКИ v2 - INPUT:', input)
|
||||
|
||||
const result = await createSupply({
|
||||
variables: { input },
|
||||
refetchQueries: [
|
||||
{ query: GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES }, // Обновляем новый v2 запрос
|
||||
],
|
||||
})
|
||||
|
||||
console.warn('🎯 РЕЗУЛЬТАТ СОЗДАНИЯ ПОСТАВКИ v2:', result)
|
||||
|
||||
if (result.data?.createFulfillmentConsumableSupply?.success) {
|
||||
toast.success('Поставка расходников создана успешно!')
|
||||
// Очищаем форму
|
||||
setSelectedSupplier(null)
|
||||
setSelectedLogistics(null)
|
||||
setSelectedConsumables([])
|
||||
setDeliveryDate('')
|
||||
setProductSearchQuery('')
|
||||
setSearchQuery('')
|
||||
setNotes('')
|
||||
|
||||
// Перенаправляем на страницу детальных поставок
|
||||
router.push('/fulfillment-supplies/detailed-supplies')
|
||||
} else {
|
||||
toast.error(result.data?.createFulfillmentConsumableSupply?.message || 'Ошибка при создании поставки')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating fulfillment consumables supply v2:', error)
|
||||
toast.error('Ошибка при создании поставки расходников')
|
||||
} finally {
|
||||
setIsCreatingSupply(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className={`flex-1 ${getSidebarMargin()} overflow-auto transition-all duration-300`}>
|
||||
<div className="min-h-full w-full flex flex-col px-3 py-2">
|
||||
{/* Заголовок */}
|
||||
<div className="flex items-center justify-between mb-3 flex-shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Создание поставки расходников фулфилмента</h1>
|
||||
<p className="text-white/60 text-sm">
|
||||
Выберите поставщика и добавьте расходники в заказ для вашего фулфилмент-центра
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push('/fulfillment-supplies/detailed-supplies')}
|
||||
className="text-white/60 hover:text-white hover:bg-white/10 text-sm"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Назад
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Основной контент с двумя блоками */}
|
||||
<div className="flex-1 flex gap-3 min-h-0">
|
||||
{/* Левая колонка - Поставщики и Расходники */}
|
||||
<div className="flex-1 flex flex-col gap-3 min-h-0">
|
||||
{/* Блок "Поставщики" */}
|
||||
<Card className="bg-gradient-to-r from-white/15 via-white/10 to-white/15 backdrop-blur-xl border border-white/30 shadow-2xl flex-shrink-0 sticky top-0 z-10 rounded-xl overflow-hidden">
|
||||
<div className="p-3 bg-gradient-to-r from-purple-500/10 to-pink-500/10">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-bold flex items-center flex-shrink-0 bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
|
||||
<Building2 className="h-5 w-5 mr-3 text-purple-400" />
|
||||
Поставщики расходников
|
||||
</h2>
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-purple-300 h-4 w-4 z-10" />
|
||||
<Input
|
||||
placeholder="Найти поставщика..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="bg-white/20 backdrop-blur border-white/30 text-white placeholder-white/50 pl-10 h-8 text-sm rounded-full shadow-inner focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50 transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
{selectedSupplier && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedSupplier(null)}
|
||||
className="text-white/70 hover:text-white hover:bg-white/20 text-sm h-8 px-3 flex-shrink-0 rounded-full transition-all duration-300 hover:scale-105"
|
||||
>
|
||||
✕ Сбросить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pb-3 h-24 overflow-hidden">
|
||||
{counterpartiesLoading ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-purple-400 border-t-transparent mx-auto mb-2"></div>
|
||||
<p className="text-white/70 text-sm font-medium">Загружаем поставщиков...</p>
|
||||
</div>
|
||||
) : filteredSuppliers.length === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="bg-gradient-to-br from-purple-500/20 to-pink-500/20 rounded-full p-3 w-fit mx-auto mb-2">
|
||||
<Building2 className="h-6 w-6 text-purple-300" />
|
||||
</div>
|
||||
<p className="text-white/70 text-sm font-medium">
|
||||
{searchQuery ? 'Поставщики не найдены' : 'Добавьте поставщиков'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 h-full pt-1">
|
||||
{filteredSuppliers.slice(0, 7).map((supplier: FulfillmentConsumableSupplier, index: number) => (
|
||||
<Card
|
||||
key={supplier.id}
|
||||
className={`relative cursor-pointer transition-all duration-300 border flex-shrink-0 rounded-xl overflow-hidden group hover:scale-105 hover:shadow-xl ${
|
||||
selectedSupplier?.id === supplier.id
|
||||
? 'bg-gradient-to-br from-orange-500/30 via-orange-400/20 to-orange-500/30 border-orange-400/60 shadow-lg shadow-orange-500/25'
|
||||
: 'bg-gradient-to-br from-white/10 via-white/5 to-white/10 border-white/20 hover:from-white/20 hover:via-white/10 hover:to-white/20 hover:border-white/40'
|
||||
}`}
|
||||
style={{
|
||||
width: 'calc((100% - 48px) / 7)', // 48px = 6 gaps * 8px each
|
||||
animationDelay: `${index * 100}ms`,
|
||||
}}
|
||||
onClick={() => setSelectedSupplier(supplier)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center h-full p-2 space-y-1">
|
||||
<div className="relative">
|
||||
<OrganizationAvatar
|
||||
organization={{
|
||||
id: supplier.id,
|
||||
name: supplier.name || supplier.fullName || 'Поставщик',
|
||||
fullName: supplier.fullName,
|
||||
users: (supplier.users || []).map((user) => ({
|
||||
id: user.id,
|
||||
avatar: user.avatar,
|
||||
})),
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
{selectedSupplier?.id === supplier.id && (
|
||||
<div className="absolute -top-1 -right-1 bg-gradient-to-r from-orange-400 to-orange-500 rounded-full w-4 h-4 flex items-center justify-center shadow-lg animate-pulse">
|
||||
<span className="text-white text-xs font-bold">✓</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center w-full space-y-0.5">
|
||||
<h3 className="text-white font-semibold text-xs truncate leading-tight group-hover:text-purple-200 transition-colors duration-300">
|
||||
{(supplier.name || supplier.fullName || 'Поставщик').slice(0, 10)}
|
||||
</h3>
|
||||
<div className="flex items-center justify-center space-x-1">
|
||||
<span className="text-yellow-400 text-sm animate-pulse">★</span>
|
||||
<span className="text-white/80 text-xs font-medium">4.5</span>
|
||||
</div>
|
||||
<div className="w-full bg-white/10 rounded-full h-1 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-purple-400 to-pink-400 h-full rounded-full animate-pulse"
|
||||
style={{ width: '90%' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover эффект */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/0 to-pink-500/0 group-hover:from-purple-500/10 group-hover:to-pink-500/10 transition-all duration-300 pointer-events-none"></div>
|
||||
</Card>
|
||||
))}
|
||||
{filteredSuppliers.length > 7 && (
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col items-center justify-center bg-gradient-to-br from-white/10 to-white/5 rounded-xl border border-white/20 text-white/70 hover:text-white transition-all duration-300 hover:scale-105"
|
||||
style={{ width: 'calc((100% - 48px) / 7)' }}
|
||||
>
|
||||
<div className="text-lg font-bold text-purple-300">+{filteredSuppliers.length - 7}</div>
|
||||
<div className="text-xs">ещё</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Блок "Расходники" */}
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 flex-1 min-h-0 flex flex-col">
|
||||
<div className="p-3 border-b border-white/10 flex-shrink-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center">
|
||||
<Wrench className="h-4 w-4 mr-2" />
|
||||
Расходники для фулфилмента
|
||||
{selectedSupplier && (
|
||||
<span className="text-white/60 text-xs font-normal ml-2 truncate">
|
||||
- {selectedSupplier.name || selectedSupplier.fullName}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
{selectedSupplier && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 text-white/40 h-3 w-3" />
|
||||
<Input
|
||||
placeholder="Поиск расходников..."
|
||||
value={productSearchQuery}
|
||||
onChange={(e) => setProductSearchQuery(e.target.value)}
|
||||
className="bg-white/10 border-white/20 text-white placeholder-white/40 pl-7 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 flex-1 overflow-y-auto">
|
||||
{!selectedSupplier ? (
|
||||
<div className="text-center py-8">
|
||||
<Wrench className="h-8 w-8 text-white/40 mx-auto mb-3" />
|
||||
<p className="text-white/60 text-sm">Выберите поставщика для просмотра расходников</p>
|
||||
</div>
|
||||
) : productsLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-white border-t-transparent mx-auto mb-2"></div>
|
||||
<p className="text-white/60 text-sm">Загрузка...</p>
|
||||
</div>
|
||||
) : supplierProducts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-8 w-8 text-white/40 mx-auto mb-3" />
|
||||
<p className="text-white/60 text-sm">Нет доступных расходников</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-7 gap-3">
|
||||
{supplierProducts.map((product: FulfillmentConsumableProduct, index: number) => {
|
||||
const selectedQuantity = getSelectedQuantity(product.id)
|
||||
return (
|
||||
<Card
|
||||
key={product.id}
|
||||
className={`relative bg-gradient-to-br from-white/10 via-white/5 to-white/10 backdrop-blur border border-white/20 p-3 rounded-xl overflow-hidden group hover:shadow-xl transition-all duration-300 ${
|
||||
selectedQuantity > 0
|
||||
? 'ring-2 ring-green-400/50 bg-gradient-to-br from-green-500/20 via-green-400/10 to-green-500/20'
|
||||
: 'hover:from-white/20 hover:via-white/10 hover:to-white/20 hover:border-white/40'
|
||||
}`}
|
||||
style={{
|
||||
animationDelay: `${index * 50}ms`,
|
||||
minHeight: '200px',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2 h-full flex flex-col">
|
||||
{/* Изображение товара */}
|
||||
<div className="aspect-square bg-white/5 rounded-lg overflow-hidden relative flex-shrink-0">
|
||||
{/* 🚫 ОВЕРЛЕЙ НЕДОСТУПНОСТИ */}
|
||||
{(() => {
|
||||
const totalStock = product.stock || (product as any).quantity || 0
|
||||
const orderedStock = (product as any).ordered || 0
|
||||
const availableStock = totalStock - orderedStock
|
||||
|
||||
if (availableStock <= 0) {
|
||||
return (
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-10">
|
||||
<div className="text-center">
|
||||
<div className="text-red-400 font-bold text-xs">НЕТ В НАЛИЧИИ</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
{product.images && product.images.length > 0 && product.images[0] ? (
|
||||
<Image
|
||||
src={product.images[0]}
|
||||
alt={product.name}
|
||||
width={100}
|
||||
height={100}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
|
||||
/>
|
||||
) : product.mainImage ? (
|
||||
<Image
|
||||
src={product.mainImage}
|
||||
alt={product.name}
|
||||
width={100}
|
||||
height={100}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Wrench className="h-8 w-8 text-white/40" />
|
||||
</div>
|
||||
)}
|
||||
{selectedQuantity > 0 && (
|
||||
<div className="absolute top-2 right-2 bg-gradient-to-r from-green-400 to-green-500 rounded-full w-6 h-6 flex items-center justify-center shadow-lg animate-pulse">
|
||||
<span className="text-white text-xs font-bold">
|
||||
{selectedQuantity > 999 ? '999+' : selectedQuantity}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Информация о товаре */}
|
||||
<div className="space-y-1 flex-grow">
|
||||
<h3 className="text-white font-medium text-sm leading-tight line-clamp-2 group-hover:text-purple-200 transition-colors duration-300">
|
||||
{product.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{product.category && (
|
||||
<Badge className="bg-purple-500/20 text-purple-300 border-purple-500/30 text-xs px-2 py-1">
|
||||
{product.category.name.slice(0, 10)}
|
||||
</Badge>
|
||||
)}
|
||||
{/* 🚨 ИНДИКАТОР НИЗКИХ ОСТАТКОВ согласно правилам (раздел 6.3) */}
|
||||
{(() => {
|
||||
const totalStock = product.stock || product.quantity || 0
|
||||
const orderedStock = product.ordered || 0
|
||||
const availableStock = totalStock - orderedStock
|
||||
|
||||
if (availableStock <= 0) {
|
||||
return (
|
||||
<Badge className="bg-red-500/30 text-red-300 border-red-500/50 text-xs px-2 py-1 animate-pulse">
|
||||
Нет в наличии
|
||||
</Badge>
|
||||
)
|
||||
} else if (availableStock <= 10) {
|
||||
return (
|
||||
<Badge className="bg-yellow-500/30 text-yellow-300 border-yellow-500/50 text-xs px-2 py-1">
|
||||
Мало остатков
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-green-400 font-semibold text-sm">
|
||||
{formatCurrency(product.price)}
|
||||
</span>
|
||||
{/* 📊 АКТУАЛЬНЫЙ ОСТАТОК согласно правилам (раздел 6.4.2) */}
|
||||
<div className="text-right">
|
||||
{(() => {
|
||||
const totalStock = product.stock || product.quantity || 0
|
||||
const orderedStock = product.ordered || 0
|
||||
const availableStock = totalStock - orderedStock
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end">
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
availableStock <= 0
|
||||
? 'text-red-400'
|
||||
: availableStock <= 10
|
||||
? 'text-yellow-400'
|
||||
: 'text-white/80'
|
||||
}`}
|
||||
>
|
||||
Доступно: {availableStock}
|
||||
</span>
|
||||
{orderedStock > 0 && (
|
||||
<span className="text-white/40 text-xs">Заказано: {orderedStock}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Управление количеством */}
|
||||
<div className="flex flex-col items-center space-y-2 mt-auto">
|
||||
{(() => {
|
||||
const totalStock = product.stock || (product as any).quantity || 0
|
||||
const orderedStock = (product as any).ordered || 0
|
||||
const availableStock = totalStock - orderedStock
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateConsumableQuantity(product.id, Math.max(0, selectedQuantity - 1))
|
||||
}
|
||||
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/20 rounded-full transition-all duration-300"
|
||||
disabled={selectedQuantity === 0}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={selectedQuantity === 0 ? '' : selectedQuantity.toString()}
|
||||
onChange={(e) => {
|
||||
let inputValue = e.target.value
|
||||
|
||||
// Удаляем все нецифровые символы
|
||||
inputValue = inputValue.replace(/[^0-9]/g, '')
|
||||
|
||||
// Удаляем ведущие нули
|
||||
inputValue = inputValue.replace(/^0+/, '')
|
||||
|
||||
// Если строка пустая после удаления нулей, устанавливаем 0
|
||||
const numericValue = inputValue === '' ? 0 : parseInt(inputValue)
|
||||
|
||||
// Ограничиваем значение максимумом доступного остатка
|
||||
const clampedValue = Math.min(numericValue, availableStock, 99999)
|
||||
|
||||
updateConsumableQuantity(product.id, clampedValue)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// При потере фокуса, если поле пустое, устанавливаем 0
|
||||
if (e.target.value === '') {
|
||||
updateConsumableQuantity(product.id, 0)
|
||||
}
|
||||
}}
|
||||
className="w-16 h-7 text-center text-sm bg-white/10 border-white/20 text-white rounded px-1 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50"
|
||||
placeholder="0"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateConsumableQuantity(
|
||||
product.id,
|
||||
Math.min(selectedQuantity + 1, availableStock, 99999),
|
||||
)
|
||||
}
|
||||
className={`h-6 w-6 p-0 rounded-full transition-all duration-300 ${
|
||||
selectedQuantity >= availableStock || availableStock <= 0
|
||||
? 'text-white/30 cursor-not-allowed'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/20'
|
||||
}`}
|
||||
disabled={selectedQuantity >= availableStock || availableStock <= 0}
|
||||
title={
|
||||
availableStock <= 0
|
||||
? 'Товар отсутствует на складе'
|
||||
: selectedQuantity >= availableStock
|
||||
? `Максимум доступно: ${availableStock}`
|
||||
: 'Увеличить количество'
|
||||
}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{selectedQuantity > 0 && (
|
||||
<div className="text-center">
|
||||
<span className="text-green-400 font-bold text-sm bg-green-500/10 px-3 py-1 rounded-full">
|
||||
{formatCurrency(product.price * selectedQuantity)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover эффект */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-purple-500/0 to-pink-500/0 group-hover:from-purple-500/5 group-hover:to-pink-500/5 transition-all duration-300 pointer-events-none rounded-xl"></div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Правая колонка - Корзина */}
|
||||
<div className="w-72 flex-shrink-0">
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-3 sticky top-0">
|
||||
<h3 className="text-white font-semibold mb-3 flex items-center text-sm">
|
||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||
Корзина ({getTotalItems()} шт)
|
||||
</h3>
|
||||
|
||||
{selectedConsumables.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<div className="bg-gradient-to-br from-purple-500/20 to-pink-500/20 rounded-full p-4 w-fit mx-auto mb-3">
|
||||
<ShoppingCart className="h-8 w-8 text-purple-300" />
|
||||
</div>
|
||||
<p className="text-white/60 text-sm font-medium mb-2">Корзина пуста</p>
|
||||
<p className="text-white/40 text-xs mb-3">Добавьте расходники для создания поставки</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 mb-3 max-h-48 overflow-y-auto">
|
||||
{selectedConsumables.map((consumable) => (
|
||||
<div key={consumable.id} className="flex items-center justify-between p-2 bg-white/5 rounded-lg">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-xs font-medium truncate">{consumable.name}</p>
|
||||
<p className="text-white/60 text-xs">
|
||||
{formatCurrency(consumable.price)} × {consumable.selectedQuantity}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-green-400 font-medium text-xs">
|
||||
{formatCurrency(consumable.price * consumable.selectedQuantity)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => updateConsumableQuantity(consumable.id, 0)}
|
||||
className="h-5 w-5 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/10"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-white/20 pt-3">
|
||||
<div className="mb-3">
|
||||
<label className="text-white/60 text-xs mb-1 block">Дата поставки:</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={deliveryDate}
|
||||
onChange={(e) => setDeliveryDate(e.target.value)}
|
||||
className="bg-white/10 border-white/20 text-white h-8 text-sm"
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Выбор логистики */}
|
||||
<div className="mb-3">
|
||||
<label className="text-white/60 text-xs mb-1 block">Логистика (опционально):</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={selectedLogistics?.id || ''}
|
||||
onChange={(e) => {
|
||||
const logisticsId = e.target.value
|
||||
const logistics = logisticsPartners.find((p: any) => p.id === logisticsId)
|
||||
setSelectedLogistics(logistics || null)
|
||||
}}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-md px-3 py-2 text-white text-sm focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-transparent appearance-none"
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
Выберите логистику
|
||||
</option>
|
||||
{logisticsPartners.map((partner: any) => (
|
||||
<option key={partner.id} value={partner.id} className="bg-gray-800 text-white">
|
||||
{partner.name || partner.fullName || partner.inn}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center px-2 pointer-events-none">
|
||||
<svg className="w-4 h-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Заметки */}
|
||||
<div className="mb-3">
|
||||
<label className="text-white/60 text-xs mb-1 block">Заметки (необязательно):</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Дополнительная информация о поставке"
|
||||
rows={3}
|
||||
className="w-full bg-white/10 border border-white/20 rounded-md px-3 py-2 text-white text-sm placeholder-white/40 focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-transparent resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-white font-semibold text-sm">Итого:</span>
|
||||
<span className="text-green-400 font-bold text-lg">{formatCurrency(getTotalAmount())}</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateSupply}
|
||||
disabled={isCreatingSupply || !deliveryDate || selectedConsumables.length === 0}
|
||||
className="w-full bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white disabled:opacity-50 h-8 text-sm"
|
||||
>
|
||||
{isCreatingSupply ? 'Создание...' : 'Создать поставку'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,183 @@
|
||||
// =============================================================================
|
||||
// 🧩 МОДУЛЬНАЯ ВЕРСИЯ ДЛЯ СЕЛЛЕРА - СОЗДАНИЕ ПОСТАВОК РАСХОДНИКОВ V2
|
||||
// =============================================================================
|
||||
// ВНИМАНИЕ: Визуал остается ТОЧНО таким же как в фулфилмент версии!
|
||||
// Отличия только в бизнес-логике и данных
|
||||
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React from 'react'
|
||||
|
||||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import { useSidebar } from '@/hooks/useSidebar'
|
||||
|
||||
// 📦 Импорт селлерских компонентов
|
||||
import {
|
||||
PageHeader,
|
||||
ConsumablesBlock, // Используем тот же компонент расходников
|
||||
} from './blocks'
|
||||
import {
|
||||
SellerSuppliersBlock,
|
||||
SellerShoppingCartBlock,
|
||||
} from './blocks/seller-blocks'
|
||||
|
||||
// 🔧 Импорт селлерских хуков
|
||||
import {
|
||||
useProductData, // Используем тот же hook для товаров
|
||||
useQuantityManagement, // Используем тот же hook для количества
|
||||
useCurrencyFormatting, // Используем тот же hook для валюты
|
||||
} from './hooks'
|
||||
import {
|
||||
useSellerSupplyForm,
|
||||
useSellerSupplyCreation,
|
||||
useSellerSupplierData,
|
||||
} from './hooks/seller-hooks'
|
||||
|
||||
export function SellerModularVersion() {
|
||||
const router = useRouter()
|
||||
const { getSidebarMargin } = useSidebar()
|
||||
const { user: _user } = useAuth()
|
||||
|
||||
// 📋 Управление состоянием формы СЕЛЛЕРА
|
||||
const {
|
||||
selectedSupplier,
|
||||
selectedFulfillment, // 🆕 Дополнительное поле для селлера
|
||||
selectedLogistics,
|
||||
selectedConsumables,
|
||||
searchQuery,
|
||||
productSearchQuery,
|
||||
deliveryDate,
|
||||
notes,
|
||||
setSelectedSupplier,
|
||||
setSelectedFulfillment, // 🆕 Дополнительный сеттер
|
||||
setSelectedLogistics,
|
||||
setSelectedConsumables,
|
||||
setSearchQuery,
|
||||
setProductSearchQuery,
|
||||
setDeliveryDate,
|
||||
setNotes,
|
||||
resetForm,
|
||||
} = useSellerSupplyForm()
|
||||
|
||||
// 🏢 Данные партнеров для селлера (поставщики + фулфилмент-центры)
|
||||
const {
|
||||
suppliers,
|
||||
fulfillmentCenters, // 🆕 Фулфилмент-центры для селлера
|
||||
filteredSuppliers,
|
||||
filteredFulfillmentCenters, // 🆕 Фильтрованные ФФ
|
||||
logisticsPartners,
|
||||
loading: suppliersLoading,
|
||||
} = useSellerSupplierData({ searchQuery })
|
||||
|
||||
// 📦 Данные товаров (используем тот же hook что и для фулфилмента)
|
||||
const {
|
||||
products,
|
||||
loading: productsLoading,
|
||||
} = useProductData({
|
||||
selectedSupplier, // 🔄 Товары от выбранного поставщика
|
||||
productSearchQuery,
|
||||
})
|
||||
|
||||
// 📊 Управление количеством (используем тот же hook)
|
||||
const {
|
||||
updateConsumableQuantity,
|
||||
getSelectedQuantity,
|
||||
getTotalAmount,
|
||||
getTotalItems,
|
||||
} = useQuantityManagement({
|
||||
selectedSupplier,
|
||||
selectedConsumables,
|
||||
products,
|
||||
setSelectedConsumables,
|
||||
})
|
||||
|
||||
// 🚀 Создание поставки СЕЛЛЕРА
|
||||
const {
|
||||
createSupply,
|
||||
isCreating: isCreatingSupply,
|
||||
} = useSellerSupplyCreation({
|
||||
selectedSupplier,
|
||||
selectedFulfillment, // 🆕 Передаем фулфилмент-центр
|
||||
selectedConsumables,
|
||||
deliveryDate,
|
||||
notes,
|
||||
resetForm,
|
||||
})
|
||||
|
||||
// 💰 Форматирование валюты (используем тот же hook)
|
||||
const { formatCurrency } = useCurrencyFormatting()
|
||||
|
||||
// 🔄 Обработчики событий
|
||||
const handleBack = () => {
|
||||
// TODO: Создать маршрут для списка поставок селлера
|
||||
router.push('/seller-supplies') // или другой подходящий маршрут
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className={`flex-1 ${getSidebarMargin()} overflow-auto transition-all duration-300`}>
|
||||
<div className="min-h-full w-full flex flex-col px-3 py-2">
|
||||
{/* 📄 Заголовок страницы */}
|
||||
<PageHeader onBack={handleBack} />
|
||||
|
||||
{/* 🧩 Основной контент с двумя блоками */}
|
||||
<div className="flex-1 flex gap-3 min-h-0">
|
||||
{/* 📦 Левая колонка - Партнеры и Расходники */}
|
||||
<div className="flex-1 flex flex-col gap-3 min-h-0">
|
||||
{/* 🏪 Блок партнеров (поставщики + фулфилмент-центры) */}
|
||||
<SellerSuppliersBlock
|
||||
suppliers={suppliers}
|
||||
fulfillmentCenters={fulfillmentCenters}
|
||||
filteredSuppliers={filteredSuppliers}
|
||||
filteredFulfillmentCenters={filteredFulfillmentCenters}
|
||||
selectedSupplier={selectedSupplier}
|
||||
selectedFulfillment={selectedFulfillment}
|
||||
searchQuery={searchQuery}
|
||||
loading={suppliersLoading}
|
||||
onSelectSupplier={setSelectedSupplier}
|
||||
onSelectFulfillment={setSelectedFulfillment}
|
||||
onSearchChange={setSearchQuery}
|
||||
/>
|
||||
|
||||
{/* 📦 Блок расходников (тот же что у фулфилмента) */}
|
||||
<ConsumablesBlock
|
||||
selectedSupplier={selectedSupplier}
|
||||
products={products}
|
||||
productsLoading={productsLoading}
|
||||
productSearchQuery={productSearchQuery}
|
||||
getSelectedQuantity={getSelectedQuantity}
|
||||
onProductSearchChange={setProductSearchQuery}
|
||||
onUpdateQuantity={updateConsumableQuantity}
|
||||
formatCurrency={formatCurrency}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 🛒 Правая колонка - Корзина СЕЛЛЕРА */}
|
||||
<div className="w-72 flex-shrink-0">
|
||||
<SellerShoppingCartBlock
|
||||
selectedConsumables={selectedConsumables}
|
||||
selectedFulfillment={selectedFulfillment} // 🆕 Показываем выбранный ФФ
|
||||
deliveryDate={deliveryDate}
|
||||
notes={notes}
|
||||
selectedLogistics={selectedLogistics}
|
||||
logisticsPartners={logisticsPartners}
|
||||
isCreatingSupply={isCreatingSupply}
|
||||
getTotalAmount={getTotalAmount}
|
||||
getTotalItems={getTotalItems}
|
||||
formatCurrency={formatCurrency}
|
||||
onUpdateQuantity={updateConsumableQuantity}
|
||||
onSetDeliveryDate={setDeliveryDate}
|
||||
onSetNotes={setNotes}
|
||||
onSetLogistics={setSelectedLogistics}
|
||||
onCreateSupply={createSupply}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,252 @@
|
||||
// =============================================================================
|
||||
// 🔧 ТИПЫ ДЛЯ СИСТЕМЫ СОЗДАНИЯ ПОСТАВОК РАСХОДНИКОВ ФУЛФИЛМЕНТА V2
|
||||
// =============================================================================
|
||||
// Эти типы экстрагированы из монолитного компонента в соответствии с правилами
|
||||
// модульной архитектуры MODULAR_ARCHITECTURE_PATTERN.md
|
||||
|
||||
// 🏢 ТИПЫ ПОСТАВЩИКОВ И ОРГАНИЗАЦИЙ
|
||||
export interface FulfillmentConsumableSupplier {
|
||||
id: string
|
||||
inn: string
|
||||
name?: string
|
||||
fullName?: string
|
||||
type: 'FULFILLMENT' | 'SELLER' | 'LOGIST' | 'WHOLESALE'
|
||||
address?: string
|
||||
phones?: Array<{ value: string }>
|
||||
emails?: Array<{ value: string }>
|
||||
users?: Array<{ id: string; avatar?: string; managerName?: string }>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 📦 ТИПЫ ТОВАРОВ И РАСХОДНИКОВ
|
||||
export interface FulfillmentConsumableProduct {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
price: number
|
||||
type?: 'PRODUCT' | 'CONSUMABLE'
|
||||
category?: { name: string }
|
||||
images: string[]
|
||||
mainImage?: string
|
||||
organization: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
stock?: number
|
||||
unit?: string
|
||||
quantity?: number
|
||||
ordered?: number
|
||||
}
|
||||
|
||||
// 🛒 ТИПЫ КОРЗИНЫ И ВЫБРАННЫХ ТОВАРОВ
|
||||
export interface SelectedFulfillmentConsumable {
|
||||
id: string
|
||||
name: string
|
||||
price: number
|
||||
selectedQuantity: number
|
||||
unit?: string
|
||||
category?: string
|
||||
supplierId: string
|
||||
supplierName: string
|
||||
}
|
||||
|
||||
// 📋 ТИПЫ ФОРМЫ СОЗДАНИЯ ПОСТАВКИ
|
||||
export interface CreateSupplyFormData {
|
||||
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||
selectedLogistics: FulfillmentConsumableSupplier | null
|
||||
selectedConsumables: SelectedFulfillmentConsumable[]
|
||||
searchQuery: string
|
||||
productSearchQuery: string
|
||||
deliveryDate: string
|
||||
notes: string
|
||||
isCreatingSupply: boolean
|
||||
}
|
||||
|
||||
// 🔍 ТИПЫ ПОИСКОВЫХ ПАРАМЕТРОВ
|
||||
export interface SupplierSearchParams {
|
||||
searchQuery: string
|
||||
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||
}
|
||||
|
||||
export interface ProductSearchParams {
|
||||
productSearchQuery: string
|
||||
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||
type: 'CONSUMABLE'
|
||||
}
|
||||
|
||||
// 📊 ТИПЫ ДАННЫХ ЗАПРОСОВ
|
||||
export interface CounterpartiesData {
|
||||
myCounterparties: FulfillmentConsumableSupplier[]
|
||||
}
|
||||
|
||||
export interface ProductsData {
|
||||
organizationProducts: FulfillmentConsumableProduct[]
|
||||
}
|
||||
|
||||
// 🎯 ТИПЫ ДЕЙСТВИЙ И ОПЕРАЦИЙ
|
||||
export interface QuantityUpdateParams {
|
||||
productId: string
|
||||
quantity: number
|
||||
product: FulfillmentConsumableProduct
|
||||
selectedSupplier: FulfillmentConsumableSupplier
|
||||
}
|
||||
|
||||
export interface SupplyCreationInput {
|
||||
supplierId: string
|
||||
logisticsPartnerId?: string // Добавляем логистического партнера
|
||||
requestedDeliveryDate: string
|
||||
items: Array<{
|
||||
productId: string
|
||||
requestedQuantity: number
|
||||
}>
|
||||
notes?: string
|
||||
}
|
||||
|
||||
// 🔧 ТИПЫ УТИЛИТ И ХЕЛПЕРОВ
|
||||
export interface StockCalculation {
|
||||
totalStock: number
|
||||
orderedStock: number
|
||||
availableStock: number
|
||||
}
|
||||
|
||||
export interface CurrencyFormatOptions {
|
||||
style: 'currency'
|
||||
currency: 'RUB'
|
||||
minimumFractionDigits: 0
|
||||
}
|
||||
|
||||
// 🚦 ТИПЫ СОСТОЯНИЙ ЗАГРУЗКИ
|
||||
export interface LoadingStates {
|
||||
counterpartiesLoading: boolean
|
||||
productsLoading: boolean
|
||||
isCreatingSupply: boolean
|
||||
}
|
||||
|
||||
// ⚠️ ТИПЫ ОШИБОК И ВАЛИДАЦИИ
|
||||
export interface ValidationError {
|
||||
field: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface StockValidationResult {
|
||||
isValid: boolean
|
||||
availableStock: number
|
||||
requestedQuantity: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
// 🎨 ТИПЫ UI КОМПОНЕНТОВ
|
||||
export interface SupplierCardProps {
|
||||
supplier: FulfillmentConsumableSupplier
|
||||
isSelected: boolean
|
||||
onSelect: (supplier: FulfillmentConsumableSupplier) => void
|
||||
index: number
|
||||
}
|
||||
|
||||
export interface ProductCardProps {
|
||||
product: FulfillmentConsumableProduct
|
||||
selectedQuantity: number
|
||||
onUpdateQuantity: (productId: string, quantity: number) => void
|
||||
index: number
|
||||
}
|
||||
|
||||
export interface ShoppingCartProps {
|
||||
selectedConsumables: SelectedFulfillmentConsumable[]
|
||||
deliveryDate: string
|
||||
notes: string
|
||||
selectedLogistics: FulfillmentConsumableSupplier | null
|
||||
logisticsPartners: FulfillmentConsumableSupplier[]
|
||||
isCreatingSupply: boolean
|
||||
onUpdateQuantity: (productId: string, quantity: number) => void
|
||||
onSetDeliveryDate: (date: string) => void
|
||||
onSetNotes: (notes: string) => void
|
||||
onSetLogistics: (logistics: FulfillmentConsumableSupplier | null) => void
|
||||
onCreateSupply: () => void
|
||||
}
|
||||
|
||||
// 📈 ТИПЫ ВЫЧИСЛЕНИЙ И АНАЛИТИКИ
|
||||
export interface SupplyTotals {
|
||||
totalAmount: number
|
||||
totalItems: number
|
||||
itemsCount: number
|
||||
}
|
||||
|
||||
// 🎯 ТИПЫ СОБЫТИЙ И КОЛБЭКОВ
|
||||
export type SupplierSelectHandler = (supplier: FulfillmentConsumableSupplier) => void
|
||||
export type QuantityUpdateHandler = (productId: string, quantity: number) => void
|
||||
export type SupplyCreateHandler = () => Promise<void>
|
||||
export type SearchUpdateHandler = (query: string) => void
|
||||
|
||||
// 💾 ТИПЫ СТОРЕЙДЖА И КЕША
|
||||
export interface CachedSupplierData {
|
||||
suppliers: FulfillmentConsumableSupplier[]
|
||||
lastFetch: string
|
||||
}
|
||||
|
||||
export interface CachedProductData {
|
||||
products: FulfillmentConsumableProduct[]
|
||||
supplierId: string
|
||||
lastFetch: string
|
||||
}
|
||||
|
||||
// 🌐 ТИПЫ ГРАФК ЗАПРОСОВ
|
||||
export interface GraphQLQueryVariables {
|
||||
organizationId?: string
|
||||
search?: string | null
|
||||
category?: string | null
|
||||
type?: 'CONSUMABLE'
|
||||
}
|
||||
|
||||
export interface CreateSupplyMutationResponse {
|
||||
createFulfillmentConsumableSupply: {
|
||||
success: boolean
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 ЭКСПОРТ ТИПОВ ДЛЯ ХУКОВ
|
||||
export interface UseSupplierDataReturn {
|
||||
suppliers: FulfillmentConsumableSupplier[]
|
||||
logisticsPartners: FulfillmentConsumableSupplier[]
|
||||
filteredSuppliers: FulfillmentConsumableSupplier[]
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export interface UseProductDataReturn {
|
||||
products: FulfillmentConsumableProduct[]
|
||||
loading: boolean
|
||||
error: Error | null
|
||||
refetch: () => void
|
||||
}
|
||||
|
||||
export interface UseSupplyFormReturn {
|
||||
selectedSupplier: FulfillmentConsumableSupplier | null
|
||||
selectedLogistics: FulfillmentConsumableSupplier | null
|
||||
selectedConsumables: SelectedFulfillmentConsumable[]
|
||||
searchQuery: string
|
||||
productSearchQuery: string
|
||||
deliveryDate: string
|
||||
notes: string
|
||||
setSelectedSupplier: (supplier: FulfillmentConsumableSupplier | null) => void
|
||||
setSelectedLogistics: (logistics: FulfillmentConsumableSupplier | null) => void
|
||||
setSelectedConsumables: (consumables: SelectedFulfillmentConsumable[]) => void
|
||||
setSearchQuery: (query: string) => void
|
||||
setProductSearchQuery: (query: string) => void
|
||||
setDeliveryDate: (date: string) => void
|
||||
setNotes: (notes: string) => void
|
||||
resetForm: () => void
|
||||
}
|
||||
|
||||
export interface UseQuantityManagementReturn {
|
||||
updateConsumableQuantity: (productId: string, quantity: number) => void
|
||||
getSelectedQuantity: (productId: string) => number
|
||||
getTotalAmount: () => number
|
||||
getTotalItems: () => number
|
||||
}
|
||||
|
||||
export interface UseSupplyCreationReturn {
|
||||
createSupply: () => Promise<void>
|
||||
isCreating: boolean
|
||||
error: string | null
|
||||
}
|
@ -0,0 +1,193 @@
|
||||
// =============================================================================
|
||||
// 🔧 ТИПЫ ДЛЯ СИСТЕМЫ СОЗДАНИЯ ПОСТАВОК РАСХОДНИКОВ СЕЛЛЕРА V2
|
||||
// =============================================================================
|
||||
// Адаптированные типы из основных типов для селлерской системы
|
||||
|
||||
import type {
|
||||
FulfillmentConsumableSupplier,
|
||||
FulfillmentConsumableProduct,
|
||||
SelectedFulfillmentConsumable,
|
||||
SupplyCreationInput as BaseSupplyCreationInput,
|
||||
CreateSupplyMutationResponse as BaseCreateSupplyMutationResponse,
|
||||
} from './index'
|
||||
|
||||
// =============================================================================
|
||||
// 📦 СЕЛЛЕРСКИЕ ТИПЫ (АДАПТАЦИЯ)
|
||||
// =============================================================================
|
||||
|
||||
// Селлер использует те же типы поставщиков что и фулфилмент
|
||||
export type SellerConsumableSupplier = FulfillmentConsumableSupplier
|
||||
export type SellerConsumableProduct = FulfillmentConsumableProduct
|
||||
export type SelectedSellerConsumable = SelectedFulfillmentConsumable
|
||||
|
||||
// =============================================================================
|
||||
// 🆕 СПЕЦИФИЧНЫЕ ТИПЫ ДЛЯ СЕЛЛЕРА
|
||||
// =============================================================================
|
||||
|
||||
// Статусы поставок селлера (5-статусная система)
|
||||
export type SellerSupplyOrderStatus =
|
||||
| 'PENDING' // Ожидает одобрения поставщика
|
||||
| 'APPROVED' // Одобрено поставщиком
|
||||
| 'SHIPPED' // Отгружено
|
||||
| 'DELIVERED' // Доставлено
|
||||
| 'COMPLETED' // Завершено
|
||||
| 'CANCELLED' // Отменено
|
||||
|
||||
// Данные для создания поставки селлера
|
||||
export interface SellerSupplyCreationInput {
|
||||
fulfillmentCenterId: string // куда доставлять (FULFILLMENT партнер)
|
||||
supplierId: string // от кого заказывать (WHOLESALE партнер)
|
||||
logisticsPartnerId?: string // кто везет (LOGIST партнер, опционально)
|
||||
requestedDeliveryDate: string // когда нужно
|
||||
items: Array<{
|
||||
productId: string
|
||||
requestedQuantity: number
|
||||
}>
|
||||
notes?: string
|
||||
}
|
||||
|
||||
// Response для создания поставки селлера
|
||||
export interface CreateSellerSupplyMutationResponse {
|
||||
createSellerConsumableSupply: {
|
||||
success: boolean
|
||||
message: string
|
||||
supplyOrder?: {
|
||||
id: string
|
||||
status: SellerSupplyOrderStatus
|
||||
createdAt: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 🎯 ТИПЫ ДЛЯ ХУКОВ (АДАПТАЦИЯ)
|
||||
// =============================================================================
|
||||
|
||||
export interface UseSellerSupplyCreationProps {
|
||||
selectedSupplier: SellerConsumableSupplier | null
|
||||
selectedFulfillment: SellerConsumableSupplier | null // 🆕 Выбор фулфилмента
|
||||
selectedConsumables: SelectedSellerConsumable[]
|
||||
deliveryDate: string
|
||||
notes: string
|
||||
resetForm: () => void
|
||||
}
|
||||
|
||||
export interface UseSellerSupplyCreationReturn {
|
||||
createSupply: () => Promise<void>
|
||||
isCreating: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface UseSellerSupplyFormReturn {
|
||||
selectedSupplier: SellerConsumableSupplier | null
|
||||
selectedFulfillment: SellerConsumableSupplier | null // 🆕 Дополнительное поле
|
||||
selectedLogistics: SellerConsumableSupplier | null
|
||||
selectedConsumables: SelectedSellerConsumable[]
|
||||
searchQuery: string
|
||||
productSearchQuery: string
|
||||
deliveryDate: string
|
||||
notes: string
|
||||
setSelectedSupplier: (supplier: SellerConsumableSupplier | null) => void
|
||||
setSelectedFulfillment: (fulfillment: SellerConsumableSupplier | null) => void // 🆕 Новый сеттер
|
||||
setSelectedLogistics: (logistics: SellerConsumableSupplier | null) => void
|
||||
setSelectedConsumables: (consumables: SelectedSellerConsumable[]) => void
|
||||
setSearchQuery: (query: string) => void
|
||||
setProductSearchQuery: (query: string) => void
|
||||
setDeliveryDate: (date: string) => void
|
||||
setNotes: (notes: string) => void
|
||||
resetForm: () => void
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 🎨 ТИПЫ UI КОМПОНЕНТОВ ДЛЯ СЕЛЛЕРА
|
||||
// =============================================================================
|
||||
|
||||
export interface SellerSuppliersBlockProps {
|
||||
suppliers: SellerConsumableSupplier[]
|
||||
fulfillmentCenters: SellerConsumableSupplier[] // 🆕 Список фулфилмент-центров
|
||||
filteredSuppliers: SellerConsumableSupplier[]
|
||||
filteredFulfillmentCenters: SellerConsumableSupplier[] // 🆕 Фильтрованные ФФ
|
||||
selectedSupplier: SellerConsumableSupplier | null
|
||||
selectedFulfillment: SellerConsumableSupplier | null // 🆕 Выбранный фулфилмент
|
||||
searchQuery: string
|
||||
loading: boolean
|
||||
onSelectSupplier: (supplier: SellerConsumableSupplier | null) => void
|
||||
onSelectFulfillment: (fulfillment: SellerConsumableSupplier | null) => void // 🆕
|
||||
onSearchChange: (query: string) => void
|
||||
}
|
||||
|
||||
export interface SellerShoppingCartBlockProps {
|
||||
selectedConsumables: SelectedSellerConsumable[]
|
||||
selectedFulfillment: SellerConsumableSupplier | null // 🆕 Показ выбранного ФФ
|
||||
deliveryDate: string
|
||||
notes: string
|
||||
selectedLogistics: SellerConsumableSupplier | null
|
||||
logisticsPartners: SellerConsumableSupplier[]
|
||||
isCreatingSupply: boolean
|
||||
getTotalAmount: () => number
|
||||
getTotalItems: () => number
|
||||
formatCurrency: (amount: number) => string
|
||||
onUpdateQuantity: (productId: string, quantity: number) => void
|
||||
onSetDeliveryDate: (date: string) => void
|
||||
onSetNotes: (notes: string) => void
|
||||
onSetLogistics: (logistics: SellerConsumableSupplier | null) => void
|
||||
onCreateSupply: () => void
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 🔄 ТИПЫ ДЛЯ ПЕРЕКЛЮЧЕНИЯ СИСТЕМ
|
||||
// =============================================================================
|
||||
|
||||
export interface SupplySystemConfig {
|
||||
type: 'FULFILLMENT' | 'SELLER'
|
||||
queries: {
|
||||
GET_MY_SUPPLIES: any
|
||||
CREATE_SUPPLY: any
|
||||
GET_COUNTERPARTIES: any
|
||||
GET_ORGANIZATION_PRODUCTS: any
|
||||
}
|
||||
components: {
|
||||
SuppliersBlock: React.ComponentType<any>
|
||||
ShoppingCartBlock: React.ComponentType<any>
|
||||
}
|
||||
hooks: {
|
||||
useSupplyCreation: any
|
||||
useSupplyForm: any
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 🌐 ТИПЫ ДАННЫХ ЗАПРОСОВ ДЛЯ СЕЛЛЕРА
|
||||
// =============================================================================
|
||||
|
||||
export interface SellerSuppliesData {
|
||||
mySellerConsumableSupplies: Array<{
|
||||
id: string
|
||||
status: SellerSupplyOrderStatus
|
||||
seller: { id: string; name: string; inn: string }
|
||||
fulfillmentCenter: { id: string; name: string; inn: string }
|
||||
supplier?: { id: string; name: string; inn: string }
|
||||
requestedDeliveryDate: string
|
||||
totalCostWithDelivery?: number
|
||||
items: Array<{
|
||||
id: string
|
||||
product: { id: string; name: string; article: string }
|
||||
requestedQuantity: number
|
||||
unitPrice: number
|
||||
totalPrice: number
|
||||
}>
|
||||
createdAt: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface IncomingSellerSuppliesData {
|
||||
incomingSellerSupplies: Array<{
|
||||
id: string
|
||||
status: SellerSupplyOrderStatus
|
||||
seller: { id: string; name: string; inn: string }
|
||||
fulfillmentCenter: { id: string; name: string; inn: string }
|
||||
requestedDeliveryDate: string
|
||||
totalCostWithDelivery?: number
|
||||
createdAt: string
|
||||
}>
|
||||
}
|
Reference in New Issue
Block a user