1887 lines
79 KiB
TypeScript
1887 lines
79 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useEffect } from "react";
|
||
import { Card } from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover";
|
||
import DatePicker from "react-datepicker";
|
||
import "react-datepicker/dist/react-datepicker.css";
|
||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||
import { useSidebar } from "@/hooks/useSidebar";
|
||
import {
|
||
Search,
|
||
Plus,
|
||
Minus,
|
||
ShoppingCart,
|
||
Calendar as CalendarIcon,
|
||
Phone,
|
||
User,
|
||
MapPin,
|
||
Package,
|
||
Wrench,
|
||
ArrowLeft,
|
||
Check,
|
||
Eye,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
} from "lucide-react";
|
||
import { WildberriesService } from "@/services/wildberries-service";
|
||
import { useAuth } from "@/hooks/useAuth";
|
||
import { useQuery, useMutation } from "@apollo/client";
|
||
import { apolloClient } from "@/lib/apollo-client";
|
||
import {
|
||
GET_MY_COUNTERPARTIES,
|
||
GET_COUNTERPARTY_SERVICES,
|
||
GET_COUNTERPARTY_SUPPLIES,
|
||
} from "@/graphql/queries";
|
||
import { CREATE_WILDBERRIES_SUPPLY } from "@/graphql/mutations";
|
||
import { toast } from "sonner";
|
||
import { format } from "date-fns";
|
||
import { ru } from "date-fns/locale";
|
||
import {
|
||
SelectedCard,
|
||
FulfillmentService,
|
||
ConsumableService,
|
||
WildberriesCard,
|
||
} from "@/types/supplies";
|
||
|
||
interface Organization {
|
||
id: string;
|
||
name?: string;
|
||
fullName?: string;
|
||
type: string;
|
||
}
|
||
|
||
interface WBProductCardsProps {
|
||
onBack: () => void;
|
||
onComplete: (selectedCards: SelectedCard[]) => void;
|
||
showSummary?: boolean;
|
||
setShowSummary?: (show: boolean) => void;
|
||
selectedCards?: SelectedCard[];
|
||
setSelectedCards?: (cards: SelectedCard[]) => void;
|
||
}
|
||
|
||
export function WBProductCards({
|
||
onBack,
|
||
onComplete,
|
||
showSummary: externalShowSummary,
|
||
setShowSummary: externalSetShowSummary,
|
||
selectedCards: externalSelectedCards,
|
||
setSelectedCards: externalSetSelectedCards,
|
||
}: WBProductCardsProps) {
|
||
const { user } = useAuth();
|
||
const { getSidebarMargin } = useSidebar();
|
||
const [searchTerm, setSearchTerm] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [wbCards, setWbCards] = useState<WildberriesCard[]>([]);
|
||
const [selectedCards, setSelectedCards] = useState<SelectedCard[]>([]); // Товары в корзине
|
||
|
||
// Используем внешнее состояние если передано
|
||
const actualSelectedCards =
|
||
externalSelectedCards !== undefined ? externalSelectedCards : selectedCards;
|
||
const actualSetSelectedCards = externalSetSelectedCards || setSelectedCards;
|
||
const [preparingCards, setPreparingCards] = useState<SelectedCard[]>([]); // Товары, готовящиеся к добавлению
|
||
const [showSummary, setShowSummary] = useState(false);
|
||
|
||
// Используем внешнее состояние если передано
|
||
const actualShowSummary =
|
||
externalShowSummary !== undefined ? externalShowSummary : showSummary;
|
||
const actualSetShowSummary = externalSetShowSummary || setShowSummary;
|
||
const [globalDeliveryDate, setGlobalDeliveryDate] = useState<
|
||
Date | undefined
|
||
>(undefined);
|
||
const [fulfillmentServices, setFulfillmentServices] = useState<
|
||
FulfillmentService[]
|
||
>([]);
|
||
const [organizationServices, setOrganizationServices] = useState<{
|
||
[orgId: string]: Array<{
|
||
id: string;
|
||
name: string;
|
||
description?: string;
|
||
price: number;
|
||
}>;
|
||
}>({});
|
||
const [organizationSupplies, setOrganizationSupplies] = useState<{
|
||
[orgId: string]: Array<{
|
||
id: string;
|
||
name: string;
|
||
description?: string;
|
||
price: number;
|
||
}>;
|
||
}>({});
|
||
const [selectedCardForDetails, setSelectedCardForDetails] =
|
||
useState<WildberriesCard | null>(null);
|
||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||
|
||
// Моковые товары для демонстрации
|
||
const getMockCards = (): WildberriesCard[] => [
|
||
{
|
||
nmID: 123456789,
|
||
vendorCode: "SKU001",
|
||
title: "Смартфон Samsung Galaxy A54",
|
||
description:
|
||
"Современный смартфон с отличной камерой и долгим временем автономной работы",
|
||
brand: "Samsung",
|
||
object: "Смартфоны",
|
||
parent: "Электроника",
|
||
countryProduction: "Корея",
|
||
supplierVendorCode: "SUPPLIER-001",
|
||
mediaFiles: [
|
||
"/api/placeholder/400/400",
|
||
"/api/placeholder/400/401",
|
||
"/api/placeholder/400/402",
|
||
],
|
||
sizes: [
|
||
{
|
||
chrtID: 123456,
|
||
techSize: "128GB",
|
||
wbSize: "128GB Черный",
|
||
price: 25990,
|
||
discountedPrice: 22990,
|
||
quantity: 15,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
nmID: 987654321,
|
||
vendorCode: "SKU002",
|
||
title: "Наушники Apple AirPods Pro",
|
||
description:
|
||
"Беспроводные наушники с активным шумоподавлением и пространственным звуком",
|
||
brand: "Apple",
|
||
object: "Наушники",
|
||
parent: "Электроника",
|
||
countryProduction: "Китай",
|
||
supplierVendorCode: "SUPPLIER-002",
|
||
mediaFiles: ["/api/placeholder/400/403", "/api/placeholder/400/404"],
|
||
sizes: [
|
||
{
|
||
chrtID: 987654,
|
||
techSize: "Standard",
|
||
wbSize: "Белый",
|
||
price: 24990,
|
||
discountedPrice: 19990,
|
||
quantity: 8,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
nmID: 555666777,
|
||
vendorCode: "SKU003",
|
||
title: "Кроссовки Nike Air Max 270",
|
||
description:
|
||
"Спортивные кроссовки с современным дизайном и комфортной посадкой",
|
||
brand: "Nike",
|
||
object: "Кроссовки",
|
||
parent: "Обувь",
|
||
countryProduction: "Вьетнам",
|
||
supplierVendorCode: "SUPPLIER-003",
|
||
mediaFiles: [
|
||
"/api/placeholder/400/405",
|
||
"/api/placeholder/400/406",
|
||
"/api/placeholder/400/407",
|
||
],
|
||
sizes: [
|
||
{
|
||
chrtID: 555666,
|
||
techSize: "42",
|
||
wbSize: "42 EU",
|
||
price: 12990,
|
||
discountedPrice: 9990,
|
||
quantity: 25,
|
||
},
|
||
{
|
||
chrtID: 555667,
|
||
techSize: "43",
|
||
wbSize: "43 EU",
|
||
price: 12990,
|
||
discountedPrice: 9990,
|
||
quantity: 20,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
nmID: 444333222,
|
||
vendorCode: "SKU004",
|
||
title: "Футболка Adidas Originals",
|
||
description:
|
||
"Классическая футболка из органического хлопка с логотипом бренда",
|
||
brand: "Adidas",
|
||
object: "Футболки",
|
||
parent: "Одежда",
|
||
countryProduction: "Бангладеш",
|
||
supplierVendorCode: "SUPPLIER-004",
|
||
mediaFiles: ["/api/placeholder/400/408", "/api/placeholder/400/409"],
|
||
sizes: [
|
||
{
|
||
chrtID: 444333,
|
||
techSize: "M",
|
||
wbSize: "M",
|
||
price: 2990,
|
||
discountedPrice: 2490,
|
||
quantity: 50,
|
||
},
|
||
{
|
||
chrtID: 444334,
|
||
techSize: "L",
|
||
wbSize: "L",
|
||
price: 2990,
|
||
discountedPrice: 2490,
|
||
quantity: 45,
|
||
},
|
||
{
|
||
chrtID: 444335,
|
||
techSize: "XL",
|
||
wbSize: "XL",
|
||
price: 2990,
|
||
discountedPrice: 2490,
|
||
quantity: 30,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
nmID: 111222333,
|
||
vendorCode: "SKU005",
|
||
title: "Рюкзак для ноутбука Xiaomi",
|
||
description:
|
||
"Стильный и функциональный рюкзак для ноутбука до 15.6 дюймов",
|
||
brand: "Xiaomi",
|
||
object: "Рюкзаки",
|
||
parent: "Аксессуары",
|
||
countryProduction: "Китай",
|
||
supplierVendorCode: "SUPPLIER-005",
|
||
mediaFiles: ["/api/placeholder/400/410"],
|
||
sizes: [
|
||
{
|
||
chrtID: 111222,
|
||
techSize: '15.6"',
|
||
wbSize: "Черный",
|
||
price: 4990,
|
||
discountedPrice: 3990,
|
||
quantity: 35,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
nmID: 777888999,
|
||
vendorCode: "SKU006",
|
||
title: "Умные часы Apple Watch Series 9",
|
||
description:
|
||
"Новейшие умные часы с передовыми функциями здоровья и фитнеса",
|
||
brand: "Apple",
|
||
object: "Умные часы",
|
||
parent: "Электроника",
|
||
countryProduction: "Китай",
|
||
supplierVendorCode: "SUPPLIER-006",
|
||
mediaFiles: [
|
||
"/api/placeholder/400/411",
|
||
"/api/placeholder/400/412",
|
||
"/api/placeholder/400/413",
|
||
],
|
||
sizes: [
|
||
{
|
||
chrtID: 777888,
|
||
techSize: "41mm",
|
||
wbSize: "41mm GPS",
|
||
price: 39990,
|
||
discountedPrice: 35990,
|
||
quantity: 12,
|
||
},
|
||
{
|
||
chrtID: 777889,
|
||
techSize: "45mm",
|
||
wbSize: "45mm GPS",
|
||
price: 42990,
|
||
discountedPrice: 38990,
|
||
quantity: 8,
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
// Загружаем контрагентов-фулфилментов
|
||
const { data: counterpartiesData } = useQuery(GET_MY_COUNTERPARTIES);
|
||
|
||
// Автоматически загружаем услуги и расходники для уже выбранных организаций
|
||
useEffect(() => {
|
||
actualSelectedCards.forEach((sc) => {
|
||
if (
|
||
sc.selectedFulfillmentOrg &&
|
||
!organizationServices[sc.selectedFulfillmentOrg]
|
||
) {
|
||
loadOrganizationServices(sc.selectedFulfillmentOrg);
|
||
}
|
||
if (
|
||
sc.selectedConsumableOrg &&
|
||
!organizationSupplies[sc.selectedConsumableOrg]
|
||
) {
|
||
loadOrganizationSupplies(sc.selectedConsumableOrg);
|
||
}
|
||
});
|
||
}, [selectedCards]);
|
||
|
||
// Функция для загрузки услуг организации
|
||
const loadOrganizationServices = async (organizationId: string) => {
|
||
if (organizationServices[organizationId]) return; // Уже загружены
|
||
|
||
try {
|
||
const response = await apolloClient.query({
|
||
query: GET_COUNTERPARTY_SERVICES,
|
||
variables: { organizationId },
|
||
});
|
||
|
||
if (response.data?.counterpartyServices) {
|
||
setOrganizationServices((prev) => ({
|
||
...prev,
|
||
[organizationId]: response.data.counterpartyServices,
|
||
}));
|
||
}
|
||
} catch (error) {
|
||
console.error("Ошибка загрузки услуг организации:", error);
|
||
}
|
||
};
|
||
|
||
// Функция для загрузки расходников организации
|
||
const loadOrganizationSupplies = async (organizationId: string) => {
|
||
if (organizationSupplies[organizationId]) return; // Уже загружены
|
||
|
||
try {
|
||
const response = await apolloClient.query({
|
||
query: GET_COUNTERPARTY_SUPPLIES,
|
||
variables: { organizationId },
|
||
});
|
||
|
||
if (response.data?.counterpartySupplies) {
|
||
setOrganizationSupplies((prev) => ({
|
||
...prev,
|
||
[organizationId]: response.data.counterpartySupplies,
|
||
}));
|
||
}
|
||
} catch (error) {
|
||
console.error("Ошибка загрузки расходников организации:", error);
|
||
}
|
||
};
|
||
|
||
// Мутация для создания поставки
|
||
const [createSupply, { loading: creatingSupply }] = useMutation(
|
||
CREATE_WILDBERRIES_SUPPLY,
|
||
{
|
||
onCompleted: (data) => {
|
||
if (data.createWildberriesSupply.success) {
|
||
toast.success(data.createWildberriesSupply.message);
|
||
onComplete(selectedCards);
|
||
} else {
|
||
toast.error(data.createWildberriesSupply.message);
|
||
}
|
||
},
|
||
onError: (error) => {
|
||
toast.error("Ошибка при создании поставки");
|
||
console.error("Error creating supply:", error);
|
||
},
|
||
}
|
||
);
|
||
|
||
// Моковые данные рынков
|
||
const markets = [
|
||
{ value: "sadovod", label: "Садовод" },
|
||
{ value: "luzhniki", label: "Лужники" },
|
||
{ value: "tishinka", label: "Тишинка" },
|
||
{ value: "food-city", label: "Фуд Сити" },
|
||
];
|
||
|
||
// Автоматически загружаем товары при открытии компонента
|
||
useEffect(() => {
|
||
const loadCards = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const wbApiKey = user?.organization?.apiKeys?.find(
|
||
(key) => key.marketplace === "WILDBERRIES"
|
||
);
|
||
|
||
console.log("WB API Key found:", !!wbApiKey);
|
||
console.log("WB API Key active:", wbApiKey?.isActive);
|
||
console.log("WB API Key validationData:", wbApiKey?.validationData);
|
||
|
||
if (wbApiKey?.isActive) {
|
||
// Попытка загрузить реальные данные из API Wildberries
|
||
const validationData = wbApiKey.validationData as Record<
|
||
string,
|
||
string
|
||
>;
|
||
|
||
// API ключ может храниться в разных местах
|
||
const apiToken =
|
||
validationData?.token ||
|
||
validationData?.apiKey ||
|
||
validationData?.key ||
|
||
(wbApiKey as { apiKey?: string }).apiKey; // Прямое поле apiKey из базы
|
||
|
||
console.log("API Token extracted:", !!apiToken);
|
||
console.log("API Token length:", apiToken?.length);
|
||
|
||
if (apiToken) {
|
||
console.log("Загружаем карточки из WB API...");
|
||
const cards = await WildberriesService.getAllCards(apiToken, 50);
|
||
setWbCards(cards);
|
||
console.log("Загружено карточек из WB API:", cards.length);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Если API ключ не настроен, оставляем пустое состояние
|
||
console.log("API ключ WB не настроен, показываем пустое состояние");
|
||
setWbCards([]);
|
||
} catch (error) {
|
||
console.error("Ошибка загрузки карточек WB:", error);
|
||
// При ошибке API показываем пустое состояние
|
||
setWbCards([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
loadCards();
|
||
}, [user]);
|
||
|
||
const loadAllCards = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const wbApiKey = user?.organization?.apiKeys?.find(
|
||
(key) => key.marketplace === "WILDBERRIES"
|
||
);
|
||
|
||
if (wbApiKey?.isActive) {
|
||
// Попытка загрузить реальные данные из API Wildberries
|
||
const validationData = wbApiKey.validationData as Record<
|
||
string,
|
||
string
|
||
>;
|
||
const apiToken =
|
||
validationData?.token ||
|
||
validationData?.apiKey ||
|
||
validationData?.key ||
|
||
(wbApiKey as { apiKey?: string }).apiKey;
|
||
|
||
if (apiToken) {
|
||
console.log("Загружаем все карточки из WB API...");
|
||
const cards = await WildberriesService.getAllCards(apiToken, 100);
|
||
setWbCards(cards);
|
||
console.log("Загружено карточек из WB API:", cards.length);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Если API ключ не настроен, загружаем моковые данные
|
||
console.log("API ключ WB не настроен, загружаем моковые данные");
|
||
const allCards = getMockCards();
|
||
setWbCards(allCards);
|
||
console.log("Загружены моковые товары:", allCards.length);
|
||
} catch (error) {
|
||
console.error("Ошибка загрузки всех карточек WB:", error);
|
||
// При ошибке загружаем моковые данные
|
||
const allCards = getMockCards();
|
||
setWbCards(allCards);
|
||
console.log("Загружены моковые товары (fallback):", allCards.length);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const searchCards = async () => {
|
||
if (!searchTerm.trim()) {
|
||
loadAllCards();
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const wbApiKey = user?.organization?.apiKeys?.find(
|
||
(key) => key.marketplace === "WILDBERRIES"
|
||
);
|
||
|
||
if (wbApiKey?.isActive) {
|
||
// Попытка поиска в реальном API Wildberries
|
||
const validationData = wbApiKey.validationData as Record<
|
||
string,
|
||
string
|
||
>;
|
||
const apiToken =
|
||
validationData?.token ||
|
||
validationData?.apiKey ||
|
||
validationData?.key ||
|
||
(wbApiKey as { apiKey?: string }).apiKey;
|
||
|
||
if (apiToken) {
|
||
console.log("Поиск в WB API:", searchTerm);
|
||
const cards = await WildberriesService.searchCards(
|
||
apiToken,
|
||
searchTerm,
|
||
50
|
||
);
|
||
setWbCards(cards);
|
||
console.log("Найдено карточек в WB API:", cards.length);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Если API ключ не настроен, ищем в моковых данных
|
||
console.log(
|
||
"API ключ WB не настроен, поиск в моковых данных:",
|
||
searchTerm
|
||
);
|
||
const mockCards = getMockCards();
|
||
|
||
// Фильтруем товары по поисковому запросу
|
||
const filteredCards = mockCards.filter(
|
||
(card) =>
|
||
card.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
card.brand.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
card.nmID.toString().includes(searchTerm.toLowerCase()) ||
|
||
card.object?.toLowerCase().includes(searchTerm.toLowerCase())
|
||
);
|
||
|
||
setWbCards(filteredCards);
|
||
console.log("Найдено моковых товаров:", filteredCards.length);
|
||
} catch (error) {
|
||
console.error("Ошибка поиска карточек WB:", error);
|
||
// При ошибке ищем в моковых данных
|
||
const mockCards = getMockCards();
|
||
const filteredCards = mockCards.filter(
|
||
(card) =>
|
||
card.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
card.brand.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
card.nmID.toString().includes(searchTerm.toLowerCase()) ||
|
||
card.object?.toLowerCase().includes(searchTerm.toLowerCase())
|
||
);
|
||
setWbCards(filteredCards);
|
||
console.log("Найдено моковых товаров (fallback):", filteredCards.length);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const updateCardSelection = (
|
||
card: WildberriesCard,
|
||
field: keyof SelectedCard,
|
||
value: string | number | string[]
|
||
) => {
|
||
setPreparingCards((prev) => {
|
||
const existing = prev.find((sc) => sc.card.nmID === card.nmID);
|
||
|
||
if (
|
||
field === "selectedQuantity" &&
|
||
typeof value === "number" &&
|
||
value === 0
|
||
) {
|
||
return prev.filter((sc) => sc.card.nmID !== card.nmID);
|
||
}
|
||
|
||
if (existing) {
|
||
const updatedCard = { ...existing, [field]: value };
|
||
|
||
// При изменении количества сбрасываем цену, чтобы пользователь ввел новую
|
||
if (
|
||
field === "selectedQuantity" &&
|
||
typeof value === "number" &&
|
||
existing.customPrice > 0
|
||
) {
|
||
updatedCard.customPrice = 0;
|
||
}
|
||
|
||
return prev.map((sc) =>
|
||
sc.card.nmID === card.nmID ? updatedCard : sc
|
||
);
|
||
} else if (
|
||
field === "selectedQuantity" &&
|
||
typeof value === "number" &&
|
||
value > 0
|
||
) {
|
||
const newSelectedCard: SelectedCard = {
|
||
card,
|
||
selectedQuantity: value as number,
|
||
customPrice: 0,
|
||
selectedFulfillmentOrg: "",
|
||
selectedFulfillmentServices: [],
|
||
selectedConsumableOrg: "",
|
||
selectedConsumableServices: [],
|
||
deliveryDate: "",
|
||
selectedMarket: "",
|
||
selectedPlace: "",
|
||
sellerName: "",
|
||
sellerPhone: "",
|
||
selectedServices: [],
|
||
};
|
||
return [...prev, newSelectedCard];
|
||
}
|
||
|
||
return prev;
|
||
});
|
||
};
|
||
|
||
// Функция для получения цены за единицу товара
|
||
const getSelectedUnitPrice = (card: WildberriesCard): number => {
|
||
const selected = preparingCards.find((sc) => sc.card.nmID === card.nmID);
|
||
if (!selected || selected.selectedQuantity === 0) return 0;
|
||
return selected.customPrice / selected.selectedQuantity;
|
||
};
|
||
|
||
// Функция для получения общей стоимости товара
|
||
const getSelectedTotalPrice = (card: WildberriesCard): number => {
|
||
const selected = preparingCards.find((sc) => sc.card.nmID === card.nmID);
|
||
return selected ? selected.customPrice : 0;
|
||
};
|
||
|
||
const getSelectedQuantity = (card: WildberriesCard): number => {
|
||
const selected = preparingCards.find((sc) => sc.card.nmID === card.nmID);
|
||
return selected ? selected.selectedQuantity : 0;
|
||
};
|
||
|
||
// Функция для добавления подготовленных товаров в корзину
|
||
const addToCart = () => {
|
||
const validCards = preparingCards.filter(
|
||
(card) => card.selectedQuantity > 0 && card.customPrice > 0
|
||
);
|
||
|
||
if (validCards.length === 0) {
|
||
toast.error("Выберите товары и укажите цены");
|
||
return;
|
||
}
|
||
|
||
if (!globalDeliveryDate) {
|
||
toast.error("Выберите дату поставки");
|
||
return;
|
||
}
|
||
|
||
const newCards = [...actualSelectedCards];
|
||
validCards.forEach((prepCard) => {
|
||
const cardWithDate = {
|
||
...prepCard,
|
||
deliveryDate: globalDeliveryDate.toISOString().split("T")[0],
|
||
};
|
||
const existingIndex = newCards.findIndex(
|
||
(sc) => sc.card.nmID === prepCard.card.nmID
|
||
);
|
||
if (existingIndex >= 0) {
|
||
// Обновляем существующий товар
|
||
newCards[existingIndex] = cardWithDate;
|
||
} else {
|
||
// Добавляем новый товар
|
||
newCards.push(cardWithDate);
|
||
}
|
||
});
|
||
actualSetSelectedCards(newCards);
|
||
|
||
// Очищаем подготовленные товары
|
||
setPreparingCards([]);
|
||
toast.success(`Добавлено ${validCards.length} товар(ов) в корзину`);
|
||
};
|
||
|
||
// Функции подсчета для подготовленных товаров
|
||
const getPreparingTotalItems = () => {
|
||
return preparingCards.reduce((sum, card) => sum + card.selectedQuantity, 0);
|
||
};
|
||
|
||
const getPreparingTotalAmount = () => {
|
||
return preparingCards.reduce((sum, card) => sum + card.customPrice, 0);
|
||
};
|
||
|
||
const formatCurrency = (amount: number) => {
|
||
return new Intl.NumberFormat("ru-RU", {
|
||
style: "currency",
|
||
currency: "RUB",
|
||
minimumFractionDigits: 0,
|
||
}).format(amount);
|
||
};
|
||
|
||
// Функция для получения цены услуги по ID
|
||
const getServicePrice = (orgId: string, serviceId: string): number => {
|
||
const services = organizationServices[orgId];
|
||
if (!services) return 0;
|
||
const service = services.find((s) => s.id === serviceId);
|
||
return service ? service.price : 0;
|
||
};
|
||
|
||
// Функция для получения цены расходника по ID
|
||
const getSupplyPrice = (orgId: string, supplyId: string): number => {
|
||
const supplies = organizationSupplies[orgId];
|
||
if (!supplies) return 0;
|
||
const supply = supplies.find((s) => s.id === supplyId);
|
||
return supply ? supply.price : 0;
|
||
};
|
||
|
||
// Функция для расчета стоимости услуг и расходников за 1 штуку
|
||
const calculateAdditionalCostPerUnit = (sc: SelectedCard): number => {
|
||
let servicesCost = 0;
|
||
let suppliesCost = 0;
|
||
|
||
// Стоимость услуг фулфилмента
|
||
if (
|
||
sc.selectedFulfillmentOrg &&
|
||
sc.selectedFulfillmentServices.length > 0
|
||
) {
|
||
servicesCost = sc.selectedFulfillmentServices.reduce((sum, serviceId) => {
|
||
return sum + getServicePrice(sc.selectedFulfillmentOrg, serviceId);
|
||
}, 0);
|
||
}
|
||
|
||
// Стоимость расходных материалов
|
||
if (sc.selectedConsumableOrg && sc.selectedConsumableServices.length > 0) {
|
||
suppliesCost = sc.selectedConsumableServices.reduce((sum, supplyId) => {
|
||
return sum + getSupplyPrice(sc.selectedConsumableOrg, supplyId);
|
||
}, 0);
|
||
}
|
||
|
||
return servicesCost + suppliesCost;
|
||
};
|
||
|
||
const getTotalAmount = () => {
|
||
return actualSelectedCards.reduce((sum, sc) => {
|
||
const additionalCostPerUnit = calculateAdditionalCostPerUnit(sc);
|
||
const totalCostPerUnit =
|
||
sc.customPrice / sc.selectedQuantity + additionalCostPerUnit;
|
||
const totalCostForAllItems = totalCostPerUnit * sc.selectedQuantity;
|
||
return sum + totalCostForAllItems;
|
||
}, 0);
|
||
};
|
||
|
||
const getTotalItems = () => {
|
||
return actualSelectedCards.reduce(
|
||
(sum, sc) => sum + sc.selectedQuantity,
|
||
0
|
||
);
|
||
};
|
||
|
||
// Функция больше не нужна, так как услуги выбираются индивидуально
|
||
|
||
const handleCardClick = (card: WildberriesCard) => {
|
||
setSelectedCardForDetails(card);
|
||
setCurrentImageIndex(0);
|
||
};
|
||
|
||
const closeDetailsModal = () => {
|
||
setSelectedCardForDetails(null);
|
||
setCurrentImageIndex(0);
|
||
};
|
||
|
||
const nextImage = () => {
|
||
if (selectedCardForDetails) {
|
||
const images = WildberriesService.getCardImages(selectedCardForDetails);
|
||
if (images.length > 1) {
|
||
setCurrentImageIndex((prev) => (prev + 1) % images.length);
|
||
}
|
||
}
|
||
};
|
||
|
||
const prevImage = () => {
|
||
if (selectedCardForDetails) {
|
||
const images = WildberriesService.getCardImages(selectedCardForDetails);
|
||
if (images.length > 1) {
|
||
setCurrentImageIndex(
|
||
(prev) => (prev - 1 + images.length) % images.length
|
||
);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleCreateSupply = async () => {
|
||
try {
|
||
const supplyInput = {
|
||
deliveryDate: selectedCards[0]?.deliveryDate || null,
|
||
cards: actualSelectedCards.map((sc) => ({
|
||
nmId: sc.card.nmID.toString(),
|
||
vendorCode: sc.card.vendorCode,
|
||
title: sc.card.title,
|
||
brand: sc.card.brand,
|
||
selectedQuantity: sc.selectedQuantity,
|
||
customPrice: sc.customPrice,
|
||
selectedFulfillmentOrg: sc.selectedFulfillmentOrg,
|
||
selectedFulfillmentServices: sc.selectedFulfillmentServices,
|
||
selectedConsumableOrg: sc.selectedConsumableOrg,
|
||
selectedConsumableServices: sc.selectedConsumableServices,
|
||
deliveryDate: sc.deliveryDate || null,
|
||
mediaFiles: sc.card.mediaFiles,
|
||
})),
|
||
};
|
||
|
||
await createSupply({ variables: { input: supplyInput } });
|
||
} catch (error) {
|
||
console.error("Error creating supply:", error);
|
||
}
|
||
};
|
||
|
||
if (actualShowSummary) {
|
||
return (
|
||
<div className="h-screen flex overflow-hidden">
|
||
<Sidebar />
|
||
<main
|
||
className={`flex-1 ${getSidebarMargin()} overflow-auto transition-all duration-300`}
|
||
>
|
||
<div className="p-6 space-y-6">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center space-x-3">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => actualSetShowSummary(false)}
|
||
className="text-white/60 hover:text-white hover:bg-white/10"
|
||
>
|
||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||
Назад
|
||
</Button>
|
||
<div>
|
||
<h2 className="text-2xl font-bold text-white mb-1">
|
||
Корзина
|
||
</h2>
|
||
<p className="text-white/60">
|
||
{actualSelectedCards.length} карточек товаров
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Массовое назначение поставщиков */}
|
||
<Card className="bg-blue-500/10 backdrop-blur border-blue-500/20 p-4 mb-6">
|
||
<h3 className="text-white font-semibold mb-4">
|
||
Быстрое назначение
|
||
</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div>
|
||
<label className="text-white/60 text-sm mb-2 block">
|
||
Поставщик услуг для всех товаров:
|
||
</label>
|
||
<Select
|
||
onValueChange={(value) => {
|
||
if (value && value !== "none") {
|
||
// Загружаем услуги для выбранной организации
|
||
loadOrganizationServices(value);
|
||
|
||
actualSelectedCards.forEach((sc) => {
|
||
updateCardSelection(
|
||
sc.card,
|
||
"selectedFulfillmentOrg",
|
||
value
|
||
);
|
||
// Сбрасываем выбранные услуги при смене организации
|
||
updateCardSelection(
|
||
sc.card,
|
||
"selectedFulfillmentServices",
|
||
[]
|
||
);
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
<SelectTrigger className="bg-white/5 border-white/20 text-white">
|
||
<SelectValue placeholder="Выберите поставщика" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">Не выбран</SelectItem>
|
||
{(counterpartiesData?.myCounterparties || [])
|
||
.filter(
|
||
(org: Organization) => org.type === "FULFILLMENT"
|
||
)
|
||
.map((org: Organization) => (
|
||
<SelectItem key={org.id} value={org.id}>
|
||
{org.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-white/60 text-sm mb-2 block">
|
||
Поставщик расходников для всех:
|
||
</label>
|
||
<Select
|
||
onValueChange={(value) => {
|
||
if (value && value !== "none") {
|
||
// Загружаем расходники для выбранной организации
|
||
loadOrganizationSupplies(value);
|
||
|
||
actualSelectedCards.forEach((sc) => {
|
||
updateCardSelection(
|
||
sc.card,
|
||
"selectedConsumableOrg",
|
||
value
|
||
);
|
||
// Сбрасываем выбранные расходники при смене организации
|
||
updateCardSelection(
|
||
sc.card,
|
||
"selectedConsumableServices",
|
||
[]
|
||
);
|
||
});
|
||
}
|
||
}}
|
||
>
|
||
<SelectTrigger className="bg-white/5 border-white/20 text-white">
|
||
<SelectValue placeholder="Выберите поставщика" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">Не выбран</SelectItem>
|
||
{(counterpartiesData?.myCounterparties || [])
|
||
.filter(
|
||
(org: Organization) => org.type === "FULFILLMENT"
|
||
)
|
||
.map((org: Organization) => (
|
||
<SelectItem key={org.id} value={org.id}>
|
||
{org.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-white/60 text-sm mb-2 block">
|
||
Дата поставки для всех:
|
||
</label>
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<button className="w-full bg-white/5 border border-white/20 text-white hover:bg-white/10 justify-start text-left font-normal h-10 px-3 py-2 rounded-md flex items-center transition-colors">
|
||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||
{globalDeliveryDate
|
||
? format(globalDeliveryDate, "dd.MM.yyyy")
|
||
: "Выберите дату"}
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto p-0">
|
||
<DatePicker
|
||
selected={globalDeliveryDate}
|
||
onChange={(date: Date | null) => {
|
||
setGlobalDeliveryDate(date || undefined);
|
||
if (date) {
|
||
const dateString = date.toISOString().split("T")[0];
|
||
actualSelectedCards.forEach((sc) => {
|
||
updateCardSelection(
|
||
sc.card,
|
||
"deliveryDate",
|
||
dateString
|
||
);
|
||
});
|
||
}
|
||
}}
|
||
minDate={new Date()}
|
||
inline
|
||
locale="ru"
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<div className="grid grid-cols-1 xl:grid-cols-4 gap-6">
|
||
<div className="xl:col-span-3 space-y-4">
|
||
{actualSelectedCards.map((sc) => {
|
||
const fulfillmentOrgs = (
|
||
counterpartiesData?.myCounterparties || []
|
||
).filter((org: Organization) => org.type === "FULFILLMENT");
|
||
const consumableOrgs = (
|
||
counterpartiesData?.myCounterparties || []
|
||
).filter((org: Organization) => org.type === "FULFILLMENT");
|
||
|
||
return (
|
||
<Card
|
||
key={sc.card.nmID}
|
||
className="bg-white/10 backdrop-blur border-white/20 p-4"
|
||
>
|
||
<div className="flex space-x-4">
|
||
<img
|
||
src={
|
||
WildberriesService.getCardImage(
|
||
sc.card,
|
||
"c246x328"
|
||
) || "/api/placeholder/120/120"
|
||
}
|
||
alt={sc.card.title}
|
||
className="w-20 h-20 rounded-lg object-cover"
|
||
/>
|
||
<div className="flex-1 space-y-4">
|
||
<div>
|
||
<h3 className="text-white font-medium">
|
||
{sc.card.title}
|
||
</h3>
|
||
<p className="text-white/60 text-sm">
|
||
WB: {sc.card.nmID}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{/* Количество и цена */}
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="text-white/60 text-sm">
|
||
Количество:
|
||
</label>
|
||
<Input
|
||
type="number"
|
||
value={sc.selectedQuantity}
|
||
onChange={(e) =>
|
||
updateCardSelection(
|
||
sc.card,
|
||
"selectedQuantity",
|
||
parseInt(e.target.value) || 0
|
||
)
|
||
}
|
||
className="bg-white/5 border-white/20 text-white mt-1"
|
||
min="1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-white/60 text-sm">
|
||
Цена за единицу:
|
||
</label>
|
||
<Input
|
||
type="number"
|
||
value={
|
||
sc.customPrice === 0
|
||
? ""
|
||
: (
|
||
sc.customPrice / sc.selectedQuantity
|
||
).toFixed(2)
|
||
}
|
||
onChange={(e) => {
|
||
const pricePerUnit =
|
||
e.target.value === ""
|
||
? 0
|
||
: parseFloat(e.target.value) || 0;
|
||
const totalPrice =
|
||
pricePerUnit * sc.selectedQuantity;
|
||
updateCardSelection(
|
||
sc.card,
|
||
"customPrice",
|
||
totalPrice
|
||
);
|
||
}}
|
||
className="bg-white/5 border-white/20 text-white mt-1"
|
||
placeholder="Введите цену за 1 штуку"
|
||
/>
|
||
|
||
{/* Показываем расчет дополнительных расходов */}
|
||
{(() => {
|
||
const additionalCost =
|
||
calculateAdditionalCostPerUnit(sc);
|
||
if (additionalCost > 0) {
|
||
return (
|
||
<div className="mt-2 p-2 bg-blue-500/20 border border-blue-500/30 rounded text-xs">
|
||
<div className="text-blue-300 font-medium">
|
||
Дополнительные расходы за 1 шт:
|
||
</div>
|
||
{sc.selectedFulfillmentServices.length >
|
||
0 && (
|
||
<div className="text-blue-200">
|
||
Услуги:{" "}
|
||
{sc.selectedFulfillmentServices
|
||
.map((serviceId) => {
|
||
const price = getServicePrice(
|
||
sc.selectedFulfillmentOrg,
|
||
serviceId
|
||
);
|
||
const services =
|
||
organizationServices[
|
||
sc.selectedFulfillmentOrg
|
||
];
|
||
const service = services?.find(
|
||
(s) => s.id === serviceId
|
||
);
|
||
return service
|
||
? `${service.name} (${price}₽)`
|
||
: "";
|
||
})
|
||
.join(", ")}
|
||
</div>
|
||
)}
|
||
{sc.selectedConsumableServices.length >
|
||
0 && (
|
||
<div className="text-blue-200">
|
||
Расходники:{" "}
|
||
{sc.selectedConsumableServices
|
||
.map((supplyId) => {
|
||
const price = getSupplyPrice(
|
||
sc.selectedConsumableOrg,
|
||
supplyId
|
||
);
|
||
const supplies =
|
||
organizationSupplies[
|
||
sc.selectedConsumableOrg
|
||
];
|
||
const supply = supplies?.find(
|
||
(s) => s.id === supplyId
|
||
);
|
||
return supply
|
||
? `${supply.name} (${price}₽)`
|
||
: "";
|
||
})
|
||
.join(", ")}
|
||
</div>
|
||
)}
|
||
<div className="text-blue-300 font-medium mt-1">
|
||
Итого доп. расходы:{" "}
|
||
{formatCurrency(additionalCost)}
|
||
</div>
|
||
<div className="text-green-300 font-medium">
|
||
Полная стоимость за 1 шт:{" "}
|
||
{formatCurrency(
|
||
sc.customPrice /
|
||
sc.selectedQuantity +
|
||
additionalCost
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
})()}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Услуги */}
|
||
<div className="space-y-3">
|
||
<div>
|
||
<label className="text-white/60 text-sm">
|
||
Фулфилмент организация:
|
||
</label>
|
||
<Select
|
||
value={sc.selectedFulfillmentOrg}
|
||
onValueChange={(value) => {
|
||
updateCardSelection(
|
||
sc.card,
|
||
"selectedFulfillmentOrg",
|
||
value
|
||
);
|
||
updateCardSelection(
|
||
sc.card,
|
||
"selectedFulfillmentServices",
|
||
[]
|
||
); // Сбрасываем услуги
|
||
if (value) {
|
||
loadOrganizationServices(value); // Автоматически загружаем услуги
|
||
}
|
||
}}
|
||
>
|
||
<SelectTrigger className="bg-white/5 border-white/20 text-white mt-1">
|
||
<SelectValue placeholder="Выберите фулфилмент" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{fulfillmentOrgs.map(
|
||
(org: Organization) => (
|
||
<SelectItem key={org.id} value={org.id}>
|
||
{org.name || org.fullName}
|
||
</SelectItem>
|
||
)
|
||
)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{sc.selectedFulfillmentOrg && (
|
||
<div>
|
||
<label className="text-white/60 text-sm">
|
||
Услуги фулфилмента:
|
||
</label>
|
||
<div className="mt-2 space-y-2 max-h-32 overflow-y-auto bg-white/5 border border-white/20 rounded p-2">
|
||
{organizationServices[
|
||
sc.selectedFulfillmentOrg
|
||
] ? (
|
||
organizationServices[
|
||
sc.selectedFulfillmentOrg
|
||
].length > 0 ? (
|
||
organizationServices[
|
||
sc.selectedFulfillmentOrg
|
||
].map((service) => {
|
||
const isSelected =
|
||
sc.selectedFulfillmentServices.includes(
|
||
service.id
|
||
);
|
||
return (
|
||
<label
|
||
key={service.id}
|
||
className="flex items-center space-x-2 cursor-pointer hover:bg-white/5 p-1 rounded"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={isSelected}
|
||
onChange={(e) => {
|
||
const newServices = e.target
|
||
.checked
|
||
? [
|
||
...sc.selectedFulfillmentServices,
|
||
service.id,
|
||
]
|
||
: sc.selectedFulfillmentServices.filter(
|
||
(id) =>
|
||
id !== service.id
|
||
);
|
||
updateCardSelection(
|
||
sc.card,
|
||
"selectedFulfillmentServices",
|
||
newServices
|
||
);
|
||
}}
|
||
className="rounded border-white/20 bg-white/10 text-purple-500 focus:ring-purple-500"
|
||
/>
|
||
<span className="text-white text-sm">
|
||
{service.name} - {service.price}{" "}
|
||
₽
|
||
</span>
|
||
</label>
|
||
);
|
||
})
|
||
) : (
|
||
<div className="text-white/60 text-sm text-center py-2">
|
||
У данной организации нет услуг
|
||
</div>
|
||
)
|
||
) : (
|
||
<div className="text-white/60 text-sm text-center py-2">
|
||
Загрузка услуг...
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="text-white/60 text-sm">
|
||
Поставщик расходников:
|
||
</label>
|
||
<Select
|
||
value={sc.selectedConsumableOrg}
|
||
onValueChange={(value) => {
|
||
updateCardSelection(
|
||
sc.card,
|
||
"selectedConsumableOrg",
|
||
value
|
||
);
|
||
updateCardSelection(
|
||
sc.card,
|
||
"selectedConsumableServices",
|
||
[]
|
||
); // Сбрасываем услуги
|
||
if (value) {
|
||
loadOrganizationSupplies(value); // Автоматически загружаем расходники
|
||
}
|
||
}}
|
||
>
|
||
<SelectTrigger className="bg-white/5 border-white/20 text-white mt-1">
|
||
<SelectValue placeholder="Выберите поставщика" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{consumableOrgs.map((org: Organization) => (
|
||
<SelectItem key={org.id} value={org.id}>
|
||
{org.name || org.fullName}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{sc.selectedConsumableOrg && (
|
||
<div>
|
||
<label className="text-white/60 text-sm">
|
||
Расходные материалы:
|
||
</label>
|
||
<div className="mt-2 space-y-2 max-h-32 overflow-y-auto bg-white/5 border border-white/20 rounded p-2">
|
||
{organizationSupplies[
|
||
sc.selectedConsumableOrg
|
||
] ? (
|
||
organizationSupplies[
|
||
sc.selectedConsumableOrg
|
||
].length > 0 ? (
|
||
organizationSupplies[
|
||
sc.selectedConsumableOrg
|
||
].map((supply) => {
|
||
const isSelected =
|
||
sc.selectedConsumableServices.includes(
|
||
supply.id
|
||
);
|
||
return (
|
||
<label
|
||
key={supply.id}
|
||
className="flex items-center space-x-2 cursor-pointer hover:bg-white/5 p-1 rounded"
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={isSelected}
|
||
onChange={(e) => {
|
||
const newSupplies = e.target
|
||
.checked
|
||
? [
|
||
...sc.selectedConsumableServices,
|
||
supply.id,
|
||
]
|
||
: sc.selectedConsumableServices.filter(
|
||
(id) => id !== supply.id
|
||
);
|
||
updateCardSelection(
|
||
sc.card,
|
||
"selectedConsumableServices",
|
||
newSupplies
|
||
);
|
||
}}
|
||
className="rounded border-white/20 bg-white/10 text-green-500 focus:ring-green-500"
|
||
/>
|
||
<span className="text-white text-sm">
|
||
{supply.name} - {supply.price} ₽
|
||
</span>
|
||
</label>
|
||
);
|
||
})
|
||
) : (
|
||
<div className="text-white/60 text-sm text-center py-2">
|
||
У данной организации нет расходников
|
||
</div>
|
||
)
|
||
) : (
|
||
<div className="text-white/60 text-sm text-center py-2">
|
||
Загрузка расходников...
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-right">
|
||
<span className="text-white font-bold text-lg">
|
||
{formatCurrency(sc.customPrice)}
|
||
</span>
|
||
{sc.selectedQuantity > 0 && sc.customPrice > 0 && (
|
||
<p className="text-white/60 text-sm">
|
||
~
|
||
{formatCurrency(
|
||
sc.customPrice / sc.selectedQuantity
|
||
)}{" "}
|
||
за шт.
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="xl:col-span-1">
|
||
<Card className="bg-white/10 backdrop-blur border-white/20 p-4 sticky top-4">
|
||
<h3 className="text-white font-semibold text-lg mb-4">
|
||
Итого
|
||
</h3>
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-white/60">Товаров:</span>
|
||
<span className="text-white">{getTotalItems()}</span>
|
||
</div>
|
||
<div className="flex justify-between text-sm">
|
||
<span className="text-white/60">Карточек:</span>
|
||
<span className="text-white">
|
||
{actualSelectedCards.length}
|
||
</span>
|
||
</div>
|
||
<div className="border-t border-white/20 pt-3 flex justify-between">
|
||
<span className="text-white font-semibold">
|
||
Общая сумма:
|
||
</span>
|
||
<span className="text-white font-bold text-lg">
|
||
{formatCurrency(getTotalAmount())}
|
||
</span>
|
||
</div>
|
||
<Button
|
||
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white"
|
||
onClick={handleCreateSupply}
|
||
disabled={creatingSupply}
|
||
>
|
||
<Check className="h-4 w-4 mr-2" />
|
||
{creatingSupply ? "Создание..." : "Создать поставку"}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Поиск */}
|
||
{/* Поиск товаров и выбор даты поставки */}
|
||
<Card className="bg-white/10 backdrop-blur border-white/20 p-3">
|
||
<div className="flex space-x-2 items-center">
|
||
{/* Поиск */}
|
||
<div className="flex-1">
|
||
<Input
|
||
placeholder="Поиск товаров..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="bg-white/5 border-white/20 text-white placeholder-white/50 h-9"
|
||
onKeyPress={(e) => e.key === "Enter" && searchCards()}
|
||
/>
|
||
</div>
|
||
|
||
{/* Выбор даты поставки */}
|
||
<div className="w-44">
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<button className="w-full justify-start text-left font-normal bg-white/5 border border-white/20 text-white hover:bg-white/10 h-9 text-xs px-3 py-2 rounded-md flex items-center transition-colors">
|
||
<CalendarIcon className="mr-1 h-3 w-3" />
|
||
{globalDeliveryDate ? (
|
||
format(globalDeliveryDate, "dd.MM.yy", { locale: ru })
|
||
) : (
|
||
<span className="text-white/50">Дата поставки</span>
|
||
)}
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto p-0" align="end">
|
||
<DatePicker
|
||
selected={globalDeliveryDate}
|
||
onChange={(date: Date | null) =>
|
||
setGlobalDeliveryDate(date || undefined)
|
||
}
|
||
minDate={new Date()}
|
||
inline
|
||
locale="ru"
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
|
||
{/* Кнопка поиска */}
|
||
<Button
|
||
onClick={searchCards}
|
||
disabled={loading || !searchTerm.trim()}
|
||
variant="glass"
|
||
size="sm"
|
||
className="h-9 px-3"
|
||
>
|
||
<Search className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Состояние загрузки */}
|
||
{loading && (
|
||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||
{[...Array(12)].map((_, i) => (
|
||
<Card
|
||
key={i}
|
||
className="bg-white/10 backdrop-blur border-white/20 p-3 animate-pulse"
|
||
>
|
||
<div className="space-y-3">
|
||
<div className="bg-white/20 rounded-lg aspect-square w-full"></div>
|
||
<div className="space-y-2">
|
||
<div className="bg-white/20 rounded h-3 w-3/4"></div>
|
||
<div className="bg-white/20 rounded h-3 w-1/2"></div>
|
||
<div className="bg-white/20 rounded h-4 w-2/3"></div>
|
||
</div>
|
||
<div className="bg-white/20 rounded h-7 w-full"></div>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Карточки товаров */}
|
||
{!loading && wbCards.length > 0 && (
|
||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
|
||
{wbCards.map((card) => {
|
||
const selectedQuantity = getSelectedQuantity(card);
|
||
const isSelected = selectedQuantity > 0;
|
||
const selectedCard = actualSelectedCards.find(
|
||
(sc) => sc.card.nmID === card.nmID
|
||
);
|
||
|
||
return (
|
||
<Card
|
||
key={card.nmID}
|
||
className={`bg-white/10 backdrop-blur border-white/20 transition-all hover:scale-105 hover:shadow-2xl group ${
|
||
isSelected ? "ring-2 ring-purple-500/50 bg-purple-500/10" : ""
|
||
} relative overflow-hidden`}
|
||
>
|
||
<div className="p-1.5 space-y-1.5">
|
||
{/* Изображение и основная информация */}
|
||
<div className="space-y-2">
|
||
<div className="relative">
|
||
<div className="aspect-square relative bg-white/5 overflow-hidden rounded-lg">
|
||
<img
|
||
src={
|
||
WildberriesService.getCardImage(card, "c516x688") ||
|
||
"/api/placeholder/300/300"
|
||
}
|
||
alt={card.title}
|
||
className="w-full h-full object-cover cursor-pointer group-hover:scale-110 transition-transform duration-500"
|
||
onClick={() => handleCardClick(card)}
|
||
/>
|
||
|
||
{/* Индикатор товара WB */}
|
||
<div className="absolute top-2 right-2">
|
||
<Badge className="bg-blue-500/90 text-white border-0 backdrop-blur text-xs font-medium px-2 py-1">
|
||
◉ WB
|
||
</Badge>
|
||
</div>
|
||
|
||
{/* Индикатор выбранного товара */}
|
||
{isSelected && (
|
||
<div className="absolute top-2 left-2">
|
||
<Badge className="bg-gradient-to-r from-orange-500 to-amber-500 text-white border-0 text-xs font-medium">
|
||
<Package className="h-3 w-3 mr-1" />
|
||
Подготовлен
|
||
</Badge>
|
||
</div>
|
||
)}
|
||
|
||
{/* Overlay с кнопкой */}
|
||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
onClick={() => handleCardClick(card)}
|
||
className="bg-white/20 backdrop-blur text-white border-white/30 hover:bg-white/30"
|
||
>
|
||
<Eye className="h-4 w-4 mr-2" />
|
||
Подробнее
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
{/* Заголовок и бренд */}
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<Badge className="bg-blue-500/20 text-blue-300 border-blue-500/30 text-xs font-medium">
|
||
{card.brand}
|
||
</Badge>
|
||
<span className="text-white/40 text-xs">
|
||
№{card.nmID}
|
||
</span>
|
||
</div>
|
||
<h3
|
||
className="text-white font-semibold text-sm mb-1 line-clamp-2 leading-tight cursor-pointer hover:text-purple-300 transition-colors"
|
||
onClick={() => handleCardClick(card)}
|
||
>
|
||
{card.title}
|
||
</h3>
|
||
</div>
|
||
|
||
{/* Информация о товаре */}
|
||
<div className="pt-2 border-t border-white/10">
|
||
<div className="text-center">
|
||
<span className="text-white/60 text-xs">
|
||
Добавьте в поставку для настройки
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Компактное управление */}
|
||
<div className="space-y-2 bg-white/5 rounded-lg p-2">
|
||
{/* Количество - компактно */}
|
||
<div className="flex items-center space-x-1">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
const newQuantity = Math.max(0, selectedQuantity - 1);
|
||
updateCardSelection(
|
||
card,
|
||
"selectedQuantity",
|
||
newQuantity
|
||
);
|
||
}}
|
||
disabled={selectedQuantity <= 0}
|
||
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10 border border-white/20 disabled:opacity-50 flex-shrink-0"
|
||
>
|
||
<Minus className="h-2.5 w-2.5" />
|
||
</Button>
|
||
<input
|
||
type="text"
|
||
inputMode="numeric"
|
||
pattern="[0-9]*"
|
||
value={selectedQuantity}
|
||
onChange={(e) => {
|
||
const value = e.target.value.replace(/[^0-9]/g, "");
|
||
const numValue = Math.max(0, parseInt(value) || 0);
|
||
updateCardSelection(
|
||
card,
|
||
"selectedQuantity",
|
||
numValue
|
||
);
|
||
}}
|
||
onFocus={(e) => e.target.select()}
|
||
className="flex-1 h-6 text-center bg-white/10 border border-white/20 text-white text-xs rounded focus:outline-none focus:ring-1 focus:ring-purple-500 focus:border-transparent [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none min-w-0"
|
||
placeholder="0"
|
||
/>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => {
|
||
const newQuantity = selectedQuantity + 1;
|
||
updateCardSelection(
|
||
card,
|
||
"selectedQuantity",
|
||
newQuantity
|
||
);
|
||
}}
|
||
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10 border border-white/20 flex-shrink-0"
|
||
>
|
||
<Plus className="h-2.5 w-2.5" />
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Цена - компактно, показывается только если есть количество */}
|
||
{selectedQuantity > 0 && (
|
||
<input
|
||
type="text"
|
||
inputMode="decimal"
|
||
value={getSelectedTotalPrice(card) || ""}
|
||
onChange={(e) => {
|
||
const value = e.target.value
|
||
.replace(/[^0-9.,]/g, "")
|
||
.replace(",", ".");
|
||
const totalPrice = parseFloat(value) || 0;
|
||
updateCardSelection(card, "customPrice", totalPrice);
|
||
}}
|
||
onFocus={(e) => e.target.select()}
|
||
className="w-full h-6 text-center bg-white/10 border border-white/20 text-white text-xs rounded focus:outline-none focus:ring-1 focus:ring-green-500 focus:border-transparent"
|
||
placeholder={`Цена за ${selectedQuantity} шт`}
|
||
/>
|
||
)}
|
||
|
||
{/* Результат - очень компактно */}
|
||
{selectedQuantity > 0 &&
|
||
getSelectedTotalPrice(card) > 0 && (
|
||
<div className="bg-gradient-to-r from-green-500/20 to-emerald-500/20 border border-green-500/30 rounded p-1.5">
|
||
<div className="text-center space-y-0.5">
|
||
<div className="text-green-300 text-xs font-medium">
|
||
{formatCurrency(getSelectedTotalPrice(card))}
|
||
</div>
|
||
<div className="text-green-200 text-[10px]">
|
||
~{formatCurrency(getSelectedUnitPrice(card))}/шт
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Индикатор подготовки к добавлению */}
|
||
{selectedQuantity > 0 && (
|
||
<div className="flex items-center justify-center">
|
||
<Badge className="bg-orange-500/20 text-orange-300 border-orange-500/30 text-[10px] px-2 py-0.5">
|
||
<Package className="h-2.5 w-2.5 mr-1" />
|
||
Подготовлен
|
||
</Badge>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Плавающая кнопка "В корзину" для подготовленных товаров */}
|
||
{preparingCards.length > 0 && getPreparingTotalItems() > 0 && (
|
||
<div className="fixed bottom-6 right-6 z-50">
|
||
<Button
|
||
onClick={addToCart}
|
||
className="bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white shadow-lg h-12 px-6"
|
||
>
|
||
<ShoppingCart className="h-5 w-5 mr-2" />В корзину (
|
||
{getPreparingTotalItems()}) •{" "}
|
||
{formatCurrency(getPreparingTotalAmount())}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{wbCards.length === 0 && !loading && (
|
||
<Card className="bg-white/10 backdrop-blur border-white/20 p-8">
|
||
<div className="text-center max-w-md mx-auto">
|
||
<Package className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||
<h3 className="text-lg font-semibold text-white mb-2">
|
||
Нет товаров
|
||
</h3>
|
||
{user?.organization?.apiKeys?.find(
|
||
(key) => key.marketplace === "WILDBERRIES"
|
||
)?.isActive ? (
|
||
<>
|
||
<p className="text-white/60 mb-4 text-sm">
|
||
Введите запрос в поле поиска, чтобы найти товары в вашем
|
||
каталоге Wildberries, или загрузите все доступные карточки
|
||
</p>
|
||
<Button
|
||
onClick={loadAllCards}
|
||
className="bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 text-white"
|
||
>
|
||
<Package className="h-4 w-4 mr-2" />
|
||
Загрузить из WB API
|
||
</Button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<p className="text-white/60 mb-3 text-sm">
|
||
Для работы с реальными карточками необходимо настроить API
|
||
ключ Wildberries
|
||
</p>
|
||
<p className="text-white/40 text-xs mb-4">
|
||
Показаны демонстрационные товары для тестирования
|
||
</p>
|
||
<Button
|
||
onClick={loadAllCards}
|
||
className="bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 text-white"
|
||
>
|
||
<Package className="h-4 w-4 mr-2" />
|
||
Показать демо товары
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Модальное окно с детальной информацией о товаре */}
|
||
<Dialog
|
||
open={!!selectedCardForDetails}
|
||
onOpenChange={(open) => !open && closeDetailsModal()}
|
||
>
|
||
<DialogContent className="glass-card border-white/10 max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-white">
|
||
Информация о товаре
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
{selectedCardForDetails && (
|
||
<div className="space-y-4">
|
||
{/* Изображение */}
|
||
<div className="relative">
|
||
<img
|
||
src={
|
||
WildberriesService.getCardImages(selectedCardForDetails)[
|
||
currentImageIndex
|
||
] || "/api/placeholder/400/400"
|
||
}
|
||
alt={selectedCardForDetails.title}
|
||
className="w-full aspect-video rounded-lg object-cover"
|
||
/>
|
||
|
||
{/* Навигация по изображениям */}
|
||
{WildberriesService.getCardImages(selectedCardForDetails)
|
||
.length > 1 && (
|
||
<>
|
||
<button
|
||
onClick={prevImage}
|
||
className="absolute left-3 top-1/2 -translate-y-1/2 bg-black/70 hover:bg-black/90 text-white p-2 rounded-full transition-all"
|
||
>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
onClick={nextImage}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 bg-black/70 hover:bg-black/90 text-white p-2 rounded-full transition-all"
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</button>
|
||
|
||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 bg-black/70 px-3 py-1 rounded-full text-white text-sm">
|
||
{currentImageIndex + 1} из{" "}
|
||
{
|
||
WildberriesService.getCardImages(selectedCardForDetails)
|
||
.length
|
||
}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Основная информация */}
|
||
<div className="space-y-3">
|
||
<div>
|
||
<h3 className="text-white font-semibold text-lg">
|
||
{selectedCardForDetails.title}
|
||
</h3>
|
||
<p className="text-white/60 text-sm">
|
||
Артикул WB: {selectedCardForDetails.nmID}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div className="flex justify-between">
|
||
<span className="text-white/60">Бренд:</span>
|
||
<span className="text-white font-medium">
|
||
{selectedCardForDetails.brand}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-white/60">Категория:</span>
|
||
<span className="text-white font-medium">
|
||
{selectedCardForDetails.object}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedCardForDetails.description && (
|
||
<div>
|
||
<h4 className="text-white font-medium mb-2">Описание</h4>
|
||
<p className="text-white/70 text-sm leading-relaxed max-h-32 overflow-y-auto">
|
||
{selectedCardForDetails.description}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Миниатюры изображений */}
|
||
{WildberriesService.getCardImages(selectedCardForDetails).length >
|
||
1 && (
|
||
<div className="flex space-x-2 overflow-x-auto pb-2">
|
||
{WildberriesService.getCardImages(selectedCardForDetails).map(
|
||
(image, index) => (
|
||
<img
|
||
key={index}
|
||
src={image}
|
||
alt={`${selectedCardForDetails.title} ${index + 1}`}
|
||
className={`w-16 h-16 rounded-lg object-cover cursor-pointer flex-shrink-0 transition-all ${
|
||
index === currentImageIndex
|
||
? "ring-2 ring-purple-500"
|
||
: "opacity-60 hover:opacity-100"
|
||
}`}
|
||
onClick={() => setCurrentImageIndex(index)}
|
||
/>
|
||
)
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|