Добавлены модели и мутации для управления заказами поставок расходников. Обновлены схемы GraphQL с новыми типами и полями для SupplyOrder и SupplyOrderItem. Реализована логика создания заказов поставок с соответствующими полями и статусами. Обновлены компоненты интерфейса для улучшения навигации и взаимодействия с новыми функциями.

This commit is contained in:
Veronika Smirnova
2025-07-21 12:45:11 +03:00
parent 39c1499f72
commit 96a328b3ac
7 changed files with 2982 additions and 1680 deletions

View File

@ -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>
);
}

View File

@ -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" />