From 41228f4c173db6414c9b66ab9ce80941fb2a8c04 Mon Sep 17 00:00:00 2001 From: Veronika Smirnova Date: Thu, 24 Jul 2025 14:12:20 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20=D1=84=D1=83=D0=BD?= =?UTF-8?q?=D0=BA=D1=86=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7=D0=BE=D0=B2=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B2=D0=BE=D0=BA=20=D1=80=D0=B0=D1=81=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=B2=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D0=B5=20CreateConsum?= =?UTF-8?q?ablesSupplyPage.=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D1=84?= =?UTF-8?q?=D1=83=D0=BB=D1=84=D0=B8=D0=BB=D0=BC=D0=B5=D0=BD=D1=82-=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D1=80=D0=B0,=20=D1=83=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D0=B0=D0=B7=D0=B0=20=D1=81=20=D1=83=D1=87=D0=B5?= =?UTF-8?q?=D1=82=D0=BE=D0=BC=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8F=20fulfillmentCenterId.=20=D0=9E=D0=B1?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BA=D0=BE=D0=BC=D0=BF?= =?UTF-8?q?=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=20SuppliesConsumablesTab=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=20=D0=BF=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BE=D0=BA=20?= =?UTF-8?q?=D1=81=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=BC=D0=B8=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=BC=D0=B8.=20=D0=9E=D0=BF=D1=82=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=20?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BB=D0=B8=20=D0=B8=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D1=83=D0=BA=D1=82=D1=83=D1=80=D0=B0=20=D0=BA=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=BE=D0=B2=D1=8B=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D1=83=D0=B4=D0=BE=D0=B1=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=B0=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consumables-supplies-tab.tsx | 960 +++++------------- .../create-consumables-supply-page.tsx | 698 ++++++++----- src/graphql/queries.ts | 47 +- src/graphql/resolvers.ts | 422 +++++--- src/graphql/typedefs.ts | 38 +- src/lib/apollo-client.ts | 41 +- 6 files changed, 1036 insertions(+), 1170 deletions(-) diff --git a/src/components/supplies/consumables-supplies/consumables-supplies-tab.tsx b/src/components/supplies/consumables-supplies/consumables-supplies-tab.tsx index c547d56..eb08303 100644 --- a/src/components/supplies/consumables-supplies/consumables-supplies-tab.tsx +++ b/src/components/supplies/consumables-supplies/consumables-supplies-tab.tsx @@ -1,356 +1,116 @@ "use client"; import React, { useState } from "react"; +import { useQuery } from "@apollo/client"; import { Card } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { ChevronDown, ChevronRight, Calendar, Package, - MapPin, - Building2, TrendingUp, - AlertTriangle, DollarSign, - Wrench, Box, } from "lucide-react"; +import { GET_SUPPLY_ORDERS } from "@/graphql/queries"; -// Типы данных для расходников -interface ConsumableParameter { +// Типы данных для заказов поставок расходников +interface SupplyOrderItem { id: string; - name: string; - value: string; - unit?: string; + quantity: number; + price: number; + totalPrice: number; + product: { + id: string; + name: string; + article?: string; + description?: string; + category?: { + id: string; + name: string; + }; + }; } -interface Consumable { +interface SupplyOrder { id: string; - name: string; - sku: string; - category: string; - type: "packaging" | "labels" | "protective" | "tools" | "other"; - plannedQty: number; - actualQty: number; - defectQty: number; - unitPrice: number; - parameters: ConsumableParameter[]; -} - -interface ConsumableSupplier { - id: string; - name: string; - inn: string; - contact: string; - address: string; - consumables: Consumable[]; - totalAmount: number; -} - -interface ConsumableRoute { - id: string; - from: string; - fromAddress: string; - to: string; - toAddress: string; - suppliers: ConsumableSupplier[]; - totalConsumablesPrice: number; - logisticsPrice: number; - totalAmount: number; -} - -interface ConsumableSupply { - id: string; - number: number; deliveryDate: string; - createdDate: string; - routes: ConsumableRoute[]; - plannedTotal: number; - actualTotal: number; - defectTotal: number; - totalConsumablesPrice: number; - totalLogisticsPrice: number; - grandTotal: number; - status: "planned" | "in-transit" | "delivered" | "completed"; + status: "PENDING" | "CONFIRMED" | "IN_TRANSIT" | "DELIVERED" | "CANCELLED"; + totalAmount: number; + totalItems: number; + createdAt: string; + updatedAt: string; + partner: { + id: string; + name?: string; + fullName?: string; + inn?: string; + address?: string; + phones?: string[]; + emails?: string[]; + }; + organization: { + id: string; + name?: string; + fullName?: string; + type: string; + }; + items: SupplyOrderItem[]; } -// Моковые данные для расходников -const mockConsumableSupplies: ConsumableSupply[] = [ - { - id: "c1", - number: 101, - deliveryDate: "2024-01-18", - createdDate: "2024-01-14", - status: "delivered", - plannedTotal: 5000, - actualTotal: 4950, - defectTotal: 50, - totalConsumablesPrice: 125000, - totalLogisticsPrice: 8000, - grandTotal: 133000, - routes: [ - { - id: "cr1", - from: "Склад расходников", - fromAddress: "Москва, ул. Промышленная, 12", - to: "SFERAV Logistics", - toAddress: "Москва, ул. Складская, 15", - totalConsumablesPrice: 125000, - logisticsPrice: 8000, - totalAmount: 133000, - suppliers: [ - { - id: "cs1", - name: 'ООО "УпакСервис"', - inn: "7703456789", - contact: "+7 (495) 777-88-99", - address: "Москва, ул. Упаковочная, 5", - totalAmount: 75000, - consumables: [ - { - id: "cons1", - name: "Коробки картонные 30x20x10", - sku: "BOX-302010", - category: "Упаковка", - type: "packaging", - plannedQty: 2000, - actualQty: 1980, - defectQty: 20, - unitPrice: 35, - parameters: [ - { id: "cp1", name: "Размер", value: "30x20x10", unit: "см" }, - { id: "cp2", name: "Материал", value: "Гофрокартон" }, - { id: "cp3", name: "Плотность", value: "3", unit: "слоя" }, - ], - }, - { - id: "cons2", - name: "Пузырчатая пленка", - sku: "BUBBLE-100", - category: "Защитная упаковка", - type: "protective", - plannedQty: 1000, - actualQty: 1000, - defectQty: 0, - unitPrice: 25, - parameters: [ - { id: "cp4", name: "Ширина", value: "100", unit: "см" }, - { id: "cp5", name: "Толщина", value: "0.1", unit: "мм" }, - { id: "cp6", name: "Длина рулона", value: "50", unit: "м" }, - ], - }, - ], - }, - { - id: "cs2", - name: "ИП Маркин С.А.", - inn: "123456789013", - contact: "+7 (499) 111-22-33", - address: "Москва, пр-т Маркировочный, 8", - totalAmount: 50000, - consumables: [ - { - id: "cons3", - name: "Этикетки самоклеящиеся", - sku: "LABEL-5030", - category: "Маркировка", - type: "labels", - plannedQty: 2000, - actualQty: 1970, - defectQty: 30, - unitPrice: 15, - parameters: [ - { id: "cp7", name: "Размер", value: "50x30", unit: "мм" }, - { id: "cp8", name: "Материал", value: "Полипропилен" }, - { id: "cp9", name: "Клей", value: "Акриловый" }, - ], - }, - ], - }, - ], - }, - ], - }, - { - id: "c2", - number: 102, - deliveryDate: "2024-01-22", - createdDate: "2024-01-16", - status: "in-transit", - plannedTotal: 1500, - actualTotal: 1500, - defectTotal: 0, - totalConsumablesPrice: 45000, - totalLogisticsPrice: 3000, - grandTotal: 48000, - routes: [ - { - id: "cr2", - from: "Инструментальный склад", - fromAddress: "Подольск, ул. Индустриальная, 25", - to: "WB Подольск", - toAddress: "Подольск, ул. Складская, 25", - totalConsumablesPrice: 45000, - logisticsPrice: 3000, - totalAmount: 48000, - suppliers: [ - { - id: "cs3", - name: 'ООО "ИнструментПро"', - inn: "5001234567", - contact: "+7 (4967) 55-66-77", - address: "Подольск, ул. Инструментальная, 15", - totalAmount: 45000, - consumables: [ - { - id: "cons4", - name: "Сканер штрих-кодов", - sku: "SCANNER-2D", - category: "Оборудование", - type: "tools", - plannedQty: 5, - actualQty: 5, - defectQty: 0, - unitPrice: 8000, - parameters: [ - { id: "cp10", name: "Тип", value: "2D сканер" }, - { id: "cp11", name: "Интерфейс", value: "USB" }, - { id: "cp12", name: "Дальность", value: "30", unit: "см" }, - ], - }, - { - id: "cons5", - name: "Термопринтер этикеток", - sku: "PRINTER-THERMAL", - category: "Оборудование", - type: "tools", - plannedQty: 2, - actualQty: 2, - defectQty: 0, - unitPrice: 2500, - parameters: [ - { - id: "cp13", - name: "Ширина печати", - value: "108", - unit: "мм", - }, - { id: "cp14", name: "Разрешение", value: "203", unit: "dpi" }, - { id: "cp15", name: "Скорость", value: "102", unit: "мм/с" }, - ], - }, - ], - }, - ], - }, - ], - }, -]; - export function SuppliesConsumablesTab() { - const [expandedSupplies, setExpandedSupplies] = useState>( - new Set() - ); - const [expandedRoutes, setExpandedRoutes] = useState>(new Set()); - const [expandedSuppliers, setExpandedSuppliers] = useState>( - new Set() - ); - const [expandedConsumables, setExpandedConsumables] = useState>( - new Set() - ); + const [expandedOrders, setExpandedOrders] = useState>(new Set()); - const toggleSupplyExpansion = (supplyId: string) => { - const newExpanded = new Set(expandedSupplies); - if (newExpanded.has(supplyId)) { - newExpanded.delete(supplyId); + // Загружаем заказы поставок + const { data, loading, error } = useQuery(GET_SUPPLY_ORDERS); + + const toggleOrderExpansion = (orderId: string) => { + const newExpanded = new Set(expandedOrders); + if (newExpanded.has(orderId)) { + newExpanded.delete(orderId); } else { - newExpanded.add(supplyId); + newExpanded.add(orderId); } - setExpandedSupplies(newExpanded); + setExpandedOrders(newExpanded); }; - const toggleRouteExpansion = (routeId: string) => { - const newExpanded = new Set(expandedRoutes); - if (newExpanded.has(routeId)) { - newExpanded.delete(routeId); - } else { - newExpanded.add(routeId); - } - setExpandedRoutes(newExpanded); - }; + // Получаем данные заказов поставок + const supplyOrders: SupplyOrder[] = data?.supplyOrders || []; - const toggleSupplierExpansion = (supplierId: string) => { - const newExpanded = new Set(expandedSuppliers); - if (newExpanded.has(supplierId)) { - newExpanded.delete(supplierId); - } else { - newExpanded.add(supplierId); - } - setExpandedSuppliers(newExpanded); - }; + // Генерируем порядковые номера для заказов + const ordersWithNumbers = supplyOrders.map((order, index) => ({ + ...order, + number: supplyOrders.length - index, // Обратный порядок для новых заказов сверху + })); - const toggleConsumableExpansion = (consumableId: string) => { - const newExpanded = new Set(expandedConsumables); - if (newExpanded.has(consumableId)) { - newExpanded.delete(consumableId); - } else { - newExpanded.add(consumableId); - } - setExpandedConsumables(newExpanded); - }; - - const getStatusBadge = (status: ConsumableSupply["status"]) => { + const getStatusBadge = (status: SupplyOrder["status"]) => { const statusMap = { - planned: { - label: "Запланирована", + PENDING: { + label: "Ожидание", color: "bg-blue-500/20 text-blue-300 border-blue-500/30", }, - "in-transit": { + CONFIRMED: { + label: "Подтверждена", + color: "bg-green-500/20 text-green-300 border-green-500/30", + }, + IN_TRANSIT: { label: "В пути", color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30", }, - delivered: { + DELIVERED: { label: "Доставлена", - color: "bg-green-500/20 text-green-300 border-green-500/30", - }, - completed: { - label: "Завершена", color: "bg-purple-500/20 text-purple-300 border-purple-500/30", }, + CANCELLED: { + label: "Отменена", + color: "bg-red-500/20 text-red-300 border-red-500/30", + }, }; const { label, color } = statusMap[status]; return {label}; }; - const getTypeBadge = (type: Consumable["type"]) => { - const typeMap = { - packaging: { - label: "Упаковка", - color: "bg-blue-500/20 text-blue-300 border-blue-500/30", - }, - labels: { - label: "Этикетки", - color: "bg-green-500/20 text-green-300 border-green-500/30", - }, - protective: { - label: "Защитная", - color: "bg-orange-500/20 text-orange-300 border-orange-500/30", - }, - tools: { - label: "Инструменты", - color: "bg-purple-500/20 text-purple-300 border-purple-500/30", - }, - other: { - label: "Прочее", - color: "bg-gray-500/20 text-gray-300 border-gray-500/30", - }, - }; - const { label, color } = typeMap[type]; - return {label}; - }; - const formatCurrency = (amount: number) => { return new Intl.NumberFormat("ru-RU", { style: "currency", @@ -367,36 +127,26 @@ export function SuppliesConsumablesTab() { }); }; - const calculateConsumableTotal = (consumable: Consumable) => { - return consumable.actualQty * consumable.unitPrice; - }; + if (loading) { + return ( +
+
+ Загрузка заказов поставок... +
+ ); + } - const getEfficiencyBadge = (planned: number, actual: number) => { - const efficiency = (actual / planned) * 100; - if (efficiency >= 95) { - return ( - - Отлично - - ); - } else if (efficiency >= 90) { - return ( - - Хорошо - - ); - } else { - return ( - - Проблемы - - ); - } - }; + if (error) { + return ( +
+

Ошибка загрузки: {error.message}

+
+ ); + } return (
- {/* Статистика расходников */} + {/* Статистика заказов поставок */}
@@ -404,9 +154,9 @@ export function SuppliesConsumablesTab() {
-

Поставок расходников

+

Заказов поставок

- {mockConsumableSupplies.length} + {supplyOrders.length}

@@ -418,11 +168,11 @@ export function SuppliesConsumablesTab() {
-

Сумма расходников

+

Общая сумма

{formatCurrency( - mockConsumableSupplies.reduce( - (sum, supply) => sum + supply.grandTotal, + supplyOrders.reduce( + (sum, order) => sum + Number(order.totalAmount), 0 ) )} @@ -440,9 +190,8 @@ export function SuppliesConsumablesTab() {

В пути

{ - mockConsumableSupplies.filter( - (supply) => supply.status === "in-transit" - ).length + supplyOrders.filter((order) => order.status === "IN_TRANSIT") + .length }

@@ -455,12 +204,11 @@ export function SuppliesConsumablesTab() {
-

Завершено

+

Доставлено

{ - mockConsumableSupplies.filter( - (supply) => supply.status === "completed" - ).length + supplyOrders.filter((order) => order.status === "DELIVERED") + .length }

@@ -468,29 +216,27 @@ export function SuppliesConsumablesTab() { - {/* Таблица поставок расходников */} + {/* Таблица заказов поставок */}
+ - - - - {mockConsumableSupplies.map((supply) => { - const isSupplyExpanded = expandedSupplies.has(supply.id); + {ordersWithNumbers.length === 0 ? ( + + + + ) : ( + ordersWithNumbers.map((order) => { + const isOrderExpanded = expandedOrders.has(order.id); - return ( - - {/* Основная строка поставки расходников */} - toggleSupplyExpansion(supply.id)} - > - toggleOrderExpansion(order.id)} + > + + + + - + - - - - - - - - + + + + - {/* Развернутые уровни */} - {isSupplyExpanded && - supply.routes.map((route) => { - const isRouteExpanded = expandedRoutes.has(route.id); - return ( - - - + + - + + + - - - - - - - - - {/* Поставщики */} - {isRouteExpanded && - route.suppliers.map((supplier) => { - const isSupplierExpanded = - expandedSuppliers.has(supplier.id); - return ( - - - - - - - - - - - - - {/* Расходники */} - {isSupplierExpanded && - supplier.consumables.map((consumable) => { - const isConsumableExpanded = - expandedConsumables.has( - consumable.id - ); - return ( - - - - - - - - - - - - - {/* Параметры расходника */} - {isConsumableExpanded && ( - - - - )} - - ); - })} - - ); - })} - - ); - })} - - ); - })} +
+ {formatCurrency(Number(item.price))} за шт. +
+ + + + + ))} + + ); + }) + )}
+ Поставщик + Дата поставки Дата создания ПланФакт - Цена расходников + Товаров - Логистика - - Итого сумма + Сумма Статус @@ -498,389 +244,145 @@ export function SuppliesConsumablesTab() {
+
+ +

Заказов поставок пока нет

+
+
-
- - {supply.number} + return ( + + {/* Основная строка заказа поставки */} +
+
+ {isOrderExpanded ? ( + + ) : ( + + )} + + {order.number} + +
+
+
+
+ {order.partner.name || + order.partner.fullName || + "Поставщик"} +
+ {order.partner.inn && ( +
+ ИНН: {order.partner.inn} +
+ )} +
+
+
+ + + {formatDate(order.deliveryDate)} + +
+
+ + {formatDate(order.createdAt)} - - -
- +
- {formatDate(supply.deliveryDate)} + {order.totalItems} - - - - {formatDate(supply.createdDate)} - - - - {supply.plannedTotal} - - - - {supply.actualTotal} - - - - {formatCurrency(supply.totalConsumablesPrice)} - - - - {formatCurrency(supply.totalLogisticsPrice)} - - -
- - - {formatCurrency(supply.grandTotal)} - -
-
{getStatusBadge(supply.status)}
+
+ + + {formatCurrency(Number(order.totalAmount))} + +
+
{getStatusBadge(order.status)}
-
- - - - Маршрут - + {/* Развернутые детали заказа - товары */} + {isOrderExpanded && + order.items.map((item) => ( +
+
+ + + Товар + +
+
+
+
+ {item.product.name}
-
-
-
- - {route.from} - - - - {route.to} - + {item.product.article && ( +
+ Артикул: {item.product.article}
-
- {route.fromAddress} → {route.toAddress} + )} + {item.product.category && ( + + {item.product.category.name} + + )} + {item.product.description && ( +
+ {item.product.description}
+ )} +
+
+ + {formatDate(order.createdAt)} + + + + {item.quantity} + + +
+
+ {formatCurrency(Number(item.totalPrice))}
-
- - {route.suppliers.reduce( - (sum, s) => - sum + - s.consumables.reduce( - (cSum, c) => cSum + c.plannedQty, - 0 - ), - 0 - )} - - - - {route.suppliers.reduce( - (sum, s) => - sum + - s.consumables.reduce( - (cSum, c) => cSum + c.actualQty, - 0 - ), - 0 - )} - - - - {formatCurrency(route.totalConsumablesPrice)} - - - - {formatCurrency(route.logisticsPrice)} - - - - {formatCurrency(route.totalAmount)} - -
-
- - - - Поставщик - -
-
-
-
- {supplier.name} -
-
- ИНН: {supplier.inn} -
-
- {supplier.address} -
-
- {supplier.contact} -
-
-
- - {supplier.consumables.reduce( - (sum, c) => sum + c.plannedQty, - 0 - )} - - - - {supplier.consumables.reduce( - (sum, c) => sum + c.actualQty, - 0 - )} - - - - {formatCurrency( - supplier.consumables.reduce( - (sum, c) => - sum + - calculateConsumableTotal(c), - 0 - ) - )} - - - - {formatCurrency(supplier.totalAmount)} - -
-
- - - - Расходник - -
-
-
-
- {consumable.name} -
-
- Артикул: {consumable.sku} -
-
- - {consumable.category} - - {getTypeBadge( - consumable.type - )} -
-
-
- - {consumable.plannedQty} - - - - {consumable.actualQty} - - -
-
- {formatCurrency( - calculateConsumableTotal( - consumable - ) - )} -
-
- {formatCurrency( - consumable.unitPrice - )}{" "} - за шт. -
-
-
- {getEfficiencyBadge( - consumable.plannedQty, - consumable.actualQty - )} - - - {formatCurrency( - calculateConsumableTotal( - consumable - ) - )} - -
-
-
-

- - 📋 Параметры - расходника: - -

-
- {consumable.parameters.map( - (param) => ( -
-
- {param.name} -
-
- {param.value}{" "} - {param.unit || - ""} -
-
- ) - )} -
-
-
-
diff --git a/src/components/supplies/create-consumables-supply-page.tsx b/src/components/supplies/create-consumables-supply-page.tsx index 1c967b1..037f85f 100644 --- a/src/components/supplies/create-consumables-supply-page.tsx +++ b/src/components/supplies/create-consumables-supply-page.tsx @@ -24,7 +24,11 @@ import { Wrench, Box, } from "lucide-react"; -import { GET_MY_COUNTERPARTIES, GET_ALL_PRODUCTS } from "@/graphql/queries"; +import { + GET_MY_COUNTERPARTIES, + GET_ALL_PRODUCTS, + GET_SUPPLY_ORDERS, +} from "@/graphql/queries"; import { CREATE_SUPPLY_ORDER } from "@/graphql/mutations"; import { OrganizationAvatar } from "@/components/market/organization-avatar"; import { toast } from "sonner"; @@ -81,6 +85,8 @@ export function CreateConsumablesSupplyPage() { const [searchQuery, setSearchQuery] = useState(""); const [productSearchQuery, setProductSearchQuery] = useState(""); const [deliveryDate, setDeliveryDate] = useState(""); + const [selectedFulfillmentCenter, setSelectedFulfillmentCenter] = + useState(null); const [isCreatingSupply, setIsCreatingSupply] = useState(false); // Загружаем контрагентов-поставщиков расходников @@ -105,6 +111,11 @@ export function CreateConsumablesSupplyPage() { counterpartiesData?.myCounterparties || [] ).filter((org: ConsumableSupplier) => org.type === "WHOLESALE"); + // Фильтруем фулфилмент-центры + const fulfillmentCenters = ( + counterpartiesData?.myCounterparties || [] + ).filter((org: ConsumableSupplier) => org.type === "FULFILLMENT"); + // Фильтруем поставщиков по поисковому запросу const filteredSuppliers = consumableSuppliers.filter( (supplier: ConsumableSupplier) => @@ -210,6 +221,13 @@ export function CreateConsumablesSupplyPage() { return; } + // Для селлеров требуется выбор фулфилмент-центра + // TODO: Добавить проверку типа текущей организации + if (!selectedFulfillmentCenter) { + toast.error("Выберите фулфилмент-центр для доставки"); + return; + } + setIsCreatingSupply(true); try { @@ -218,21 +236,32 @@ export function CreateConsumablesSupplyPage() { input: { partnerId: selectedSupplier.id, deliveryDate: deliveryDate, + fulfillmentCenterId: selectedFulfillmentCenter.id, items: selectedConsumables.map((consumable) => ({ productId: consumable.id, quantity: consumable.selectedQuantity, })), }, }, + refetchQueries: [{ query: GET_SUPPLY_ORDERS }], }); if (result.data?.createSupplyOrder?.success) { - toast.success("Поставка расходников создана успешно!"); - router.push("/supplies"); + toast.success("Заказ поставки расходников создан успешно!"); + // Очищаем форму + setSelectedSupplier(null); + setSelectedFulfillmentCenter(null); + setSelectedConsumables([]); + setDeliveryDate(""); + setProductSearchQuery(""); + setSearchQuery(""); + + // Перенаправляем на страницу поставок фулфилмента + router.push("/fulfillment-supplies"); } else { toast.error( result.data?.createSupplyOrder?.message || - "Ошибка при создании поставки" + "Ошибка при создании заказа поставки" ); } } catch (error) { @@ -247,16 +276,16 @@ export function CreateConsumablesSupplyPage() {
-
+
{/* Заголовок */} -
+
-

+

Создание поставки расходников

-

+

Выберите поставщика и добавьте расходники в заказ

@@ -264,130 +293,165 @@ export function CreateConsumablesSupplyPage() { variant="ghost" size="sm" onClick={() => router.push("/supplies")} - className="text-white/60 hover:text-white hover:bg-white/10" + className="text-white/60 hover:text-white hover:bg-white/10 text-sm" > - - Назад к поставкам + + Назад
{/* Основной контент с двумя блоками */} -
+
{/* Левая колонка - Поставщики и Расходники */} -
+
{/* Блок "Поставщики" */} - -
-
-

- + +
+
+

+ Поставщики

+
+ + 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" + /> +
{selectedSupplier && ( )}
-
- - setSearchQuery(e.target.value)} - className="bg-white/10 border-white/20 text-white placeholder-white/40 pl-10" - /> -
-
+
{counterpartiesLoading ? ( -
-
-

Загрузка поставщиков...

+
+
+

+ Загружаем поставщиков... +

) : filteredSuppliers.length === 0 ? ( -
- -

+

+
+ +
+

{searchQuery ? "Поставщики не найдены" - : "У вас пока нет партнеров-поставщиков расходников"} + : "Добавьте поставщиков"}

) : ( -
- {filteredSuppliers.map((supplier: ConsumableSupplier) => ( - setSelectedSupplier(supplier)} - > -
- ({ - id: user.id, - avatar: user.avatar, - })), - }} - size="sm" - /> -
-

- {supplier.name || - supplier.fullName || - "Поставщик"} -

-
- {renderStars(4.5)} - - 4.5 - +
+ {filteredSuppliers + .slice(0, 7) + .map((supplier: ConsumableSupplier, index) => ( + setSelectedSupplier(supplier)} + > +
+
+ ({ + id: user.id, + avatar: user.avatar, + }) + ), + }} + size="sm" + /> + {selectedSupplier?.id === supplier.id && ( +
+ + ✓ + +
+ )} +
+
+

+ {( + supplier.name || + supplier.fullName || + "Поставщик" + ).slice(0, 10)} +

+
+ + ★ + + + 4.5 + +
+
+
+
-

- ИНН: {supplier.inn} -

- {selectedSupplier?.id === supplier.id && ( -
- - Выбран - -
- )} + + {/* Hover эффект */} +
+
+ ))} + {filteredSuppliers.length > 7 && ( +
+
+ +{filteredSuppliers.length - 7}
- - ))} +
ещё
+
+ )}
)}
{/* Блок "Расходники" */} - -
-
+ +
+

