
- Обновлена схема Prisma с новыми полями и связями - Актуализированы правила системы в rules-complete.md - Оптимизированы GraphQL типы, запросы и мутации - Улучшены компоненты интерфейса и валидация данных - Исправлены критические ESLint ошибки: удалены неиспользуемые импорты и переменные - Добавлены тестовые файлы для проверки функционала 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
849 lines
42 KiB
TypeScript
849 lines
42 KiB
TypeScript
'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 } 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 { CREATE_SUPPLY_ORDER } from '@/graphql/mutations'
|
||
import { GET_MY_COUNTERPARTIES, GET_ORGANIZATION_PRODUCTS, GET_SUPPLY_ORDERS, GET_MY_SUPPLIES } from '@/graphql/queries'
|
||
import { useAuth } from '@/hooks/useAuth'
|
||
import { useSidebar } from '@/hooks/useSidebar'
|
||
|
||
interface ConsumableSupplier {
|
||
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 ConsumableProduct {
|
||
id: string
|
||
name: string
|
||
description?: string
|
||
price: number
|
||
category?: { name: string }
|
||
images: string[]
|
||
mainImage?: string
|
||
organization: {
|
||
id: string
|
||
name: string
|
||
}
|
||
stock?: number
|
||
unit?: string
|
||
}
|
||
|
||
interface SelectedConsumable {
|
||
id: string
|
||
name: string
|
||
price: number
|
||
selectedQuantity: number
|
||
unit?: string
|
||
category?: string
|
||
supplierId: string
|
||
supplierName: string
|
||
}
|
||
|
||
export function CreateConsumablesSupplyPage() {
|
||
const router = useRouter()
|
||
const { user } = useAuth()
|
||
const { getSidebarMargin } = useSidebar()
|
||
const [selectedSupplier, setSelectedSupplier] = useState<ConsumableSupplier | null>(null)
|
||
const [selectedConsumables, setSelectedConsumables] = useState<SelectedConsumable[]>([])
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const [productSearchQuery, setProductSearchQuery] = useState('')
|
||
const [deliveryDate, setDeliveryDate] = useState('')
|
||
const [selectedFulfillmentCenter, setSelectedFulfillmentCenter] = useState<ConsumableSupplier | null>(null)
|
||
const [selectedLogistics, setSelectedLogistics] = useState<ConsumableSupplier | null>(null)
|
||
const [isCreatingSupply, setIsCreatingSupply] = useState(false)
|
||
|
||
// Загружаем контрагентов-поставщиков расходников
|
||
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(GET_MY_COUNTERPARTIES)
|
||
|
||
// Загружаем товары для выбранного поставщика с фильтрацией по типу CONSUMABLE
|
||
const { data: productsData, loading: productsLoading } = useQuery(GET_ORGANIZATION_PRODUCTS, {
|
||
skip: !selectedSupplier,
|
||
variables: {
|
||
organizationId: selectedSupplier.id,
|
||
search: productSearchQuery || null,
|
||
category: null,
|
||
type: 'CONSUMABLE', // Фильтруем только расходники согласно rules2.md
|
||
},
|
||
})
|
||
|
||
// Мутация для создания заказа поставки расходников
|
||
const [createSupplyOrder] = useMutation(CREATE_SUPPLY_ORDER)
|
||
|
||
// Фильтруем только поставщиков расходников (поставщиков)
|
||
const consumableSuppliers = (counterpartiesData?.myCounterparties || []).filter(
|
||
(org: ConsumableSupplier) => org.type === 'WHOLESALE',
|
||
)
|
||
|
||
// Фильтруем фулфилмент-центры
|
||
const fulfillmentCenters = (counterpartiesData?.myCounterparties || []).filter(
|
||
(org: ConsumableSupplier) => org.type === 'FULFILLMENT',
|
||
)
|
||
|
||
// Фильтруем логистические компании
|
||
const logisticsPartners = (counterpartiesData?.myCounterparties || []).filter(
|
||
(org: ConsumableSupplier) => org.type === 'LOGIST',
|
||
)
|
||
|
||
// Фильтруем поставщиков по поисковому запросу
|
||
const filteredSuppliers = consumableSuppliers.filter(
|
||
(supplier: ConsumableSupplier) =>
|
||
supplier.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
supplier.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
supplier.inn?.toLowerCase().includes(searchQuery.toLowerCase()),
|
||
)
|
||
|
||
// Получаем товары поставщика (уже отфильтрованы в GraphQL запросе)
|
||
const supplierProducts = productsData?.organizationProducts || []
|
||
|
||
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: ConsumableProduct) => p.id === productId)
|
||
if (!product || !selectedSupplier) return
|
||
|
||
// ✅ ПРОВЕРКА ОСТАТКОВ согласно rules2.md раздел 9.4.5
|
||
if (quantity > 0) {
|
||
// Проверяем доступность на складе
|
||
if (product.stock !== undefined && quantity > product.stock) {
|
||
toast.error(`Недостаточно товара на складе. Доступно: ${product.stock} ${product.unit || 'шт'}`)
|
||
return
|
||
}
|
||
|
||
// Логируем попытку добавления для аудита
|
||
console.warn('📊 Stock check:', {
|
||
action: 'add_to_cart',
|
||
productId: product.id,
|
||
productName: product.name,
|
||
requested: quantity,
|
||
available: product.stock || 'unlimited',
|
||
result: quantity <= (product.stock || Infinity) ? 'allowed' : 'blocked',
|
||
userId: selectedSupplier.id,
|
||
timestamp: new Date().toISOString(),
|
||
})
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// ✅ ФИНАЛЬНАЯ ПРОВЕРКА ОСТАТКОВ перед созданием заказа
|
||
for (const consumable of selectedConsumables) {
|
||
const product = supplierProducts.find((p) => p.id === consumable.id)
|
||
if (product?.stock !== undefined && consumable.selectedQuantity > product.stock) {
|
||
toast.error(
|
||
`Товар "${consumable.name}" недоступен в количестве ${consumable.selectedQuantity}. ` +
|
||
`Доступно: ${product.stock} ${product.unit || 'шт'}`,
|
||
)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Для селлеров требуется выбор фулфилмент-центра
|
||
if (!selectedFulfillmentCenter) {
|
||
toast.error('Выберите фулфилмент-центр для доставки')
|
||
return
|
||
}
|
||
|
||
// Логистика опциональна - может выбрать селлер или оставить фулфилменту
|
||
if (selectedLogistics && !selectedLogistics.id) {
|
||
toast.error('Некорректно выбрана логистическая компания')
|
||
return
|
||
}
|
||
|
||
// Дополнительные проверки
|
||
if (!selectedFulfillmentCenter.id) {
|
||
toast.error('ID фулфилмент-центра не найден')
|
||
return
|
||
}
|
||
|
||
if (!selectedSupplier.id) {
|
||
toast.error('ID поставщика не найден')
|
||
return
|
||
}
|
||
|
||
if (selectedConsumables.length === 0) {
|
||
toast.error('Не выбраны расходники')
|
||
return
|
||
}
|
||
|
||
// ✅ ПРОВЕРКА ДАТЫ согласно rules2.md - запрет прошедших дат
|
||
const deliveryDateObj = new Date(deliveryDate)
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
|
||
if (isNaN(deliveryDateObj.getTime())) {
|
||
toast.error('Некорректная дата поставки')
|
||
return
|
||
}
|
||
|
||
if (deliveryDateObj < today) {
|
||
toast.error('Нельзя выбрать прошедшую дату поставки')
|
||
return
|
||
}
|
||
|
||
setIsCreatingSupply(true)
|
||
|
||
// 🔍 ОТЛАДКА: проверяем текущего пользователя
|
||
console.warn('👤 Текущий пользователь:', {
|
||
userId: user?.id,
|
||
phone: user?.phone,
|
||
organizationId: user?.organization?.id,
|
||
organizationType: user?.organization?.type,
|
||
organizationName: user?.organization?.name || user?.organization?.fullName,
|
||
})
|
||
|
||
console.warn('🚀 Создаем поставку с данными:', {
|
||
partnerId: selectedSupplier.id,
|
||
deliveryDate: deliveryDate,
|
||
fulfillmentCenterId: selectedFulfillmentCenter.id,
|
||
logisticsPartnerId: selectedLogistics?.id,
|
||
hasLogistics: !!selectedLogistics?.id,
|
||
consumableType: 'SELLER_CONSUMABLES',
|
||
itemsCount: selectedConsumables.length,
|
||
mutationInput: {
|
||
partnerId: selectedSupplier.id,
|
||
deliveryDate: deliveryDate,
|
||
fulfillmentCenterId: selectedFulfillmentCenter.id,
|
||
...(selectedLogistics?.id ? { logisticsPartnerId: selectedLogistics.id } : {}),
|
||
consumableType: 'SELLER_CONSUMABLES',
|
||
items: selectedConsumables.map((consumable) => ({
|
||
productId: consumable.id,
|
||
quantity: consumable.selectedQuantity,
|
||
})),
|
||
},
|
||
})
|
||
|
||
try {
|
||
const result = await createSupplyOrder({
|
||
variables: {
|
||
input: {
|
||
partnerId: selectedSupplier.id,
|
||
deliveryDate: deliveryDate,
|
||
fulfillmentCenterId: selectedFulfillmentCenter.id,
|
||
// 🔄 ЛОГИСТИКА ОПЦИОНАЛЬНА: селлер может выбрать или оставить фулфилменту
|
||
...(selectedLogistics?.id ? { logisticsPartnerId: selectedLogistics.id } : {}),
|
||
// 🏷️ КЛАССИФИКАЦИЯ согласно правилам (раздел 2.2)
|
||
consumableType: 'SELLER_CONSUMABLES', // Расходники селлеров
|
||
items: selectedConsumables.map((consumable) => ({
|
||
productId: consumable.id,
|
||
quantity: consumable.selectedQuantity,
|
||
})),
|
||
},
|
||
},
|
||
refetchQueries: [
|
||
{ query: GET_SUPPLY_ORDERS }, // Обновляем заказы поставок
|
||
{ query: GET_MY_SUPPLIES }, // Обновляем расходники фулфилмента
|
||
],
|
||
})
|
||
|
||
if (result.data?.createSupplyOrder?.success) {
|
||
toast.success('Заказ поставки расходников создан успешно!')
|
||
// Очищаем форму
|
||
setSelectedSupplier(null)
|
||
setSelectedFulfillmentCenter(null)
|
||
setSelectedConsumables([])
|
||
setDeliveryDate('')
|
||
setProductSearchQuery('')
|
||
setSearchQuery('')
|
||
|
||
// Перенаправляем на страницу поставок селлера с открытой вкладкой "Расходники"
|
||
router.push('/supplies?tab=consumables')
|
||
} else {
|
||
toast.error(result.data?.createSupplyOrder?.message || 'Ошибка при создании заказа поставки')
|
||
}
|
||
} catch (error) {
|
||
console.error('Error creating consumables supply:', error)
|
||
|
||
// Детальная диагностика ошибки
|
||
if (error instanceof Error) {
|
||
console.error('Error details:', {
|
||
message: error.message,
|
||
stack: error.stack,
|
||
name: error.name,
|
||
})
|
||
|
||
// Показываем конкретную ошибку пользователю
|
||
toast.error(`Ошибка: ${error.message}`)
|
||
} else {
|
||
console.error('Unknown error:', 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 p-4`}>
|
||
<div className="min-h-full w-full flex flex-col gap-4">
|
||
{/* Заголовок */}
|
||
<div className="flex items-center justify-between 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('/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-4 min-h-0">
|
||
{/* Левая колонка - Поставщики и Расходники */}
|
||
<div className="flex-1 flex flex-col gap-4 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 text-white 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"
|
||
>
|
||
✕ Сбросить
|
||
</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: ConsumableSupplier, index) => (
|
||
<Card
|
||
key={supplier.id}
|
||
className={`relative cursor-pointer transition-all duration-300 border flex-shrink-0 rounded-xl overflow-hidden group ${
|
||
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"
|
||
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: ConsumableProduct, index) => {
|
||
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">
|
||
{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"
|
||
/>
|
||
) : product.mainImage ? (
|
||
<Image
|
||
src={product.mainImage}
|
||
alt={product.name}
|
||
width={100}
|
||
height={100}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : (
|
||
<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>
|
||
{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>
|
||
)}
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-green-400 font-semibold text-sm">
|
||
{formatCurrency(product.price)}
|
||
</span>
|
||
{product.stock && <span className="text-white/60 text-xs">{product.stock}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Управление количеством */}
|
||
<div className="flex flex-col items-center space-y-2 mt-auto">
|
||
<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)
|
||
|
||
// Ограничиваем значение максимумом 99999
|
||
const clampedValue = Math.min(numericValue, 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, 99999))
|
||
}
|
||
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/20 rounded-full transition-all duration-300"
|
||
>
|
||
<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>
|
||
{selectedFulfillmentCenter && (
|
||
<div className="bg-white/5 rounded-lg p-2 mb-2">
|
||
<p className="text-white/60 text-xs">Доставка в:</p>
|
||
<p className="text-white text-xs font-medium">
|
||
{selectedFulfillmentCenter.name || selectedFulfillmentCenter.fullName}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</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>
|
||
<div className="relative">
|
||
<select
|
||
value={selectedFulfillmentCenter?.id || ''}
|
||
onChange={(e) => {
|
||
const center = fulfillmentCenters.find((fc) => fc.id === e.target.value)
|
||
setSelectedFulfillmentCenter(center || null)
|
||
}}
|
||
className="w-full bg-white/10 border border-white/20 text-white h-8 text-sm rounded px-2 pr-8 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50 appearance-none"
|
||
required
|
||
>
|
||
<option value="" className="bg-gray-800 text-white">
|
||
Выберите фулфилмент-центр
|
||
</option>
|
||
{fulfillmentCenters.map((center) => (
|
||
<option key={center.id} value={center.id} className="bg-gray-800 text-white">
|
||
{center.name || center.fullName || 'Фулфилмент-центр'}
|
||
</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">
|
||
Логистическая компания:
|
||
<span className="text-white/40 ml-1">(опционально)</span>
|
||
</label>
|
||
<div className="relative">
|
||
<select
|
||
value={selectedLogistics?.id || ''}
|
||
onChange={(e) => {
|
||
const logisticsId = e.target.value
|
||
const logistics = logisticsPartners.find((p) => p.id === logisticsId)
|
||
setSelectedLogistics(logistics || null)
|
||
}}
|
||
className="w-full bg-white/10 border border-white/20 text-white h-8 text-sm rounded px-2 pr-8 focus:ring-2 focus:ring-purple-400/50 focus:border-purple-400/50 appearance-none"
|
||
>
|
||
<option value="" className="bg-gray-800 text-white">
|
||
Выберите логистику или оставьте фулфилменту
|
||
</option>
|
||
{logisticsPartners.map((partner) => (
|
||
<option key={partner.id} value={partner.id} className="bg-gray-800 text-white">
|
||
{partner.name || partner.fullName || 'Логистика'}
|
||
</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>
|
||
<Input
|
||
type="date"
|
||
value={deliveryDate}
|
||
onChange={(e) => {
|
||
const selectedDate = new Date(e.target.value)
|
||
const today = new Date()
|
||
today.setHours(0, 0, 0, 0)
|
||
|
||
if (selectedDate < today) {
|
||
toast.error('Нельзя выбрать прошедшую дату поставки')
|
||
return
|
||
}
|
||
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="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 ||
|
||
!selectedFulfillmentCenter ||
|
||
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>
|
||
)
|
||
}
|