diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1946528..969f43d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -101,6 +101,7 @@ model Organization { partnerSupplyOrders SupplyOrder[] @relation("SupplyOrderPartner") fulfillmentSupplyOrders SupplyOrder[] @relation("SupplyOrderFulfillmentCenter") wildberriesSupplies WildberriesSupply[] + supplySuppliers SupplySupplier[] @relation("SupplySuppliers") @@map("organizations") } @@ -480,3 +481,20 @@ model SupplyOrderItem { @@unique([supplyOrderId, productId]) @@map("supply_order_items") } + +model SupplySupplier { + id String @id @default(cuid()) + name String + contactName String + phone String + market String? + address String? + place String? + telegram String? + organizationId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organization Organization @relation("SupplySuppliers", fields: [organizationId], references: [id], onDelete: Cascade) + + @@map("supply_suppliers") +} diff --git a/src/components/supplies/create-supply-page.tsx b/src/components/supplies/create-supply-page.tsx index d8d4153..e9f4451 100644 --- a/src/components/supplies/create-supply-page.tsx +++ b/src/components/supplies/create-supply-page.tsx @@ -5,61 +5,62 @@ import { Sidebar } from "@/components/dashboard/sidebar"; import { useSidebar } from "@/hooks/useSidebar"; import { useRouter } from "next/navigation"; import { DirectSupplyCreation } from "./direct-supply-creation"; -import { WholesalerProductsPage } from "./wholesaler-products-page"; -import { TabsHeader } from "./tabs-header"; -import { WholesalerGrid } from "./wholesaler-grid"; -import { CartSummary } from "./cart-summary"; -import { FloatingCart } from "./floating-cart"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { - WholesalerForCreation, - WholesalerProduct, - SelectedProduct, - CounterpartyWholesaler, -} from "./types"; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useQuery } from "@apollo/client"; -import { GET_MY_COUNTERPARTIES, GET_ALL_PRODUCTS } from "@/graphql/queries"; +import { apolloClient } from "@/lib/apollo-client"; +import { GET_MY_COUNTERPARTIES, GET_ORGANIZATION_LOGISTICS } from "@/graphql/queries"; +import { + ArrowLeft, + Package, + CalendarIcon, + Building, +} from "lucide-react"; + +// Компонент создания поставки товаров с новым интерфейсом + +interface Organization { + id: string; + name?: string; + fullName?: string; + type: string; +} export function CreateSupplyPage() { const router = useRouter(); const { getSidebarMargin } = useSidebar(); - const [activeTab, setActiveTab] = useState<"cards" | "wholesaler">("cards"); - const [selectedWholesaler, setSelectedWholesaler] = - useState(null); - const [selectedProducts, setSelectedProducts] = useState( - [] - ); - const [showSummary, setShowSummary] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); const [canCreateSupply, setCanCreateSupply] = useState(false); const [isCreatingSupply, setIsCreatingSupply] = useState(false); - // Загружаем контрагентов-оптовиков - const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery( - GET_MY_COUNTERPARTIES + // Состояния для полей формы + const [deliveryDate, setDeliveryDate] = useState(""); + const [selectedFulfillment, setSelectedFulfillment] = useState(""); + const [goodsVolume, setGoodsVolume] = useState(0); + const [cargoPlaces, setCargoPlaces] = useState(0); + const [goodsPrice, setGoodsPrice] = useState(0); + const [fulfillmentServicesPrice, setFulfillmentServicesPrice] = useState(0); + const [logisticsPrice, setLogisticsPrice] = useState(0); + const [selectedServicesCost, setSelectedServicesCost] = useState(0); + const [selectedConsumablesCost, setSelectedConsumablesCost] = useState(0); + const [hasItemsInSupply, setHasItemsInSupply] = useState(false); + + // Загружаем контрагентов-фулфилментов + const { data: counterpartiesData } = useQuery(GET_MY_COUNTERPARTIES); + + // Фильтруем только фулфилмент организации + const fulfillmentOrgs = (counterpartiesData?.myCounterparties || []).filter( + (org: Organization) => org.type === "FULFILLMENT" ); - // Загружаем товары для выбранного оптовика - const { data: productsData, loading: productsLoading } = useQuery( - GET_ALL_PRODUCTS, - { - skip: !selectedWholesaler, - variables: { search: null, category: null }, - } - ); - - // Фильтруем только оптовиков - const wholesalers: CounterpartyWholesaler[] = ( - counterpartiesData?.myCounterparties || [] - ).filter((org: { type: string }) => org.type === "WHOLESALE"); - - // Фильтруем товары по выбранному оптовику - const wholesalerProducts: WholesalerProduct[] = selectedWholesaler - ? (productsData?.allProducts || []).filter( - (product: { organization: { id: string } }) => - product.organization.id === selectedWholesaler.id - ) - : []; - const formatCurrency = (amount: number) => { return new Intl.NumberFormat("ru-RU", { style: "currency", @@ -68,96 +69,87 @@ export function CreateSupplyPage() { }).format(amount); }; - const updateProductQuantity = (productId: string, quantity: number) => { - const product = wholesalerProducts.find((p) => p.id === productId); - if (!product || !selectedWholesaler) return; + // Функция для обновления цены товаров из поставки + const handleItemsUpdate = (totalItemsPrice: number) => { + setGoodsPrice(totalItemsPrice); + }; - setSelectedProducts((prev) => { - const existing = prev.find( - (p) => p.id === productId && p.wholesalerId === selectedWholesaler.id + // Функция для обновления статуса наличия товаров + const handleItemsCountChange = (hasItems: boolean) => { + setHasItemsInSupply(hasItems); + }; + + // Функция для обновления объема товаров из поставки + const handleVolumeUpdate = (totalVolume: number) => { + setGoodsVolume(totalVolume); + // После обновления объема пересчитываем логистику (если есть поставщик) + // calculateLogisticsPrice будет вызван из handleSuppliersUpdate + }; + + // Функция для обновления информации о поставщиках (для расчета логистики) + const handleSuppliersUpdate = (suppliersData: any[]) => { + // Находим рынок из выбранного поставщика + const selectedSupplier = suppliersData.find(supplier => supplier.selected); + const supplierMarket = selectedSupplier?.market; + + console.log("Обновление поставщиков:", { selectedSupplier, supplierMarket, volume: goodsVolume }); + + // Пересчитываем логистику с учетом рынка поставщика + calculateLogisticsPrice(goodsVolume, supplierMarket); + }; + + // Функция для расчета логистики по рынку поставщика и объему + const calculateLogisticsPrice = async (volume: number, supplierMarket?: string) => { + // Логистика рассчитывается ТОЛЬКО если есть: + // 1. Выбранный фулфилмент + // 2. Объем товаров > 0 + // 3. Рынок поставщика (откуда везти) + if (!selectedFulfillment || !volume || volume <= 0 || !supplierMarket) { + setLogisticsPrice(0); + return; + } + + try { + console.log(`Расчет логистики: ${supplierMarket} → ${selectedFulfillment}, объем: ${volume.toFixed(4)} м³`); + + // Получаем логистику выбранного фулфилмента из БД + const { data: logisticsData } = await apolloClient.query({ + query: GET_ORGANIZATION_LOGISTICS, + variables: { organizationId: selectedFulfillment }, + fetchPolicy: 'network-only' + }); + + const logistics = logisticsData?.organizationLogistics || []; + console.log(`Логистика фулфилмента ${selectedFulfillment}:`, logistics); + + // Ищем логистику для данного рынка + const logisticsRoute = logistics.find((route: any) => + route.fromLocation.toLowerCase().includes(supplierMarket.toLowerCase()) || + supplierMarket.toLowerCase().includes(route.fromLocation.toLowerCase()) ); - if (quantity === 0) { - return prev.filter( - (p) => - !(p.id === productId && p.wholesalerId === selectedWholesaler.id) - ); + if (!logisticsRoute) { + console.log(`Логистика для рынка "${supplierMarket}" не найдена`); + setLogisticsPrice(0); + return; } - if (existing) { - return prev.map((p) => - p.id === productId && p.wholesalerId === selectedWholesaler.id - ? { ...p, selectedQuantity: quantity } - : p - ); - } else { - return [ - ...prev, - { - ...product, - selectedQuantity: quantity, - wholesalerId: selectedWholesaler.id, - wholesalerName: selectedWholesaler.name, - }, - ]; - } - }); - }; - - const getTotalAmount = () => { - return selectedProducts.reduce((sum, product) => { - const discountedPrice = product.discount - ? product.price * (1 - product.discount / 100) - : product.price; - return sum + discountedPrice * product.selectedQuantity; - }, 0); - }; - - const getTotalItems = () => { - return selectedProducts.reduce( - (sum, product) => sum + product.selectedQuantity, - 0 - ); - }; - - const handleCreateSupply = () => { - if (activeTab === "cards") { - console.log("Создание поставки с карточками Wildberries"); - } else { - console.log("Создание поставки с товарами:", selectedProducts); - } - router.push("/supplies"); - }; - - const handleGoBack = () => { - if (selectedWholesaler) { - setSelectedWholesaler(null); - setShowSummary(false); - } else { - router.push("/supplies"); + // Выбираем цену в зависимости от объема + const pricePerM3 = volume <= 1 ? logisticsRoute.priceUnder1m3 : logisticsRoute.priceOver1m3; + const calculatedPrice = volume * pricePerM3; + + console.log(`Найдена логистика: ${logisticsRoute.fromLocation} → ${logisticsRoute.toLocation}`); + console.log(`Цена: ${pricePerM3}₽/м³ (${volume <= 1 ? 'до 1м³' : 'больше 1м³'}) × ${volume.toFixed(4)}м³ = ${calculatedPrice.toFixed(2)}₽`); + + setLogisticsPrice(calculatedPrice); + } catch (error) { + console.error("Error calculating logistics price:", error); + setLogisticsPrice(0); } }; - const handleRemoveProduct = (productId: string, wholesalerId: string) => { - setSelectedProducts((prev) => - prev.filter( - (p) => !(p.id === productId && p.wholesalerId === wholesalerId) - ) - ); - }; - - const handleCartQuantityChange = ( - productId: string, - wholesalerId: string, - quantity: number - ) => { - setSelectedProducts((prev) => - prev.map((p) => - p.id === productId && p.wholesalerId === wholesalerId - ? { ...p, selectedQuantity: quantity } - : p - ) - ); + const getTotalSum = () => { + return goodsPrice + selectedServicesCost + selectedConsumablesCost + logisticsPrice; }; const handleSupplyComplete = () => { @@ -172,100 +164,231 @@ export function CreateSupplyPage() { setCanCreateSupply(canCreate); }; + // Пересчитываем логистику при изменении фулфилмента (если есть поставщик) + React.useEffect(() => { + // Логистика пересчитается автоматически через handleSuppliersUpdate + // когда будет выбран поставщик с рынком + }, [selectedFulfillment, goodsVolume]); + const handleSupplyCompleted = () => { setIsCreatingSupply(false); handleSupplyComplete(); }; - // Рендер страницы товаров оптовика - if (selectedWholesaler && activeTab === "wholesaler") { - return ( - - ); - } - - // Главная страница с табами + // Главная страница с табами в новом стиле интерфейса return (
-
- router.push("/supplies")} - cartInfo={ - activeTab === "wholesaler" && selectedProducts.length > 0 - ? { - itemCount: selectedProducts.length, - totalAmount: getTotalAmount(), - formatCurrency, - } - : undefined - } - onCartClick={() => setShowSummary(true)} - onCreateSupply={handleCreateSupplyClick} - canCreateSupply={canCreateSupply} - isCreatingSupply={isCreatingSupply} - /> +
+ {/* Заголовок */} +
+
+

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

