Добавлен новый модель SupplySupplier в схему Prisma и реализована логика для работы с поставщиками в компонентах создания поставок. Обновлены компоненты CreateSupplyPage и DirectSupplyCreation для интеграции новых функций, включая обработку поставщиков и расчет логистики. Оптимизирован интерфейс с использованием новых компонентов и улучшена логика отображения данных.

This commit is contained in:
Bivekich
2025-07-26 01:38:29 +03:00
parent 7d9b76a792
commit a211a6786f
7 changed files with 1115 additions and 522 deletions

View File

@ -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<WholesalerForCreation | null>(null);
const [selectedProducts, setSelectedProducts] = useState<SelectedProduct[]>(
[]
);
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<string>("");
const [selectedFulfillment, setSelectedFulfillment] = useState<string>("");
const [goodsVolume, setGoodsVolume] = useState<number>(0);
const [cargoPlaces, setCargoPlaces] = useState<number>(0);
const [goodsPrice, setGoodsPrice] = useState<number>(0);
const [fulfillmentServicesPrice, setFulfillmentServicesPrice] = useState<number>(0);
const [logisticsPrice, setLogisticsPrice] = useState<number>(0);
const [selectedServicesCost, setSelectedServicesCost] = useState<number>(0);
const [selectedConsumablesCost, setSelectedConsumablesCost] = useState<number>(0);
const [hasItemsInSupply, setHasItemsInSupply] = useState<boolean>(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 (
<WholesalerProductsPage
selectedWholesaler={selectedWholesaler}
products={wholesalerProducts}
selectedProducts={selectedProducts}
onQuantityChange={updateProductQuantity}
onBack={handleGoBack}
onCreateSupply={handleCreateSupply}
formatCurrency={formatCurrency}
showSummary={showSummary}
setShowSummary={setShowSummary}
loading={productsLoading}
/>
);
}
// Главная страница с табами
// Главная страница с табами в новом стиле интерфейса
return (
<div className="h-screen flex overflow-hidden">
<Sidebar />
<main
className={`flex-1 ${getSidebarMargin()} overflow-hidden transition-all duration-300`}
style={{ padding: '1rem' }}
className={`flex-1 ${getSidebarMargin()} overflow-auto transition-all duration-300`}
>
<div className="flex flex-col" style={{ height: 'calc(100vh - 2rem)' }}>
<TabsHeader
activeTab={activeTab}
onTabChange={setActiveTab}
onBack={() => 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}
/>
<div className="min-h-full w-full flex flex-col px-3 py-2">
{/* Заголовок */}
<div className="flex items-center justify-between mb-3 flex-shrink-0">
<div>
<h1 className="text-xl font-bold text-white mb-1">
Создание поставки товаров
</h1>
<p className="text-white/60 text-sm">
Выберите карточки товаров Wildberries для создания поставки
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/supplies")}
className="text-white/60 hover:text-white hover:bg-white/10 text-sm"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Назад
</Button>
</div>
{/* Контент карточек - новый компонент прямого создания поставки */}
{activeTab === "cards" && (
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
{/* Основной контент - карточки Wildberries */}
<div className="flex-1 flex gap-3 min-h-0">
{/* Левая колонка - карточки товаров */}
<div className="flex-1 min-h-0">
<DirectSupplyCreation
onComplete={handleSupplyCompleted}
onCreateSupply={handleCreateSupplyClick}
canCreateSupply={canCreateSupply}
isCreatingSupply={isCreatingSupply}
onCanCreateSupplyChange={handleCanCreateSupplyChange}
selectedFulfillmentId={selectedFulfillment}
onServicesCostChange={setSelectedServicesCost}
onItemsPriceChange={handleItemsUpdate}
onItemsCountChange={handleItemsCountChange}
onConsumablesCostChange={setSelectedConsumablesCost}
onVolumeChange={handleVolumeUpdate}
onSuppliersChange={handleSuppliersUpdate}
/>
</div>
)}
{/* Контент оптовиков */}
{activeTab === "wholesaler" && (
<div>
<CartSummary
selectedProducts={selectedProducts}
onQuantityChange={handleCartQuantityChange}
onRemoveProduct={handleRemoveProduct}
onCreateSupply={handleCreateSupply}
onToggleVisibility={() => setShowSummary(false)}
formatCurrency={formatCurrency}
visible={showSummary && selectedProducts.length > 0}
/>
{/* Правая колонка - Форма поставки */}
<div className="w-80 flex-shrink-0">
<Card className="bg-white/10 backdrop-blur-xl border-white/20 p-4 sticky top-0">
<h3 className="text-white font-semibold mb-4 flex items-center text-sm">
<Package className="h-4 w-4 mr-2" />
Параметры поставки
</h3>
<WholesalerGrid
wholesalers={wholesalers}
onWholesalerSelect={setSelectedWholesaler}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
loading={counterpartiesLoading}
/>
{/* Первая строка */}
<div className="space-y-3 mb-4">
{/* 1. Модуль выбора даты */}
<div>
<Label className="text-white/80 text-xs mb-1 block flex items-center gap-1">
<CalendarIcon className="h-3 w-3" />
Дата
</Label>
<input
type="date"
value={deliveryDate}
onChange={(e) => 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]}
/>
</div>
<FloatingCart
itemCount={selectedProducts.length}
totalAmount={getTotalAmount()}
formatCurrency={formatCurrency}
onClick={() => setShowSummary(true)}
visible={selectedProducts.length > 0 && !showSummary}
/>
{/* 2. Модуль выбора фулфилмента */}
<div>
<Label className="text-white/80 text-xs mb-1 block flex items-center gap-1">
<Building className="h-3 w-3" />
Фулфилмент
</Label>
<Select
value={selectedFulfillment}
onValueChange={(value) => {
console.log('Выбран фулфилмент:', value);
setSelectedFulfillment(value);
}}
>
<SelectTrigger className="w-full h-8 py-0 px-3 bg-white/20 border-0 text-white focus:bg-white/30 focus:ring-1 focus:ring-white/20 text-xs">
<SelectValue placeholder="ФУЛФИЛМЕНТ ИВАНОВО" />
</SelectTrigger>
<SelectContent>
{fulfillmentOrgs.map((org: Organization) => (
<SelectItem key={org.id} value={org.id}>
{org.name || org.fullName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 3. Объём товаров (автоматически) */}
<div>
<Label className="text-white/80 text-xs mb-1 block">
Объём товаров
</Label>
<div className="h-8 bg-white/10 border border-white/20 rounded-lg flex items-center px-3">
<span className="text-white/80 text-xs">
{goodsVolume > 0 ? `${goodsVolume.toFixed(2)} м³` : 'Рассчитывается автоматически'}
</span>
</div>
</div>
{/* 4. Грузовые места */}
<div>
<Label className="text-white/80 text-xs mb-1 block">
Грузовые места
</Label>
<Input
type="number"
value={cargoPlaces || ""}
onChange={(e) => 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"
/>
</div>
</div>
{/* Вторая группа - цены */}
<div className="space-y-3 mb-4">
{/* 5. Цена товаров (автоматически) */}
<div>
<Label className="text-white/80 text-xs mb-1 block">
Цена товаров
</Label>
<div className="h-8 bg-white/10 border border-white/20 rounded-lg flex items-center px-3">
<span className="text-white/80 text-xs font-medium">
{goodsPrice > 0 ? formatCurrency(goodsPrice) : 'Рассчитывается автоматически'}
</span>
</div>
</div>
{/* 6. Цена услуг фулфилмента (автоматически) */}
<div>
<Label className="text-white/80 text-xs mb-1 block">
Цена услуг фулфилмента
</Label>
<div className="h-8 bg-green-500/20 border border-green-400/30 rounded-lg flex items-center px-3">
<span className="text-green-400 text-xs font-medium">
{selectedServicesCost > 0 ? formatCurrency(selectedServicesCost) : 'Выберите услуги'}
</span>
</div>
</div>
{/* 7. Цена расходников фулфилмента (автоматически) */}
<div>
<Label className="text-white/80 text-xs mb-1 block">
Цена расходников фулфилмента
</Label>
<div className="h-8 bg-orange-500/20 border border-orange-400/30 rounded-lg flex items-center px-3">
<span className="text-orange-400 text-xs font-medium">
{selectedConsumablesCost > 0 ? formatCurrency(selectedConsumablesCost) : 'Выберите расходники'}
</span>
</div>
</div>
{/* 8. Цена логистики (автоматически) */}
<div>
<Label className="text-white/80 text-xs mb-1 block">
Логистика до фулфилмента
</Label>
<div className="h-8 bg-blue-500/20 border border-blue-400/30 rounded-lg flex items-center px-3">
<span className="text-blue-400 text-xs font-medium">
{logisticsPrice > 0 ? formatCurrency(logisticsPrice) : 'Выберите поставщика'}
</span>
</div>
</div>
</div>
{/* 9. Итоговая сумма */}
<div className="border-t border-white/20 pt-4 mb-4">
<Label className="text-white/80 text-xs mb-2 block">
Итого
</Label>
<div className="h-10 bg-gradient-to-r from-green-500/20 to-blue-500/20 rounded-lg flex items-center justify-center border border-green-400/30">
<span className="text-white font-bold text-lg">
{formatCurrency(getTotalSum())}
</span>
</div>
</div>
{/* 10. Кнопка создания поставки */}
<Button
onClick={handleCreateSupplyClick}
disabled={!canCreateSupply || isCreatingSupply || !deliveryDate || !selectedFulfillment || !hasItemsInSupply}
className={`w-full h-12 text-sm font-medium transition-all duration-200 ${
canCreateSupply && deliveryDate && selectedFulfillment && hasItemsInSupply && !isCreatingSupply
? 'bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600 text-white'
: 'bg-gray-500/20 text-gray-400 cursor-not-allowed'
}`}
>
{isCreatingSupply ? (
<div className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border border-white/30 border-t-white"></div>
<span>Создание...</span>
</div>
) : (
'Создать поставку'
)}
</Button>
</Card>
</div>
)}
</div>
</div>
</main>
</div>