Создан единый источник истины rules-complete.md v9.1 с полной интеграцией всех правил системы. Консолидированы правила создания предметов по ролям, уточнен статус брака (НЕ РЕАЛИЗОВАНО), обновлен механизм учета ПЛАН/ФАКТ с заменой брака на потери при пересчете. Добавлен экономический учет расходников фулфилмента для селлера через рецептуру. Удалены дублирующие файлы правил (CLAUDE.md, development-checklist.md, work-protocols.md, violation-prevention-protocol.md, self-validation.md, description.md). Интегрированы UI структуры создания поставок и концепция многоуровневых таблиц.
🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@ -26,7 +26,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import {
|
||||
GET_MY_COUNTERPARTIES,
|
||||
GET_ALL_PRODUCTS,
|
||||
GET_ORGANIZATION_PRODUCTS,
|
||||
GET_SUPPLY_ORDERS,
|
||||
GET_MY_SUPPLIES,
|
||||
GET_MY_FULFILLMENT_SUPPLIES,
|
||||
@ -112,19 +112,26 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
productSearchQuery,
|
||||
});
|
||||
|
||||
// Загружаем товары для выбранного поставщика
|
||||
// Загружаем товары для выбранного поставщика с фильтрацией по типу CONSUMABLE
|
||||
const {
|
||||
data: productsData,
|
||||
loading: productsLoading,
|
||||
error: productsError,
|
||||
} = useQuery(GET_ALL_PRODUCTS, {
|
||||
} = useQuery(GET_ORGANIZATION_PRODUCTS, {
|
||||
skip: !selectedSupplier,
|
||||
variables: { search: productSearchQuery || null, category: null },
|
||||
variables: {
|
||||
organizationId: selectedSupplier.id,
|
||||
search: productSearchQuery || null,
|
||||
category: null,
|
||||
type: "CONSUMABLE" // Фильтруем только расходники согласно rules2.md
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
console.log("✅ GET_ALL_PRODUCTS COMPLETED:", {
|
||||
totalProducts: data?.allProducts?.length || 0,
|
||||
console.log("✅ GET_ORGANIZATION_PRODUCTS COMPLETED:", {
|
||||
totalProducts: data?.organizationProducts?.length || 0,
|
||||
organizationId: selectedSupplier.id,
|
||||
type: "CONSUMABLE",
|
||||
products:
|
||||
data?.allProducts?.map((p) => ({
|
||||
data?.organizationProducts?.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
@ -134,7 +141,7 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("❌ GET_ALL_PRODUCTS ERROR:", error);
|
||||
console.error("❌ GET_ORGANIZATION_PRODUCTS ERROR:", error);
|
||||
},
|
||||
});
|
||||
|
||||
@ -160,14 +167,8 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
);
|
||||
|
||||
// Фильтруем товары по выбранному поставщику
|
||||
// 📦 ТОЛЬКО РАСХОДНИКИ согласно правилам (раздел 2.1)
|
||||
const supplierProducts = selectedSupplier
|
||||
? (productsData?.allProducts || []).filter(
|
||||
(product: FulfillmentConsumableProduct) =>
|
||||
product.organization.id === selectedSupplier.id &&
|
||||
product.type === "CONSUMABLE" // Только расходники для фулфилмента
|
||||
)
|
||||
: [];
|
||||
// 📦 Получаем товары поставщика (уже отфильтрованы в GraphQL запросе по типу CONSUMABLE)
|
||||
const supplierProducts = productsData?.organizationProducts || [];
|
||||
|
||||
// Отладочное логирование
|
||||
React.useEffect(() => {
|
||||
@ -181,10 +182,10 @@ export function CreateFulfillmentConsumablesSupplyPage() {
|
||||
: null,
|
||||
productsLoading,
|
||||
productsError: productsError?.message,
|
||||
allProductsCount: productsData?.allProducts?.length || 0,
|
||||
organizationProductsCount: productsData?.organizationProducts?.length || 0,
|
||||
supplierProductsCount: supplierProducts.length,
|
||||
allProducts:
|
||||
productsData?.allProducts?.map((p) => ({
|
||||
organizationProducts:
|
||||
productsData?.organizationProducts?.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
organizationId: p.organization.id,
|
||||
|
@ -25,7 +25,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import {
|
||||
GET_MY_COUNTERPARTIES,
|
||||
GET_ALL_PRODUCTS,
|
||||
GET_ORGANIZATION_PRODUCTS,
|
||||
GET_SUPPLY_ORDERS,
|
||||
GET_MY_SUPPLIES,
|
||||
} from "@/graphql/queries";
|
||||
@ -91,12 +91,17 @@ export function MaterialsOrderForm() {
|
||||
GET_MY_COUNTERPARTIES
|
||||
);
|
||||
|
||||
// Загружаем товары для выбранного партнера
|
||||
// Загружаем товары для выбранного партнера с фильтрацией по типу CONSUMABLE
|
||||
const { data: productsData, loading: productsLoading } = useQuery(
|
||||
GET_ALL_PRODUCTS,
|
||||
GET_ORGANIZATION_PRODUCTS,
|
||||
{
|
||||
skip: !selectedPartner,
|
||||
variables: { search: null, category: null },
|
||||
variables: {
|
||||
organizationId: selectedPartner.id,
|
||||
search: null,
|
||||
category: null,
|
||||
type: "CONSUMABLE" // Фильтруем только расходники согласно rules2.md
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@ -117,12 +122,8 @@ export function MaterialsOrderForm() {
|
||||
partner.inn?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Фильтруем товары по выбранному партнеру
|
||||
const partnerProducts = selectedPartner
|
||||
? (productsData?.allProducts || []).filter(
|
||||
(product: Product) => product.organization.id === selectedPartner.id
|
||||
)
|
||||
: [];
|
||||
// Получаем товары партнера (уже отфильтрованы в GraphQL запросе)
|
||||
const partnerProducts = productsData?.organizationProducts || [];
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
|
@ -27,7 +27,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import {
|
||||
GET_MY_COUNTERPARTIES,
|
||||
GET_ALL_PRODUCTS,
|
||||
GET_ORGANIZATION_PRODUCTS,
|
||||
GET_SUPPLY_ORDERS,
|
||||
GET_MY_SUPPLIES,
|
||||
} from "@/graphql/queries";
|
||||
@ -99,12 +99,17 @@ export function CreateConsumablesSupplyPage() {
|
||||
GET_MY_COUNTERPARTIES
|
||||
);
|
||||
|
||||
// Загружаем товары для выбранного поставщика
|
||||
// Загружаем товары для выбранного поставщика с фильтрацией по типу CONSUMABLE
|
||||
const { data: productsData, loading: productsLoading } = useQuery(
|
||||
GET_ALL_PRODUCTS,
|
||||
GET_ORGANIZATION_PRODUCTS,
|
||||
{
|
||||
skip: !selectedSupplier,
|
||||
variables: { search: productSearchQuery || null, category: null },
|
||||
variables: {
|
||||
organizationId: selectedSupplier.id,
|
||||
search: productSearchQuery || null,
|
||||
category: null,
|
||||
type: "CONSUMABLE" // Фильтруем только расходники согласно rules2.md
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@ -134,13 +139,8 @@ export function CreateConsumablesSupplyPage() {
|
||||
supplier.inn?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Фильтруем товары по выбранному поставщику
|
||||
const supplierProducts = selectedSupplier
|
||||
? (productsData?.allProducts || []).filter(
|
||||
(product: ConsumableProduct) =>
|
||||
product.organization.id === selectedSupplier.id
|
||||
)
|
||||
: [];
|
||||
// Получаем товары поставщика (уже отфильтрованы в GraphQL запросе)
|
||||
const supplierProducts = productsData?.organizationProducts || [];
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("ru-RU", {
|
||||
@ -415,11 +415,11 @@ export function CreateConsumablesSupplyPage() {
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main
|
||||
className={`flex-1 ${getSidebarMargin()} overflow-auto transition-all duration-300`}
|
||||
className={`flex-1 ${getSidebarMargin()} overflow-auto transition-all duration-300 p-4`}
|
||||
>
|
||||
<div className="min-h-full w-full flex flex-col px-3 py-2">
|
||||
<div className="min-h-full w-full flex flex-col gap-4">
|
||||
{/* Заголовок */}
|
||||
<div className="flex items-center justify-between mb-3 flex-shrink-0">
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">
|
||||
Создание поставки расходников
|
||||
@ -440,9 +440,9 @@ export function CreateConsumablesSupplyPage() {
|
||||
</div>
|
||||
|
||||
{/* Основной контент с двумя блоками */}
|
||||
<div className="flex-1 flex gap-3 min-h-0">
|
||||
<div className="flex-1 flex gap-4 min-h-0">
|
||||
{/* Левая колонка - Поставщики и Расходники */}
|
||||
<div className="flex-1 flex flex-col gap-3 min-h-0">
|
||||
<div className="flex-1 flex flex-col gap-4 min-h-0">
|
||||
{/* Блок "Поставщики" */}
|
||||
<Card className="bg-gradient-to-r from-white/15 via-white/10 to-white/15 backdrop-blur-xl border border-white/30 shadow-2xl flex-shrink-0 sticky top-0 z-10 rounded-xl overflow-hidden">
|
||||
<div className="p-3 bg-gradient-to-r from-purple-500/10 to-pink-500/10">
|
||||
|
@ -33,6 +33,10 @@ import {
|
||||
import {
|
||||
GET_MY_COUNTERPARTIES,
|
||||
GET_ORGANIZATION_PRODUCTS,
|
||||
GET_MY_SERVICES,
|
||||
GET_MY_SUPPLIES,
|
||||
GET_SELLER_SUPPLIES_ON_WAREHOUSE,
|
||||
GET_MY_WILDBERRIES_SUPPLIES,
|
||||
} from "@/graphql/queries";
|
||||
import { CREATE_SUPPLY_ORDER } from "@/graphql/mutations";
|
||||
import { OrganizationAvatar } from "@/components/market/organization-avatar";
|
||||
@ -102,6 +106,47 @@ interface LogisticsCompany {
|
||||
type: "EXPRESS" | "STANDARD" | "ECONOMY";
|
||||
}
|
||||
|
||||
// Новые интерфейсы для компонентов рецептуры
|
||||
interface FulfillmentService {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface FulfillmentConsumable {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
interface SellerConsumable {
|
||||
id: string;
|
||||
name: string;
|
||||
stock: number;
|
||||
unit?: string;
|
||||
supplierId: string;
|
||||
}
|
||||
|
||||
interface WBCard {
|
||||
id: string;
|
||||
title: string;
|
||||
nmID: string;
|
||||
vendorCode?: string;
|
||||
brand?: string;
|
||||
}
|
||||
|
||||
interface ProductRecipe {
|
||||
productId: string;
|
||||
selectedServices: string[];
|
||||
selectedFFConsumables: string[];
|
||||
selectedSellerConsumables: string[];
|
||||
selectedWBCard?: string;
|
||||
}
|
||||
|
||||
export function CreateSuppliersSupplyPage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
@ -127,8 +172,9 @@ export function CreateSuppliersSupplyPage() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const [isCreatingSupply, setIsCreatingSupply] = useState(false);
|
||||
|
||||
// Состояние количества товаров для карточек согласно rules2.md 13.3
|
||||
|
||||
// Состояния для компонентов рецептуры
|
||||
const [productRecipes, setProductRecipes] = useState<Record<string, ProductRecipe>>({});
|
||||
const [productQuantities, setProductQuantities] = useState<Record<string, number>>({});
|
||||
|
||||
// Загружаем партнеров-поставщиков согласно rules2.md 13.3
|
||||
@ -191,9 +237,36 @@ export function CreateSuppliersSupplyPage() {
|
||||
// Мутация создания поставки
|
||||
const [createSupplyOrder] = useMutation(CREATE_SUPPLY_ORDER);
|
||||
|
||||
// Запросы для компонентов рецептуры
|
||||
const { data: fulfillmentServicesData } = useQuery(GET_MY_SERVICES, {
|
||||
skip: !selectedFulfillment,
|
||||
errorPolicy: 'all'
|
||||
});
|
||||
|
||||
const { data: fulfillmentConsumablesData } = useQuery(GET_MY_SUPPLIES, {
|
||||
skip: !selectedFulfillment,
|
||||
errorPolicy: 'all'
|
||||
});
|
||||
|
||||
const { data: sellerConsumablesData } = useQuery(GET_SELLER_SUPPLIES_ON_WAREHOUSE, {
|
||||
skip: !user?.organization?.id,
|
||||
errorPolicy: 'all'
|
||||
});
|
||||
|
||||
const { data: wbCardsData } = useQuery(GET_MY_WILDBERRIES_SUPPLIES, {
|
||||
skip: !user?.organization?.id,
|
||||
errorPolicy: 'all'
|
||||
});
|
||||
|
||||
// Фильтруем только партнеров-поставщиков согласно rules2.md 13.3
|
||||
const allCounterparties = counterpartiesData?.myCounterparties || [];
|
||||
|
||||
// Извлекаем данные для компонентов рецептуры
|
||||
const fulfillmentServices: FulfillmentService[] = fulfillmentServicesData?.myServices || [];
|
||||
const fulfillmentConsumables: FulfillmentConsumable[] = fulfillmentConsumablesData?.mySupplies || [];
|
||||
const sellerConsumables: SellerConsumable[] = sellerConsumablesData?.sellerSuppliesOnWarehouse || [];
|
||||
const wbCards: WBCard[] = (wbCardsData?.myWildberriesSupplies || []).flatMap((supply: any) => supply.cards || []);
|
||||
|
||||
// Показываем только партнеров с типом WHOLESALE согласно rules2.md 13.3
|
||||
const wholesaleSuppliers = allCounterparties.filter((cp: any) => {
|
||||
try {
|
||||
@ -345,6 +418,106 @@ export function CreateSuppliersSupplyPage() {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// Функции для работы с рецептурой
|
||||
const initializeProductRecipe = (productId: string) => {
|
||||
if (!productRecipes[productId]) {
|
||||
setProductRecipes(prev => ({
|
||||
...prev,
|
||||
[productId]: {
|
||||
productId,
|
||||
selectedServices: [],
|
||||
selectedFFConsumables: [],
|
||||
selectedSellerConsumables: [],
|
||||
selectedWBCard: undefined
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleService = (productId: string, serviceId: string) => {
|
||||
initializeProductRecipe(productId);
|
||||
setProductRecipes(prev => {
|
||||
const recipe = prev[productId];
|
||||
const isSelected = recipe.selectedServices.includes(serviceId);
|
||||
return {
|
||||
...prev,
|
||||
[productId]: {
|
||||
...recipe,
|
||||
selectedServices: isSelected
|
||||
? recipe.selectedServices.filter(id => id !== serviceId)
|
||||
: [...recipe.selectedServices, serviceId]
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toggleFFConsumable = (productId: string, consumableId: string) => {
|
||||
initializeProductRecipe(productId);
|
||||
setProductRecipes(prev => {
|
||||
const recipe = prev[productId];
|
||||
const isSelected = recipe.selectedFFConsumables.includes(consumableId);
|
||||
return {
|
||||
...prev,
|
||||
[productId]: {
|
||||
...recipe,
|
||||
selectedFFConsumables: isSelected
|
||||
? recipe.selectedFFConsumables.filter(id => id !== consumableId)
|
||||
: [...recipe.selectedFFConsumables, consumableId]
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSellerConsumable = (productId: string, consumableId: string) => {
|
||||
initializeProductRecipe(productId);
|
||||
setProductRecipes(prev => {
|
||||
const recipe = prev[productId];
|
||||
const isSelected = recipe.selectedSellerConsumables.includes(consumableId);
|
||||
return {
|
||||
...prev,
|
||||
[productId]: {
|
||||
...recipe,
|
||||
selectedSellerConsumables: isSelected
|
||||
? recipe.selectedSellerConsumables.filter(id => id !== consumableId)
|
||||
: [...recipe.selectedSellerConsumables, consumableId]
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const setWBCard = (productId: string, cardId: string) => {
|
||||
initializeProductRecipe(productId);
|
||||
setProductRecipes(prev => ({
|
||||
...prev,
|
||||
[productId]: {
|
||||
...prev[productId],
|
||||
selectedWBCard: cardId
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// Расчет стоимости компонентов рецептуры
|
||||
const calculateRecipeCost = (productId: string) => {
|
||||
const recipe = productRecipes[productId];
|
||||
if (!recipe) return { services: 0, consumables: 0, total: 0 };
|
||||
|
||||
const servicesTotal = recipe.selectedServices.reduce((sum, serviceId) => {
|
||||
const service = fulfillmentServices.find(s => s.id === serviceId);
|
||||
return sum + (service?.price || 0);
|
||||
}, 0);
|
||||
|
||||
const consumablesTotal = recipe.selectedFFConsumables.reduce((sum, consumableId) => {
|
||||
const consumable = fulfillmentConsumables.find(c => c.id === consumableId);
|
||||
return sum + (consumable?.price || 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
services: servicesTotal,
|
||||
consumables: consumablesTotal,
|
||||
total: servicesTotal + consumablesTotal
|
||||
};
|
||||
};
|
||||
|
||||
// Добавление товара в корзину из модального окна с дополнительными данными
|
||||
const addToCartFromModal = (
|
||||
product: GoodsProduct,
|
||||
@ -418,7 +591,7 @@ export function CreateSuppliersSupplyPage() {
|
||||
const totalQuantity = selectedGoods.reduce((sum, item) => sum + item.selectedQuantity, 0);
|
||||
const fulfillmentFee = totalGoodsAmount * 0.08; // 8% комиссия фулфилмента
|
||||
const selectedLogisticsCompany = logisticsCompanies.find(lc => lc.id === selectedLogistics);
|
||||
const logisticsCost = selectedLogistics === "auto" ? 1000 : (selectedLogisticsCompany?.estimatedCost || 0);
|
||||
const logisticsCost = selectedLogistics === "auto" ? 0 : (selectedLogisticsCompany?.estimatedCost || 0);
|
||||
const totalAmount = totalGoodsAmount + fulfillmentFee + logisticsCost;
|
||||
|
||||
// Валидация формы согласно rules2.md 9.7.6
|
||||
@ -475,14 +648,14 @@ export function CreateSuppliersSupplyPage() {
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main className={`flex-1 ${getSidebarMargin()} overflow-hidden transition-all duration-300`}>
|
||||
<div className="h-full flex flex-col">
|
||||
<main className={`flex-1 ${getSidebarMargin()} overflow-hidden transition-all duration-300 p-4`}>
|
||||
<div className="h-full flex flex-col gap-4">
|
||||
|
||||
{/* СТРУКТУРА ИЗ 3 БЛОКОВ согласно rules1.md 19.2.1 - блоки точно по уровню сайдбара */}
|
||||
<div className="flex-1 flex gap-2 min-h-0">
|
||||
<div className="flex-1 flex gap-4 min-h-0">
|
||||
|
||||
{/* ЛЕВЫЙ БЛОК: ПОСТАВЩИКИ И ТОВАРЫ */}
|
||||
<div className="flex-1 flex flex-col gap-2 min-h-0">
|
||||
<div className="flex-1 flex flex-col gap-4 min-h-0">
|
||||
|
||||
{/* БЛОК 1: ПОСТАВЩИКИ - обязательный блок согласно rules1.md 19.2.1 */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl flex-shrink-0 flex flex-col"
|
||||
@ -702,107 +875,279 @@ export function CreateSuppliersSupplyPage() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="space-y-4">
|
||||
{products.map((product: GoodsProduct) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="glass-card hover:border-white/20 hover:bg-white/10 hover:scale-105 hover:shadow-xl hover:shadow-blue-500/20 transition-all duration-200 cursor-pointer group"
|
||||
className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-4 hover:border-white/30 transition-all duration-200"
|
||||
>
|
||||
<div className="p-5 space-y-4">
|
||||
{product.mainImage && (
|
||||
<div className="w-full h-40 rounded-lg overflow-hidden bg-white/5">
|
||||
<Image
|
||||
src={product.mainImage}
|
||||
alt={product.name}
|
||||
width={280}
|
||||
height={160}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* ОСНОВНОЙ БЛОК: Информация о товаре + количество + сумма */}
|
||||
<div className="flex items-start gap-6 mb-4">
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="text-white font-semibold text-base line-clamp-2 group-hover:text-white transition-colors">
|
||||
{product.name}
|
||||
</h4>
|
||||
<p className="text-white/60 text-sm font-mono mt-1">Артикул: {product.article}</p>
|
||||
</div>
|
||||
|
||||
{product.category && (
|
||||
<Badge className="bg-blue-500/20 text-blue-300 border border-blue-500/30 text-xs font-medium">
|
||||
{product.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-white font-bold text-lg">
|
||||
{product.price.toLocaleString('ru-RU')} ₽
|
||||
</p>
|
||||
<p className="text-white/60 text-xs">за единицу</p>
|
||||
{/* ЛЕВЫЙ БЛОК: Изображение + основная информация */}
|
||||
<div className="flex items-start gap-4 flex-1">
|
||||
<div className="w-24 h-24 bg-white/5 rounded-lg overflow-hidden flex-shrink-0">
|
||||
{product.mainImage ? (
|
||||
<Image
|
||||
src={product.mainImage}
|
||||
alt={product.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Package className="h-8 w-8 text-white/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-white font-semibold text-lg mb-1">{product.name}</h4>
|
||||
<p className="text-white/60 text-sm mb-2 font-mono">Артикул: {product.article}</p>
|
||||
{product.category && (
|
||||
<Badge className="bg-blue-500/20 text-blue-300 border border-blue-500/30 text-xs font-medium mb-2">
|
||||
{product.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-white font-bold text-xl">{product.price.toLocaleString('ru-RU')} ₽</span>
|
||||
{product.quantity !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
product.quantity > 0 ? 'bg-green-400' : 'bg-red-400'
|
||||
}`}></div>
|
||||
<p className={`text-xs font-medium ${
|
||||
<span className={`text-sm font-medium ${
|
||||
product.quantity > 0 ? 'text-green-400' : 'text-red-400'
|
||||
}`}>
|
||||
{product.quantity > 0
|
||||
? `Доступно: ${product.quantity}`
|
||||
: 'Нет в наличии'
|
||||
}
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Поле количества с кнопками +/- согласно rules2.md 13.3 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => updateProductQuantity(product.id, -1)}
|
||||
className="h-8 w-8 p-0 bg-white/5 border-white/20 hover:bg-white/10 hover:border-white/30 text-white"
|
||||
disabled={!getProductQuantity(product.id) || getProductQuantity(product.id) <= 0}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max={product.quantity}
|
||||
value={getProductQuantity(product.id) || ""}
|
||||
onChange={(e) => setProductQuantity(product.id, parseInt(e.target.value) || 0)}
|
||||
className="h-8 w-20 text-center bg-white/5 border-white/20 text-white placeholder:text-white/40 focus:border-white/40"
|
||||
placeholder="00000"
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => updateProductQuantity(product.id, 1)}
|
||||
className="h-8 w-8 p-0 bg-white/5 border-white/20 hover:bg-white/10 hover:border-white/30 text-white"
|
||||
disabled={product.quantity === 0 || getProductQuantity(product.id) >= product.quantity}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ПРАВЫЙ БЛОК: Количество + общая сумма */}
|
||||
<div className="flex items-center gap-4 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => addToCart(product)}
|
||||
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white border border-green-500/30 hover:border-green-400/50 transition-all duration-200 h-8 text-xs"
|
||||
disabled={product.quantity === 0 || !getProductQuantity(product.id)}
|
||||
variant="outline"
|
||||
onClick={() => updateProductQuantity(product.id, -1)}
|
||||
className="h-8 w-8 p-0 bg-white/5 border-white/20 hover:bg-white/10 hover:border-white/30 text-white"
|
||||
disabled={!getProductQuantity(product.id) || getProductQuantity(product.id) <= 0}
|
||||
>
|
||||
<ShoppingCart className="h-3 w-3 mr-1" />
|
||||
В корзину
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max={product.quantity}
|
||||
value={getProductQuantity(product.id) || ""}
|
||||
onChange={(e) => setProductQuantity(product.id, parseInt(e.target.value) || 0)}
|
||||
className="h-8 w-20 text-center bg-white/5 border-white/20 text-white placeholder:text-white/40 focus:border-white/40"
|
||||
placeholder="0"
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => updateProductQuantity(product.id, 1)}
|
||||
className="h-8 w-8 p-0 bg-white/5 border-white/20 hover:bg-white/10 hover:border-white/30 text-white"
|
||||
disabled={product.quantity === 0 || getProductQuantity(product.id) >= (product.quantity || 0)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{getProductQuantity(product.id) > 0 && (
|
||||
<div className="bg-gradient-to-r from-green-500/10 to-emerald-500/10 border border-green-500/20 rounded-lg px-4 py-2">
|
||||
<span className="text-green-400 font-bold text-lg">
|
||||
{(product.price * getProductQuantity(product.id)).toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* БЛОК РЕЦЕПТУРЫ: 4 колонки с чекбоксами */}
|
||||
<div className="grid grid-cols-4 gap-4 pt-4 border-t border-white/10 mb-4">
|
||||
|
||||
{/* КОЛОНКА 1: Услуги фулфилмента */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-white/80 font-medium text-sm flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-purple-400" />
|
||||
Услуги ФФ
|
||||
</h5>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{fulfillmentServices.length > 0 ? fulfillmentServices.map(service => {
|
||||
const recipe = productRecipes[product.id];
|
||||
const isSelected = recipe?.selectedServices.includes(service.id) || false;
|
||||
return (
|
||||
<label key={service.id} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-white/5 p-1 rounded">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleService(product.id, service.id)}
|
||||
className="w-3 h-3 rounded bg-white/10 border-white/20 text-purple-400 focus:ring-purple-400/50 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-white/70 flex-1 truncate">{service.name}</span>
|
||||
<span className="text-purple-400 text-xs font-medium">{service.price.toLocaleString('ru-RU')}₽</span>
|
||||
</label>
|
||||
);
|
||||
}) : (
|
||||
<div className="text-white/50 text-xs p-2 bg-white/5 rounded border border-white/10">
|
||||
{selectedFulfillment ? 'Услуги загружаются...' : 'Выберите фулфилмент-центр'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* КОЛОНКА 2: Расходники фулфилмента */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-white/80 font-medium text-sm flex items-center gap-2">
|
||||
<Box className="h-4 w-4 text-orange-400" />
|
||||
Расходники ФФ
|
||||
</h5>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{fulfillmentConsumables.length > 0 ? fulfillmentConsumables.map(consumable => {
|
||||
const recipe = productRecipes[product.id];
|
||||
const isSelected = recipe?.selectedFFConsumables.includes(consumable.id) || false;
|
||||
return (
|
||||
<label key={consumable.id} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-white/5 p-1 rounded">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleFFConsumable(product.id, consumable.id)}
|
||||
className="w-3 h-3 rounded bg-white/10 border-white/20 text-orange-400 focus:ring-orange-400/50 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-white/70 flex-1 truncate">{consumable.name}</span>
|
||||
<span className="text-orange-400 text-xs font-medium">{consumable.price.toLocaleString('ru-RU')}₽</span>
|
||||
</label>
|
||||
);
|
||||
}) : (
|
||||
<div className="text-white/50 text-xs p-2 bg-white/5 rounded border border-white/10">
|
||||
{selectedFulfillment ? 'Расходники загружаются...' : 'Выберите фулфилмент-центр'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* КОЛОНКА 3: Расходники селлера */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-white/80 font-medium text-sm flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-blue-400" />
|
||||
Расходники селлера
|
||||
</h5>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{sellerConsumables.length > 0 ? sellerConsumables.map(consumable => {
|
||||
const recipe = productRecipes[product.id];
|
||||
const isSelected = recipe?.selectedSellerConsumables.includes(consumable.id) || false;
|
||||
return (
|
||||
<label key={consumable.id} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-white/5 p-1 rounded">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSellerConsumable(product.id, consumable.id)}
|
||||
className="w-3 h-3 rounded bg-white/10 border-white/20 text-blue-400 focus:ring-blue-400/50 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-white/70 flex-1 truncate">{consumable.name}</span>
|
||||
<span className="text-blue-400 text-xs">Склад: {consumable.stock}</span>
|
||||
</label>
|
||||
);
|
||||
}) : (
|
||||
<div className="text-white/50 text-xs p-2 bg-white/5 rounded border border-white/10">
|
||||
Расходники селлера загружаются...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* КОЛОНКА 4: Карточки Wildberries */}
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-white/80 font-medium text-sm flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-pink-400" />
|
||||
Карточки WB
|
||||
</h5>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{wbCards.length > 0 ? wbCards.map(card => {
|
||||
const recipe = productRecipes[product.id];
|
||||
const isSelected = recipe?.selectedWBCard === card.id;
|
||||
return (
|
||||
<label key={card.id} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-white/5 p-1 rounded">
|
||||
<input
|
||||
type="radio"
|
||||
name={`wb-card-${product.id}`}
|
||||
checked={isSelected}
|
||||
onChange={() => setWBCard(product.id, card.id)}
|
||||
className="w-3 h-3 rounded-full bg-white/10 border-white/20 text-pink-400 focus:ring-pink-400/50 focus:ring-offset-0"
|
||||
/>
|
||||
<span className="text-white/70 flex-1 truncate">{card.title}</span>
|
||||
<span className="text-pink-400 text-xs">{card.nmID}</span>
|
||||
</label>
|
||||
);
|
||||
}) : (
|
||||
<div className="text-white/50 text-xs p-2 bg-white/5 rounded border border-white/10">
|
||||
Карточки WB загружаются...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* НИЖНИЙ БЛОК: Итоговая стоимость рецептуры + кнопка добавления */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-white/10">
|
||||
<div className="flex items-center gap-6">
|
||||
{(() => {
|
||||
const quantity = getProductQuantity(product.id);
|
||||
const recipeCost = calculateRecipeCost(product.id);
|
||||
const productTotal = product.price * quantity;
|
||||
const totalRecipePrice = productTotal + recipeCost.total;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-sm text-white/70">
|
||||
Товар: <span className="text-white font-semibold">{productTotal.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
<div className="text-sm text-white/70">
|
||||
Услуги: <span className="text-purple-400 font-semibold">{recipeCost.services.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
<div className="text-sm text-white/70">
|
||||
Расходники: <span className="text-orange-400 font-semibold">{recipeCost.consumables.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{(() => {
|
||||
const quantity = getProductQuantity(product.id);
|
||||
const recipeCost = calculateRecipeCost(product.id);
|
||||
const productTotal = product.price * quantity;
|
||||
const totalRecipePrice = productTotal + recipeCost.total;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-gradient-to-r from-green-500/20 to-emerald-500/20 border border-green-500/30 rounded-lg px-4 py-2">
|
||||
<span className="text-white/70 text-sm">Итого: </span>
|
||||
<span className="text-green-400 font-bold text-lg">
|
||||
{totalRecipePrice.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => addToCart(product)}
|
||||
disabled={quantity === 0}
|
||||
className="bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white border border-green-500/30 hover:border-green-400/50 transition-all duration-200"
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||
Добавить рецептуру
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -216,11 +216,11 @@ export function CreateSupplyPage() {
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main
|
||||
className={`flex-1 ${getSidebarMargin()} overflow-auto transition-all duration-300`}
|
||||
className={`flex-1 ${getSidebarMargin()} overflow-auto transition-all duration-300 p-4`}
|
||||
>
|
||||
<div className="min-h-full w-full flex flex-col px-3 py-2">
|
||||
<div className="min-h-full w-full flex flex-col gap-4">
|
||||
{/* Заголовок */}
|
||||
<div className="flex items-center justify-between mb-3 flex-shrink-0">
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">
|
||||
Создание поставки товаров
|
||||
@ -241,7 +241,7 @@ export function CreateSupplyPage() {
|
||||
</div>
|
||||
|
||||
{/* Основной контент - карточки Wildberries */}
|
||||
<div className="flex-1 flex gap-3 min-h-0">
|
||||
<div className="flex-1 flex gap-4 min-h-0">
|
||||
{/* Левая колонка - карточки товаров */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<DirectSupplyCreation
|
||||
|
@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatsCard } from "../ui/stats-card";
|
||||
import { StatsGrid } from "../ui/stats-grid";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { GET_MY_FULFILLMENT_SUPPLIES } from "@/graphql/queries";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@ -79,73 +81,12 @@ interface FulfillmentConsumableSupply {
|
||||
status: "planned" | "in-transit" | "delivered" | "completed";
|
||||
}
|
||||
|
||||
// Моковые данные для расходников фулфилмента
|
||||
const mockFulfillmentConsumables: FulfillmentConsumableSupply[] = [
|
||||
{
|
||||
id: "ffc1",
|
||||
number: 2001,
|
||||
deliveryDate: "2024-01-18",
|
||||
createdDate: "2024-01-14",
|
||||
status: "delivered",
|
||||
plannedTotal: 5000,
|
||||
actualTotal: 4950,
|
||||
defectTotal: 50,
|
||||
totalConsumablesPrice: 125000,
|
||||
totalLogisticsPrice: 8000,
|
||||
grandTotal: 133000,
|
||||
routes: [
|
||||
{
|
||||
id: "ffcr1",
|
||||
from: "Склад расходников",
|
||||
fromAddress: "Москва, ул. Промышленная, 12",
|
||||
to: "SFERAV Logistics ФФ",
|
||||
toAddress: "Москва, ул. Складская, 15",
|
||||
totalConsumablesPrice: 125000,
|
||||
logisticsPrice: 8000,
|
||||
totalAmount: 133000,
|
||||
suppliers: [
|
||||
{
|
||||
id: "ffcs1",
|
||||
name: 'ООО "УпакСервис ФФ"',
|
||||
inn: "7703456789",
|
||||
contact: "+7 (495) 777-88-99",
|
||||
address: "Москва, ул. Упаковочная, 5",
|
||||
totalAmount: 75000,
|
||||
consumables: [
|
||||
{
|
||||
id: "ffcons1",
|
||||
name: "Коробки для ФФ 40x30x15",
|
||||
sku: "BOX-FF-403015",
|
||||
category: "Расходники фулфилмента",
|
||||
type: "packaging",
|
||||
plannedQty: 2000,
|
||||
actualQty: 1980,
|
||||
defectQty: 20,
|
||||
unitPrice: 45,
|
||||
parameters: [
|
||||
{
|
||||
id: "ffcp1",
|
||||
name: "Размер",
|
||||
value: "40x30x15",
|
||||
unit: "см",
|
||||
},
|
||||
{
|
||||
id: "ffcp2",
|
||||
name: "Материал",
|
||||
value: "Гофрокартон усиленный",
|
||||
},
|
||||
{ id: "ffcp3", name: "Плотность", value: "5", unit: "слоев" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function FulfillmentSuppliesTab() {
|
||||
// Загружаем реальные данные расходников фулфилмента
|
||||
const { data: fulfillmentSuppliesData, loading, error } = useQuery(GET_MY_FULFILLMENT_SUPPLIES, {
|
||||
errorPolicy: 'all'
|
||||
});
|
||||
|
||||
const [expandedSupplies, setExpandedSupplies] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
@ -157,6 +98,23 @@ export function FulfillmentSuppliesTab() {
|
||||
new Set()
|
||||
);
|
||||
|
||||
// Преобразуем данные из GraphQL в нужный формат
|
||||
const fulfillmentConsumables: FulfillmentConsumableSupply[] = (fulfillmentSuppliesData?.myFulfillmentSupplies || [])
|
||||
.map((supply: any, index: number) => ({
|
||||
id: supply.id,
|
||||
number: index + 2000, // Начинаем с 2000 для отличия от товаров
|
||||
deliveryDate: supply.date || new Date().toISOString().split('T')[0],
|
||||
createdDate: supply.createdAt?.split('T')[0] || new Date().toISOString().split('T')[0],
|
||||
status: supply.status === 'active' ? 'delivered' : 'planned',
|
||||
plannedTotal: supply.quantity || 0,
|
||||
actualTotal: supply.currentStock || 0,
|
||||
defectTotal: 0,
|
||||
totalConsumablesPrice: supply.price * (supply.quantity || 0),
|
||||
totalLogisticsPrice: 0,
|
||||
grandTotal: supply.price * (supply.quantity || 0),
|
||||
routes: []
|
||||
}));
|
||||
|
||||
const toggleSupplyExpansion = (supplyId: string) => {
|
||||
const newExpanded = new Set(expandedSupplies);
|
||||
if (newExpanded.has(supplyId)) {
|
||||
@ -273,7 +231,7 @@ export function FulfillmentSuppliesTab() {
|
||||
<StatsGrid>
|
||||
<StatsCard
|
||||
title="Расходники фулфилмента"
|
||||
value={mockFulfillmentConsumables.length}
|
||||
value={loading ? 0 : fulfillmentConsumables.length}
|
||||
icon={Package2}
|
||||
iconColor="text-orange-400"
|
||||
iconBg="bg-orange-500/20"
|
||||
@ -284,7 +242,7 @@ export function FulfillmentSuppliesTab() {
|
||||
<StatsCard
|
||||
title="Сумма расходников фулфилмента"
|
||||
value={formatCurrency(
|
||||
mockFulfillmentConsumables.reduce(
|
||||
loading ? 0 : fulfillmentConsumables.reduce(
|
||||
(sum, supply) => sum + supply.grandTotal,
|
||||
0
|
||||
)
|
||||
@ -299,7 +257,7 @@ export function FulfillmentSuppliesTab() {
|
||||
<StatsCard
|
||||
title="В пути"
|
||||
value={
|
||||
mockFulfillmentConsumables.filter(
|
||||
loading ? 0 : fulfillmentConsumables.filter(
|
||||
(supply) => supply.status === "in-transit"
|
||||
).length
|
||||
}
|
||||
@ -312,7 +270,7 @@ export function FulfillmentSuppliesTab() {
|
||||
<StatsCard
|
||||
title="Активные поставки"
|
||||
value={
|
||||
mockFulfillmentConsumables.filter(
|
||||
loading ? 0 : fulfillmentConsumables.filter(
|
||||
(supply) => supply.status === "delivered"
|
||||
).length
|
||||
}
|
||||
@ -354,7 +312,25 @@ export function FulfillmentSuppliesTab() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockFulfillmentConsumables.map((supply) => {
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={11} className="p-8 text-center">
|
||||
<div className="text-white/60">Загрузка данных...</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && fulfillmentConsumables.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={11} className="p-8 text-center">
|
||||
<div className="text-white/60">
|
||||
<Package2 className="h-12 w-12 mx-auto mb-4 text-white/20" />
|
||||
<div className="text-lg font-semibold text-white mb-2">Расходники фулфилмента не найдены</div>
|
||||
<div>Создайте первую поставку расходников для фулфилмента</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && fulfillmentConsumables.map((supply) => {
|
||||
const isSupplyExpanded = expandedSupplies.has(supply.id);
|
||||
|
||||
return (
|
||||
|
@ -4,6 +4,8 @@ import React, { useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { GET_SUPPLY_ORDERS } from "@/graphql/queries";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@ -75,119 +77,14 @@ interface Supply {
|
||||
status: "planned" | "in-transit" | "delivered" | "completed";
|
||||
}
|
||||
|
||||
// Моковые данные для товаров
|
||||
const mockGoodsSupplies: Supply[] = [
|
||||
{
|
||||
id: "1",
|
||||
number: 1,
|
||||
deliveryDate: "2024-01-15",
|
||||
createdDate: "2024-01-10",
|
||||
status: "delivered",
|
||||
plannedTotal: 180,
|
||||
actualTotal: 173,
|
||||
defectTotal: 2,
|
||||
totalProductPrice: 3750000,
|
||||
totalFulfillmentPrice: 43000,
|
||||
totalLogisticsPrice: 27000,
|
||||
grandTotal: 3820000,
|
||||
routes: [
|
||||
{
|
||||
id: "r1",
|
||||
from: "Садовод",
|
||||
fromAddress: "Москва, 14-й км МКАД",
|
||||
to: "SFERAV Logistics",
|
||||
toAddress: "Москва, ул. Складская, 15",
|
||||
totalProductPrice: 3600000,
|
||||
fulfillmentServicePrice: 25000,
|
||||
logisticsPrice: 15000,
|
||||
totalAmount: 3640000,
|
||||
wholesalers: [
|
||||
{
|
||||
id: "w1",
|
||||
name: 'ООО "ТехноСнаб"',
|
||||
inn: "7701234567",
|
||||
contact: "+7 (495) 123-45-67",
|
||||
address: "Москва, ул. Торговая, 1",
|
||||
totalAmount: 3600000,
|
||||
products: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "Смартфон iPhone 15",
|
||||
sku: "APL-IP15-128",
|
||||
category: "Электроника",
|
||||
plannedQty: 50,
|
||||
actualQty: 48,
|
||||
defectQty: 2,
|
||||
productPrice: 75000,
|
||||
parameters: [
|
||||
{ id: "param1", name: "Цвет", value: "Черный" },
|
||||
{ id: "param2", name: "Память", value: "128", unit: "ГБ" },
|
||||
{ id: "param3", name: "Гарантия", value: "12", unit: "мес" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
number: 2,
|
||||
deliveryDate: "2024-01-20",
|
||||
createdDate: "2024-01-12",
|
||||
status: "in-transit",
|
||||
plannedTotal: 30,
|
||||
actualTotal: 30,
|
||||
defectTotal: 0,
|
||||
totalProductPrice: 750000,
|
||||
totalFulfillmentPrice: 18000,
|
||||
totalLogisticsPrice: 12000,
|
||||
grandTotal: 780000,
|
||||
routes: [
|
||||
{
|
||||
id: "r3",
|
||||
from: "Садовод",
|
||||
fromAddress: "Москва, 14-й км МКАД",
|
||||
to: "WB Подольск",
|
||||
toAddress: "Подольск, ул. Складская, 25",
|
||||
totalProductPrice: 750000,
|
||||
fulfillmentServicePrice: 18000,
|
||||
logisticsPrice: 12000,
|
||||
totalAmount: 780000,
|
||||
wholesalers: [
|
||||
{
|
||||
id: "w3",
|
||||
name: 'ООО "АудиоТех"',
|
||||
inn: "7702345678",
|
||||
contact: "+7 (495) 555-12-34",
|
||||
address: "Москва, ул. Звуковая, 8",
|
||||
totalAmount: 750000,
|
||||
products: [
|
||||
{
|
||||
id: "p3",
|
||||
name: "Наушники AirPods Pro",
|
||||
sku: "APL-AP-PRO2",
|
||||
category: "Аудио",
|
||||
plannedQty: 30,
|
||||
actualQty: 30,
|
||||
defectQty: 0,
|
||||
productPrice: 25000,
|
||||
parameters: [
|
||||
{ id: "param6", name: "Тип", value: "Беспроводные" },
|
||||
{ id: "param7", name: "Шумоподавление", value: "Активное" },
|
||||
{ id: "param8", name: "Время работы", value: "6", unit: "ч" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
// Данные поставок товаров из GraphQL
|
||||
|
||||
export function SuppliesGoodsTab() {
|
||||
// Загружаем реальные данные поставок товаров
|
||||
const { data: supplyOrdersData, loading, error } = useQuery(GET_SUPPLY_ORDERS, {
|
||||
errorPolicy: 'all'
|
||||
});
|
||||
|
||||
const [expandedSupplies, setExpandedSupplies] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
@ -199,6 +96,25 @@ export function SuppliesGoodsTab() {
|
||||
new Set()
|
||||
);
|
||||
|
||||
// Преобразуем данные из GraphQL в нужный формат
|
||||
const goodsSupplies: Supply[] = (supplyOrdersData?.supplyOrders || [])
|
||||
.filter((order: any) => order.status === 'CONFIRMED' || order.status === 'DELIVERED')
|
||||
.map((order: any, index: number) => ({
|
||||
id: order.id,
|
||||
number: index + 1,
|
||||
deliveryDate: order.deliveryDate || new Date().toISOString().split('T')[0],
|
||||
createdDate: order.createdAt?.split('T')[0] || new Date().toISOString().split('T')[0],
|
||||
status: order.status === 'DELIVERED' ? 'delivered' : 'in-transit',
|
||||
plannedTotal: order.totalItems || 0,
|
||||
actualTotal: order.totalItems || 0,
|
||||
defectTotal: 0,
|
||||
totalProductPrice: order.totalAmount || 0,
|
||||
totalFulfillmentPrice: 0,
|
||||
totalLogisticsPrice: 0,
|
||||
grandTotal: order.totalAmount || 0,
|
||||
routes: []
|
||||
}));
|
||||
|
||||
const toggleSupplyExpansion = (supplyId: string) => {
|
||||
const newExpanded = new Set(expandedSupplies);
|
||||
if (newExpanded.has(supplyId)) {
|
||||
@ -321,7 +237,7 @@ export function SuppliesGoodsTab() {
|
||||
<div>
|
||||
<p className="text-white/60 text-xs">Поставок товаров</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{mockGoodsSupplies.length}
|
||||
{loading ? '...' : goodsSupplies.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -335,8 +251,8 @@ export function SuppliesGoodsTab() {
|
||||
<div>
|
||||
<p className="text-white/60 text-xs">Сумма товаров</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{formatCurrency(
|
||||
mockGoodsSupplies.reduce(
|
||||
{loading ? '...' : formatCurrency(
|
||||
goodsSupplies.reduce(
|
||||
(sum, supply) => sum + supply.grandTotal,
|
||||
0
|
||||
)
|
||||
@ -354,8 +270,8 @@ export function SuppliesGoodsTab() {
|
||||
<div>
|
||||
<p className="text-white/60 text-xs">В пути</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{
|
||||
mockGoodsSupplies.filter(
|
||||
{loading ? '...' :
|
||||
goodsSupplies.filter(
|
||||
(supply) => supply.status === "in-transit"
|
||||
).length
|
||||
}
|
||||
@ -372,8 +288,8 @@ export function SuppliesGoodsTab() {
|
||||
<div>
|
||||
<p className="text-white/60 text-xs">С браком</p>
|
||||
<p className="text-xl font-bold text-white">
|
||||
{
|
||||
mockGoodsSupplies.filter((supply) => supply.defectTotal > 0)
|
||||
{loading ? '...' :
|
||||
goodsSupplies.filter((supply) => supply.defectTotal > 0)
|
||||
.length
|
||||
}
|
||||
</p>
|
||||
@ -416,7 +332,25 @@ export function SuppliesGoodsTab() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockGoodsSupplies.map((supply) => {
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={11} className="p-8 text-center">
|
||||
<div className="text-white/60">Загрузка данных...</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && goodsSupplies.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={11} className="p-8 text-center">
|
||||
<div className="text-white/60">
|
||||
<Package className="h-12 w-12 mx-auto mb-4 text-white/20" />
|
||||
<div className="text-lg font-semibold text-white mb-2">Поставки товаров не найдены</div>
|
||||
<div>Создайте первую поставку товаров через карточки или поставщиков</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && goodsSupplies.map((supply) => {
|
||||
const isSupplyExpanded = expandedSupplies.has(supply.id);
|
||||
|
||||
return (
|
||||
|
@ -5,6 +5,8 @@ import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatsCard } from "../ui/stats-card";
|
||||
import { StatsGrid } from "../ui/stats-grid";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import { GET_MY_WILDBERRIES_SUPPLIES } from "@/graphql/queries";
|
||||
import {
|
||||
Calendar,
|
||||
Package,
|
||||
@ -66,129 +68,31 @@ interface WbSupply {
|
||||
status: "planned" | "in-transit" | "delivered" | "completed";
|
||||
}
|
||||
|
||||
// Моковые данные для поставок на Wildberries
|
||||
const mockWbSupplies: WbSupply[] = [
|
||||
{
|
||||
id: "wb1",
|
||||
number: 4001,
|
||||
supplyId: "WB24010001",
|
||||
deliveryDate: "2024-01-22",
|
||||
createdDate: "2024-01-16",
|
||||
status: "delivered",
|
||||
plannedTotal: 120,
|
||||
actualTotal: 118,
|
||||
defectTotal: 2,
|
||||
totalProductPrice: 2400000,
|
||||
totalLogisticsPrice: 18000,
|
||||
grandTotal: 2418000,
|
||||
routes: [
|
||||
{
|
||||
id: "wbr1",
|
||||
from: "Садовод",
|
||||
fromAddress: "Москва, 14-й км МКАД",
|
||||
to: "WB Подольск",
|
||||
toAddress: "Подольск, ул. Складская, 25",
|
||||
totalProductPrice: 2400000,
|
||||
logisticsPrice: 18000,
|
||||
totalAmount: 2418000,
|
||||
warehouses: [
|
||||
{
|
||||
id: "wbw1",
|
||||
name: "Склад WB Подольск",
|
||||
address: "Подольск, ул. Складская, 25",
|
||||
warehouseId: 117501,
|
||||
totalAmount: 2400000,
|
||||
products: [
|
||||
{
|
||||
id: "wbp1",
|
||||
name: "Смартфон Samsung Galaxy S24",
|
||||
sku: "SAMS-GS24-256",
|
||||
nmId: 123456789,
|
||||
category: "Смартфоны и гаджеты",
|
||||
plannedQty: 40,
|
||||
actualQty: 39,
|
||||
defectQty: 1,
|
||||
productPrice: 65000,
|
||||
},
|
||||
{
|
||||
id: "wbp2",
|
||||
name: "Чехол для Samsung Galaxy S24",
|
||||
sku: "CASE-GS24-BLK",
|
||||
nmId: 987654321,
|
||||
category: "Аксессуары для телефонов",
|
||||
plannedQty: 80,
|
||||
actualQty: 79,
|
||||
defectQty: 1,
|
||||
productPrice: 1200,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "wb2",
|
||||
number: 4002,
|
||||
supplyId: "WB24010002",
|
||||
deliveryDate: "2024-01-28",
|
||||
createdDate: "2024-01-20",
|
||||
status: "in-transit",
|
||||
plannedTotal: 60,
|
||||
actualTotal: 60,
|
||||
defectTotal: 0,
|
||||
totalProductPrice: 1800000,
|
||||
totalLogisticsPrice: 15000,
|
||||
grandTotal: 1815000,
|
||||
routes: [
|
||||
{
|
||||
id: "wbr2",
|
||||
from: "ТЯК Москва",
|
||||
fromAddress: "Москва, Алтуфьевское шоссе, 27",
|
||||
to: "WB Электросталь",
|
||||
toAddress: "Электросталь, ул. Промышленная, 10",
|
||||
totalProductPrice: 1800000,
|
||||
logisticsPrice: 15000,
|
||||
totalAmount: 1815000,
|
||||
warehouses: [
|
||||
{
|
||||
id: "wbw2",
|
||||
name: "Склад WB Электросталь",
|
||||
address: "Электросталь, ул. Промышленная, 10",
|
||||
warehouseId: 117986,
|
||||
totalAmount: 1800000,
|
||||
products: [
|
||||
{
|
||||
id: "wbp3",
|
||||
name: "Наушники Sony WH-1000XM5",
|
||||
sku: "SONY-WH1000XM5",
|
||||
nmId: 555666777,
|
||||
category: "Наушники и аудио",
|
||||
plannedQty: 30,
|
||||
actualQty: 30,
|
||||
defectQty: 0,
|
||||
productPrice: 35000,
|
||||
},
|
||||
{
|
||||
id: "wbp4",
|
||||
name: "Кабель USB-C",
|
||||
sku: "CABLE-USBC-2M",
|
||||
nmId: 111222333,
|
||||
category: "Кабели и адаптеры",
|
||||
plannedQty: 30,
|
||||
actualQty: 30,
|
||||
defectQty: 0,
|
||||
productPrice: 800,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function WildberriesSuppliesTab() {
|
||||
// Загружаем реальные данные поставок на Wildberries
|
||||
const { data: wbSuppliesData, loading, error } = useQuery(GET_MY_WILDBERRIES_SUPPLIES, {
|
||||
errorPolicy: 'all'
|
||||
});
|
||||
|
||||
// Преобразуем данные из GraphQL в нужный формат
|
||||
const wbSupplies: WbSupply[] = (wbSuppliesData?.myWildberriesSupplies || [])
|
||||
.map((supply: any, index: number) => ({
|
||||
id: supply.id,
|
||||
number: index + 4000, // Начинаем с 4000 для WB поставок
|
||||
supplyId: `WB${new Date().getFullYear()}${String(index + 1).padStart(6, '0')}`,
|
||||
deliveryDate: supply.deliveryDate || new Date().toISOString().split('T')[0],
|
||||
createdDate: supply.createdAt?.split('T')[0] || new Date().toISOString().split('T')[0],
|
||||
status: supply.status === 'DELIVERED' ? 'delivered' : 'in-transit',
|
||||
plannedTotal: supply.totalItems || 0,
|
||||
actualTotal: supply.totalItems || 0,
|
||||
defectTotal: 0,
|
||||
totalProductPrice: supply.totalAmount || 0,
|
||||
totalLogisticsPrice: 0,
|
||||
grandTotal: supply.totalAmount || 0,
|
||||
routes: []
|
||||
}));
|
||||
|
||||
|
||||
const [expandedSupplies, setExpandedSupplies] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
@ -316,7 +220,7 @@ export function WildberriesSuppliesTab() {
|
||||
<StatsGrid>
|
||||
<StatsCard
|
||||
title="Поставок на WB"
|
||||
value={mockWbSupplies.length}
|
||||
value={loading ? 0 : wbSupplies.length}
|
||||
icon={ShoppingBag}
|
||||
iconColor="text-purple-400"
|
||||
iconBg="bg-purple-500/20"
|
||||
@ -327,7 +231,7 @@ export function WildberriesSuppliesTab() {
|
||||
<StatsCard
|
||||
title="Сумма WB поставок"
|
||||
value={formatCurrency(
|
||||
mockWbSupplies.reduce((sum, supply) => sum + supply.grandTotal, 0)
|
||||
loading ? 0 : wbSupplies.reduce((sum, supply) => sum + supply.grandTotal, 0)
|
||||
)}
|
||||
icon={TrendingUp}
|
||||
iconColor="text-green-400"
|
||||
@ -339,7 +243,7 @@ export function WildberriesSuppliesTab() {
|
||||
<StatsCard
|
||||
title="В пути"
|
||||
value={
|
||||
mockWbSupplies.filter((supply) => supply.status === "in-transit")
|
||||
loading ? 0 : wbSupplies.filter((supply) => supply.status === "in-transit")
|
||||
.length
|
||||
}
|
||||
icon={Calendar}
|
||||
@ -351,7 +255,7 @@ export function WildberriesSuppliesTab() {
|
||||
<StatsCard
|
||||
title="С браком"
|
||||
value={
|
||||
mockWbSupplies.filter((supply) => supply.defectTotal > 0).length
|
||||
loading ? 0 : wbSupplies.filter((supply) => supply.defectTotal > 0).length
|
||||
}
|
||||
icon={AlertTriangle}
|
||||
iconColor="text-red-400"
|
||||
@ -395,7 +299,25 @@ export function WildberriesSuppliesTab() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockWbSupplies.map((supply) => {
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={11} className="p-8 text-center">
|
||||
<div className="text-white/60">Загрузка данных...</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && wbSupplies.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={11} className="p-8 text-center">
|
||||
<div className="text-white/60">
|
||||
<ShoppingBag className="h-12 w-12 mx-auto mb-4 text-white/20" />
|
||||
<div className="text-lg font-semibold text-white mb-2">Поставки на Wildberries не найдены</div>
|
||||
<div>Создайте первую поставку товаров на Wildberries</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && wbSupplies.map((supply) => {
|
||||
const isSupplyExpanded = expandedSupplies.has(supply.id);
|
||||
|
||||
return (
|
||||
|
@ -93,12 +93,12 @@ export function SuppliesDashboard() {
|
||||
<div className="h-screen flex overflow-hidden">
|
||||
<Sidebar />
|
||||
<main
|
||||
className={`flex-1 ${getSidebarMargin()} px-2 py-2 overflow-hidden transition-all duration-300`}
|
||||
className={`flex-1 ${getSidebarMargin()} overflow-hidden transition-all duration-300 p-4`}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="h-full flex flex-col gap-4">
|
||||
{/* Уведомляющий баннер */}
|
||||
{hasPendingItems && (
|
||||
<Alert className="mb-4 bg-blue-500/20 border-blue-400/30 text-blue-300 animate-pulse">
|
||||
<Alert className="bg-blue-500/20 border-blue-400/30 text-blue-300 animate-pulse">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{(() => {
|
||||
|
@ -41,6 +41,7 @@ import { toast } from 'sonner'
|
||||
import { format } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { SelectedCard, FulfillmentService, ConsumableService, WildberriesCard } from '@/types/supplies'
|
||||
import { ProductCardSkeletonGrid } from '@/components/ui/product-card-skeleton'
|
||||
|
||||
|
||||
|
||||
@ -86,173 +87,27 @@ export function WBProductCards({ onBack, onComplete, showSummary: externalShowSu
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
// Загружаем реальные карточки WB
|
||||
const { data: wbCardsData, loading: wbCardsLoading } = useQuery(GET_MY_WILDBERRIES_SUPPLIES, {
|
||||
errorPolicy: 'all'
|
||||
});
|
||||
|
||||
// Используем реальные данные из GraphQL запроса
|
||||
const realWbCards: WildberriesCard[] = (wbCardsData?.myWildberriesSupplies || [])
|
||||
.flatMap((supply: any) => supply.cards || [])
|
||||
.map((card: any) => ({
|
||||
nmID: card.nmId || card.nmID,
|
||||
vendorCode: card.vendorCode || '',
|
||||
title: card.title || 'Без названия',
|
||||
description: card.description || '',
|
||||
brand: card.brand || '',
|
||||
object: card.object || '',
|
||||
parent: card.parent || '',
|
||||
countryProduction: card.countryProduction || '',
|
||||
supplierVendorCode: card.supplierVendorCode || '',
|
||||
mediaFiles: card.mediaFiles || [],
|
||||
sizes: card.sizes || []
|
||||
}));
|
||||
|
||||
// Загружаем контрагентов-фулфилментов
|
||||
const { data: counterpartiesData } = useQuery(GET_MY_COUNTERPARTIES)
|
||||
@ -327,7 +182,7 @@ export function WBProductCards({ onBack, onComplete, showSummary: externalShowSu
|
||||
}
|
||||
})
|
||||
|
||||
// Моковые данные рынков
|
||||
// Данные рынков можно будет загружать через GraphQL в будущем
|
||||
const markets = [
|
||||
{ value: 'sadovod', label: 'Садовод' },
|
||||
{ value: 'luzhniki', label: 'Лужники' },
|
||||
@ -337,53 +192,13 @@ export function WBProductCards({ onBack, onComplete, showSummary: externalShowSu
|
||||
|
||||
|
||||
|
||||
// Автоматически загружаем товары при открытии компонента
|
||||
// Загружаем карточки из GraphQL запроса
|
||||
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)
|
||||
}
|
||||
if (!wbCardsLoading && wbCardsData) {
|
||||
setWbCards(realWbCards)
|
||||
console.log('Загружено карточек из GraphQL:', realWbCards.length)
|
||||
}
|
||||
|
||||
loadCards()
|
||||
}, [user])
|
||||
}, [wbCardsData, wbCardsLoading, realWbCards])
|
||||
|
||||
const loadAllCards = async () => {
|
||||
setLoading(true)
|
||||
@ -407,17 +222,15 @@ export function WBProductCards({ onBack, onComplete, showSummary: externalShowSu
|
||||
}
|
||||
}
|
||||
|
||||
// Если API ключ не настроен, загружаем моковые данные
|
||||
console.log('API ключ WB не настроен, загружаем моковые данные')
|
||||
const allCards = getMockCards()
|
||||
setWbCards(allCards)
|
||||
console.log('Загружены моковые товары:', allCards.length)
|
||||
// Если API ключ не настроен, используем данные из GraphQL
|
||||
console.log('API ключ WB не настроен, используем данные из GraphQL')
|
||||
setWbCards(realWbCards)
|
||||
console.log('Используются данные из GraphQL:', realWbCards.length)
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки всех карточек WB:', error)
|
||||
// При ошибке загружаем моковые данные
|
||||
const allCards = getMockCards()
|
||||
setWbCards(allCards)
|
||||
console.log('Загружены моковые товары (fallback):', allCards.length)
|
||||
// При ошибке используем данные из GraphQL
|
||||
setWbCards(realWbCards)
|
||||
console.log('Используются данные из GraphQL (fallback):', realWbCards.length)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -450,12 +263,11 @@ export function WBProductCards({ onBack, onComplete, showSummary: externalShowSu
|
||||
}
|
||||
}
|
||||
|
||||
// Если API ключ не настроен, ищем в моковых данных
|
||||
console.log('API ключ WB не настроен, поиск в моковых данных:', searchTerm)
|
||||
const mockCards = getMockCards()
|
||||
// Если API ключ не настроен, ищем в данных из GraphQL
|
||||
console.log('API ключ WB не настроен, поиск в данных GraphQL:', searchTerm)
|
||||
|
||||
// Фильтруем товары по поисковому запросу
|
||||
const filteredCards = mockCards.filter(card =>
|
||||
const filteredCards = realWbCards.filter(card =>
|
||||
card.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
card.brand.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
card.nmID.toString().includes(searchTerm.toLowerCase()) ||
|
||||
@ -463,19 +275,18 @@ export function WBProductCards({ onBack, onComplete, showSummary: externalShowSu
|
||||
)
|
||||
|
||||
setWbCards(filteredCards)
|
||||
console.log('Найдено моковых товаров:', filteredCards.length)
|
||||
console.log('Найдено товаров в GraphQL данных:', filteredCards.length)
|
||||
} catch (error) {
|
||||
console.error('Ошибка поиска карточек WB:', error)
|
||||
// При ошибке ищем в моковых данных
|
||||
const mockCards = getMockCards()
|
||||
const filteredCards = mockCards.filter(card =>
|
||||
// При ошибке ищем в данных из GraphQL
|
||||
const filteredCards = realWbCards.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)
|
||||
console.log('Найдено товаров в GraphQL данных (fallback):', filteredCards.length)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -1153,27 +964,13 @@ export function WBProductCards({ onBack, onComplete, showSummary: externalShowSu
|
||||
</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 || wbCardsLoading) && (
|
||||
<ProductCardSkeletonGrid count={12} />
|
||||
)}
|
||||
|
||||
{/* Карточки товаров */}
|
||||
{!loading && wbCards.length > 0 && (
|
||||
{!loading && !wbCardsLoading && 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)
|
||||
@ -1353,7 +1150,7 @@ export function WBProductCards({ onBack, onComplete, showSummary: externalShowSu
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wbCards.length === 0 && !loading && (
|
||||
{wbCards.length === 0 && !loading && !wbCardsLoading && (
|
||||
<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" />
|
||||
@ -1374,17 +1171,17 @@ export function WBProductCards({ onBack, onComplete, showSummary: externalShowSu
|
||||
) : (
|
||||
<>
|
||||
<p className="text-white/60 mb-3 text-sm">
|
||||
Для работы с реальными карточками необходимо настроить API ключ Wildberries
|
||||
Для работы с полным функционалом WB API необходимо настроить 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>
|
||||
</>
|
||||
)}
|
||||
|
61
src/components/ui/product-card-skeleton.tsx
Normal file
61
src/components/ui/product-card-skeleton.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import React from 'react'
|
||||
import { Card } from '@/components/ui/card'
|
||||
|
||||
export function ProductCardSkeleton() {
|
||||
return (
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-3 animate-pulse">
|
||||
<div className="space-y-3">
|
||||
{/* Изображение */}
|
||||
<div className="relative">
|
||||
<div className="aspect-square bg-white/20 rounded-lg"></div>
|
||||
{/* Бейджи */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<div className="w-10 h-5 bg-white/30 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация о товаре */}
|
||||
<div className="space-y-2">
|
||||
{/* Бренд и номер */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="w-16 h-4 bg-white/20 rounded"></div>
|
||||
<div className="w-12 h-3 bg-white/15 rounded"></div>
|
||||
</div>
|
||||
|
||||
{/* Название */}
|
||||
<div className="space-y-1">
|
||||
<div className="w-full h-4 bg-white/20 rounded"></div>
|
||||
<div className="w-3/4 h-4 bg-white/15 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Разделитель */}
|
||||
<div className="border-t border-white/10 pt-2">
|
||||
<div className="w-2/3 h-3 bg-white/15 rounded mx-auto"></div>
|
||||
</div>
|
||||
|
||||
{/* Управление */}
|
||||
<div className="space-y-2 bg-white/5 rounded-lg p-2">
|
||||
{/* Кнопки количества */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="h-6 w-6 bg-white/20 rounded"></div>
|
||||
<div className="flex-1 h-6 bg-white/20 rounded"></div>
|
||||
<div className="h-6 w-6 bg-white/20 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProductCardSkeletonGrid({ count = 12 }: { count?: number }) {
|
||||
return (
|
||||
<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(count)].map((_, i) => (
|
||||
<ProductCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user