Добавлен новый модель 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

@ -101,6 +101,7 @@ model Organization {
partnerSupplyOrders SupplyOrder[] @relation("SupplyOrderPartner") partnerSupplyOrders SupplyOrder[] @relation("SupplyOrderPartner")
fulfillmentSupplyOrders SupplyOrder[] @relation("SupplyOrderFulfillmentCenter") fulfillmentSupplyOrders SupplyOrder[] @relation("SupplyOrderFulfillmentCenter")
wildberriesSupplies WildberriesSupply[] wildberriesSupplies WildberriesSupply[]
supplySuppliers SupplySupplier[] @relation("SupplySuppliers")
@@map("organizations") @@map("organizations")
} }
@ -480,3 +481,20 @@ model SupplyOrderItem {
@@unique([supplyOrderId, productId]) @@unique([supplyOrderId, productId])
@@map("supply_order_items") @@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")
}

View File

@ -5,61 +5,62 @@ import { Sidebar } from "@/components/dashboard/sidebar";
import { useSidebar } from "@/hooks/useSidebar"; import { useSidebar } from "@/hooks/useSidebar";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { DirectSupplyCreation } from "./direct-supply-creation"; import { DirectSupplyCreation } from "./direct-supply-creation";
import { WholesalerProductsPage } from "./wholesaler-products-page"; import { Card } from "@/components/ui/card";
import { TabsHeader } from "./tabs-header"; import { Button } from "@/components/ui/button";
import { WholesalerGrid } from "./wholesaler-grid"; import { Input } from "@/components/ui/input";
import { CartSummary } from "./cart-summary"; import { Label } from "@/components/ui/label";
import { FloatingCart } from "./floating-cart";
import { import {
WholesalerForCreation, Select,
WholesalerProduct, SelectContent,
SelectedProduct, SelectItem,
CounterpartyWholesaler, SelectTrigger,
} from "./types"; SelectValue,
} from "@/components/ui/select";
import { useQuery } from "@apollo/client"; 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() { export function CreateSupplyPage() {
const router = useRouter(); const router = useRouter();
const { getSidebarMargin } = useSidebar(); 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 [canCreateSupply, setCanCreateSupply] = useState(false);
const [isCreatingSupply, setIsCreatingSupply] = useState(false); const [isCreatingSupply, setIsCreatingSupply] = useState(false);
// Загружаем контрагентов-оптовиков // Состояния для полей формы
const { data: counterpartiesData, loading: counterpartiesLoading } = useQuery( const [deliveryDate, setDeliveryDate] = useState<string>("");
GET_MY_COUNTERPARTIES 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) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("ru-RU", { return new Intl.NumberFormat("ru-RU", {
style: "currency", style: "currency",
@ -68,96 +69,87 @@ export function CreateSupplyPage() {
}).format(amount); }).format(amount);
}; };
const updateProductQuantity = (productId: string, quantity: number) => { // Функция для обновления цены товаров из поставки
const product = wholesalerProducts.find((p) => p.id === productId); const handleItemsUpdate = (totalItemsPrice: number) => {
if (!product || !selectedWholesaler) return; setGoodsPrice(totalItemsPrice);
};
setSelectedProducts((prev) => { // Функция для обновления статуса наличия товаров
const existing = prev.find( const handleItemsCountChange = (hasItems: boolean) => {
(p) => p.id === productId && p.wholesalerId === selectedWholesaler.id 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) { if (!logisticsRoute) {
return prev.filter( console.log(`Логистика для рынка "${supplierMarket}" не найдена`);
(p) => setLogisticsPrice(0);
!(p.id === productId && p.wholesalerId === selectedWholesaler.id) return;
);
} }
if (existing) { // Выбираем цену в зависимости от объема
return prev.map((p) => const pricePerM3 = volume <= 1 ? logisticsRoute.priceUnder1m3 : logisticsRoute.priceOver1m3;
p.id === productId && p.wholesalerId === selectedWholesaler.id const calculatedPrice = volume * pricePerM3;
? { ...p, selectedQuantity: quantity }
: p
);
} else {
return [
...prev,
{
...product,
selectedQuantity: quantity,
wholesalerId: selectedWholesaler.id,
wholesalerName: selectedWholesaler.name,
},
];
}
});
};
const getTotalAmount = () => { console.log(`Найдена логистика: ${logisticsRoute.fromLocation}${logisticsRoute.toLocation}`);
return selectedProducts.reduce((sum, product) => { console.log(`Цена: ${pricePerM3}₽/м³ (${volume <= 1 ? 'до 1м³' : 'больше 1м³'}) × ${volume.toFixed(4)}м³ = ${calculatedPrice.toFixed(2)}`);
const discountedPrice = product.discount
? product.price * (1 - product.discount / 100)
: product.price;
return sum + discountedPrice * product.selectedQuantity;
}, 0);
};
const getTotalItems = () => { setLogisticsPrice(calculatedPrice);
return selectedProducts.reduce( } catch (error) {
(sum, product) => sum + product.selectedQuantity, console.error("Error calculating logistics price:", error);
0 setLogisticsPrice(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 handleRemoveProduct = (productId: string, wholesalerId: string) => { const getTotalSum = () => {
setSelectedProducts((prev) => return goodsPrice + selectedServicesCost + selectedConsumablesCost + logisticsPrice;
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 handleSupplyComplete = () => { const handleSupplyComplete = () => {
@ -172,100 +164,231 @@ export function CreateSupplyPage() {
setCanCreateSupply(canCreate); setCanCreateSupply(canCreate);
}; };
// Пересчитываем логистику при изменении фулфилмента (если есть поставщик)
React.useEffect(() => {
// Логистика пересчитается автоматически через handleSuppliersUpdate
// когда будет выбран поставщик с рынком
}, [selectedFulfillment, goodsVolume]);
const handleSupplyCompleted = () => { const handleSupplyCompleted = () => {
setIsCreatingSupply(false); setIsCreatingSupply(false);
handleSupplyComplete(); 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 ( return (
<div className="h-screen flex overflow-hidden"> <div className="h-screen flex overflow-hidden">
<Sidebar /> <Sidebar />
<main <main
className={`flex-1 ${getSidebarMargin()} overflow-hidden transition-all duration-300`} className={`flex-1 ${getSidebarMargin()} overflow-auto transition-all duration-300`}
style={{ padding: '1rem' }}
> >
<div className="flex flex-col" style={{ height: 'calc(100vh - 2rem)' }}> <div className="min-h-full w-full flex flex-col px-3 py-2">
<TabsHeader {/* Заголовок */}
activeTab={activeTab} <div className="flex items-center justify-between mb-3 flex-shrink-0">
onTabChange={setActiveTab} <div>
onBack={() => router.push("/supplies")} <h1 className="text-xl font-bold text-white mb-1">
cartInfo={ Создание поставки товаров
activeTab === "wholesaler" && selectedProducts.length > 0 </h1>
? { <p className="text-white/60 text-sm">
itemCount: selectedProducts.length, Выберите карточки товаров Wildberries для создания поставки
totalAmount: getTotalAmount(), </p>
formatCurrency, </div>
} <Button
: undefined variant="ghost"
} size="sm"
onCartClick={() => setShowSummary(true)} onClick={() => router.push("/supplies")}
onCreateSupply={handleCreateSupplyClick} className="text-white/60 hover:text-white hover:bg-white/10 text-sm"
canCreateSupply={canCreateSupply} >
isCreatingSupply={isCreatingSupply} <ArrowLeft className="h-4 w-4 mr-1" />
/> Назад
</Button>
</div>
{/* Контент карточек - новый компонент прямого создания поставки */} {/* Основной контент - карточки Wildberries */}
{activeTab === "cards" && ( <div className="flex-1 flex gap-3 min-h-0">
<div className="flex-1 flex flex-col overflow-hidden min-h-0"> {/* Левая колонка - карточки товаров */}
<div className="flex-1 min-h-0">
<DirectSupplyCreation <DirectSupplyCreation
onComplete={handleSupplyCompleted} onComplete={handleSupplyCompleted}
onCreateSupply={handleCreateSupplyClick} onCreateSupply={handleCreateSupplyClick}
canCreateSupply={canCreateSupply} canCreateSupply={canCreateSupply}
isCreatingSupply={isCreatingSupply} isCreatingSupply={isCreatingSupply}
onCanCreateSupplyChange={handleCanCreateSupplyChange} onCanCreateSupplyChange={handleCanCreateSupplyChange}
selectedFulfillmentId={selectedFulfillment}
onServicesCostChange={setSelectedServicesCost}
onItemsPriceChange={handleItemsUpdate}
onItemsCountChange={handleItemsCountChange}
onConsumablesCostChange={setSelectedConsumablesCost}
onVolumeChange={handleVolumeUpdate}
onSuppliersChange={handleSuppliersUpdate}
/> />
</div> </div>
)}
{/* Контент оптовиков */} {/* Правая колонка - Форма поставки */}
{activeTab === "wholesaler" && ( <div className="w-80 flex-shrink-0">
<div> <Card className="bg-white/10 backdrop-blur-xl border-white/20 p-4 sticky top-0">
<CartSummary <h3 className="text-white font-semibold mb-4 flex items-center text-sm">
selectedProducts={selectedProducts} <Package className="h-4 w-4 mr-2" />
onQuantityChange={handleCartQuantityChange} Параметры поставки
onRemoveProduct={handleRemoveProduct} </h3>
onCreateSupply={handleCreateSupply}
onToggleVisibility={() => setShowSummary(false)}
formatCurrency={formatCurrency}
visible={showSummary && selectedProducts.length > 0}
/>
<WholesalerGrid {/* Первая строка */}
wholesalers={wholesalers} <div className="space-y-3 mb-4">
onWholesalerSelect={setSelectedWholesaler} {/* 1. Модуль выбора даты */}
searchQuery={searchQuery} <div>
onSearchChange={setSearchQuery} <Label className="text-white/80 text-xs mb-1 block flex items-center gap-1">
loading={counterpartiesLoading} <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 {/* 2. Модуль выбора фулфилмента */}
itemCount={selectedProducts.length} <div>
totalAmount={getTotalAmount()} <Label className="text-white/80 text-xs mb-1 block flex items-center gap-1">
formatCurrency={formatCurrency} <Building className="h-3 w-3" />
onClick={() => setShowSummary(true)} Фулфилмент
visible={selectedProducts.length > 0 && !showSummary} </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>
</div> </div>
</main> </main>
</div> </div>

View File

@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { PhoneInput } from "@/components/ui/phone-input";
import { formatPhoneInput, isValidPhone, formatNameInput } from "@/lib/input-masks";
import { import {
Select, Select,
SelectContent, SelectContent,
@ -47,8 +49,9 @@ import {
GET_MY_COUNTERPARTIES, GET_MY_COUNTERPARTIES,
GET_COUNTERPARTY_SERVICES, GET_COUNTERPARTY_SERVICES,
GET_COUNTERPARTY_SUPPLIES, GET_COUNTERPARTY_SUPPLIES,
GET_SUPPLY_SUPPLIERS,
} from "@/graphql/queries"; } 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 { toast } from "sonner";
import { format } from "date-fns"; import { format } from "date-fns";
import { ru } from "date-fns/locale"; import { ru } from "date-fns/locale";
@ -70,6 +73,7 @@ interface SupplyItem {
pricePerUnit: number; pricePerUnit: number;
totalPrice: number; totalPrice: number;
supplierId: string; supplierId: string;
priceType: "perUnit" | "total"; // за штуку или за общее количество
} }
interface Organization { interface Organization {
@ -103,6 +107,13 @@ interface DirectSupplyCreationProps {
canCreateSupply: boolean; canCreateSupply: boolean;
isCreatingSupply: boolean; isCreatingSupply: boolean;
onCanCreateSupplyChange?: (canCreate: boolean) => void; 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({ export function DirectSupplyCreation({
@ -111,6 +122,13 @@ export function DirectSupplyCreation({
canCreateSupply, canCreateSupply,
isCreatingSupply, isCreatingSupply,
onCanCreateSupplyChange, onCanCreateSupplyChange,
selectedFulfillmentId,
onServicesCostChange,
onItemsPriceChange,
onItemsCountChange,
onConsumablesCostChange,
onVolumeChange,
onSuppliersChange,
}: DirectSupplyCreationProps) { }: DirectSupplyCreationProps) {
const { user } = useAuth(); const { user } = useAuth();
@ -152,6 +170,12 @@ export function DirectSupplyCreation({
place: "", place: "",
telegram: "", telegram: "",
}); });
const [supplierErrors, setSupplierErrors] = useState({
name: "",
contactName: "",
phone: "",
telegram: "",
});
// Данные для фулфилмента // Данные для фулфилмента
const [organizationServices, setOrganizationServices] = useState<{ const [organizationServices, setOrganizationServices] = useState<{
@ -163,8 +187,9 @@ export function DirectSupplyCreation({
// Загружаем контрагентов-фулфилментов // Загружаем контрагентов-фулфилментов
const { data: counterpartiesData } = useQuery(GET_MY_COUNTERPARTIES); const { data: counterpartiesData } = useQuery(GET_MY_COUNTERPARTIES);
const { data: suppliersData, refetch: refetchSuppliers } = useQuery(GET_SUPPLY_SUPPLIERS);
// Мутация для создания поставки // Мутации
const [createSupply, { loading: creatingSupply }] = useMutation( const [createSupply, { loading: creatingSupply }] = useMutation(
CREATE_WILDBERRIES_SUPPLY, CREATE_WILDBERRIES_SUPPLY,
{ {
@ -183,6 +208,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[] => [ const getMockCards = (): WildberriesCard[] => [
{ {
@ -196,6 +259,13 @@ export function DirectSupplyCreation({
countryProduction: "Россия", countryProduction: "Россия",
supplierVendorCode: "SUPPLIER-001", supplierVendorCode: "SUPPLIER-001",
mediaFiles: ["/api/placeholder/400/400"], mediaFiles: ["/api/placeholder/400/400"],
dimensions: {
length: 30, // 30 см
width: 25, // 25 см
height: 5, // 5 см
weightBrutto: 0.3, // 300г
isValid: true
},
sizes: [ sizes: [
{ {
chrtID: 123456, chrtID: 123456,
@ -218,6 +288,13 @@ export function DirectSupplyCreation({
countryProduction: "Россия", countryProduction: "Россия",
supplierVendorCode: "SUPPLIER-002", supplierVendorCode: "SUPPLIER-002",
mediaFiles: ["/api/placeholder/400/403"], mediaFiles: ["/api/placeholder/400/403"],
dimensions: {
length: 35, // 35 см
width: 28, // 28 см
height: 6, // 6 см
weightBrutto: 0.4, // 400г
isValid: true
},
sizes: [ sizes: [
{ {
chrtID: 987654, chrtID: 987654,
@ -324,6 +401,46 @@ export function DirectSupplyCreation({
loadCards(); loadCards();
}, [user]); }, [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 () => { const loadCards = async () => {
setLoading(true); setLoading(true);
try { try {
@ -344,9 +461,21 @@ export function DirectSupplyCreation({
if (apiToken) { if (apiToken) {
console.log("Загружаем карточки из WB API..."); 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); setWbCards(cards);
console.log("Загружено карточек из WB API:", cards.length); console.log("Загружено карточек из WB API:", cards.length);
console.log("Карточки с размерами:", cards.filter(card => card.dimensions).length);
return; return;
} }
} }
@ -391,10 +520,22 @@ export function DirectSupplyCreation({
const cards = await WildberriesService.searchCards( const cards = await WildberriesService.searchCards(
apiToken, apiToken,
searchTerm, 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); setWbCards(cards);
console.log("Найдено карточек в WB API:", cards.length); console.log("Найдено карточек в WB API:", cards.length);
console.log("Найденные карточки с размерами:", cards.filter(card => card.dimensions).length);
return; return;
} }
} }
@ -485,10 +626,11 @@ export function DirectSupplyCreation({
const newItem: SupplyItem = { const newItem: SupplyItem = {
card, card,
quantity: 1200, quantity: 0,
pricePerUnit: 0, pricePerUnit: 0,
totalPrice: 0, totalPrice: 0,
supplierId: "", supplierId: "",
priceType: "perUnit",
}; };
setSupplyItems((prev) => [...prev, newItem]); setSupplyItems((prev) => [...prev, newItem]);
@ -504,45 +646,105 @@ export function DirectSupplyCreation({
field: keyof SupplyItem, field: keyof SupplyItem,
value: string | number value: string | number
) => { ) => {
setSupplyItems((prev) => setSupplyItems((prev) => {
prev.map((item) => { const newItems = prev.map((item) => {
if (item.card.nmID === nmID) { if (item.card.nmID === nmID) {
const updatedItem = { ...item, [field]: value }; const updatedItem = { ...item, [field]: value };
if (field === "quantity" || field === "pricePerUnit") {
updatedItem.totalPrice = // Пересчитываем totalPrice в зависимости от типа цены
updatedItem.quantity * updatedItem.pricePerUnit; 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 updatedItem;
} }
return item; 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 = () => { const handleCreateSupplier = async () => {
if (!newSupplier.name || !newSupplier.contactName || !newSupplier.phone) { if (!validateAllSupplierFields()) {
toast.error("Заполните обязательные поля"); toast.error("Исправьте ошибки в форме");
return; return;
} }
const supplier: Supplier = { try {
id: Date.now().toString(), await createSupplierMutation({
...newSupplier, variables: {
}; input: {
name: newSupplier.name,
setSuppliers((prev) => [...prev, supplier]); contactName: newSupplier.contactName,
setNewSupplier({ phone: newSupplier.phone,
name: "", market: newSupplier.market || null,
contactName: "", address: newSupplier.address || null,
phone: "", place: newSupplier.place || null,
market: "", telegram: newSupplier.telegram || null,
address: "", },
place: "", },
telegram: "", });
}); } catch (error) {
setShowSupplierModal(false); // Ошибка обрабатывается в onError мутации
toast.success("Поставщик создан"); }
}; };
// Расчеты для нового блока // Расчеты для нового блока
@ -555,14 +757,39 @@ export function DirectSupplyCreation({
return supplyItems.reduce((sum, item) => sum + item.quantity, 0); 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 = () => { const getTotalItemsCost = () => {
return supplyItems.reduce((sum, item) => sum + item.totalPrice, 0); return supplyItems.reduce((sum, item) => sum + item.totalPrice, 0);
}; };
const getServicesCost = () => { 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 ( return (
selectedServices.reduce((sum, serviceId) => { selectedServices.reduce((sum, serviceId) => {
const service = services.find((s) => s.id === serviceId); const service = services.find((s) => s.id === serviceId);
@ -572,9 +799,9 @@ export function DirectSupplyCreation({
}; };
const getConsumablesCost = () => { 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 ( return (
selectedConsumables.reduce((sum, supplyId) => { selectedConsumables.reduce((sum, supplyId) => {
const supply = supplies.find((s) => s.id === supplyId); const supply = supplies.find((s) => s.id === supplyId);
@ -645,6 +872,39 @@ export function DirectSupplyCreation({
} }
}, [isCreatingSupply]); }, [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(() => { React.useEffect(() => {
const canCreate = const canCreate =
@ -669,142 +929,7 @@ export function DirectSupplyCreation({
<> <>
<style>{lineClampStyles}</style> <style>{lineClampStyles}</style>
<div className="flex flex-col h-full space-y-2 w-full min-h-0"> <div className="flex flex-col h-full space-y-2 w-full min-h-0">
{/* НОВЫЙ БЛОК СОЗДАНИЯ ПОСТАВКИ */}
<Card className="bg-white/10 backdrop-blur-xl border border-white/20 p-2">
{/* Первая строка */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-1.5 items-end mb-0.5">
{/* 1. Модуль выбора даты */}
<div>
<Label className="text-white/80 text-xs mb-0.5 block flex items-center gap-1">
<CalendarIcon className="h-3 w-3" />
Дата
</Label>
<div className="relative">
<input
type="date"
value={deliveryDate}
onChange={(e) => 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]}
/>
</div>
</div>
{/* 2. Модуль выбора фулфилмента */}
<div>
<Label className="text-white/80 text-xs mb-0.5 block flex items-center gap-1">
<Building className="h-3 w-3" />
Фулфилмент
</Label>
<Select
value={selectedFulfillment}
onValueChange={setSelectedFulfillment}
>
<SelectTrigger className="w-full h-7 py-0 px-2 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-0.5 block">
Объём товаров
</Label>
<Input
type="number"
value={goodsVolume || ""}
onChange={(e) =>
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"
/>
</div>
{/* 4. Грузовые места */}
<div>
<Label className="text-white/80 text-xs mb-0.5 block">
Грузовые места
</Label>
<Input
type="number"
value={cargoPlaces || ""}
onChange={(e) => 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"
/>
</div>
</div>
{/* Вторая строка */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-1.5 items-end">
{/* 5. Цена товаров */}
<div>
<Label className="text-white/80 text-xs mb-0.5 block">
Цена товаров
</Label>
<Input
type="number"
value={goodsPrice || ""}
onChange={(e) => 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"
/>
</div>
{/* 6. Цена услуг фулфилмента */}
<div>
<Label className="text-white/80 text-xs mb-0.5 block">
Цена услуг фулфилмент
</Label>
<Input
type="number"
value={fulfillmentServicesPrice || ""}
onChange={(e) =>
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"
/>
</div>
{/* 7. Цена логистики */}
<div>
<Label className="text-white/80 text-xs mb-0.5 block">
Логистика до фулфилмента
</Label>
<Input
type="number"
value={logisticsPrice || ""}
onChange={(e) =>
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"
/>
</div>
{/* 8. Итоговая сумма */}
<div>
<Label className="text-white/80 text-xs mb-0.5 block">
Итого
</Label>
<div className="h-7 bg-white/10 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">
{formatCurrency(getTotalSum()).replace(" ₽", " ₽")}
</span>
</div>
</div>
</div>
</Card>
{/* Элегантный блок поиска и товаров */} {/* Элегантный блок поиска и товаров */}
<div className="relative"> <div className="relative">
@ -917,17 +1042,9 @@ export function DirectSupplyCreation({
<h4 className="text-white font-medium text-sm line-clamp-2 mb-1"> <h4 className="text-white font-medium text-sm line-clamp-2 mb-1">
{card.title} {card.title}
</h4> </h4>
<p className="text-white/80 text-xs mb-1"> <p className="text-white/80 text-xs">
Арт: {card.vendorCode} WB: {card.nmID}
</p> </p>
{card.sizes && card.sizes[0] && (
<p className="text-purple-300 font-semibold text-sm">
от{" "}
{card.sizes[0].discountedPrice ||
card.sizes[0].price}{" "}
</p>
)}
</div> </div>
{/* Индикаторы */} {/* Индикаторы */}
@ -1079,6 +1196,11 @@ export function DirectSupplyCreation({
<span className="text-white font-medium text-sm"> <span className="text-white font-medium text-sm">
Товары в поставке Товары в поставке
</span> </span>
{supplyItems.length > 0 && (
<span className="text-blue-400 text-xs font-medium bg-blue-500/20 px-2 py-1 rounded">
{getTotalVolume().toFixed(4)} м³
</span>
)}
</div> </div>
{supplyItems.length === 0 ? ( {supplyItems.length === 0 ? (
@ -1098,13 +1220,18 @@ export function DirectSupplyCreation({
className="bg-white/5 border-white/10 p-1.5" className="bg-white/5 border-white/10 p-1.5"
> >
{/* Компактный заголовок товара */} {/* Компактный заголовок товара */}
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2 min-w-0"> <div className="flex flex-col space-y-1 min-w-0 flex-1">
<div className="text-white font-medium text-xs line-clamp-1 truncate"> <div className="text-white font-medium text-xs line-clamp-1 truncate">
{item.card.title} {item.card.title}
</div> </div>
<div className="text-white/60 text-[10px] flex-shrink-0"> <div className="text-white/60 text-[10px] flex space-x-2">
Арт: {item.card.vendorCode} <span>WB: {item.card.nmID}</span>
{calculateItemVolume(item.card) > 0 ? (
<span className="text-blue-400">| {(calculateItemVolume(item.card) * item.quantity).toFixed(4)} м³</span>
) : (
<span className="text-orange-400">| размеры не указаны</span>
)}
</div> </div>
</div> </div>
<Button <Button
@ -1162,19 +1289,65 @@ export function DirectSupplyCreation({
</div> </div>
{/* Блок 2: Параметры */} {/* Блок 2: Параметры */}
<div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20"> <div className="bg-white/10 rounded-lg p-3 flex flex-col justify-center h-20">
<div className="space-y-1"> <div className="flex flex-wrap gap-1 justify-center items-center">
<div className="text-white/70 text-xs text-center"> {/* Создаем массив валидных параметров */}
{item.card.object} {(() => {
</div> const params = [];
<div className="text-white/70 text-xs text-center">
{item.card.countryProduction} // Бренд
</div> if (item.card.brand && item.card.brand.trim() && item.card.brand !== '0') {
{item.card.sizes && item.card.sizes[0] && ( params.push({
<div className="text-white/70 text-xs text-center"> value: item.card.brand,
{item.card.sizes[0].techSize} color: 'bg-blue-500/80',
</div> key: 'brand'
)} });
}
// Категория (объект)
if (item.card.object && item.card.object.trim() && item.card.object !== '0') {
params.push({
value: item.card.object,
color: 'bg-green-500/80',
key: 'object'
});
}
// Страна (только если не пустая и не 0)
if (item.card.countryProduction && item.card.countryProduction.trim() && item.card.countryProduction !== '0') {
params.push({
value: item.card.countryProduction,
color: 'bg-purple-500/80',
key: 'country'
});
}
// Цена WB
if (item.card.sizes?.[0]?.price && item.card.sizes[0].price > 0) {
params.push({
value: formatCurrency(item.card.sizes[0].price),
color: 'bg-yellow-500/80',
key: 'price'
});
}
// Внутренний артикул
if (item.card.vendorCode && item.card.vendorCode.trim() && item.card.vendorCode !== '0') {
params.push({
value: item.card.vendorCode,
color: 'bg-gray-500/80',
key: 'vendor'
});
}
// НАМЕРЕННО НЕ ВКЛЮЧАЕМ techSize и wbSize так как они равны '0'
return params.map(param => (
<span key={param.key} className={`${param.color} text-white text-[9px] px-2 py-1 rounded font-medium`}>
{param.value}
</span>
));
})()}
</div> </div>
</div> </div>
@ -1200,9 +1373,34 @@ export function DirectSupplyCreation({
{/* Блок 4: Цена */} {/* Блок 4: Цена */}
<div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20"> <div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20">
<div className="text-white/60 text-xs mb-1 text-center"> {/* Переключатель типа цены */}
За единицу <div className="flex mb-1">
<button
onClick={() =>
updateSupplyItem(item.card.nmID, "priceType", "perUnit")
}
className={`text-[9px] px-1 py-0.5 rounded-l ${
item.priceType === "perUnit"
? "bg-blue-500 text-white"
: "bg-white/20 text-white/60"
}`}
>
За шт
</button>
<button
onClick={() =>
updateSupplyItem(item.card.nmID, "priceType", "total")
}
className={`text-[9px] px-1 py-0.5 rounded-r ${
item.priceType === "total"
? "bg-blue-500 text-white"
: "bg-white/20 text-white/60"
}`}
>
За все
</button>
</div> </div>
<Input <Input
type="number" type="number"
value={item.pricePerUnit || ""} value={item.pricePerUnit || ""}
@ -1217,144 +1415,162 @@ export function DirectSupplyCreation({
placeholder="₽" placeholder="₽"
/> />
<div className="text-white/80 text-xs font-medium text-center mt-1"> <div className="text-white/80 text-xs font-medium text-center mt-1">
{formatCurrency(item.totalPrice).replace(" ₽", "₽")} Итого: {formatCurrency(item.totalPrice).replace(" ₽", "₽")}
</div> </div>
</div> </div>
{/* Блок 5: Услуги фулфилмента */} {/* Блок 5: Услуги фулфилмента */}
<div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20"> <div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20">
<div className="space-y-2 max-h-16 overflow-y-auto"> <div className="space-y-1 max-h-16 overflow-y-auto">
{selectedFulfillmentOrg && {/* DEBUG */}
organizationServices[selectedFulfillmentOrg] ? ( {console.log('DEBUG SERVICES:', {
organizationServices[selectedFulfillmentOrg] selectedFulfillmentId,
.slice(0, 4) hasServices: !!organizationServices[selectedFulfillmentId],
servicesCount: organizationServices[selectedFulfillmentId]?.length || 0,
allOrganizationServices: Object.keys(organizationServices)
})}
{selectedFulfillmentId &&
organizationServices[selectedFulfillmentId] ? (
organizationServices[selectedFulfillmentId]
.slice(0, 3)
.map((service) => ( .map((service) => (
<label <label
key={service.id} key={service.id}
className="flex items-center space-x-2 cursor-pointer" className="flex items-center justify-between cursor-pointer text-xs"
> >
<input <div className="flex items-center space-x-2">
type="checkbox" <input
checked={selectedServices.includes( type="checkbox"
service.id checked={selectedServices.includes(
)} service.id
onChange={(e) => { )}
if (e.target.checked) { onChange={(e) => {
setSelectedServices((prev) => [ if (e.target.checked) {
...prev, setSelectedServices((prev) => [
service.id, ...prev,
]); service.id,
} else { ]);
setSelectedServices((prev) => } else {
prev.filter((id) => id !== service.id) setSelectedServices((prev) =>
); prev.filter((id) => id !== service.id)
} );
}} }
className="w-3 h-3" }}
/> className="w-3 h-3"
<span className="text-white text-xs"> />
{service.name.substring(0, 8)}... <span className="text-white text-[10px]">
{service.name.substring(0, 10)}
</span>
</div>
<span className="text-green-400 text-[10px] font-medium">
{service.price ? `${service.price}` : 'Бесплатно'}
</span> </span>
</label> </label>
)) ))
) : ( ) : (
<span className="text-white/60 text-xs text-center"> <span className="text-white/60 text-xs text-center">
Выберите фулфилмент {selectedFulfillmentId ? 'Нет услуг' : 'Выберите фулфилмент'}
</span> </span>
)} )}
</div> </div>
</div> </div>
{/* Блок 6: Поставщик */} {/* Блок 6: Поставщик */}
<div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center space-y-2 h-20"> <div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20">
<Select <div className="space-y-1">
value={item.supplierId} <Select
onValueChange={(value) => value={item.supplierId}
updateSupplyItem(item.card.nmID, "supplierId", value) onValueChange={(value) =>
} updateSupplyItem(item.card.nmID, "supplierId", value)
> }
<SelectTrigger className="bg-white/20 border-white/20 text-white h-7 text-xs"> >
<SelectValue placeholder="Выбрать" /> <SelectTrigger className="bg-white/20 border-white/20 text-white h-6 text-xs">
</SelectTrigger> <SelectValue placeholder="Выбрать" />
<SelectContent> </SelectTrigger>
{suppliers.map((supplier) => ( <SelectContent>
<SelectItem key={supplier.id} value={supplier.id}> {suppliers.map((supplier) => (
{supplier.name} <SelectItem key={supplier.id} value={supplier.id}>
</SelectItem> {supplier.name}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</Select>
{/* Информация о выбранном поставщике */} {/* Компактная информация о выбранном поставщике */}
{item.supplierId && {item.supplierId && suppliers.find((s) => s.id === item.supplierId) ? (
suppliers.find((s) => s.id === item.supplierId) && ( <div className="text-center">
<div className="text-xs text-white/60 space-y-1"> <div className="text-white/80 text-[10px] font-medium truncate">
<div className="truncate"> {suppliers.find((s) => s.id === item.supplierId)?.contactName}
{
suppliers.find((s) => s.id === item.supplierId)
?.contactName
}
</div> </div>
<div className="truncate"> <div className="text-white/60 text-[9px] truncate">
{ {suppliers.find((s) => s.id === item.supplierId)?.phone}
suppliers.find((s) => s.id === item.supplierId)
?.phone
}
</div> </div>
</div> </div>
) : (
<Button
onClick={() => setShowSupplierModal(true)}
variant="outline"
size="sm"
className="bg-white/5 border-white/20 text-white hover:bg-white/10 h-5 px-2 text-[10px] w-full"
>
<Plus className="h-2 w-2 mr-1" />
Добавить
</Button>
)} )}
</div>
{/* Кнопка добавления поставщика */}
<Button
onClick={() => setShowSupplierModal(true)}
variant="outline"
size="sm"
className="bg-white/5 border-white/20 text-white hover:bg-white/10 h-6 px-2 text-xs w-full"
>
<Plus className="h-3 w-3 mr-1" />
Добавить
</Button>
</div> </div>
{/* Блок 7: Расходники фф */} {/* Блок 7: Расходники фф */}
<div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20"> <div className="bg-white/10 rounded-lg p-2 flex flex-col justify-center h-20">
<div className="space-y-2 max-h-16 overflow-y-auto"> <div className="space-y-1 max-h-16 overflow-y-auto">
{selectedFulfillmentOrg && {/* DEBUG для расходников */}
organizationSupplies[selectedFulfillmentOrg] ? ( {console.log('DEBUG CONSUMABLES:', {
organizationSupplies[selectedFulfillmentOrg] selectedFulfillmentId,
.slice(0, 4) hasConsumables: !!organizationSupplies[selectedFulfillmentId],
consumablesCount: organizationSupplies[selectedFulfillmentId]?.length || 0,
allOrganizationSupplies: Object.keys(organizationSupplies)
})}
{selectedFulfillmentId &&
organizationSupplies[selectedFulfillmentId] ? (
organizationSupplies[selectedFulfillmentId]
.slice(0, 3)
.map((supply) => ( .map((supply) => (
<label <label
key={supply.id} key={supply.id}
className="flex items-center space-x-2 cursor-pointer" className="flex items-center justify-between cursor-pointer text-xs"
> >
<input <div className="flex items-center space-x-2">
type="checkbox" <input
checked={selectedConsumables.includes( type="checkbox"
supply.id checked={selectedConsumables.includes(
)} supply.id
onChange={(e) => { )}
if (e.target.checked) { onChange={(e) => {
setSelectedConsumables((prev) => [ if (e.target.checked) {
...prev, setSelectedConsumables((prev) => [
supply.id, ...prev,
]); supply.id,
} else { ]);
setSelectedConsumables((prev) => } else {
prev.filter((id) => id !== supply.id) setSelectedConsumables((prev) =>
); prev.filter((id) => id !== supply.id)
} );
}} }
className="w-3 h-3" }}
/> className="w-3 h-3"
<span className="text-white text-xs"> />
{supply.name.substring(0, 6)}... <span className="text-white text-[10px]">
{supply.name.substring(0, 10)}
</span>
</div>
<span className="text-orange-400 text-[10px] font-medium">
{supply.price ? `${supply.price}` : 'Бесплатно'}
</span> </span>
</label> </label>
)) ))
) : ( ) : (
<span className="text-white/60 text-xs text-center"> <span className="text-white/60 text-xs text-center">
Выберите фулфилмент {selectedFulfillmentId ? 'Нет расходников' : 'Выберите фулфилмент'}
</span> </span>
)} )}
</div> </div>
@ -1389,8 +1605,11 @@ export function DirectSupplyCreation({
<DialogContent className="glass-card border-white/10 max-w-md"> <DialogContent className="glass-card border-white/10 max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-white"> <DialogTitle className="text-white">
Создать поставщика Добавить поставщика
</DialogTitle> </DialogTitle>
<p className="text-white/60 text-xs">
Контактная информация поставщика для этой поставки
</p>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@ -1398,46 +1617,66 @@ export function DirectSupplyCreation({
<Label className="text-white/60 text-xs">Название *</Label> <Label className="text-white/60 text-xs">Название *</Label>
<Input <Input
value={newSupplier.name} value={newSupplier.name}
onChange={(e) => onChange={(e) => {
const value = formatNameInput(e.target.value);
setNewSupplier((prev) => ({ setNewSupplier((prev) => ({
...prev, ...prev,
name: e.target.value, name: value,
})) }));
} validateSupplierField("name", value);
className="bg-white/10 border-white/20 text-white h-8 text-xs" }}
className={`bg-white/10 border-white/20 text-white h-8 text-xs ${
supplierErrors.name ? 'border-red-400 focus:border-red-400' : ''
}`}
placeholder="Название" placeholder="Название"
/> />
{supplierErrors.name && (
<p className="text-red-400 text-xs mt-1">{supplierErrors.name}</p>
)}
</div> </div>
<div> <div>
<Label className="text-white/60 text-xs">Имя *</Label> <Label className="text-white/60 text-xs">Имя *</Label>
<Input <Input
value={newSupplier.contactName} value={newSupplier.contactName}
onChange={(e) => onChange={(e) => {
const value = formatNameInput(e.target.value);
setNewSupplier((prev) => ({ setNewSupplier((prev) => ({
...prev, ...prev,
contactName: e.target.value, contactName: value,
})) }));
} validateSupplierField("contactName", value);
className="bg-white/10 border-white/20 text-white h-8 text-xs" }}
className={`bg-white/10 border-white/20 text-white h-8 text-xs ${
supplierErrors.contactName ? 'border-red-400 focus:border-red-400' : ''
}`}
placeholder="Имя" placeholder="Имя"
/> />
{supplierErrors.contactName && (
<p className="text-red-400 text-xs mt-1">{supplierErrors.contactName}</p>
)}
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<Label className="text-white/60 text-xs">Телефон *</Label> <Label className="text-white/60 text-xs">Телефон *</Label>
<Input <PhoneInput
value={newSupplier.phone} value={newSupplier.phone}
onChange={(e) => onChange={(value) => {
setNewSupplier((prev) => ({ setNewSupplier((prev) => ({
...prev, ...prev,
phone: e.target.value, phone: value,
})) }));
} validateSupplierField("phone", value);
className="bg-white/10 border-white/20 text-white h-8 text-xs" }}
placeholder="+7 999 123-45-67" 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 && (
<p className="text-red-400 text-xs mt-1">{supplierErrors.phone}</p>
)}
</div> </div>
<div> <div>
<Label className="text-white/60 text-xs">Рынок</Label> <Label className="text-white/60 text-xs">Рынок</Label>
@ -1496,15 +1735,22 @@ export function DirectSupplyCreation({
<Label className="text-white/60 text-xs">Телеграм</Label> <Label className="text-white/60 text-xs">Телеграм</Label>
<Input <Input
value={newSupplier.telegram} value={newSupplier.telegram}
onChange={(e) => onChange={(e) => {
const value = e.target.value;
setNewSupplier((prev) => ({ setNewSupplier((prev) => ({
...prev, ...prev,
telegram: e.target.value, telegram: value,
})) }));
} validateSupplierField("telegram", value);
className="bg-white/10 border-white/20 text-white h-8 text-xs" }}
className={`bg-white/10 border-white/20 text-white h-8 text-xs ${
supplierErrors.telegram ? 'border-red-400 focus:border-red-400' : ''
}`}
placeholder="@username" placeholder="@username"
/> />
{supplierErrors.telegram && (
<p className="text-red-400 text-xs mt-1">{supplierErrors.telegram}</p>
)}
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
@ -1517,9 +1763,17 @@ export function DirectSupplyCreation({
</Button> </Button>
<Button <Button
onClick={handleCreateSupplier} onClick={handleCreateSupplier}
className="flex-1 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 h-8 text-xs" disabled={!newSupplier.name || !newSupplier.contactName || !newSupplier.phone || Object.values(supplierErrors).some(error => error !== "") || creatingSupplier}
className="flex-1 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 disabled:opacity-50 disabled:cursor-not-allowed h-8 text-xs"
> >
Создать {creatingSupplier ? (
<div className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-3 w-3 border border-white/30 border-t-white"></div>
<span>Добавление...</span>
</div>
) : (
'Добавить'
)}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -1137,3 +1137,23 @@ export const ADMIN_LOGOUT = gql`
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
}
}
}
`

View File

@ -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` export const GET_INCOMING_REQUESTS = gql`
query GetIncomingRequests { query GetIncomingRequests {
incomingRequests { incomingRequests {

View File

@ -379,6 +379,45 @@ export const resolvers = {
return counterparties.map((c) => c.counterparty); 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) => { incomingRequests: async (_: unknown, __: unknown, context: Context) => {
if (!context.user) { 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: "Ошибка при добавлении поставщика",
};
}
},
}, },
// Резолверы типов // Резолверы типов

View File

@ -16,6 +16,12 @@ export const typeDefs = gql`
# Мои контрагенты # Мои контрагенты
myCounterparties: [Organization!]! myCounterparties: [Organization!]!
# Поставщики поставок
supplySuppliers: [SupplySupplier!]!
# Логистика организации
organizationLogistics(organizationId: ID!): [Logistics!]!
# Входящие заявки # Входящие заявки
incomingRequests: [CounterpartyRequest!]! incomingRequests: [CounterpartyRequest!]!
@ -217,6 +223,11 @@ export const typeDefs = gql`
): WildberriesSupplyResponse! ): WildberriesSupplyResponse!
deleteWildberriesSupply(id: ID!): Boolean! deleteWildberriesSupply(id: ID!): Boolean!
# Работа с поставщиками для поставок
createSupplySupplier(
input: CreateSupplySupplierInput!
): SupplySupplierResponse!
# Админ мутации # Админ мутации
adminLogin(username: String!, password: String!): AdminAuthResponse! adminLogin(username: String!, password: String!): AdminAuthResponse!
adminLogout: Boolean! adminLogout: Boolean!
@ -963,6 +974,35 @@ export const typeDefs = gql`
type: Int! 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 { input WildberriesCampaignStatsInput {
campaigns: [CampaignStatsRequest!]! campaigns: [CampaignStatsRequest!]!