diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 411e6d8..50323b6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -118,6 +118,12 @@ model Organization { services Service[] supplies Supply[] + // Товары (только для оптовиков) + products Product[] + + // Корзины + carts Cart[] + @@map("organizations") } @@ -274,3 +280,106 @@ model Supply { @@map("supplies") } + +// Модель категорий товаров +model Category { + id String @id @default(cuid()) + name String @unique // Название категории + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Связь с товарами + products Product[] + + @@map("categories") +} + +// Модель товаров (для оптовиков) +model Product { + id String @id @default(cuid()) + + // Основные поля + name String // Название товара + article String // Артикул/номер записи + description String? // Описание + + // Цена и количество + price Decimal @db.Decimal(12,2) // Цена за единицу + quantity Int @default(0) // Количество в наличии + + // Основные характеристики + category Category? @relation(fields: [categoryId], references: [id]) + categoryId String? // ID категории + brand String? // Бренд + + // Дополнительные характеристики (необязательные) + color String? // Цвет + size String? // Размер + weight Decimal? @db.Decimal(8,3) // Вес в кг + dimensions String? // Габариты (ДxШxВ) + material String? // Материал + + // Изображения (JSON массив URL-ов в S3) + images Json @default("[]") // Массив URL изображений + mainImage String? // URL главного изображения + + // Статус товара + isActive Boolean @default(true) // Активен ли товар + + // Временные метки + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Связь с организацией (только оптовики) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId String + + // Связь с элементами корзины + cartItems CartItem[] + + // Уникальность артикула в рамках организации + @@unique([organizationId, article]) + @@map("products") +} + +// Модель корзины +model Cart { + id String @id @default(cuid()) + + // Связь с организацией (только покупатель может иметь корзину) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + organizationId String @unique // У каждой организации может быть только одна корзина + + // Элементы корзины + items CartItem[] + + // Временные метки + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("carts") +} + +// Модель элемента корзины +model CartItem { + id String @id @default(cuid()) + + // Связь с корзиной + cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) + cartId String + + // Связь с товаром + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + productId String + + // Количество товара в корзине + quantity Int @default(1) + + // Временные метки + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Уникальность: один товар может быть только один раз в корзине + @@unique([cartId, productId]) + @@map("cart_items") +} diff --git a/src/app/api/upload-file/route.ts b/src/app/api/upload-file/route.ts index de0f379..bcae239 100644 --- a/src/app/api/upload-file/route.ts +++ b/src/app/api/upload-file/route.ts @@ -42,12 +42,25 @@ export async function POST(request: NextRequest) { const file = formData.get('file') as File const userId = formData.get('userId') as string const messageType = formData.get('messageType') as string // 'IMAGE' или 'FILE' + const type = formData.get('type') as string // Для товаров: 'product' - if (!file || !userId || !messageType) { - return NextResponse.json( - { error: 'File, userId and messageType are required' }, - { status: 400 } - ) + // Проверяем параметры в зависимости от типа загрузки + if (type === 'product') { + // Для товаров нужен только файл + if (!file) { + return NextResponse.json( + { error: 'File is required' }, + { status: 400 } + ) + } + } else { + // Для мессенджера нужны все параметры + if (!file || !userId || !messageType) { + return NextResponse.json( + { error: 'File, userId and messageType are required' }, + { status: 400 } + ) + } } // Проверяем, что файл не пустой @@ -66,8 +79,8 @@ export async function POST(request: NextRequest) { ) } - // Проверяем тип файла в зависимости от типа сообщения - const isImage = messageType === 'IMAGE' + // Проверяем тип файла в зависимости от типа загрузки + const isImage = type === 'product' || messageType === 'IMAGE' const allowedTypes = isImage ? ALLOWED_IMAGE_TYPES : [...ALLOWED_IMAGE_TYPES, ...ALLOWED_FILE_TYPES] if (!allowedTypes.includes(file.type)) { @@ -96,16 +109,41 @@ export async function POST(request: NextRequest) { .replace(/_{2,}/g, '_') // Убираем множественные подчеркивания .toLowerCase() // Приводим к нижнему регистру - const folder = isImage ? 'images' : 'files' - const key = `${folder}/${userId}/${timestamp}-${safeFileName}` + // Определяем папку и ключ в зависимости от типа загрузки + let folder: string + let key: string + + if (type === 'product') { + folder = 'products' + key = `${folder}/${timestamp}-${safeFileName}` + } else { + folder = isImage ? 'images' : 'files' + key = `${folder}/${userId}/${timestamp}-${safeFileName}` + } // Конвертируем файл в Buffer const buffer = Buffer.from(await file.arrayBuffer()) // Очищаем метаданные от недопустимых символов const cleanOriginalName = file.name.replace(/[^\w\s.-]/g, '_') - const cleanUserId = userId.replace(/[^\w-]/g, '') - const cleanMessageType = messageType.replace(/[^\w]/g, '') + + // Подготавливаем метаданные в зависимости от типа загрузки + let metadata: Record + + if (type === 'product') { + metadata = { + originalname: cleanOriginalName, + uploadtype: 'product' + } + } else { + const cleanUserId = userId.replace(/[^\w-]/g, '') + const cleanMessageType = messageType.replace(/[^\w]/g, '') + metadata = { + originalname: cleanOriginalName, + uploadedby: cleanUserId, + messagetype: cleanMessageType + } + } // Загружаем в S3 const command = new PutObjectCommand({ @@ -114,11 +152,7 @@ export async function POST(request: NextRequest) { Body: buffer, ContentType: file.type, ACL: 'public-read', - Metadata: { - originalname: cleanOriginalName, - uploadedby: cleanUserId, - messagetype: cleanMessageType - } + Metadata: metadata }) await s3Client.send(command) @@ -126,15 +160,22 @@ export async function POST(request: NextRequest) { // Возвращаем URL файла и метаданные const url = `https://s3.twcstorage.ru/${BUCKET_NAME}/${key}` - return NextResponse.json({ + const response: Record = { success: true, - url, + fileUrl: url, // Изменяем на fileUrl для совместимости с формой товаров + url, // Оставляем для совместимости с мессенджером key, originalName: file.name, size: file.size, - type: file.type, - messageType - }) + type: file.type + } + + // Добавляем messageType только для мессенджера + if (messageType) { + response.messageType = messageType + } + + return NextResponse.json(response) } catch (error) { console.error('Error uploading file:', error) diff --git a/src/app/warehouse/page.tsx b/src/app/warehouse/page.tsx new file mode 100644 index 0000000..2ee1b18 --- /dev/null +++ b/src/app/warehouse/page.tsx @@ -0,0 +1,10 @@ +import { AuthGuard } from "@/components/auth-guard" +import { WarehouseDashboard } from "@/components/warehouse/warehouse-dashboard" + +export default function WarehousePage() { + return ( + + + + ) +} \ No newline at end of file diff --git a/src/components/auth/confirmation-step.tsx b/src/components/auth/confirmation-step.tsx index 5b59aa0..8934293 100644 --- a/src/components/auth/confirmation-step.tsx +++ b/src/components/auth/confirmation-step.tsx @@ -42,6 +42,20 @@ export function ConfirmationStep({ data, onConfirm, onBack }: ConfirmationStepPr const { registerFulfillmentOrganization, registerSellerOrganization } = useAuth() + // Преобразование типа кабинета в тип организации + const getOrganizationType = (cabinetType: string): 'FULFILLMENT' | 'LOGIST' | 'WHOLESALE' => { + switch (cabinetType) { + case 'fulfillment': + return 'FULFILLMENT' + case 'logist': + return 'LOGIST' + case 'wholesale': + return 'WHOLESALE' + default: + return 'FULFILLMENT' + } + } + const formatPhone = (phone: string) => { return phone || "+7 (___) ___-__-__" } @@ -58,7 +72,8 @@ export function ConfirmationStep({ data, onConfirm, onBack }: ConfirmationStepPr if ((data.cabinetType === 'fulfillment' || data.cabinetType === 'logist' || data.cabinetType === 'wholesale') && data.inn) { result = await registerFulfillmentOrganization( data.phone.replace(/\D/g, ''), - data.inn + data.inn, + getOrganizationType(data.cabinetType) ) } else if (data.cabinetType === 'seller') { result = await registerSellerOrganization({ diff --git a/src/components/cart/cart-dashboard.tsx b/src/components/cart/cart-dashboard.tsx new file mode 100644 index 0000000..7fe5fc5 --- /dev/null +++ b/src/components/cart/cart-dashboard.tsx @@ -0,0 +1,109 @@ +"use client" + +import { useQuery } from '@apollo/client' +import { Card } from '@/components/ui/card' +import { Sidebar } from '@/components/dashboard/sidebar' +import { CartItems } from './cart-items' +import { CartSummary } from './cart-summary' +import { GET_MY_CART } from '@/graphql/queries' +import { ShoppingCart, Package } from 'lucide-react' + +export function CartDashboard() { + const { data, loading, error } = useQuery(GET_MY_CART) + + const cart = data?.myCart + const hasItems = cart?.items && cart.items.length > 0 + + if (loading) { + return ( +
+ +
+
+
+
+

Загружаем корзину...

+
+
+
+
+ ) + } + + if (error) { + return ( +
+ +
+
+
+ +

Ошибка загрузки корзины

+

{error.message}

+
+
+
+
+ ) + } + + return ( +
+ +
+
+ {/* Заголовок */} +
+ +
+

Корзина

+

+ {hasItems + ? `${cart.totalItems} товаров на сумму ${new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB' }).format(cart.totalPrice)}` + : 'Ваша корзина пуста' + } +

+
+
+ + {/* Основной контент */} +
+ {hasItems ? ( +
+ {/* Товары в корзине */} +
+ + + +
+ + {/* Сводка заказа */} +
+ + + +
+
+ ) : ( + +
+ +

Корзина пуста

+

+ Добавьте товары из маркета, чтобы оформить заказ +

+ +
+
+ )} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/cart/cart-items.tsx b/src/components/cart/cart-items.tsx new file mode 100644 index 0000000..132fa0e --- /dev/null +++ b/src/components/cart/cart-items.tsx @@ -0,0 +1,389 @@ +"use client" + +import { useState } from 'react' +import { useMutation } from '@apollo/client' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { + Trash2, + AlertTriangle, + Package, + Store, + Minus, + Plus +} from 'lucide-react' +import { OrganizationAvatar } from '@/components/market/organization-avatar' +import { Input } from '@/components/ui/input' +import Image from 'next/image' +import { UPDATE_CART_ITEM, REMOVE_FROM_CART, CLEAR_CART } from '@/graphql/mutations' +import { GET_MY_CART } from '@/graphql/queries' +import { toast } from 'sonner' + +interface CartItem { + id: string + quantity: number + totalPrice: number + isAvailable: boolean + availableQuantity: number + product: { + id: string + name: string + article: string + price: number + quantity: number + images: string[] + mainImage?: string + organization: { + id: string + name?: string + fullName?: string + inn: string + } + } +} + +interface Cart { + id: string + items: CartItem[] + totalPrice: number + totalItems: number +} + +interface CartItemsProps { + cart: Cart +} + +export function CartItems({ cart }: CartItemsProps) { + const [loadingItems, setLoadingItems] = useState>(new Set()) + + const [updateCartItem] = useMutation(UPDATE_CART_ITEM, { + refetchQueries: [{ query: GET_MY_CART }], + onCompleted: (data) => { + if (data.updateCartItem.success) { + toast.success(data.updateCartItem.message) + } else { + toast.error(data.updateCartItem.message) + } + }, + onError: (error) => { + toast.error('Ошибка при обновлении заявки') + console.error('Error updating cart item:', error) + } + }) + + const [removeFromCart] = useMutation(REMOVE_FROM_CART, { + refetchQueries: [{ query: GET_MY_CART }], + onCompleted: (data) => { + if (data.removeFromCart.success) { + toast.success(data.removeFromCart.message) + } else { + toast.error(data.removeFromCart.message) + } + }, + onError: (error) => { + toast.error('Ошибка при удалении заявки') + console.error('Error removing from cart:', error) + } + }) + + const [clearCart] = useMutation(CLEAR_CART, { + refetchQueries: [{ query: GET_MY_CART }], + onCompleted: () => { + toast.success('Заявки очищены') + }, + onError: (error) => { + toast.error('Ошибка при очистке заявок') + console.error('Error clearing cart:', error) + } + }) + + const updateQuantity = async (productId: string, newQuantity: number) => { + if (newQuantity <= 0) return + + setLoadingItems(prev => new Set(prev).add(productId)) + + try { + await updateCartItem({ + variables: { + productId, + quantity: newQuantity + } + }) + } finally { + setLoadingItems(prev => { + const newSet = new Set(prev) + newSet.delete(productId) + return newSet + }) + } + } + + const removeItem = async (productId: string) => { + setLoadingItems(prev => new Set(prev).add(productId)) + + try { + await removeFromCart({ + variables: { productId } + }) + } finally { + setLoadingItems(prev => { + const newSet = new Set(prev) + newSet.delete(productId) + return newSet + }) + } + } + + const handleClearCart = async () => { + if (confirm('Вы уверены, что хотите очистить все заявки?')) { + await clearCart() + } + } + + const formatPrice = (price: number) => { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB' + }).format(price) + } + + const unavailableItems = cart.items.filter(item => !item.isAvailable) + const availableItems = cart.items.filter(item => item.isAvailable) + + // Группировка товаров по поставщикам + const groupedItems = cart.items.reduce((groups, item) => { + const orgId = item.product.organization.id + if (!groups[orgId]) { + groups[orgId] = { + organization: item.product.organization, + items: [], + totalPrice: 0, + totalItems: 0 + } + } + groups[orgId].items.push(item) + groups[orgId].totalPrice += item.totalPrice + groups[orgId].totalItems += item.quantity + return groups + }, {} as Record) + + const supplierGroups = Object.values(groupedItems) + + return ( +
+ {/* Заголовок с кнопкой очистки */} +
+

Заявки на товары

+ {cart.items.length > 0 && ( + + )} +
+ + {/* Предупреждение о недоступных товарах */} + {unavailableItems.length > 0 && ( +
+
+ + + {unavailableItems.length} заявок недоступно для оформления + +
+
+ )} + + {/* Группы поставщиков */} +
+ {supplierGroups.map((group) => ( +
+ {/* Заголовок поставщика */} +
+
+
+ +
+

+ {group.organization.name || group.organization.fullName || `ИНН ${group.organization.inn}`} +

+
+ + + {group.totalItems} товаров + + + + {formatPrice(group.totalPrice)} + +
+
+
+ + {group.items.length} заявок + +
+
+ + {/* Товары этого поставщика */} +
+ {group.items.map((item) => { + const isLoading = loadingItems.has(item.product.id) + const mainImage = item.product.images?.[0] || item.product.mainImage + + return ( +
+ {/* Информация о поставщике в карточке товара */} +
+
+ + Поставщик: + + {item.product.organization.name || item.product.organization.fullName || `ИНН ${item.product.organization.inn}`} + +
+
+ + {/* Основное содержимое карточки */} +
+
+ {/* Изображение товара */} +
+
+ {mainImage ? ( + {item.product.name} + ) : ( +
+ +
+ )} +
+
+ + {/* Информация о товаре */} +
+ {/* Название и артикул */} +
+

+ {item.product.name} +

+

+ Артикул: {item.product.article} +

+
+ + {/* Статус доступности */} + {!item.isAvailable && ( + + Недоступно + + )} + + {/* Нижняя секция: управление количеством и цена */} +
+ {/* Управление количеством */} +
+
+ Количество: +
+ + + { + const value = parseInt(e.target.value) || 1 + if (value >= 1 && value <= item.availableQuantity && !isLoading && item.isAvailable) { + updateQuantity(item.product.id, value) + } + }} + disabled={isLoading || !item.isAvailable} + className="w-16 h-8 text-sm text-center bg-white/5 border border-white/20 rounded-lg text-white focus:border-purple-400/50 focus:bg-white/10" + /> + + +
+
+ + + из {item.availableQuantity} доступно + +
+ + {/* Цена и кнопка удаления */} +
+
+
+ {formatPrice(item.totalPrice)} +
+
+ {formatPrice(item.product.price)} за шт. +
+
+ + +
+
+
+
+
+
+ ) + })} +
+
+ ))} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/cart/cart-summary.tsx b/src/components/cart/cart-summary.tsx new file mode 100644 index 0000000..e73a5bf --- /dev/null +++ b/src/components/cart/cart-summary.tsx @@ -0,0 +1,242 @@ +"use client" + +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { + ShoppingCart, + AlertTriangle, + CheckCircle, + Info +} from 'lucide-react' + +interface CartItem { + id: string + quantity: number + totalPrice: number + isAvailable: boolean + availableQuantity: number + product: { + id: string + name: string + article: string + price: number + quantity: number + organization: { + id: string + name?: string + fullName?: string + inn: string + } + } +} + +interface Cart { + id: string + items: CartItem[] + totalPrice: number + totalItems: number +} + +interface CartSummaryProps { + cart: Cart +} + +export function CartSummary({ cart }: CartSummaryProps) { + const [isProcessingOrder, setIsProcessingOrder] = useState(false) + + const formatPrice = (price: number) => { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB' + }).format(price) + } + + // Анализ товаров в корзине + const availableItems = cart.items.filter(item => item.isAvailable) + const unavailableItems = cart.items.filter(item => !item.isAvailable) + + const availableTotal = availableItems.reduce((sum, item) => sum + item.totalPrice, 0) + const availableItemsCount = availableItems.reduce((sum, item) => sum + item.quantity, 0) + + // Группировка по продавцам + const sellerGroups = availableItems.reduce((groups, item) => { + const sellerId = item.product.organization.id + if (!groups[sellerId]) { + groups[sellerId] = { + organization: item.product.organization, + items: [], + total: 0 + } + } + groups[sellerId].items.push(item) + groups[sellerId].total += item.totalPrice + return groups + }, {} as Record) + + const sellerCount = Object.keys(sellerGroups).length + const canOrder = availableItems.length > 0 + + const handleOrder = () => { + if (!canOrder) return + + setIsProcessingOrder(true) + // Здесь будет логика отправки заявок + setTimeout(() => { + alert('Функция отправки заявок будет реализована в следующих версиях') + setIsProcessingOrder(false) + }, 1000) + } + + const getOrganizationName = (org: CartItem['product']['organization']) => { + return org.name || org.fullName || 'Неизвестная организация' + } + + return ( +
+

Сводка заявок

+ +
+ {/* Общая информация */} +
+
+ Всего заявок: + {cart.totalItems} +
+
+ Готово к отправке: + {availableItemsCount} +
+ {unavailableItems.length > 0 && ( +
+ Недоступно: + {unavailableItems.length} +
+ )} +
+ + + + {/* Информация о поставщиках */} + {sellerCount > 0 && ( +
+
+ + Поставщики ({sellerCount}): +
+ +
+ {Object.values(sellerGroups).map((group, index) => ( +
+
+ + {getOrganizationName(group.organization)} + + + {formatPrice(group.total)} + +
+
+ ИНН: {group.organization.inn} +
+
+ Заявок: {group.items.length} +
+
+ ))} +
+
+ )} + + + + {/* Итоговая стоимость */} +
+
+ Стоимость заявок: + {formatPrice(availableTotal)} +
+
+ Общая сумма: + {formatPrice(availableTotal)} +
+
+ + + + {/* Статус заявок */} +
+ {canOrder ? ( +
+ + Готово к отправке +
+ ) : ( +
+ + Нет доступных заявок +
+ )} + + {unavailableItems.length > 0 && ( +
+
+ Внимание! +
+
+ {unavailableItems.length} заявок недоступно. + Они будут исключены при отправке. +
+
+ )} + + {sellerCount > 1 && ( +
+
+ Несколько продавцов +
+
+ Ваши заявки будут отправлены {sellerCount} разным продавцам + для рассмотрения. +
+
+ )} +
+ + {/* Кнопка отправки заявок */} + + + {/* Дополнительная информация */} +
+

• Заявки будут отправлены продавцам для рассмотрения

+

• Окончательные условия согласовываются с каждым продавцом

+

• Вы можете изменить количество товаров до отправки заявок

+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/dashboard/sidebar.tsx b/src/components/dashboard/sidebar.tsx index a6ed190..457f4d8 100644 --- a/src/components/dashboard/sidebar.tsx +++ b/src/components/dashboard/sidebar.tsx @@ -3,7 +3,6 @@ import { useAuth } from '@/hooks/useAuth' import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' - import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import { useRouter, usePathname } from 'next/navigation' import { @@ -11,7 +10,8 @@ import { LogOut, Store, MessageCircle, - Wrench + Wrench, + Warehouse } from 'lucide-react' export function Sidebar() { @@ -67,10 +67,15 @@ export function Sidebar() { router.push('/services') } + const handleWarehouseClick = () => { + router.push('/warehouse') + } + const isSettingsActive = pathname === '/settings' const isMarketActive = pathname.startsWith('/market') const isMessengerActive = pathname.startsWith('/messenger') const isServicesActive = pathname.startsWith('/services') + const isWarehouseActive = pathname.startsWith('/warehouse') return (
@@ -152,6 +157,22 @@ export function Sidebar() { Услуги )} + + {/* Склад - только для оптовиков */} + {user?.organization?.type === 'WHOLESALE' && ( + + )} + )} +
+ + {/* Категории */} +
+ {categories.length === 0 ? ( +
+
+ +

+ Категории отсутствуют +

+

+ Пока нет доступных категорий товаров +

+
+
+ ) : ( +
+ {/* Карточка "Все товары" */} + onSelectCategory('', 'Все товары')} + className="group relative overflow-hidden bg-gradient-to-br from-indigo-500/10 via-purple-500/10 to-pink-500/10 backdrop-blur border-white/10 hover:border-white/20 transition-all duration-300 cursor-pointer hover:scale-105" + > +
+
+
+ +
+ +
+
+

+ Все товары +

+

+ Просмотреть весь каталог +

+
+
+ + {/* Эффект при наведении */} +
+ + + {/* Карточки категорий */} + {categories.map((category, index) => { + // Разные градиенты для разных категорий + const gradients = [ + 'from-purple-500/10 via-pink-500/10 to-red-500/10', + 'from-blue-500/10 via-cyan-500/10 to-teal-500/10', + 'from-green-500/10 via-emerald-500/10 to-lime-500/10', + 'from-yellow-500/10 via-orange-500/10 to-red-500/10', + 'from-pink-500/10 via-rose-500/10 to-purple-500/10', + 'from-indigo-500/10 via-blue-500/10 to-cyan-500/10', + 'from-teal-500/10 via-green-500/10 to-emerald-500/10' + ] + + const borderColors = [ + 'border-purple-500/30', + 'border-blue-500/30', + 'border-green-500/30', + 'border-orange-500/30', + 'border-pink-500/30', + 'border-indigo-500/30', + 'border-teal-500/30' + ] + + const iconColors = [ + 'text-purple-400', + 'text-blue-400', + 'text-green-400', + 'text-orange-400', + 'text-pink-400', + 'text-indigo-400', + 'text-teal-400' + ] + + const bgColors = [ + 'from-purple-500/20 to-pink-500/20', + 'from-blue-500/20 to-cyan-500/20', + 'from-green-500/20 to-emerald-500/20', + 'from-yellow-500/20 to-orange-500/20', + 'from-pink-500/20 to-rose-500/20', + 'from-indigo-500/20 to-blue-500/20', + 'from-teal-500/20 to-green-500/20' + ] + + const gradient = gradients[index % gradients.length] + const borderColor = borderColors[index % borderColors.length] + const iconColor = iconColors[index % iconColors.length] + const bgColor = bgColors[index % bgColors.length] + + return ( + onSelectCategory(category.id, category.name)} + className={`group relative overflow-hidden bg-gradient-to-br ${gradient} backdrop-blur border-white/10 hover:${borderColor} transition-all duration-300 cursor-pointer hover:scale-105`} + > +
+
+
+ +
+ +
+
+

+ {category.name} +

+

+ Товары категории +

+
+
+ + {/* Эффект при наведении */} +
+ + ) + })} +
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/market/market-dashboard.tsx b/src/components/market/market-dashboard.tsx index 5e07dbd..c74e796 100644 --- a/src/components/market/market-dashboard.tsx +++ b/src/components/market/market-dashboard.tsx @@ -1,5 +1,6 @@ "use client" +import { useState } from 'react' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Card } from '@/components/ui/card' import { Sidebar } from '@/components/dashboard/sidebar' @@ -8,8 +9,29 @@ import { MarketFulfillment } from './market-fulfillment' import { MarketSellers } from './market-sellers' import { MarketLogistics } from './market-logistics' import { MarketWholesale } from './market-wholesale' +import { MarketProducts } from './market-products' +import { MarketCategories } from './market-categories' +import { MarketRequests } from './market-requests' export function MarketDashboard() { + const [productsView, setProductsView] = useState<'categories' | 'products' | 'cart'>('categories') + const [selectedCategory, setSelectedCategory] = useState<{ id: string; name: string } | null>(null) + + const handleSelectCategory = (categoryId: string, categoryName: string) => { + setSelectedCategory({ id: categoryId, name: categoryName }) + setProductsView('products') + } + + const handleBackToCategories = () => { + setProductsView('categories') + setSelectedCategory(null) + } + + const handleShowCart = () => { + setProductsView('cart') + setSelectedCategory(null) + } + return (
@@ -17,8 +39,18 @@ export function MarketDashboard() {
{/* Основной контент с табами */}
- - + { + if (value === 'products') { + // Сбрасываем состояние когда переходим на вкладку товаров + setProductsView('categories') + setSelectedCategory(null) + } + }} + > + Оптовик + + Товары + @@ -80,6 +118,22 @@ export function MarketDashboard() { + + + + {productsView === 'categories' ? ( + + ) : productsView === 'products' ? ( + + ) : ( + + )} + +
diff --git a/src/components/market/market-products.tsx b/src/components/market/market-products.tsx new file mode 100644 index 0000000..82c5a51 --- /dev/null +++ b/src/components/market/market-products.tsx @@ -0,0 +1,243 @@ +"use client" + +import { useState, useMemo } from 'react' +import { useQuery } from '@apollo/client' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Search, ShoppingBag, Package2, ArrowLeft } from 'lucide-react' +import { ProductCard } from './product-card' +import { GET_ALL_PRODUCTS } from '@/graphql/queries' + +interface Product { + id: string + name: string + article: string + description?: string + price: number + quantity: number + category?: { id: string; name: string } + brand?: string + color?: string + size?: string + weight?: number + dimensions?: string + material?: string + images: string[] + mainImage?: string + isActive: boolean + createdAt: string + organization: { + id: string + inn: string + name?: string + fullName?: string + type: string + address?: string + phones?: Array<{ value: string }> + emails?: Array<{ value: string }> + users?: Array<{ id: string, avatar?: string, managerName?: string }> + } +} + +interface MarketProductsProps { + selectedCategoryId?: string + selectedCategoryName?: string + onBackToCategories?: () => void +} + +export function MarketProducts({ selectedCategoryId, selectedCategoryName, onBackToCategories }: MarketProductsProps) { + const [searchTerm, setSearchTerm] = useState('') + const [selectedCategory, setSelectedCategory] = useState('') + const [localSearch, setLocalSearch] = useState('') + + const { data, loading, refetch } = useQuery(GET_ALL_PRODUCTS, { + variables: { + search: searchTerm || null, + category: selectedCategoryId || selectedCategory || null + } + }) + + const products: Product[] = data?.allProducts || [] + + // Получаем уникальные категории из товаров + const categories = useMemo(() => { + const allCategories = products + .map(product => product.category?.name) + .filter(Boolean) + .filter((category, index, arr) => arr.indexOf(category) === index) + .sort() + + return allCategories + }, [products]) + + const handleSearch = () => { + setSearchTerm(localSearch.trim()) + } + + + + + + // Фильтруем товары по доступности + const availableProducts = products.filter(product => product.isActive && product.quantity > 0) + const totalProducts = products.length + const availableCount = availableProducts.length + + return ( +
+ {/* Кнопка назад и заголовок */} + {selectedCategoryName && onBackToCategories && ( +
+ +
+

{selectedCategoryName}

+

Товары выбранной категории

+
+
+ )} + + {/* Поиск */} +
+
+ + setLocalSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + className="pl-10 glass-input text-white placeholder:text-white/40 h-10" + /> +
+ + +
+ + {/* Категории */} +
+

+ + Категории +

+ +
+ + {categories.map((category) => ( + + ))} +
+
+ + {/* Заголовок с статистикой */} +
+
+ +
+

Товары оптовиков

+

+ Найдено {totalProducts} товаров, доступно {availableCount} +

+
+
+ + {/* Активные фильтры */} +
+ {searchTerm && ( +
+ + {searchTerm} + +
+ )} + {selectedCategory && ( +
+ + {selectedCategory} + +
+ )} +
+
+ + {/* Список товаров */} +
+ {loading ? ( +
+
Загружаем товары...
+
+ ) : products.length === 0 ? ( +
+
+ +

+ {searchTerm || selectedCategory ? 'Товары не найдены' : 'Пока нет товаров для отображения'} +

+

+ {searchTerm || selectedCategory + ? 'Попробуйте изменить условия поиска или фильтры' + : 'Оптовики еще не добавили свои товары' + } +

+
+
+ ) : ( +
+ {products.map((product) => ( + + ))} +
+ )} +
+ {/* Пагинация будет добавлена позже если понадобится */} +
+ ) +} \ No newline at end of file diff --git a/src/components/market/market-requests.tsx b/src/components/market/market-requests.tsx new file mode 100644 index 0000000..8558965 --- /dev/null +++ b/src/components/market/market-requests.tsx @@ -0,0 +1,84 @@ +"use client" + +import { useQuery } from '@apollo/client' +import { CartItems } from '../cart/cart-items' +import { CartSummary } from '../cart/cart-summary' +import { GET_MY_CART } from '@/graphql/queries' +import { ShoppingCart, Package } from 'lucide-react' + +export function MarketRequests() { + const { data, loading, error } = useQuery(GET_MY_CART) + + const cart = data?.myCart + const hasItems = cart?.items && cart.items.length > 0 + + if (loading) { + return ( +
+
+
+

Загружаем заявки...

+
+
+ ) + } + + if (error) { + return ( +
+
+ +

Ошибка загрузки заявок

+

{error.message}

+
+
+ ) + } + + return ( +
+ {/* Заголовок */} +
+ +
+

Мои заявки

+

+ {hasItems + ? `${cart.totalItems} заявок на сумму ${new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB' }).format(cart.totalPrice)}` + : 'У вас пока нет заявок' + } +

+
+
+ + {/* Основной контент */} +
+ {hasItems ? ( +
+ {/* Заявки */} +
+
+ +
+
+ + {/* Сводка заявок */} +
+
+ +
+
+
+ ) : ( +
+ +

Нет заявок

+

+ Добавьте товары в заявки из раздела "Товары", чтобы создать заявку для оптовика +

+
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/market/product-card.tsx b/src/components/market/product-card.tsx new file mode 100644 index 0000000..b3444c7 --- /dev/null +++ b/src/components/market/product-card.tsx @@ -0,0 +1,292 @@ +"use client" + +import { useState } from 'react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { + ShoppingCart, + Eye, + ChevronLeft, + ChevronRight +} from 'lucide-react' +import { OrganizationAvatar } from './organization-avatar' +import { Input } from '@/components/ui/input' +import Image from 'next/image' +import { useMutation } from '@apollo/client' +import { ADD_TO_CART } from '@/graphql/mutations' +import { GET_MY_CART } from '@/graphql/queries' +import { toast } from 'sonner' + +interface Product { + id: string + name: string + article: string + description?: string + price: number + quantity: number + category?: { id: string; name: string } + brand?: string + color?: string + size?: string + weight?: number + dimensions?: string + material?: string + images: string[] + mainImage?: string + isActive: boolean + createdAt: string + organization: { + id: string + inn: string + name?: string + fullName?: string + type: string + address?: string + phones?: Array<{ value: string }> + emails?: Array<{ value: string }> + users?: Array<{ id: string, avatar?: string, managerName?: string }> + } +} + +interface ProductCardProps { + product: Product +} + +export function ProductCard({ product }: ProductCardProps) { + const [currentImageIndex, setCurrentImageIndex] = useState(0) + const [isImageDialogOpen, setIsImageDialogOpen] = useState(false) + const [quantity, setQuantity] = useState(1) + + const [addToCart, { loading: addingToCart }] = useMutation(ADD_TO_CART, { + refetchQueries: [{ query: GET_MY_CART }], + onCompleted: (data) => { + if (data.addToCart.success) { + toast.success(data.addToCart.message) + setQuantity(1) // Сбрасываем количество после добавления + } else { + toast.error(data.addToCart.message) + } + }, + onError: (error) => { + toast.error('Ошибка при добавлении в заявки') + console.error('Error adding to cart:', error) + } + }) + + const displayPrice = new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB' + }).format(product.price) + + const displayName = product.organization.name || product.organization.fullName || 'Неизвестная организация' + const images = product.images.length > 0 ? product.images : [product.mainImage].filter(Boolean) + const hasMultipleImages = images.length > 1 + + const handleAddToCart = async () => { + try { + await addToCart({ + variables: { + productId: product.id, + quantity: quantity + } + }) + } catch (error) { + console.error('Error adding to cart:', error) + } + } + + const nextImage = () => { + setCurrentImageIndex((prev) => (prev + 1) % images.length) + } + + const prevImage = () => { + setCurrentImageIndex((prev) => (prev - 1 + images.length) % images.length) + } + + const getStockStatus = () => { + if (product.quantity === 0) return { text: 'Нет в наличии', color: 'bg-red-500/20 text-red-300' } + if (product.quantity < 10) return { text: 'Мало', color: 'bg-orange-500/20 text-orange-300' } + return { text: 'В наличии', color: 'bg-green-500/20 text-green-300' } + } + + const stockStatus = getStockStatus() + const canAddToCart = product.quantity > 0 && product.isActive + + return ( +
+ {/* Изображения товара */} +
+ {images.length > 0 ? ( + <> + {product.name} setIsImageDialogOpen(true)} + /> + + {/* Навигация по изображениям */} + {hasMultipleImages && ( + <> + + + + {/* Индикаторы изображений */} +
+ {images.map((_, index) => ( +
+ ))} +
+ + )} + + {/* Кнопка увеличения */} + + + ) : ( +
+ +
+ )} +
+ + {/* Информация о товаре */} +
+ {/* Название и цена */} +
+

{product.name}

+
+ {displayPrice} + + {stockStatus.text} + +
+
+ + {/* Краткая информация */} +
+ Арт: {product.article} + {product.category && ( + {product.category.name} + )} +
+ + {/* Информация о продавце */} +
+
+ +
+

{displayName}

+

ИНН: {product.organization.inn}

+
+
+
+ + {/* Выбор количества и добавление в заявки */} + {canAddToCart ? ( +
+ {/* Выбор количества */} +
+ Количество: + { + const value = parseInt(e.target.value) || 1 + if (value >= 1 && value <= product.quantity) { + setQuantity(value) + } + }} + className="w-16 h-6 text-xs text-center glass-input text-white border-white/20" + /> +
+ + {/* Кнопка добавления в заявки */} + +
+ ) : ( + + )} +
+ + {/* Диалог просмотра изображений */} + + + + Просмотр изображения товара {product.name} + +
+ {images.length > 0 && ( +
+ {product.name} + + {hasMultipleImages && ( + <> + + + + )} + +
+ {currentImageIndex + 1} из {images.length} +
+
+ )} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/warehouse/product-card.tsx b/src/components/warehouse/product-card.tsx new file mode 100644 index 0000000..7dbc54e --- /dev/null +++ b/src/components/warehouse/product-card.tsx @@ -0,0 +1,219 @@ +"use client" + +import { useState } from 'react' +import { useMutation } from '@apollo/client' +import { Card } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' +import { DELETE_PRODUCT } from '@/graphql/mutations' +import { Edit3, Trash2, Package, Eye, EyeOff } from 'lucide-react' +import { toast } from 'sonner' + +interface Product { + id: string + name: string + article: string + description: string + price: number + quantity: number + category: { id: string; name: string } | null + brand: string + color: string + size: string + weight: number + dimensions: string + material: string + images: string[] + mainImage: string + isActive: boolean + createdAt: string + updatedAt: string +} + +interface ProductCardProps { + product: Product + onEdit: (product: Product) => void + onDeleted: () => void +} + +export function ProductCard({ product, onEdit, onDeleted }: ProductCardProps) { + const [deleteProduct, { loading: deleting }] = useMutation(DELETE_PRODUCT) + + const handleDelete = async () => { + try { + await deleteProduct({ + variables: { id: product.id } + }) + toast.success('Товар успешно удален') + onDeleted() + } catch (error) { + console.error('Error deleting product:', error) + toast.error('Ошибка при удалении товара') + } + } + + const formatPrice = (price: number) => { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB', + minimumFractionDigits: 0 + }).format(price) + } + + const getStatusColor = () => { + if (!product.isActive) return 'bg-gray-500/20 text-gray-300 border-gray-400/30' + if (product.quantity === 0) return 'bg-red-500/20 text-red-300 border-red-400/30' + if (product.quantity < 10) return 'bg-yellow-500/20 text-yellow-300 border-yellow-400/30' + return 'bg-green-500/20 text-green-300 border-green-400/30' + } + + const getStatusText = () => { + if (!product.isActive) return 'Неактивен' + if (product.quantity === 0) return 'Нет в наличии' + if (product.quantity < 10) return 'Мало на складе' + return 'В наличии' + } + + return ( + + {/* Изображение товара */} +
+ {product.mainImage || product.images[0] ? ( + {product.name} + ) : ( +
+ +
+ )} + + {/* Статус товара */} +
+ + {getStatusText()} + +
+ + {/* Индикатор активности */} +
+ {product.isActive ? ( + + ) : ( + + )} +
+ + {/* Кнопки управления */} +
+ + + + + + + + + Удалить товар? + + Вы уверены, что хотите удалить товар "{product.name}"? + Это действие нельзя отменить. + + + + + Отмена + + + {deleting ? 'Удаление...' : 'Удалить'} + + + + +
+
+ + {/* Информация о товаре */} +
+ {/* Название и артикул */} +
+

+ {product.name} +

+

+ Арт. {product.article} +

+
+ + {/* Цена и количество */} +
+
+ {formatPrice(product.price)} +
+
+ {product.quantity} шт. +
+
+ + {/* Дополнительная информация */} +
+ {product.category && ( +
+ + {product.category.name} + +
+ )} + +
+ {product.brand && ( + + {product.brand} + + )} + {product.color && ( + + {product.color} + + )} + {product.size && ( + + {product.size} + + )} +
+
+ + {/* Описание (если есть) */} + {product.description && ( +

+ {product.description} +

+ )} +
+ + {/* Эффект градиента при наведении */} +
+ + ) +} \ No newline at end of file diff --git a/src/components/warehouse/product-form.tsx b/src/components/warehouse/product-form.tsx new file mode 100644 index 0000000..9e33a95 --- /dev/null +++ b/src/components/warehouse/product-form.tsx @@ -0,0 +1,487 @@ +"use client" + +import { useState, useRef } from 'react' +import { useMutation, useQuery } from '@apollo/client' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Card } from '@/components/ui/card' +import { CREATE_PRODUCT, UPDATE_PRODUCT } from '@/graphql/mutations' +import { GET_CATEGORIES } from '@/graphql/queries' +import { Upload, X, Star, Plus, Image as ImageIcon } from 'lucide-react' +import { toast } from 'sonner' + +interface Product { + id: string + name: string + article: string + description: string + price: number + quantity: number + category: { id: string; name: string } | null + brand: string + color: string + size: string + weight: number + dimensions: string + material: string + images: string[] + mainImage: string + isActive: boolean +} + +interface ProductFormProps { + product?: Product | null + onSave: () => void + onCancel: () => void +} + +export function ProductForm({ product, onSave, onCancel }: ProductFormProps) { + const [formData, setFormData] = useState({ + name: product?.name || '', + article: product?.article || '', + description: product?.description || '', + price: product?.price || 0, + quantity: product?.quantity || 0, + categoryId: product?.category?.id || 'none', + brand: product?.brand || '', + color: product?.color || '', + size: product?.size || '', + weight: product?.weight || 0, + dimensions: product?.dimensions || '', + material: product?.material || '', + images: product?.images || [], + mainImage: product?.mainImage || '', + isActive: product?.isActive ?? true + }) + + const [isUploading, setIsUploading] = useState(false) + const [uploadingImages, setUploadingImages] = useState>(new Set()) + const fileInputRef = useRef(null) + + const [createProduct, { loading: creating }] = useMutation(CREATE_PRODUCT) + const [updateProduct, { loading: updating }] = useMutation(UPDATE_PRODUCT) + + // Загружаем категории + const { data: categoriesData } = useQuery(GET_CATEGORIES) + + const loading = creating || updating + + const handleInputChange = (field: string, value: string | number | boolean) => { + setFormData(prev => ({ + ...prev, + [field]: value + })) + } + + const handleImageUpload = async (files: FileList) => { + const newUploadingIndexes = new Set() + const startIndex = formData.images.length + + // Добавляем плейсхолдеры для загружаемых изображений + const placeholders = Array.from(files).map((_, index) => { + newUploadingIndexes.add(startIndex + index) + return '' // Пустой URL как плейсхолдер + }) + + setUploadingImages(prev => new Set([...prev, ...newUploadingIndexes])) + setFormData(prev => ({ + ...prev, + images: [...prev.images, ...placeholders] + })) + + try { + // Загружаем каждое изображение + const uploadPromises = Array.from(files).map(async (file, index) => { + const actualIndex = startIndex + index + const formData = new FormData() + formData.append('file', file) + formData.append('type', 'product') + + const response = await fetch('/api/upload-file', { + method: 'POST', + body: formData + }) + + if (!response.ok) { + throw new Error('Ошибка загрузки изображения') + } + + const result = await response.json() + return { index: actualIndex, url: result.fileUrl } + }) + + const results = await Promise.all(uploadPromises) + + // Обновляем URLs загруженных изображений + setFormData(prev => { + const newImages = [...prev.images] + results.forEach(({ index, url }) => { + newImages[index] = url + }) + return { + ...prev, + images: newImages, + mainImage: prev.mainImage || results[0]?.url || '' // Устанавливаем первое изображение как главное + } + }) + + toast.success('Изображения успешно загружены') + } catch (error) { + console.error('Error uploading images:', error) + toast.error('Ошибка загрузки изображений') + + // Удаляем неудачные плейсхолдеры + setFormData(prev => ({ + ...prev, + images: prev.images.slice(0, startIndex) + })) + } finally { + // Убираем индикаторы загрузки + setUploadingImages(prev => { + const updated = new Set(prev) + newUploadingIndexes.forEach(index => updated.delete(index)) + return updated + }) + } + } + + const handleRemoveImage = (indexToRemove: number) => { + setFormData(prev => { + const newImages = prev.images.filter((_, index) => index !== indexToRemove) + const removedImageUrl = prev.images[indexToRemove] + + return { + ...prev, + images: newImages, + mainImage: prev.mainImage === removedImageUrl ? (newImages[0] || '') : prev.mainImage + } + }) + } + + const handleSetMainImage = (imageUrl: string) => { + setFormData(prev => ({ + ...prev, + mainImage: imageUrl + })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!formData.name || !formData.article || formData.price <= 0) { + toast.error('Пожалуйста, заполните все обязательные поля') + return + } + + try { + const input = { + name: formData.name, + article: formData.article, + description: formData.description || undefined, + price: formData.price, + quantity: formData.quantity, + categoryId: formData.categoryId && formData.categoryId !== 'none' ? formData.categoryId : undefined, + brand: formData.brand || undefined, + color: formData.color || undefined, + size: formData.size || undefined, + weight: formData.weight || undefined, + dimensions: formData.dimensions || undefined, + material: formData.material || undefined, + images: formData.images.filter(img => img), // Убираем пустые строки + mainImage: formData.mainImage || undefined, + isActive: formData.isActive + } + + if (product) { + await updateProduct({ + variables: { id: product.id, input } + }) + toast.success('Товар успешно обновлен') + } else { + await createProduct({ + variables: { input } + }) + toast.success('Товар успешно создан') + } + + onSave() + } catch (error: unknown) { + console.error('Error saving product:', error) + toast.error((error as Error).message || 'Ошибка при сохранении товара') + } + } + + return ( +
+ {/* Основная информация */} + +

Основная информация

+
+
+ + handleInputChange('name', e.target.value)} + placeholder="iPhone 15 Pro Max" + className="glass-input text-white placeholder:text-white/40 h-10" + required + /> +
+ +
+ + handleInputChange('article', e.target.value)} + placeholder="IP15PM-256-BLU" + className="glass-input text-white placeholder:text-white/40 h-10" + required + /> +
+
+ +
+ +