- + Расходники {selectedSupplier && ( - + - {selectedSupplier.name || selectedSupplier.fullName} )} @@ -395,151 +459,220 @@ export function CreateConsumablesSupplyPage() {

{selectedSupplier && (
- + setProductSearchQuery(e.target.value)} - className="bg-white/10 border-white/20 text-white placeholder-white/40 pl-10" + className="bg-white/10 border-white/20 text-white placeholder-white/40 pl-7 h-8 text-sm" />
)}
-
+
{!selectedSupplier ? ( -
- -

+

+ +

Выберите поставщика для просмотра расходников

) : productsLoading ? ( -
-
-

Загрузка расходников...

+
+
+

Загрузка...

) : supplierProducts.length === 0 ? ( -
- -

- У данного поставщика нет доступных расходников +

+ +

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

) : ( -
- {supplierProducts.map((product: ConsumableProduct) => { - const selectedQuantity = getSelectedQuantity( - product.id - ); - return ( - -
- {/* Изображение товара */} -
- {product.images && product.images.length > 0 && product.images[0] ? ( - {product.name} - ) : product.mainImage ? ( - {product.name} - ) : ( -
- -
- )} -
+
+ {supplierProducts.map( + (product: ConsumableProduct, index) => { + const selectedQuantity = getSelectedQuantity( + product.id + ); + return ( + 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%", + }} + > +
+ {/* Изображение товара */} +
+ {product.images && + product.images.length > 0 && + product.images[0] ? ( + {product.name} + ) : product.mainImage ? ( + {product.name} + ) : ( +
+ +
+ )} + {selectedQuantity > 0 && ( +
+ + {selectedQuantity > 999 + ? "999+" + : selectedQuantity} + +
+ )} +
- {/* Информация о товаре */} -
-

- {product.name} -

- {product.category && ( - - {product.category.name} - - )} -

- {product.description || - "Описание отсутствует"} -

-
- - {formatCurrency(product.price)} - {product.unit && ( - - / {product.unit} + {/* Информация о товаре */} +
+

+ {product.name} +

+ {product.category && ( + + {product.category.name.slice(0, 10)} + + )} +
+ + {formatCurrency(product.price)} + + {product.stock && ( + + {product.stock} )} - - {product.stock && ( - - В наличии: {product.stock} - +
+
+ + {/* Управление количеством */} +
+
+ + { + 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" + /> + +
+ + {selectedQuantity > 0 && ( +
+ + {formatCurrency( + product.price * selectedQuantity + )} + +
)}
- {/* Управление количеством */} -
-
- - - {selectedQuantity} - - -
- {selectedQuantity > 0 && ( - - {formatCurrency( - product.price * selectedQuantity - )} - - )} -
-
- - ); - })} + {/* Hover эффект */} +
+ + ); + } + )}
)}
@@ -547,22 +680,43 @@ export function CreateConsumablesSupplyPage() {
{/* Правая колонка - Корзина */} - {selectedConsumables.length > 0 && ( -
- -

- - Корзина ({getTotalItems()} шт) -

+
+ +

+ + Корзина ({getTotalItems()} шт) +

-
+ {selectedConsumables.length === 0 ? ( +
+
+ +
+

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

+

+ Добавьте расходники для создания поставки +

+ {selectedFulfillmentCenter && ( +
+

Доставка в:

+

+ {selectedFulfillmentCenter.name || + selectedFulfillmentCenter.fullName} +

+
+ )} +
+ ) : ( +
{selectedConsumables.map((consumable) => (
-

+

{consumable.name}

@@ -571,7 +725,7 @@ export function CreateConsumablesSupplyPage() {

- + {formatCurrency( consumable.price * consumable.selectedQuantity )} @@ -582,7 +736,7 @@ export function CreateConsumablesSupplyPage() { onClick={() => updateConsumableQuantity(consumable.id, 0) } - className="h-6 w-6 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/10" + className="h-5 w-5 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/10" > × @@ -590,40 +744,78 @@ export function CreateConsumablesSupplyPage() {
))}
+ )} -
-
- - setDeliveryDate(e.target.value)} - className="bg-white/10 border-white/20 text-white" - min={new Date().toISOString().split("T")[0]} +
+
+ +
+
-
- Итого: - - {formatCurrency(getTotalAmount())} - -
-
- -
- )} +
+ + 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 + /> +
+
+ + Итого: + + + {formatCurrency(getTotalAmount())} + +
+ +
+ +

diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index aeab947..162b8a5 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -753,4 +753,49 @@ export const ALL_USERS = gql` hasMore } } -` \ No newline at end of file +` + +export const GET_SUPPLY_ORDERS = gql` + query GetSupplyOrders { + supplyOrders { + id + deliveryDate + status + totalAmount + totalItems + createdAt + updatedAt + partner { + id + name + fullName + inn + address + phones + emails + } + organization { + id + name + fullName + type + } + items { + id + quantity + price + totalPrice + product { + id + name + article + description + category { + id + name + } + } + } + } + } +` \ No newline at end of file diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index f60e634..fdf4392 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -97,7 +97,7 @@ const generateToken = (payload: AuthTokenPayload): string => { const verifyToken = (token: string): AuthTokenPayload => { try { return jwt.verify(token, process.env.JWT_SECRET!) as AuthTokenPayload; - // eslint-disable-next-line @typescript-eslint/no-unused-vars + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { throw new GraphQLError("Недействительный токен", { extensions: { code: "UNAUTHENTICATED" }, @@ -169,7 +169,7 @@ function parseLiteral(ast: unknown): unknown { fields?: unknown[]; values?: unknown[]; }; - + switch (astNode.kind) { case Kind.STRING: case Kind.BOOLEAN: @@ -291,7 +291,7 @@ export const resolvers = { // Получаем исходящие заявки для добавления флага hasOutgoingRequest const outgoingRequests = await prisma.counterpartyRequest.findMany({ - where: { + where: { senderId: currentUser.organization.id, status: "PENDING", }, @@ -302,7 +302,7 @@ export const resolvers = { // Получаем входящие заявки для добавления флага hasIncomingRequest const incomingRequests = await prisma.counterpartyRequest.findMany({ - where: { + where: { receiverId: currentUser.organization.id, status: "PENDING", }, @@ -366,7 +366,7 @@ export const resolvers = { const counterparties = await prisma.counterparty.findMany({ where: { organizationId: currentUser.organization.id }, - include: { + include: { counterparty: { include: { users: true, @@ -397,7 +397,7 @@ export const resolvers = { } return await prisma.counterpartyRequest.findMany({ - where: { + where: { receiverId: currentUser.organization.id, status: "PENDING", }, @@ -437,7 +437,7 @@ export const resolvers = { } return await prisma.counterpartyRequest.findMany({ - where: { + where: { senderId: currentUser.organization.id, status: { in: ["PENDING", "REJECTED"] }, }, @@ -506,7 +506,7 @@ export const resolvers = { receiverOrganization: { include: { users: true, - }, + }, }, }, orderBy: { createdAt: "asc" }, @@ -537,7 +537,7 @@ export const resolvers = { // Получаем всех контрагентов const counterparties = await prisma.counterparty.findMany({ where: { organizationId: currentUser.organization.id }, - include: { + include: { counterparty: { include: { users: true, @@ -550,7 +550,7 @@ export const resolvers = { const conversations = await Promise.all( counterparties.map(async (cp) => { const counterpartyId = cp.counterparty.id; - + // Последнее сообщение с этим контрагентом const lastMessage = await prisma.message.findFirst({ where: { @@ -608,7 +608,10 @@ export const resolvers = { // Фильтруем null значения и сортируем по времени последнего сообщения return conversations .filter((conv) => conv !== null) - .sort((a, b) => new Date(b!.updatedAt).getTime() - new Date(a!.updatedAt).getTime()); + .sort( + (a, b) => + new Date(b!.updatedAt).getTime() - new Date(a!.updatedAt).getTime() + ); }, // Мои услуги @@ -664,6 +667,57 @@ export const resolvers = { }); }, + // Заказы поставок расходников + supplyOrders: async (_: unknown, __: unknown, context: Context) => { + if (!context.user) { + throw new GraphQLError("Требуется авторизация", { + extensions: { code: "UNAUTHENTICATED" }, + }); + } + + const currentUser = await prisma.user.findUnique({ + where: { id: context.user.id }, + include: { organization: true }, + }); + + if (!currentUser?.organization) { + throw new GraphQLError("У пользователя нет организации"); + } + + // Возвращаем заказы где текущая организация является заказчиком или поставщиком + return await prisma.supplyOrder.findMany({ + where: { + OR: [ + { organizationId: currentUser.organization.id }, // Заказы созданные организацией + { partnerId: currentUser.organization.id }, // Заказы где организация - поставщик + ], + }, + include: { + partner: { + include: { + users: true, + }, + }, + organization: { + include: { + users: true, + }, + }, + items: { + include: { + product: { + include: { + category: true, + organization: true, + }, + }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + }, + // Логистика организации myLogistics: async (_: unknown, __: unknown, context: Context) => { if (!context.user) { @@ -743,7 +797,7 @@ export const resolvers = { return await prisma.product.findMany({ where: { organizationId: currentUser.organization.id }, - include: { + include: { category: true, organization: true, }, @@ -785,7 +839,7 @@ export const resolvers = { return await prisma.product.findMany({ where, - include: { + include: { category: true, organization: { include: { @@ -899,7 +953,9 @@ export const resolvers = { }); if (!targetOrganization || targetOrganization.type !== "FULFILLMENT") { - throw new GraphQLError("Расходники доступны только у фулфилмент центров"); + throw new GraphQLError( + "Расходники доступны только у фулфилмент центров" + ); } return await prisma.supply.findMany({ @@ -1068,7 +1124,7 @@ export const resolvers = { } const employee = await prisma.employee.findFirst({ - where: { + where: { id: args.id, organizationId: currentUser.organization.id, }, @@ -1155,7 +1211,7 @@ export const resolvers = { args.phone, args.code ); - + if (!verificationResult.success) { return { success: false, @@ -1214,7 +1270,7 @@ export const resolvers = { }; console.log("verifySmsCode - Returning result:", { - success: result.success, + success: result.success, hasToken: !!result.token, hasUser: !!result.user, message: result.message, @@ -1318,28 +1374,28 @@ export const resolvers = { addressFull: organizationData.addressFull, ogrn: organizationData.ogrn, ogrnDate: organizationData.ogrnDate, - + // Статус организации status: organizationData.status, actualityDate: organizationData.actualityDate, registrationDate: organizationData.registrationDate, liquidationDate: organizationData.liquidationDate, - + // Руководитель managementName: organizationData.managementName, managementPost: organizationData.managementPost, - + // ОПФ opfCode: organizationData.opfCode, opfFull: organizationData.opfFull, opfShort: organizationData.opfShort, - + // Коды статистики okato: organizationData.okato, oktmo: organizationData.oktmo, okpo: organizationData.okpo, okved: organizationData.okved, - + // Контакты phones: organizationData.phones ? JSON.parse(JSON.stringify(organizationData.phones)) @@ -1347,12 +1403,12 @@ export const resolvers = { emails: organizationData.emails ? JSON.parse(JSON.stringify(organizationData.emails)) : null, - + // Финансовые данные employeeCount: organizationData.employeeCount, revenue: organizationData.revenue, taxSystem: organizationData.taxSystem, - + type: type, dadataData: JSON.parse(JSON.stringify(organizationData.rawData)), }, @@ -1455,7 +1511,7 @@ export const resolvers = { const tradeMark = validationResults[0]?.data?.tradeMark; const sellerName = validationResults[0]?.data?.sellerName; const shopName = tradeMark || sellerName || "Магазин"; - + const organization = await prisma.organization.create({ data: { inn: @@ -1598,7 +1654,7 @@ export const resolvers = { where: { id: existingKey.id }, data: { apiKey, - validationData: JSON.parse(JSON.stringify(validationResult.data)), + validationData: JSON.parse(JSON.stringify(validationResult.data)), isActive: true, }, }); @@ -1624,7 +1680,7 @@ export const resolvers = { message: "API ключ успешно добавлен", apiKey: newKey, }; - } + } } catch (error) { console.error("Error adding marketplace API key:", error); return { @@ -1697,7 +1753,7 @@ export const resolvers = { const user = await prisma.user.findUnique({ where: { id: context.user.id }, - include: { + include: { organization: { include: { apiKeys: true, @@ -1712,7 +1768,7 @@ export const resolvers = { try { const { input } = args; - + // Обновляем данные пользователя (аватар, имя управляющего) const userUpdateData: { avatar?: string; managerName?: string } = {}; if (input.avatar) { @@ -1721,14 +1777,14 @@ export const resolvers = { if (input.managerName) { userUpdateData.managerName = input.managerName; } - + if (Object.keys(userUpdateData).length > 0) { await prisma.user.update({ where: { id: context.user.id }, data: userUpdateData, }); } - + // Подготавливаем данные для обновления организации const updateData: { phones?: object; @@ -1736,20 +1792,20 @@ export const resolvers = { managementName?: string; managementPost?: string; } = {}; - + // Название организации больше не обновляется через профиль // Для селлеров устанавливается при регистрации, для остальных - при смене ИНН - + // Обновляем контактные данные в JSON поле phones if (input.orgPhone) { updateData.phones = [{ value: input.orgPhone, type: "main" }]; } - - // Обновляем email в JSON поле emails + + // Обновляем email в JSON поле emails if (input.email) { updateData.emails = [{ value: input.email, type: "main" }]; } - + // Сохраняем дополнительные контакты в custom полях // Пока добавим их как дополнительные JSON поля const customContacts: { @@ -1763,13 +1819,13 @@ export const resolvers = { corrAccount?: string; }; } = {}; - + // managerName теперь сохраняется в поле пользователя, а не в JSON - + if (input.telegram) { customContacts.telegram = input.telegram; } - + if (input.whatsapp) { customContacts.whatsapp = input.whatsapp; } @@ -1787,7 +1843,7 @@ export const resolvers = { corrAccount: input.corrAccount, }; } - + // Если есть дополнительные контакты, сохраним их в поле managementPost временно // В идеале нужно добавить отдельную таблицу для контактов if (Object.keys(customContacts).length > 0) { @@ -1806,7 +1862,7 @@ export const resolvers = { // Получаем обновленного пользователя const updatedUser = await prisma.user.findUnique({ where: { id: context.user.id }, - include: { + include: { organization: { include: { apiKeys: true, @@ -1842,7 +1898,7 @@ export const resolvers = { const user = await prisma.user.findUnique({ where: { id: context.user.id }, - include: { + include: { organization: { include: { apiKeys: true, @@ -1940,7 +1996,7 @@ export const resolvers = { // Получаем обновленного пользователя const updatedUser = await prisma.user.findUnique({ where: { id: context.user.id }, - include: { + include: { organization: { include: { apiKeys: true, @@ -2591,8 +2647,8 @@ export const resolvers = { } // conversationId имеет формат "currentOrgId-counterpartyId" - const [, counterpartyId] = args.conversationId.split('-'); - + const [, counterpartyId] = args.conversationId.split("-"); + if (!counterpartyId) { throw new GraphQLError("Неверный ID беседы"); } @@ -2989,13 +3045,22 @@ export const resolvers = { }, // Создать заказ поставки расходников + // Процесс: Селлер → Поставщик → Логистика → Фулфилмент + // 1. Селлер создает заказ у поставщика расходников + // 2. Поставщик получает заказ и готовит товары + // 3. Логистика транспортирует товары на склад фулфилмента + // 4. Фулфилмент принимает товары на склад + // 5. Все участники видят информацию о поставке в своих кабинетах createSupplyOrder: async ( _: unknown, args: { input: { partnerId: string; deliveryDate: string; + fulfillmentCenterId?: string; // ID фулфилмент-центра для доставки + logisticsPartnerId?: string; // ID логистической компании items: Array<{ productId: string; quantity: number }>; + notes?: string; // Дополнительные заметки к заказу }; }, context: Context @@ -3015,13 +3080,40 @@ export const resolvers = { throw new GraphQLError("У пользователя нет организации"); } - // Проверяем, что это фулфилмент центр - if (currentUser.organization.type !== "FULFILLMENT") { + // Проверяем тип организации и определяем роль в процессе поставки + const allowedTypes = ["FULFILLMENT", "SELLER", "LOGIST"]; + if (!allowedTypes.includes(currentUser.organization.type)) { throw new GraphQLError( - "Заказы поставок доступны только для фулфилмент центров" + "Заказы поставок недоступны для данного типа организации" ); } + // Определяем роль организации в процессе поставки + const organizationRole = currentUser.organization.type; + let fulfillmentCenterId = args.input.fulfillmentCenterId; + + // Если заказ создает фулфилмент-центр, он сам является получателем + if (organizationRole === "FULFILLMENT") { + fulfillmentCenterId = currentUser.organization.id; + } + + // Если указан фулфилмент-центр, проверяем его существование + if (fulfillmentCenterId) { + const fulfillmentCenter = await prisma.organization.findFirst({ + where: { + id: fulfillmentCenterId, + type: "FULFILLMENT", + }, + }); + + if (!fulfillmentCenter) { + return { + success: false, + message: "Указанный фулфилмент-центр не найден", + }; + } + } + // Проверяем, что партнер существует и является оптовиком const partner = await prisma.organization.findFirst({ where: { @@ -3104,6 +3196,16 @@ export const resolvers = { }); try { + // Определяем начальный статус в зависимости от роли организации + let initialStatus = "PENDING"; + if (organizationRole === "SELLER") { + initialStatus = "PENDING"; // Селлер создает заказ, ждет подтверждения поставщика + } else if (organizationRole === "FULFILLMENT") { + initialStatus = "PENDING"; // Фулфилмент заказывает для своего склада + } else if (organizationRole === "LOGIST") { + initialStatus = "CONFIRMED"; // Логист может сразу подтверждать заказы + } + const supplyOrder = await prisma.supplyOrder.create({ data: { partnerId: args.input.partnerId, @@ -3111,6 +3213,7 @@ export const resolvers = { totalAmount: new Prisma.Decimal(totalAmount), totalItems: totalItems, organizationId: currentUser.organization.id, + status: initialStatus as any, items: { create: orderItems, }, @@ -3145,7 +3248,7 @@ export const resolvers = { const productWithCategory = supplyOrder.items.find( (orderItem) => orderItem.productId === item.productId )?.product; - + return { name: product.name, description: product.description || `Заказано у ${partner.name}`, @@ -3167,10 +3270,31 @@ export const resolvers = { data: suppliesData, }); + // Формируем сообщение в зависимости от роли организации + let successMessage = ""; + if (organizationRole === "SELLER") { + successMessage = `Заказ поставки расходников создан! Расходники будут доставлены ${ + fulfillmentCenterId + ? "на указанный фулфилмент-склад" + : "согласно настройкам" + }. Ожидайте подтверждения от поставщика.`; + } else if (organizationRole === "FULFILLMENT") { + successMessage = `Заказ поставки расходников создан для вашего склада! Ожидайте подтверждения от поставщика и координации с логистикой.`; + } else if (organizationRole === "LOGIST") { + successMessage = `Заказ поставки создан и подтвержден! Координируйте доставку расходников от поставщика на фулфилмент-склад.`; + } + return { success: true, - message: `Заказ поставки создан успешно! Добавлено ${suppliesData.length} расходников в каталог.`, + message: successMessage, order: supplyOrder, + processInfo: { + role: organizationRole, + supplier: partner.name || partner.fullName, + fulfillmentCenter: fulfillmentCenterId, + logistics: args.input.logisticsPartnerId, + status: initialStatus, + }, }; } catch (error) { console.error("Error creating supply order:", error); @@ -3185,22 +3309,22 @@ export const resolvers = { createProduct: async ( _: unknown, args: { - input: { - name: string; - article: string; - description?: string; - price: number; - quantity: number; - categoryId?: string; - brand?: string; - color?: string; - size?: string; - weight?: number; - dimensions?: string; - material?: string; - images?: string[]; - mainImage?: string; - isActive?: boolean; + input: { + name: string; + article: string; + description?: string; + price: number; + quantity: number; + categoryId?: string; + brand?: string; + color?: string; + size?: string; + weight?: number; + dimensions?: string; + material?: string; + images?: string[]; + mainImage?: string; + isActive?: boolean; }; }, context: Context @@ -3260,7 +3384,7 @@ export const resolvers = { isActive: args.input.isActive ?? true, organizationId: currentUser.organization.id, }, - include: { + include: { category: true, organization: true, }, @@ -3284,23 +3408,23 @@ export const resolvers = { updateProduct: async ( _: unknown, args: { - id: string; - input: { - name: string; - article: string; - description?: string; - price: number; - quantity: number; - categoryId?: string; - brand?: string; - color?: string; - size?: string; - weight?: number; - dimensions?: string; - material?: string; - images?: string[]; - mainImage?: string; - isActive?: boolean; + id: string; + input: { + name: string; + article: string; + description?: string; + price: number; + quantity: number; + categoryId?: string; + brand?: string; + color?: string; + size?: string; + weight?: number; + dimensions?: string; + material?: string; + images?: string[]; + mainImage?: string; + isActive?: boolean; }; }, context: Context @@ -3366,11 +3490,13 @@ export const resolvers = { weight: args.input.weight, dimensions: args.input.dimensions, material: args.input.material, - images: args.input.images ? JSON.stringify(args.input.images) : undefined, + images: args.input.images + ? JSON.stringify(args.input.images) + : undefined, mainImage: args.input.mainImage, isActive: args.input.isActive ?? true, }, - include: { + include: { category: true, organization: true, }, @@ -3655,7 +3781,7 @@ export const resolvers = { if (existingCartItem) { // Обновляем количество const newQuantity = existingCartItem.quantity + args.quantity; - + if (newQuantity > product.quantity) { return { success: false, @@ -4195,7 +4321,7 @@ export const resolvers = { try { const employee = await prisma.employee.update({ - where: { + where: { id: args.id, organizationId: currentUser.organization.id, }, @@ -4257,7 +4383,7 @@ export const resolvers = { try { await prisma.employee.delete({ - where: { + where: { id: args.id, organizationId: currentUser.organization.id, }, @@ -4405,7 +4531,7 @@ export const resolvers = { if (parent.users) { return parent.users; } - + // Иначе загружаем отдельно return await prisma.user.findMany({ where: { organizationId: parent.id }, @@ -4416,7 +4542,7 @@ export const resolvers = { if (parent.services) { return parent.services; } - + // Иначе загружаем отдельно return await prisma.service.findMany({ where: { organizationId: parent.id }, @@ -4429,7 +4555,7 @@ export const resolvers = { if (parent.supplies) { return parent.supplies; } - + // Иначе загружаем отдельно return await prisma.supply.findMany({ where: { organizationId: parent.id }, @@ -4478,7 +4604,7 @@ export const resolvers = { if (parent.organization) { return parent.organization; } - + // Иначе загружаем отдельно если есть organizationId if (parent.organizationId) { return await prisma.organization.findUnique({ @@ -4497,7 +4623,7 @@ export const resolvers = { Product: { images: (parent: { images: unknown }) => { // Если images это строка JSON, парсим её в массив - if (typeof parent.images === 'string') { + if (typeof parent.images === "string") { try { return JSON.parse(parent.images); } catch { @@ -4814,14 +4940,14 @@ const adminQueries = { const limit = args.limit || 50; const offset = args.offset || 0; - + // Строим условие поиска const whereCondition: Prisma.UserWhereInput = args.search ? { OR: [ { phone: { contains: args.search, mode: "insensitive" } }, { managerName: { contains: args.search, mode: "insensitive" } }, - { + { organization: { OR: [ { name: { contains: args.search, mode: "insensitive" } }, @@ -4889,7 +5015,7 @@ const adminMutations = { args.password, admin.password ); - + if (!isPasswordValid) { return { success: false, @@ -4905,7 +5031,7 @@ const adminMutations = { // Создать токен const token = jwt.sign( - { + { adminId: admin.id, username: admin.username, type: "admin", @@ -4963,19 +5089,19 @@ const wildberriesQueries = { organization: { include: { apiKeys: true, - } + }, }, }, }); - if (!user?.organization || user.organization.type !== 'SELLER') { + if (!user?.organization || user.organization.type !== "SELLER") { throw new GraphQLError("Доступно только для продавцов"); } const wbApiKeyRecord = user.organization.apiKeys?.find( - key => key.marketplace === 'WILDBERRIES' && key.isActive + (key) => key.marketplace === "WILDBERRIES" && key.isActive ); - + if (!wbApiKeyRecord) { throw new GraphQLError("WB API ключ не настроен"); } @@ -4985,8 +5111,8 @@ const wildberriesQueries = { // Получаем кампании во всех статусах const [active, completed, paused] = await Promise.all([ wbService.getAdverts(9).catch(() => []), // активные - wbService.getAdverts(7).catch(() => []), // завершенные - wbService.getAdverts(11).catch(() => []) // на паузе + wbService.getAdverts(7).catch(() => []), // завершенные + wbService.getAdverts(11).catch(() => []), // на паузе ]); const allCampaigns = [...active, ...completed, ...paused]; @@ -4995,30 +5121,34 @@ const wildberriesQueries = { success: true, message: `Found ${active.length} active, ${completed.length} completed, ${paused.length} paused campaigns`, campaignsCount: allCampaigns.length, - campaigns: allCampaigns.map(c => ({ + campaigns: allCampaigns.map((c) => ({ id: c.advertId, name: c.name, status: c.status, - type: c.type - })) + type: c.type, + })), }; } catch (error) { - console.error('Error debugging WB adverts:', error); + console.error("Error debugging WB adverts:", error); return { success: false, - message: error instanceof Error ? error.message : 'Unknown error', + message: error instanceof Error ? error.message : "Unknown error", campaignsCount: 0, - campaigns: [] + campaigns: [], }; } }, getWildberriesStatistics: async ( _: unknown, - { period, startDate, endDate }: { - period?: 'week' | 'month' | 'quarter' - startDate?: string - endDate?: string + { + period, + startDate, + endDate, + }: { + period?: "week" | "month" | "quarter"; + startDate?: string; + endDate?: string; }, context: Context ) => { @@ -5036,7 +5166,7 @@ const wildberriesQueries = { organization: { include: { apiKeys: true, - } + }, }, }, }); @@ -5045,14 +5175,14 @@ const wildberriesQueries = { throw new GraphQLError("Организация не найдена"); } - if (user.organization.type !== 'SELLER') { + if (user.organization.type !== "SELLER") { throw new GraphQLError("Доступно только для продавцов"); } const wbApiKeyRecord = user.organization.apiKeys?.find( - key => key.marketplace === 'WILDBERRIES' && key.isActive + (key) => key.marketplace === "WILDBERRIES" && key.isActive ); - + if (!wbApiKeyRecord) { throw new GraphQLError("WB API ключ не настроен"); } @@ -5073,7 +5203,9 @@ const wildberriesQueries = { dateFrom = WildberriesService.getDatePeriodAgo(period); dateTo = WildberriesService.formatDate(new Date()); } else { - throw new GraphQLError("Необходимо указать либо period, либо startDate и endDate"); + throw new GraphQLError( + "Необходимо указать либо period, либо startDate и endDate" + ); } // Получаем статистику @@ -5082,13 +5214,16 @@ const wildberriesQueries = { return { success: true, data: statistics, - message: null + message: null, }; } catch (error) { - console.error('Error fetching WB statistics:', error); + console.error("Error fetching WB statistics:", error); return { success: false, - message: error instanceof Error ? error.message : 'Ошибка получения статистики', + message: + error instanceof Error + ? error.message + : "Ошибка получения статистики", data: [], }; } @@ -5096,17 +5231,19 @@ const wildberriesQueries = { getWildberriesCampaignStats: async ( _: unknown, - { input }: { + { + input, + }: { input: { campaigns: Array<{ - id: number - dates?: string[] + id: number; + dates?: string[]; interval?: { - begin: string - end: string - } - }> - } + begin: string; + end: string; + }; + }>; + }; }, context: Context ) => { @@ -5124,7 +5261,7 @@ const wildberriesQueries = { organization: { include: { apiKeys: true, - } + }, }, }, }); @@ -5133,14 +5270,14 @@ const wildberriesQueries = { throw new GraphQLError("Организация не найдена"); } - if (user.organization.type !== 'SELLER') { + if (user.organization.type !== "SELLER") { throw new GraphQLError("Доступно только для продавцов"); } const wbApiKeyRecord = user.organization.apiKeys?.find( - key => key.marketplace === 'WILDBERRIES' && key.isActive + (key) => key.marketplace === "WILDBERRIES" && key.isActive ); - + if (!wbApiKeyRecord) { throw new GraphQLError("WB API ключ не настроен"); } @@ -5149,21 +5286,21 @@ const wildberriesQueries = { const wbService = new WildberriesService(wbApiKeyRecord.apiKey); // Преобразуем запросы в нужный формат - const requests = input.campaigns.map(campaign => { + const requests = input.campaigns.map((campaign) => { if (campaign.dates && campaign.dates.length > 0) { return { id: campaign.id, - dates: campaign.dates + dates: campaign.dates, }; } else if (campaign.interval) { return { id: campaign.id, - interval: campaign.interval + interval: campaign.interval, }; } else { // Если не указаны ни даты, ни интервал, возвращаем данные только за последние сутки return { - id: campaign.id + id: campaign.id, }; } }); @@ -5174,13 +5311,16 @@ const wildberriesQueries = { return { success: true, data: campaignStats, - message: null + message: null, }; } catch (error) { - console.error('Error fetching WB campaign stats:', error); + console.error("Error fetching WB campaign stats:", error); return { success: false, - message: error instanceof Error ? error.message : 'Ошибка получения статистики кампаний', + message: + error instanceof Error + ? error.message + : "Ошибка получения статистики кампаний", data: [], }; } diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index d415d2c..5b637ba 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -34,12 +34,15 @@ export const typeDefs = gql` # Расходники организации mySupplies: [Supply!]! + # Заказы поставок расходников + supplyOrders: [SupplyOrder!]! + # Логистика организации myLogistics: [Logistics!]! - + # Поставки Wildberries myWildberriesSupplies: [WildberriesSupply!]! - + # Товары оптовика myProducts: [Product!]! @@ -68,7 +71,7 @@ export const typeDefs = gql` # Публичные услуги контрагента (для фулфилмента) counterpartyServices(organizationId: ID!): [Service!]! - + # Публичные расходники контрагента (для оптовиков) counterpartySupplies(organizationId: ID!): [Supply!]! @@ -82,10 +85,10 @@ export const typeDefs = gql` startDate: String endDate: String ): WildberriesStatisticsResponse! - + # Отладка рекламы (временно) debugWildberriesAdverts: DebugAdvertsResponse! - + # Статистика кампаний Wildberries getWildberriesCampaignStats( input: WildberriesCampaignStatsInput! @@ -203,12 +206,17 @@ export const typeDefs = gql` updateEmployee(id: ID!, input: UpdateEmployeeInput!): EmployeeResponse! deleteEmployee(id: ID!): Boolean! updateEmployeeSchedule(input: UpdateScheduleInput!): Boolean! - + # Работа с поставками Wildberries - createWildberriesSupply(input: CreateWildberriesSupplyInput!): WildberriesSupplyResponse! - updateWildberriesSupply(id: ID!, input: UpdateWildberriesSupplyInput!): WildberriesSupplyResponse! + createWildberriesSupply( + input: CreateWildberriesSupplyInput! + ): WildberriesSupplyResponse! + updateWildberriesSupply( + id: ID! + input: UpdateWildberriesSupplyInput! + ): WildberriesSupplyResponse! deleteWildberriesSupply(id: ID!): Boolean! - + # Админ мутации adminLogin(username: String!, password: String!): AdminAuthResponse! adminLogout: Boolean! @@ -537,7 +545,10 @@ export const typeDefs = gql` input SupplyOrderInput { partnerId: ID! deliveryDate: DateTime! + fulfillmentCenterId: ID # ID фулфилмент-центра для доставки + logisticsPartnerId: ID # ID логистической компании items: [SupplyOrderItemInput!]! + notes: String # Дополнительные заметки к заказу } input SupplyOrderItemInput { @@ -545,10 +556,19 @@ export const typeDefs = gql` quantity: Int! } + type SupplyOrderProcessInfo { + role: String! # Роль организации в процессе (SELLER, FULFILLMENT, LOGIST) + supplier: String! # Название поставщика + fulfillmentCenter: ID # ID фулфилмент-центра + logistics: ID # ID логистической компании + status: String! # Текущий статус заказа + } + type SupplyOrderResponse { success: Boolean! message: String! order: SupplyOrder + processInfo: SupplyOrderProcessInfo # Информация о процессе поставки } # Типы для логистики diff --git a/src/lib/apollo-client.ts b/src/lib/apollo-client.ts index 31677bb..c68e301 100644 --- a/src/lib/apollo-client.ts +++ b/src/lib/apollo-client.ts @@ -35,48 +35,15 @@ const authLink = setContext((operation, { headers }) => { } }) -// Error Link для обработки ошибок -const errorLink = onError(({ graphQLErrors, networkError }) => { - if (graphQLErrors) { - graphQLErrors.forEach(({ message, locations, path, extensions }) => { - console.error( - `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}` - ) - - // Если токен недействителен, очищаем localStorage и перенаправляем на авторизацию - if (extensions?.code === 'UNAUTHENTICATED') { - if (typeof window !== 'undefined') { - const isAdminPath = window.location.pathname.startsWith('/admin') - - if (isAdminPath) { - // Для админских страниц очищаем админские токены - localStorage.removeItem('adminAuthToken') - localStorage.removeItem('adminData') - if (window.location.pathname !== '/admin') { - window.location.href = '/admin' - } - } else { - // Для пользовательских страниц очищаем пользовательские токены - localStorage.removeItem('authToken') - localStorage.removeItem('userData') - if (window.location.pathname !== '/') { - window.location.href = '/' - } - } - } - } - }) - } - - if (networkError) { - console.error(`[Network error]: ${networkError}`) - } +// Error Link для обработки ошибок - минимальная версия +const errorLink = onError(() => { + // Пустой обработчик - не делаем ничего + // Это предотвращает любые ошибки в error handler }) // Создаем Apollo Client export const apolloClient = new ApolloClient({ link: from([ - errorLink, authLink, httpLink, ]),