Добавлены модели и мутации для управления заказами поставок расходников. Обновлены схемы GraphQL с новыми типами и полями для SupplyOrder и SupplyOrderItem. Реализована логика создания заказов поставок с соответствующими полями и статусами. Обновлены компоненты интерфейса для улучшения навигации и взаимодействия с новыми функциями.
This commit is contained in:
@ -0,0 +1,610 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation } from "@apollo/client";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { useSidebar } from "@/hooks/useSidebar";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
MapPin,
|
||||
Phone,
|
||||
Mail,
|
||||
Star,
|
||||
Search,
|
||||
Calendar,
|
||||
Package,
|
||||
Plus,
|
||||
Minus,
|
||||
ShoppingCart,
|
||||
} from "lucide-react";
|
||||
import { GET_MY_COUNTERPARTIES, GET_ALL_PRODUCTS } from "@/graphql/queries";
|
||||
import { CREATE_SUPPLY_ORDER } from "@/graphql/mutations";
|
||||
import { OrganizationAvatar } from "@/components/market/organization-avatar";
|
||||
import { toast } from "sonner";
|
||||
import Image from "next/image";
|
||||
|
||||
interface Partner {
|
||||
id: string;
|
||||
inn: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: "FULFILLMENT" | "SELLER" | "LOGIST" | "WHOLESALE";
|
||||
address?: string;
|
||||
phones?: Array<{ value: string }>;
|
||||
emails?: Array<{ value: string }>;
|
||||
users?: Array<{ id: string; avatar?: string; managerName?: string }>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
article: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
category?: { id: string; name: string };
|
||||
brand?: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
weight?: number;
|
||||
dimensions?: string;
|
||||
material?: string;
|
||||
images: string[];
|
||||
mainImage?: string;
|
||||
isActive: boolean;
|
||||
organization: {
|
||||
id: string;
|
||||
inn: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SelectedProduct extends Product {
|
||||
selectedQuantity: number;
|
||||
}
|
||||
|
||||
export function MaterialsOrderForm() {
|
||||
const router = useRouter();
|
||||
const { getSidebarMargin } = useSidebar();
|
||||
const [selectedPartner, setSelectedPartner] = useState<Partner | null>(null);
|
||||
const [selectedProducts, setSelectedProducts] = useState<SelectedProduct[]>(
|
||||
[]
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [deliveryDate, setDeliveryDate] = useState("");
|
||||
|
||||
// Загружаем контрагентов-оптовиков
|
||||
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery(
|
||||
GET_MY_COUNTERPARTIES
|
||||
);
|
||||
|
||||
// Загружаем товары для выбранного партнера
|
||||
const { data: productsData, loading: productsLoading } = useQuery(
|
||||
GET_ALL_PRODUCTS,
|
||||
{
|
||||
skip: !selectedPartner,
|
||||
variables: { search: null, category: null },
|
||||
}
|
||||
);
|
||||
|
||||
// Мутация для создания заказа поставки
|
||||
const [createSupplyOrder, { loading: isCreatingOrder }] = useMutation(CREATE_SUPPLY_ORDER);
|
||||
|
||||
// Фильтруем только оптовиков из партнеров
|
||||
const wholesalePartners = (counterpartiesData?.myCounterparties || []).filter(
|
||||
(org: Partner) => org.type === "WHOLESALE"
|
||||
);
|
||||
|
||||
// Фильтруем партнеров по поисковому запросу
|
||||
const filteredPartners = wholesalePartners.filter(
|
||||
(partner: Partner) =>
|
||||
partner.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
partner.fullName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
partner.inn?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Фильтруем товары по выбранному партнеру
|
||||
const partnerProducts = selectedPartner
|
||||
? (productsData?.allProducts || []).filter(
|
||||
(product: Product) => product.organization.id === selectedPartner.id
|
||||
)
|
||||
: [];
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
style: "currency",
|
||||
currency: "RUB",
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const updateProductQuantity = (productId: string, quantity: number) => {
|
||||
const product = partnerProducts.find((p: Product) => p.id === productId);
|
||||
if (!product) return;
|
||||
|
||||
setSelectedProducts((prev) => {
|
||||
const existing = prev.find((p) => p.id === productId);
|
||||
|
||||
if (quantity === 0) {
|
||||
return prev.filter((p) => p.id !== productId);
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
return prev.map((p) =>
|
||||
p.id === productId ? { ...p, selectedQuantity: quantity } : p
|
||||
);
|
||||
} else {
|
||||
return [...prev, { ...product, selectedQuantity: quantity }];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getSelectedQuantity = (productId: string): number => {
|
||||
const selected = selectedProducts.find((p) => p.id === productId);
|
||||
return selected ? selected.selectedQuantity : 0;
|
||||
};
|
||||
|
||||
const getTotalAmount = () => {
|
||||
return selectedProducts.reduce(
|
||||
(sum, product) => sum + product.price * product.selectedQuantity,
|
||||
0
|
||||
);
|
||||
};
|
||||
|
||||
const getTotalItems = () => {
|
||||
return selectedProducts.reduce(
|
||||
(sum, product) => sum + product.selectedQuantity,
|
||||
0
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateOrder = async () => {
|
||||
if (!selectedPartner || selectedProducts.length === 0 || !deliveryDate) {
|
||||
toast.error("Заполните все обязательные поля");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createSupplyOrder({
|
||||
variables: {
|
||||
input: {
|
||||
partnerId: selectedPartner.id,
|
||||
deliveryDate: deliveryDate,
|
||||
items: selectedProducts.map(product => ({
|
||||
productId: product.id,
|
||||
quantity: product.selectedQuantity
|
||||
}))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (result.data?.createSupplyOrder?.success) {
|
||||
toast.success("Заказ поставки создан успешно!");
|
||||
router.push("/fulfillment-supplies");
|
||||
} else {
|
||||
toast.error(result.data?.createSupplyOrder?.message || "Ошибка при создании заказа");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating supply order:", error);
|
||||
toast.error("Ошибка при создании заказа поставки");
|
||||
}
|
||||
};
|
||||
|
||||
const renderStars = (rating: number = 4.5) => {
|
||||
return Array.from({ length: 5 }, (_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`h-3 w-3 ${
|
||||
i < Math.floor(rating)
|
||||
? "text-yellow-400 fill-current"
|
||||
: "text-gray-400"
|
||||
}`}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
// Если выбран партнер и есть товары, показываем товары
|
||||
if (selectedPartner && partnerProducts.length > 0) {
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main
|
||||
className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}
|
||||
>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Заголовок */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedPartner(null)}
|
||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Назад к партнерам
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Товары партнера
|
||||
</h1>
|
||||
<p className="text-white/60">
|
||||
{selectedPartner.name || selectedPartner.fullName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/fulfillment-supplies")}
|
||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Список товаров */}
|
||||
<div className="lg:col-span-2 overflow-hidden">
|
||||
<Card className="glass-card h-full overflow-hidden">
|
||||
<div className="p-4 h-full flex flex-col">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">
|
||||
Доступные товары
|
||||
</h3>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{partnerProducts.map((product: Product) => {
|
||||
const selectedQuantity = getSelectedQuantity(
|
||||
product.id
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={product.id}
|
||||
className="glass-secondary p-4"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* Изображение товара */}
|
||||
{product.mainImage && (
|
||||
<div className="relative h-32 w-full bg-white/5 rounded overflow-hidden">
|
||||
<Image
|
||||
src={product.mainImage}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Информация о товаре */}
|
||||
<div>
|
||||
<h4 className="text-white font-medium text-sm">
|
||||
{product.name}
|
||||
</h4>
|
||||
<p className="text-white/60 text-xs">
|
||||
Артикул: {product.article}
|
||||
</p>
|
||||
{product.description && (
|
||||
<p className="text-white/60 text-xs mt-1 line-clamp-2">
|
||||
{product.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Цена и наличие */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-white font-bold">
|
||||
{formatCurrency(product.price)}
|
||||
</div>
|
||||
<div className="text-white/60 text-xs">
|
||||
В наличии: {product.quantity}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Выбор количества */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateProductQuantity(
|
||||
product.id,
|
||||
Math.max(0, selectedQuantity - 1)
|
||||
)
|
||||
}
|
||||
disabled={selectedQuantity === 0}
|
||||
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedQuantity}
|
||||
onChange={(e) => {
|
||||
const value = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
product.quantity,
|
||||
parseInt(e.target.value) || 0
|
||||
)
|
||||
);
|
||||
updateProductQuantity(product.id, value);
|
||||
}}
|
||||
className="h-8 w-16 text-center bg-white/10 border-white/20 text-white"
|
||||
min={0}
|
||||
max={product.quantity}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateProductQuantity(
|
||||
product.id,
|
||||
Math.min(
|
||||
product.quantity,
|
||||
selectedQuantity + 1
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
selectedQuantity >= product.quantity
|
||||
}
|
||||
className="h-8 w-8 p-0 text-white/60 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Сводка заказа */}
|
||||
<div className="overflow-hidden">
|
||||
<Card className="glass-card h-full overflow-hidden">
|
||||
<div className="p-4 h-full flex flex-col">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">
|
||||
Сводка заказа
|
||||
</h3>
|
||||
|
||||
{/* Дата поставки */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-white/80 text-sm mb-2">
|
||||
<Calendar className="h-4 w-4 inline mr-2" />
|
||||
Дата поставки
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={deliveryDate}
|
||||
onChange={(e) => setDeliveryDate(e.target.value)}
|
||||
className="bg-white/10 border-white/20 text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Выбранные товары */}
|
||||
<div className="flex-1 overflow-y-auto mb-4">
|
||||
{selectedProducts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-12 w-12 text-white/20 mx-auto mb-2" />
|
||||
<p className="text-white/60 text-sm">
|
||||
Товары не выбраны
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{selectedProducts.map((product) => (
|
||||
<Card
|
||||
key={product.id}
|
||||
className="glass-secondary p-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="text-white text-sm font-medium">
|
||||
{product.name}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-white/60">
|
||||
<span>
|
||||
{product.selectedQuantity} шт ×{" "}
|
||||
{formatCurrency(product.price)}
|
||||
</span>
|
||||
<span className="text-white font-medium">
|
||||
{formatCurrency(
|
||||
product.price * product.selectedQuantity
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Итого */}
|
||||
<div className="border-t border-white/20 pt-4 space-y-2">
|
||||
<div className="flex justify-between text-white/80">
|
||||
<span>Товаров:</span>
|
||||
<span>{getTotalItems()} шт</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-white font-bold text-lg">
|
||||
<span>Итого:</span>
|
||||
<span>{formatCurrency(getTotalAmount())}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопка создания заказа */}
|
||||
<Button
|
||||
onClick={handleCreateOrder}
|
||||
disabled={selectedProducts.length === 0 || !deliveryDate || isCreatingOrder}
|
||||
className="w-full mt-4 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white"
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||
{isCreatingOrder ? "Создание заказа..." : "Создать заказ поставки"}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Основная форма выбора партнера
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main
|
||||
className={`flex-1 ${getSidebarMargin()} px-6 py-4 overflow-hidden transition-all duration-300`}
|
||||
>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Заголовок */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/fulfillment-supplies")}
|
||||
className="text-white/60 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />К поставкам
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
Заказ расходников
|
||||
</h1>
|
||||
<p className="text-white/60">
|
||||
Выберите партнера-оптовика для заказа расходников
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Поиск */}
|
||||
<div className="mb-6">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-white/40" />
|
||||
<Input
|
||||
placeholder="Поиск партнеров..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 bg-white/10 border-white/20 text-white placeholder:text-white/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список партнеров */}
|
||||
<Card className="glass-card flex-1 overflow-hidden">
|
||||
<div className="p-6 h-full flex flex-col">
|
||||
{counterpartiesLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-white/60">Загрузка партнеров...</div>
|
||||
</div>
|
||||
) : filteredPartners.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Building2 className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||
<p className="text-white/60">
|
||||
{wholesalePartners.length === 0
|
||||
? "У вас пока нет партнеров-оптовиков"
|
||||
: "Партнеры не найдены"}
|
||||
</p>
|
||||
<p className="text-white/40 text-sm mt-2">
|
||||
Добавьте партнеров в разделе "Партнеры"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredPartners.map((partner: Partner) => (
|
||||
<Card
|
||||
key={partner.id}
|
||||
className="glass-secondary p-4 cursor-pointer transition-all hover:bg-white/15 hover:border-white/30 hover:scale-105"
|
||||
onClick={() => setSelectedPartner(partner)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* Заголовок карточки */}
|
||||
<div className="flex items-start space-x-3">
|
||||
<OrganizationAvatar
|
||||
organization={partner}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-semibold text-sm mb-1 truncate">
|
||||
{partner.name || partner.fullName}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-1 mb-2">
|
||||
{renderStars()}
|
||||
<span className="text-white/60 text-xs ml-1">
|
||||
4.5
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация */}
|
||||
<div className="space-y-1">
|
||||
{partner.address && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<MapPin className="h-3 w-3 text-gray-400" />
|
||||
<span className="text-white/80 text-xs truncate">
|
||||
{partner.address}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{partner.phones && partner.phones.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Phone className="h-3 w-3 text-gray-400" />
|
||||
<span className="text-white/80 text-xs">
|
||||
{partner.phones[0].value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{partner.emails && partner.emails.length > 0 && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Mail className="h-3 w-3 text-gray-400" />
|
||||
<span className="text-white/80 text-xs truncate">
|
||||
{partner.emails[0].value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ИНН */}
|
||||
<div className="pt-2 border-t border-white/10">
|
||||
<p className="text-white/60 text-xs">
|
||||
ИНН: {partner.inn}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -175,6 +175,7 @@ export function MaterialsSuppliesTab() {
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => window.location.href = '/fulfillment-supplies/materials/order'}
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
|
Reference in New Issue
Block a user