Создан единый источник истины 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:
Veronika Smirnova
2025-08-05 15:29:41 +03:00
parent ee72a9488b
commit d30e3f9666
23 changed files with 2038 additions and 6162 deletions

View File

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

View File

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

View File

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

View File

@ -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 (

View File

@ -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 (

View File

@ -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 (

View File

@ -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>
{(() => {

View File

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