+

+ Выберите карточки товаров Wildberries для создания поставки +

+
+ +
- {/* Контент карточек - новый компонент прямого создания поставки */} - {activeTab === "cards" && ( -
+ {/* Основной контент - карточки Wildberries */} +
+ {/* Левая колонка - карточки товаров */} +
- )} - {/* Контент оптовиков */} - {activeTab === "wholesaler" && ( -
- setShowSummary(false)} - formatCurrency={formatCurrency} - visible={showSummary && selectedProducts.length > 0} - /> + {/* Правая колонка - Форма поставки */} +
+ +

+ + Параметры поставки +

- + {/* Первая строка */} +
+ {/* 1. Модуль выбора даты */} +
+ + setDeliveryDate(e.target.value)} + className="w-full h-8 rounded-lg border-0 bg-white/20 backdrop-blur px-3 py-1 text-white placeholder:text-white/50 focus:bg-white/30 focus:outline-none focus:ring-1 focus:ring-white/20 text-xs font-medium" + min={new Date().toISOString().split("T")[0]} + /> +
- setShowSummary(true)} - visible={selectedProducts.length > 0 && !showSummary} - /> + {/* 2. Модуль выбора фулфилмента */} +
+ + +
+ + {/* 3. Объём товаров (автоматически) */} +
+ +
+ + {goodsVolume > 0 ? `${goodsVolume.toFixed(2)} м³` : 'Рассчитывается автоматически'} + +
+
+ + {/* 4. Грузовые места */} +
+ + setCargoPlaces(parseInt(e.target.value) || 0)} + placeholder="шт" + className="h-8 bg-white/20 border-0 text-white placeholder:text-white/50 focus:bg-white/30 focus:ring-1 focus:ring-white/20 text-xs" + /> +
+
+ + {/* Вторая группа - цены */} +
+ {/* 5. Цена товаров (автоматически) */} +
+ +
+ + {goodsPrice > 0 ? formatCurrency(goodsPrice) : 'Рассчитывается автоматически'} + +
+
+ + {/* 6. Цена услуг фулфилмента (автоматически) */} +
+ +
+ + {selectedServicesCost > 0 ? formatCurrency(selectedServicesCost) : 'Выберите услуги'} + +
+
+ + {/* 7. Цена расходников фулфилмента (автоматически) */} +
+ +
+ + {selectedConsumablesCost > 0 ? formatCurrency(selectedConsumablesCost) : 'Выберите расходники'} + +
+
+ + {/* 8. Цена логистики (автоматически) */} +
+ +
+ + {logisticsPrice > 0 ? formatCurrency(logisticsPrice) : 'Выберите поставщика'} + +
+
+ + +
+ + {/* 9. Итоговая сумма */} +
+ +
+ + {formatCurrency(getTotalSum())} + +
+
+ + {/* 10. Кнопка создания поставки */} + +
- )} +
diff --git a/src/components/supplies/direct-supply-creation.tsx b/src/components/supplies/direct-supply-creation.tsx index 3f0208c..c914090 100644 --- a/src/components/supplies/direct-supply-creation.tsx +++ b/src/components/supplies/direct-supply-creation.tsx @@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { Label } from "@/components/ui/label"; +import { PhoneInput } from "@/components/ui/phone-input"; +import { formatPhoneInput, isValidPhone, formatNameInput } from "@/lib/input-masks"; import { Select, SelectContent, @@ -47,8 +49,9 @@ import { GET_MY_COUNTERPARTIES, GET_COUNTERPARTY_SERVICES, GET_COUNTERPARTY_SUPPLIES, + GET_SUPPLY_SUPPLIERS, } from "@/graphql/queries"; -import { CREATE_WILDBERRIES_SUPPLY } from "@/graphql/mutations"; +import { CREATE_WILDBERRIES_SUPPLY, CREATE_SUPPLY_SUPPLIER } from "@/graphql/mutations"; import { toast } from "sonner"; import { format } from "date-fns"; import { ru } from "date-fns/locale"; @@ -70,6 +73,7 @@ interface SupplyItem { pricePerUnit: number; totalPrice: number; supplierId: string; + priceType: "perUnit" | "total"; // за штуку или за общее количество } interface Organization { @@ -103,6 +107,13 @@ interface DirectSupplyCreationProps { canCreateSupply: boolean; isCreatingSupply: boolean; onCanCreateSupplyChange?: (canCreate: boolean) => void; + selectedFulfillmentId?: string; + onServicesCostChange?: (cost: number) => void; + onItemsPriceChange?: (totalPrice: number) => void; + onItemsCountChange?: (hasItems: boolean) => void; + onConsumablesCostChange?: (cost: number) => void; + onVolumeChange?: (totalVolume: number) => void; + onSuppliersChange?: (suppliers: any[]) => void; } export function DirectSupplyCreation({ @@ -111,6 +122,13 @@ export function DirectSupplyCreation({ canCreateSupply, isCreatingSupply, onCanCreateSupplyChange, + selectedFulfillmentId, + onServicesCostChange, + onItemsPriceChange, + onItemsCountChange, + onConsumablesCostChange, + onVolumeChange, + onSuppliersChange, }: DirectSupplyCreationProps) { const { user } = useAuth(); @@ -152,6 +170,12 @@ export function DirectSupplyCreation({ place: "", telegram: "", }); + const [supplierErrors, setSupplierErrors] = useState({ + name: "", + contactName: "", + phone: "", + telegram: "", + }); // Данные для фулфилмента const [organizationServices, setOrganizationServices] = useState<{ @@ -163,8 +187,9 @@ export function DirectSupplyCreation({ // Загружаем контрагентов-фулфилментов const { data: counterpartiesData } = useQuery(GET_MY_COUNTERPARTIES); + const { data: suppliersData, refetch: refetchSuppliers } = useQuery(GET_SUPPLY_SUPPLIERS); - // Мутация для создания поставки + // Мутации const [createSupply, { loading: creatingSupply }] = useMutation( CREATE_WILDBERRIES_SUPPLY, { @@ -182,6 +207,44 @@ export function DirectSupplyCreation({ }, } ); + + const [createSupplierMutation, { loading: creatingSupplier }] = useMutation( + CREATE_SUPPLY_SUPPLIER, + { + onCompleted: (data) => { + if (data.createSupplySupplier.success) { + toast.success("Поставщик добавлен успешно!"); + + // Обновляем список поставщиков из БД + refetchSuppliers(); + + // Очищаем форму + setNewSupplier({ + name: "", + contactName: "", + phone: "", + market: "", + address: "", + place: "", + telegram: "", + }); + setSupplierErrors({ + name: "", + contactName: "", + phone: "", + telegram: "", + }); + setShowSupplierModal(false); + } else { + toast.error(data.createSupplySupplier.message || "Ошибка при добавлении поставщика"); + } + }, + onError: (error) => { + toast.error("Ошибка при создании поставщика"); + console.error("Error creating supplier:", error); + }, + } + ); // Моковые данные товаров для демонстрации const getMockCards = (): WildberriesCard[] => [ @@ -196,6 +259,13 @@ export function DirectSupplyCreation({ countryProduction: "Россия", supplierVendorCode: "SUPPLIER-001", mediaFiles: ["/api/placeholder/400/400"], + dimensions: { + length: 30, // 30 см + width: 25, // 25 см + height: 5, // 5 см + weightBrutto: 0.3, // 300г + isValid: true + }, sizes: [ { chrtID: 123456, @@ -218,6 +288,13 @@ export function DirectSupplyCreation({ countryProduction: "Россия", supplierVendorCode: "SUPPLIER-002", mediaFiles: ["/api/placeholder/400/403"], + dimensions: { + length: 35, // 35 см + width: 28, // 28 см + height: 6, // 6 см + weightBrutto: 0.4, // 400г + isValid: true + }, sizes: [ { chrtID: 987654, @@ -324,6 +401,46 @@ export function DirectSupplyCreation({ loadCards(); }, [user]); + // Загружаем услуги и расходники при выборе фулфилмента + useEffect(() => { + if (selectedFulfillmentId) { + console.log('Загружаем услуги и расходники для фулфилмента:', selectedFulfillmentId); + loadOrganizationServices(selectedFulfillmentId); + loadOrganizationSupplies(selectedFulfillmentId); + } + }, [selectedFulfillmentId]); + + // Уведомляем об изменении стоимости услуг + useEffect(() => { + if (onServicesCostChange) { + const servicesCost = getServicesCost(); + onServicesCostChange(servicesCost); + } + }, [selectedServices, selectedFulfillmentId, onServicesCostChange]); + + // Уведомляем об изменении общей стоимости товаров + useEffect(() => { + if (onItemsPriceChange) { + const totalItemsPrice = getTotalItemsCost(); + onItemsPriceChange(totalItemsPrice); + } + }, [supplyItems, onItemsPriceChange]); + + // Уведомляем об изменении количества товаров + useEffect(() => { + if (onItemsCountChange) { + onItemsCountChange(supplyItems.length > 0); + } + }, [supplyItems.length, onItemsCountChange]); + + // Уведомляем об изменении стоимости расходников + useEffect(() => { + if (onConsumablesCostChange) { + const consumablesCost = getConsumablesCost(); + onConsumablesCostChange(consumablesCost); + } + }, [selectedConsumables, selectedFulfillmentId, supplyItems.length, onConsumablesCostChange]); + const loadCards = async () => { setLoading(true); try { @@ -344,9 +461,21 @@ export function DirectSupplyCreation({ if (apiToken) { console.log("Загружаем карточки из WB API..."); - const cards = await WildberriesService.getAllCards(apiToken, 20); + const cards = await WildberriesService.getAllCards(apiToken, 500); + + // Логируем информацию о размерах товаров + cards.forEach(card => { + if (card.dimensions) { + const volume = (card.dimensions.length / 100) * (card.dimensions.width / 100) * (card.dimensions.height / 100); + console.log(`WB API: Карточка ${card.nmID} - размеры: ${card.dimensions.length}x${card.dimensions.width}x${card.dimensions.height} см, объем: ${volume.toFixed(6)} м³`); + } else { + console.log(`WB API: Карточка ${card.nmID} - размеры отсутствуют`); + } + }); + setWbCards(cards); console.log("Загружено карточек из WB API:", cards.length); + console.log("Карточки с размерами:", cards.filter(card => card.dimensions).length); return; } } @@ -391,10 +520,22 @@ export function DirectSupplyCreation({ const cards = await WildberriesService.searchCards( apiToken, searchTerm, - 20 + 100 ); + + // Логируем информацию о размерах найденных товаров + cards.forEach(card => { + if (card.dimensions) { + const volume = (card.dimensions.length / 100) * (card.dimensions.width / 100) * (card.dimensions.height / 100); + console.log(`WB API: Найденная карточка ${card.nmID} - размеры: ${card.dimensions.length}x${card.dimensions.width}x${card.dimensions.height} см, объем: ${volume.toFixed(6)} м³`); + } else { + console.log(`WB API: Найденная карточка ${card.nmID} - размеры отсутствуют`); + } + }); + setWbCards(cards); console.log("Найдено карточек в WB API:", cards.length); + console.log("Найденные карточки с размерами:", cards.filter(card => card.dimensions).length); return; } } @@ -485,10 +626,11 @@ export function DirectSupplyCreation({ const newItem: SupplyItem = { card, - quantity: 1200, + quantity: 0, pricePerUnit: 0, totalPrice: 0, supplierId: "", + priceType: "perUnit", }; setSupplyItems((prev) => [...prev, newItem]); @@ -504,45 +646,105 @@ export function DirectSupplyCreation({ field: keyof SupplyItem, value: string | number ) => { - setSupplyItems((prev) => - prev.map((item) => { + setSupplyItems((prev) => { + const newItems = prev.map((item) => { if (item.card.nmID === nmID) { const updatedItem = { ...item, [field]: value }; - if (field === "quantity" || field === "pricePerUnit") { - updatedItem.totalPrice = - updatedItem.quantity * updatedItem.pricePerUnit; + + // Пересчитываем totalPrice в зависимости от типа цены + if (field === "quantity" || field === "pricePerUnit" || field === "priceType") { + if (updatedItem.priceType === "perUnit") { + // Цена за штуку - умножаем на количество + updatedItem.totalPrice = updatedItem.quantity * updatedItem.pricePerUnit; + } else { + // Цена за общее количество - pricePerUnit становится общей ценой + updatedItem.totalPrice = updatedItem.pricePerUnit; + } } return updatedItem; } return item; - }) - ); + }); + + // Если изменился поставщик, уведомляем родительский компонент асинхронно + if (field === "supplierId" && onSuppliersChange) { + // Создаем список поставщиков с информацией о выборе + const suppliersInfo = suppliers.map(supplier => ({ + ...supplier, + selected: newItems.some(item => item.supplierId === supplier.id) + })); + + console.log("Обновление поставщиков из updateSupplyItem:", suppliersInfo); + + // Вызываем асинхронно чтобы не обновлять состояние во время рендера + setTimeout(() => { + onSuppliersChange(suppliersInfo); + }, 0); + } + + return newItems; + }); + }; + + // Валидация полей поставщика + const validateSupplierField = (field: string, value: string) => { + let error = ""; + switch (field) { + case "name": + if (!value.trim()) error = "Название обязательно"; + else if (value.length < 2) error = "Минимум 2 символа"; + break; + case "contactName": + if (!value.trim()) error = "Имя обязательно"; + else if (value.length < 2) error = "Минимум 2 символа"; + break; + case "phone": + if (!value.trim()) error = "Телефон обязателен"; + else if (!isValidPhone(value)) error = "Неверный формат телефона"; + break; + case "telegram": + if (value && !value.match(/^@[a-zA-Z0-9_]{5,32}$/)) { + error = "Формат: @username (5-32 символа)"; + } + break; + } + + setSupplierErrors(prev => ({...prev, [field]: error})); + return error === ""; + }; + + const validateAllSupplierFields = () => { + const nameValid = validateSupplierField("name", newSupplier.name); + const contactNameValid = validateSupplierField("contactName", newSupplier.contactName); + const phoneValid = validateSupplierField("phone", newSupplier.phone); + const telegramValid = validateSupplierField("telegram", newSupplier.telegram); + return nameValid && contactNameValid && phoneValid && telegramValid; }; // Работа с поставщиками - const handleCreateSupplier = () => { - if (!newSupplier.name || !newSupplier.contactName || !newSupplier.phone) { - toast.error("Заполните обязательные поля"); + const handleCreateSupplier = async () => { + if (!validateAllSupplierFields()) { + toast.error("Исправьте ошибки в форме"); return; } - const supplier: Supplier = { - id: Date.now().toString(), - ...newSupplier, - }; - - setSuppliers((prev) => [...prev, supplier]); - setNewSupplier({ - name: "", - contactName: "", - phone: "", - market: "", - address: "", - place: "", - telegram: "", - }); - setShowSupplierModal(false); - toast.success("Поставщик создан"); + try { + await createSupplierMutation({ + variables: { + input: { + name: newSupplier.name, + contactName: newSupplier.contactName, + phone: newSupplier.phone, + market: newSupplier.market || null, + address: newSupplier.address || null, + place: newSupplier.place || null, + telegram: newSupplier.telegram || null, + }, + }, + }); + } catch (error) { + // Ошибка обрабатывается в onError мутации + } }; // Расчеты для нового блока @@ -555,14 +757,39 @@ export function DirectSupplyCreation({ return supplyItems.reduce((sum, item) => sum + item.quantity, 0); }; + // Функция для расчета объема одного товара в м³ + const calculateItemVolume = (card: WildberriesCard): number => { + if (!card.dimensions) return 0; + + const { length, width, height } = card.dimensions; + + // Проверяем что все размеры указаны и больше 0 + if (!length || !width || !height || length <= 0 || width <= 0 || height <= 0) { + return 0; + } + + // Переводим из сантиметров в метры и рассчитываем объем + const volumeInM3 = (length / 100) * (width / 100) * (height / 100); + + return volumeInM3; + }; + + // Функция для расчета общего объема всех товаров в поставке + const getTotalVolume = () => { + return supplyItems.reduce((totalVolume, item) => { + const itemVolume = calculateItemVolume(item.card); + return totalVolume + (itemVolume * item.quantity); + }, 0); + }; + const getTotalItemsCost = () => { return supplyItems.reduce((sum, item) => sum + item.totalPrice, 0); }; const getServicesCost = () => { - if (!selectedFulfillmentOrg || selectedServices.length === 0) return 0; + if (!selectedFulfillmentId || selectedServices.length === 0) return 0; - const services = organizationServices[selectedFulfillmentOrg] || []; + const services = organizationServices[selectedFulfillmentId] || []; return ( selectedServices.reduce((sum, serviceId) => { const service = services.find((s) => s.id === serviceId); @@ -572,9 +799,9 @@ export function DirectSupplyCreation({ }; const getConsumablesCost = () => { - if (!selectedFulfillmentOrg || selectedConsumables.length === 0) return 0; + if (!selectedFulfillmentId || selectedConsumables.length === 0) return 0; - const supplies = organizationSupplies[selectedFulfillmentOrg] || []; + const supplies = organizationSupplies[selectedFulfillmentId] || []; return ( selectedConsumables.reduce((sum, supplyId) => { const supply = supplies.find((s) => s.id === supplyId); @@ -645,6 +872,39 @@ export function DirectSupplyCreation({ } }, [isCreatingSupply]); + // Уведомление об изменении объема товаров + React.useEffect(() => { + const totalVolume = getTotalVolume(); + if (onVolumeChange) { + onVolumeChange(totalVolume); + } + }, [supplyItems, onVolumeChange]); + + // Загрузка поставщиков из правильного источника + React.useEffect(() => { + if (suppliersData?.supplySuppliers) { + console.log("Загружаем поставщиков из БД:", suppliersData.supplySuppliers); + setSuppliers(suppliersData.supplySuppliers); + + // Проверяем есть ли уже выбранные поставщики и уведомляем родителя + if (onSuppliersChange && supplyItems.length > 0) { + const suppliersInfo = suppliersData.supplySuppliers.map((supplier: any) => ({ + ...supplier, + selected: supplyItems.some(item => item.supplierId === supplier.id) + })); + + if (suppliersInfo.some((s: any) => s.selected)) { + console.log("Найдены выбранные поставщики при загрузке:", suppliersInfo); + + // Вызываем асинхронно чтобы не обновлять состояние во время рендера + setTimeout(() => { + onSuppliersChange(suppliersInfo); + }, 0); + } + } + } + }, [suppliersData]); + // Обновление статуса возможности создания поставки React.useEffect(() => { const canCreate = @@ -669,142 +929,7 @@ export function DirectSupplyCreation({ <>
- {/* НОВЫЙ БЛОК СОЗДАНИЯ ПОСТАВКИ */} - - {/* Первая строка */} -
- {/* 1. Модуль выбора даты */} -
- -
- setDeliveryDate(e.target.value)} - className="w-full h-7 rounded-lg border-0 bg-white/20 backdrop-blur px-2 py-1 text-white placeholder:text-white/50 focus:bg-white/30 focus:outline-none focus:ring-1 focus:ring-white/20 text-xs font-medium" - min={new Date().toISOString().split("T")[0]} - /> -
-
- {/* 2. Модуль выбора фулфилмента */} -
- - -
- - {/* 3. Объём товаров */} -
- - - setGoodsVolume(parseFloat(e.target.value) || 0) - } - placeholder="м³" - className="h-7 bg-white/20 border-0 text-white placeholder:text-white/50 focus:bg-white/30 focus:ring-1 focus:ring-white/20 text-xs" - /> -
- - {/* 4. Грузовые места */} -
- - setCargoPlaces(parseInt(e.target.value) || 0)} - placeholder="шт" - className="h-7 bg-white/20 border-0 text-white placeholder:text-white/50 focus:bg-white/30 focus:ring-1 focus:ring-white/20 text-xs" - /> -
-
- - {/* Вторая строка */} -
- {/* 5. Цена товаров */} -
- - setGoodsPrice(parseFloat(e.target.value) || 0)} - placeholder="₽" - className="h-7 bg-white/20 border-0 text-white placeholder:text-white/50 focus:bg-white/30 focus:ring-1 focus:ring-white/20 text-xs" - /> -
- - {/* 6. Цена услуг фулфилмента */} -
- - - setFulfillmentServicesPrice(parseFloat(e.target.value) || 0) - } - placeholder="₽" - className="h-7 bg-white/20 border-0 text-white placeholder:text-white/50 focus:bg-white/30 focus:ring-1 focus:ring-white/20 text-xs" - /> -
- - {/* 7. Цена логистики */} -
- - - setLogisticsPrice(parseFloat(e.target.value) || 0) - } - placeholder="₽" - className="h-7 bg-white/20 border-0 text-white placeholder:text-white/50 focus:bg-white/30 focus:ring-1 focus:ring-white/20 text-xs" - /> -
- - {/* 8. Итоговая сумма */} -
- -
- - {formatCurrency(getTotalSum()).replace(" ₽", " ₽")} - -
-
-
-
{/* Элегантный блок поиска и товаров */}
@@ -917,17 +1042,9 @@ export function DirectSupplyCreation({

{card.title}

-

- Арт: {card.vendorCode} +

+ WB: {card.nmID}

- {card.sizes && card.sizes[0] && ( -

- от{" "} - {card.sizes[0].discountedPrice || - card.sizes[0].price}{" "} - ₽ -

- )}
{/* Индикаторы */} @@ -1079,6 +1196,11 @@ export function DirectSupplyCreation({ Товары в поставке + {supplyItems.length > 0 && ( + + ∑ {getTotalVolume().toFixed(4)} м³ + + )}
{supplyItems.length === 0 ? ( @@ -1098,13 +1220,18 @@ export function DirectSupplyCreation({ className="bg-white/5 border-white/10 p-1.5" > {/* Компактный заголовок товара */} -
-
+
+
{item.card.title}
-
- Арт: {item.card.vendorCode} +
+ WB: {item.card.nmID} + {calculateItemVolume(item.card) > 0 ? ( + | {(calculateItemVolume(item.card) * item.quantity).toFixed(4)} м³ + ) : ( + | размеры не указаны + )}
+
+
- {formatCurrency(item.totalPrice).replace(" ₽", "₽")} + Итого: {formatCurrency(item.totalPrice).replace(" ₽", "₽")}
{/* Блок 5: Услуги фулфилмента */}
-
- {selectedFulfillmentOrg && - organizationServices[selectedFulfillmentOrg] ? ( - organizationServices[selectedFulfillmentOrg] - .slice(0, 4) +
+ {/* DEBUG */} + {console.log('DEBUG SERVICES:', { + selectedFulfillmentId, + hasServices: !!organizationServices[selectedFulfillmentId], + servicesCount: organizationServices[selectedFulfillmentId]?.length || 0, + allOrganizationServices: Object.keys(organizationServices) + })} + {selectedFulfillmentId && + organizationServices[selectedFulfillmentId] ? ( + organizationServices[selectedFulfillmentId] + .slice(0, 3) .map((service) => ( )) ) : ( - Выберите фулфилмент + {selectedFulfillmentId ? 'Нет услуг' : 'Выберите фулфилмент'} )}
{/* Блок 6: Поставщик */} -
- +
+
+ - {/* Информация о выбранном поставщике */} - {item.supplierId && - suppliers.find((s) => s.id === item.supplierId) && ( -
-
- { - suppliers.find((s) => s.id === item.supplierId) - ?.contactName - } + {/* Компактная информация о выбранном поставщике */} + {item.supplierId && suppliers.find((s) => s.id === item.supplierId) ? ( +
+
+ {suppliers.find((s) => s.id === item.supplierId)?.contactName}
-
- { - suppliers.find((s) => s.id === item.supplierId) - ?.phone - } +
+ {suppliers.find((s) => s.id === item.supplierId)?.phone}
+ ) : ( + )} - - {/* Кнопка добавления поставщика */} - +
{/* Блок 7: Расходники фф */}
-
- {selectedFulfillmentOrg && - organizationSupplies[selectedFulfillmentOrg] ? ( - organizationSupplies[selectedFulfillmentOrg] - .slice(0, 4) +
+ {/* DEBUG для расходников */} + {console.log('DEBUG CONSUMABLES:', { + selectedFulfillmentId, + hasConsumables: !!organizationSupplies[selectedFulfillmentId], + consumablesCount: organizationSupplies[selectedFulfillmentId]?.length || 0, + allOrganizationSupplies: Object.keys(organizationSupplies) + })} + {selectedFulfillmentId && + organizationSupplies[selectedFulfillmentId] ? ( + organizationSupplies[selectedFulfillmentId] + .slice(0, 3) .map((supply) => ( )) ) : ( - Выберите фулфилмент + {selectedFulfillmentId ? 'Нет расходников' : 'Выберите фулфилмент'} )}
@@ -1389,8 +1605,11 @@ export function DirectSupplyCreation({ - Создать поставщика + Добавить поставщика +

+ Контактная информация поставщика для этой поставки +

@@ -1398,46 +1617,66 @@ export function DirectSupplyCreation({ + onChange={(e) => { + const value = formatNameInput(e.target.value); setNewSupplier((prev) => ({ ...prev, - name: e.target.value, - })) - } - className="bg-white/10 border-white/20 text-white h-8 text-xs" + name: value, + })); + validateSupplierField("name", value); + }} + className={`bg-white/10 border-white/20 text-white h-8 text-xs ${ + supplierErrors.name ? 'border-red-400 focus:border-red-400' : '' + }`} placeholder="Название" /> + {supplierErrors.name && ( +

{supplierErrors.name}

+ )}
+ onChange={(e) => { + const value = formatNameInput(e.target.value); setNewSupplier((prev) => ({ ...prev, - contactName: e.target.value, - })) - } - className="bg-white/10 border-white/20 text-white h-8 text-xs" + contactName: value, + })); + validateSupplierField("contactName", value); + }} + className={`bg-white/10 border-white/20 text-white h-8 text-xs ${ + supplierErrors.contactName ? 'border-red-400 focus:border-red-400' : '' + }`} placeholder="Имя" /> + {supplierErrors.contactName && ( +

{supplierErrors.contactName}

+ )}
- + onChange={(value) => { setNewSupplier((prev) => ({ ...prev, - phone: e.target.value, - })) - } - className="bg-white/10 border-white/20 text-white h-8 text-xs" - placeholder="+7 999 123-45-67" + phone: value, + })); + validateSupplierField("phone", value); + }} + className={`bg-white/10 border-white/20 text-white h-8 text-xs ${ + supplierErrors.phone ? 'border-red-400 focus:border-red-400' : '' + }`} + placeholder="+7 (999) 123-45-67" /> + {supplierErrors.phone && ( +

{supplierErrors.phone}

+ )}
@@ -1496,15 +1735,22 @@ export function DirectSupplyCreation({ + onChange={(e) => { + const value = e.target.value; setNewSupplier((prev) => ({ ...prev, - telegram: e.target.value, - })) - } - className="bg-white/10 border-white/20 text-white h-8 text-xs" + telegram: value, + })); + validateSupplierField("telegram", value); + }} + className={`bg-white/10 border-white/20 text-white h-8 text-xs ${ + supplierErrors.telegram ? 'border-red-400 focus:border-red-400' : '' + }`} placeholder="@username" /> + {supplierErrors.telegram && ( +

{supplierErrors.telegram}

+ )}
@@ -1517,9 +1763,17 @@ export function DirectSupplyCreation({
diff --git a/src/graphql/mutations.ts b/src/graphql/mutations.ts index 011ecc6..d25790c 100644 --- a/src/graphql/mutations.ts +++ b/src/graphql/mutations.ts @@ -1136,4 +1136,24 @@ export const ADMIN_LOGOUT = gql` mutation AdminLogout { adminLogout } +` + +export const CREATE_SUPPLY_SUPPLIER = gql` + mutation CreateSupplySupplier($input: CreateSupplySupplierInput!) { + createSupplySupplier(input: $input) { + success + message + supplier { + id + name + contactName + phone + market + address + place + telegram + createdAt + } + } + } ` \ No newline at end of file diff --git a/src/graphql/queries.ts b/src/graphql/queries.ts index f4b3910..ad0d876 100644 --- a/src/graphql/queries.ts +++ b/src/graphql/queries.ts @@ -178,6 +178,35 @@ export const GET_MY_COUNTERPARTIES = gql` } ` +export const GET_SUPPLY_SUPPLIERS = gql` + query GetSupplySuppliers { + supplySuppliers { + id + name + contactName + phone + market + address + place + telegram + createdAt + } + } +` + +export const GET_ORGANIZATION_LOGISTICS = gql` + query GetOrganizationLogistics($organizationId: ID!) { + organizationLogistics(organizationId: $organizationId) { + id + fromLocation + toLocation + priceUnder1m3 + priceOver1m3 + description + } + } +` + export const GET_INCOMING_REQUESTS = gql` query GetIncomingRequests { incomingRequests { diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index 0e12197..e5021a4 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -379,6 +379,45 @@ export const resolvers = { return counterparties.map((c) => c.counterparty); }, + // Поставщики поставок + supplySuppliers: 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("У пользователя нет организации"); + } + + const suppliers = await prisma.supplySupplier.findMany({ + where: { organizationId: currentUser.organization.id }, + orderBy: { createdAt: 'desc' } + }); + + return suppliers; + }, + + // Логистика конкретной организации + organizationLogistics: async (_: unknown, args: { organizationId: string }, context: Context) => { + if (!context.user) { + throw new GraphQLError("Требуется авторизация", { + extensions: { code: "UNAUTHENTICATED" }, + }); + } + + return await prisma.logistics.findMany({ + where: { organizationId: args.organizationId }, + orderBy: { createdAt: "desc" }, + }); + }, + // Входящие заявки incomingRequests: async (_: unknown, __: unknown, context: Context) => { if (!context.user) { @@ -4534,6 +4573,76 @@ export const resolvers = { }; } }, + + // Создать поставщика для поставки + createSupplySupplier: async ( + _: unknown, + args: { + input: { + name: string; + contactName: string; + phone: string; + market?: string; + address?: string; + place?: string; + telegram?: string; + }; + }, + 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("У пользователя нет организации"); + } + + try { + // Создаем поставщика в базе данных + const supplier = await prisma.supplySupplier.create({ + data: { + name: args.input.name, + contactName: args.input.contactName, + phone: args.input.phone, + market: args.input.market, + address: args.input.address, + place: args.input.place, + telegram: args.input.telegram, + organizationId: currentUser.organization.id, + }, + }); + + return { + success: true, + message: "Поставщик добавлен успешно!", + supplier: { + id: supplier.id, + name: supplier.name, + contactName: supplier.contactName, + phone: supplier.phone, + market: supplier.market, + address: supplier.address, + place: supplier.place, + telegram: supplier.telegram, + createdAt: supplier.createdAt, + }, + }; + } catch (error) { + console.error("Error creating supply supplier:", error); + return { + success: false, + message: "Ошибка при добавлении поставщика", + }; + } + }, }, // Резолверы типов diff --git a/src/graphql/typedefs.ts b/src/graphql/typedefs.ts index 14556f4..9539c8c 100644 --- a/src/graphql/typedefs.ts +++ b/src/graphql/typedefs.ts @@ -16,6 +16,12 @@ export const typeDefs = gql` # Мои контрагенты myCounterparties: [Organization!]! + # Поставщики поставок + supplySuppliers: [SupplySupplier!]! + + # Логистика организации + organizationLogistics(organizationId: ID!): [Logistics!]! + # Входящие заявки incomingRequests: [CounterpartyRequest!]! @@ -217,6 +223,11 @@ export const typeDefs = gql` ): WildberriesSupplyResponse! deleteWildberriesSupply(id: ID!): Boolean! + # Работа с поставщиками для поставок + createSupplySupplier( + input: CreateSupplySupplierInput! + ): SupplySupplierResponse! + # Админ мутации adminLogin(username: String!, password: String!): AdminAuthResponse! adminLogout: Boolean! @@ -963,6 +974,35 @@ export const typeDefs = gql` type: Int! } + # Типы для поставщиков поставок + type SupplySupplier { + id: ID! + name: String! + contactName: String! + phone: String! + market: String + address: String + place: String + telegram: String + createdAt: DateTime! + } + + input CreateSupplySupplierInput { + name: String! + contactName: String! + phone: String! + market: String + address: String + place: String + telegram: String + } + + type SupplySupplierResponse { + success: Boolean! + message: String + supplier: SupplySupplier + } + # Типы для статистики кампаний input WildberriesCampaignStatsInput { campaigns: [CampaignStatsRequest!]!