Объединены файлы правил системы в единую базу знаний v3.0 с устранением противоречий и дублирования. Создан rules-unified.md на основе rules.md, rules1.md и rules2.md с добавлением всех уникальных разделов. Обновлена терминология системы с соответствием реальной схеме БД (ТОВАР→PRODUCT, РАСХОДНИКИ→CONSUMABLE). Архивированы старые файлы правил в папку archive. Обновлены ссылки в CLAUDE.md и development-checklist.md на новый единый источник истины.
🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@ -1,19 +1,10 @@
|
||||
import { AuthGuard } from "@/components/auth-guard";
|
||||
import { CreateSuppliersSupplyPage } from "@/components/supplies/create-suppliers-supply-page";
|
||||
|
||||
export default function CreateSuppliersSupplyPageRoute() {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900">
|
||||
<div className="text-center text-white">
|
||||
<h1 className="text-3xl font-bold mb-4">
|
||||
Создание поставки поставщиков
|
||||
</h1>
|
||||
<p className="text-white/70 mb-6">
|
||||
Заказ товаров у поставщиков с рецептурой
|
||||
</p>
|
||||
<p className="text-sm text-white/50">Раздел находится в разработке</p>
|
||||
</div>
|
||||
</div>
|
||||
<CreateSuppliersSupplyPage />
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
392
src/components/supplies/add-goods-modal.tsx
Normal file
392
src/components/supplies/add-goods-modal.tsx
Normal file
@ -0,0 +1,392 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Package,
|
||||
Plus,
|
||||
Minus,
|
||||
X,
|
||||
FileText,
|
||||
Settings,
|
||||
ShoppingCart,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
interface GoodsProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
category?: { name: string };
|
||||
images: string[];
|
||||
mainImage?: string;
|
||||
sku: string;
|
||||
organization: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
stock?: number;
|
||||
unit?: string;
|
||||
weight?: number;
|
||||
dimensions?: {
|
||||
length: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProductParameter {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface AddGoodsModalProps {
|
||||
product: GoodsProduct | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAdd: (
|
||||
product: GoodsProduct,
|
||||
quantity: number,
|
||||
additionalData: {
|
||||
completeness?: string;
|
||||
recipe?: string;
|
||||
specialRequirements?: string;
|
||||
parameters?: ProductParameter[];
|
||||
customPrice?: number;
|
||||
}
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function AddGoodsModal({
|
||||
product,
|
||||
isOpen,
|
||||
onClose,
|
||||
onAdd,
|
||||
}: AddGoodsModalProps) {
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [completeness, setCompleteness] = useState("");
|
||||
const [recipe, setRecipe] = useState("");
|
||||
const [specialRequirements, setSpecialRequirements] = useState("");
|
||||
const [customPrice, setCustomPrice] = useState("");
|
||||
const [parameters, setParameters] = useState<ProductParameter[]>([]);
|
||||
const [newParameterName, setNewParameterName] = useState("");
|
||||
const [newParameterValue, setNewParameterValue] = useState("");
|
||||
|
||||
// Сброс формы при закрытии
|
||||
const handleClose = () => {
|
||||
setQuantity(1);
|
||||
setCompleteness("");
|
||||
setRecipe("");
|
||||
setSpecialRequirements("");
|
||||
setCustomPrice("");
|
||||
setParameters([]);
|
||||
setNewParameterName("");
|
||||
setNewParameterValue("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Добавление параметра
|
||||
const addParameter = () => {
|
||||
if (newParameterName.trim() && newParameterValue.trim()) {
|
||||
setParameters(prev => [
|
||||
...prev,
|
||||
{ name: newParameterName.trim(), value: newParameterValue.trim() }
|
||||
]);
|
||||
setNewParameterName("");
|
||||
setNewParameterValue("");
|
||||
}
|
||||
};
|
||||
|
||||
// Удаление параметра
|
||||
const removeParameter = (index: number) => {
|
||||
setParameters(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// Обработка добавления в корзину
|
||||
const handleAdd = () => {
|
||||
if (!product) return;
|
||||
|
||||
// Проверка остатков согласно rules2.md 9.7.9
|
||||
if (product.stock !== undefined && quantity > product.stock) {
|
||||
return; // Ошибка будет показана в родительском компоненте
|
||||
}
|
||||
|
||||
const finalPrice = customPrice ? parseFloat(customPrice) : product.price;
|
||||
|
||||
onAdd(product, quantity, {
|
||||
completeness: completeness.trim() || undefined,
|
||||
recipe: recipe.trim() || undefined,
|
||||
specialRequirements: specialRequirements.trim() || undefined,
|
||||
parameters: parameters.length > 0 ? parameters : undefined,
|
||||
customPrice: finalPrice !== product.price ? finalPrice : undefined,
|
||||
});
|
||||
|
||||
handleClose();
|
||||
};
|
||||
|
||||
if (!product) return null;
|
||||
|
||||
const isStockAvailable = product.stock === undefined || quantity <= product.stock;
|
||||
const totalPrice = (customPrice ? parseFloat(customPrice) || product.price : product.price) * quantity;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[600px] bg-white/10 backdrop-blur-xl border border-white/20 text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||
<Package className="h-6 w-6 text-white/80" />
|
||||
Добавить товар в корзину
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 max-h-[70vh] overflow-y-auto pr-2">
|
||||
{/* Информация о товаре */}
|
||||
<div className="bg-white/5 border border-white/10 rounded-lg p-4">
|
||||
<div className="flex gap-4">
|
||||
{product.mainImage && (
|
||||
<div className="w-24 h-24 rounded-lg overflow-hidden bg-white/5 flex-shrink-0">
|
||||
<Image
|
||||
src={product.mainImage}
|
||||
alt={product.name}
|
||||
width={96}
|
||||
height={96}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<h3 className="text-white font-semibold text-lg">{product.name}</h3>
|
||||
<p className="text-white/60 text-sm">Артикул: {product.sku}</p>
|
||||
{product.category && (
|
||||
<Badge className="bg-white/10 text-white/70 text-xs">
|
||||
{product.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-white font-medium">
|
||||
Цена: {product.price.toLocaleString('ru-RU')} ₽ за {product.unit || 'шт'}
|
||||
</p>
|
||||
{product.stock !== undefined && (
|
||||
<p className="text-white/60 text-sm">
|
||||
В наличии: {product.stock} {product.unit || 'шт'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{product.description && (
|
||||
<p className="text-white/70 text-sm mt-3 pt-3 border-t border-white/10">
|
||||
{product.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Основные поля */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Количество */}
|
||||
<div>
|
||||
<Label className="text-white/70 text-sm mb-2 block">
|
||||
Количество *
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
className="border-white/20 text-white hover:bg-white/10"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={product.stock}
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="bg-white/10 border-white/20 text-white text-center w-20"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setQuantity(product.stock ? Math.min(product.stock, quantity + 1) : quantity + 1)}
|
||||
className="border-white/20 text-white hover:bg-white/10"
|
||||
disabled={product.stock !== undefined && quantity >= product.stock}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{!isStockAvailable && (
|
||||
<p className="text-red-400 text-xs mt-1">
|
||||
Недостаточно товара на складе
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Договорная цена */}
|
||||
<div>
|
||||
<Label className="text-white/70 text-sm mb-2 block">
|
||||
Договорная цена (₽)
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={customPrice}
|
||||
onChange={(e) => setCustomPrice(e.target.value)}
|
||||
placeholder={product.price.toString()}
|
||||
className="bg-white/10 border-white/20 text-white"
|
||||
/>
|
||||
<p className="text-white/40 text-xs mt-1">
|
||||
Оставьте пустым для использования базовой цены
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Комплектность согласно rules2.md 9.7.2 */}
|
||||
<div>
|
||||
<Label className="text-white/70 text-sm mb-2 block">
|
||||
Комплектность (если есть)
|
||||
</Label>
|
||||
<Textarea
|
||||
value={completeness}
|
||||
onChange={(e) => setCompleteness(e.target.value)}
|
||||
placeholder="Опишите состав комплекта, если товар продается набором..."
|
||||
className="bg-white/10 border-white/20 text-white placeholder-white/40 resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Рецептура/состав согласно rules2.md 9.7.2 */}
|
||||
<div>
|
||||
<Label className="text-white/70 text-sm mb-2 block">
|
||||
Рецептура/состав
|
||||
</Label>
|
||||
<Textarea
|
||||
value={recipe}
|
||||
onChange={(e) => setRecipe(e.target.value)}
|
||||
placeholder="Дополнительные требования к составу, рецептуре или производству..."
|
||||
className="bg-white/10 border-white/20 text-white placeholder-white/40 resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Особые требования */}
|
||||
<div>
|
||||
<Label className="text-white/70 text-sm mb-2 block">
|
||||
Особые требования к товару
|
||||
</Label>
|
||||
<Textarea
|
||||
value={specialRequirements}
|
||||
onChange={(e) => setSpecialRequirements(e.target.value)}
|
||||
placeholder="Особые требования к качеству, упаковке, срокам и т.д..."
|
||||
className="bg-white/10 border-white/20 text-white placeholder-white/40 resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Параметры товара согласно rules2.md 9.7.5 */}
|
||||
<div>
|
||||
<Label className="text-white/70 text-sm mb-2 block flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
Параметры товара
|
||||
</Label>
|
||||
|
||||
{/* Существующие параметры */}
|
||||
{parameters.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
{parameters.map((param, index) => (
|
||||
<div key={index} className="flex items-center gap-2 bg-white/5 border border-white/10 rounded-lg p-2">
|
||||
<span className="text-white/80 text-sm font-medium">{param.name}:</span>
|
||||
<span className="text-white text-sm">{param.value}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeParameter(index)}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/10 ml-auto p-1 h-auto"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Добавление нового параметра */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newParameterName}
|
||||
onChange={(e) => setNewParameterName(e.target.value)}
|
||||
placeholder="Название параметра (цвет, размер...)"
|
||||
className="bg-white/10 border-white/20 text-white placeholder-white/40 flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={newParameterValue}
|
||||
onChange={(e) => setNewParameterValue(e.target.value)}
|
||||
placeholder="Значение"
|
||||
className="bg-white/10 border-white/20 text-white placeholder-white/40 flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addParameter}
|
||||
disabled={!newParameterName.trim() || !newParameterValue.trim()}
|
||||
className="bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 border-blue-500/30"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Итоговая стоимость */}
|
||||
<div className="bg-white/5 border border-white/10 rounded-lg p-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-white/70">Итоговая стоимость:</span>
|
||||
<span className="text-green-400 font-bold text-lg">
|
||||
{totalPrice.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
{customPrice && parseFloat(customPrice) !== product.price && (
|
||||
<p className="text-yellow-400 text-xs mt-1">
|
||||
Используется договорная цена: {parseFloat(customPrice).toLocaleString('ru-RU')} ₽ за {product.unit || 'шт'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="border-white/20 text-white hover:bg-white/10"
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
disabled={!isStockAvailable || quantity < 1}
|
||||
className="bg-gradient-to-r from-green-500 to-blue-500 hover:from-green-600 hover:to-blue-600 text-white"
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||
Добавить в корзину
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
@ -169,6 +169,29 @@ export function CreateConsumablesSupplyPage() {
|
||||
);
|
||||
if (!product || !selectedSupplier) return;
|
||||
|
||||
// ✅ ПРОВЕРКА ОСТАТКОВ согласно rules2.md раздел 9.4.5
|
||||
if (quantity > 0) {
|
||||
// Проверяем доступность на складе
|
||||
if (product.stock !== undefined && quantity > product.stock) {
|
||||
toast.error(
|
||||
`Недостаточно товара на складе. Доступно: ${product.stock} ${product.unit || 'шт'}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Логируем попытку добавления для аудита
|
||||
console.log('📊 Stock check:', {
|
||||
action: 'add_to_cart',
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
requested: quantity,
|
||||
available: product.stock || 'unlimited',
|
||||
result: quantity <= (product.stock || Infinity) ? 'allowed' : 'blocked',
|
||||
userId: selectedSupplier.id,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedConsumables((prev) => {
|
||||
const existing = prev.find((p) => p.id === productId);
|
||||
|
||||
@ -233,6 +256,18 @@ export function CreateConsumablesSupplyPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ ФИНАЛЬНАЯ ПРОВЕРКА ОСТАТКОВ перед созданием заказа
|
||||
for (const consumable of selectedConsumables) {
|
||||
const product = supplierProducts.find(p => p.id === consumable.id);
|
||||
if (product?.stock !== undefined && consumable.selectedQuantity > product.stock) {
|
||||
toast.error(
|
||||
`Товар "${consumable.name}" недоступен в количестве ${consumable.selectedQuantity}. ` +
|
||||
`Доступно: ${product.stock} ${product.unit || 'шт'}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Для селлеров требуется выбор фулфилмент-центра
|
||||
if (!selectedFulfillmentCenter) {
|
||||
toast.error("Выберите фулфилмент-центр для доставки");
|
||||
@ -261,12 +296,20 @@ export function CreateConsumablesSupplyPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем дату
|
||||
// ✅ ПРОВЕРКА ДАТЫ согласно rules2.md - запрет прошедших дат
|
||||
const deliveryDateObj = new Date(deliveryDate);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (isNaN(deliveryDateObj.getTime())) {
|
||||
toast.error("Некорректная дата поставки");
|
||||
return;
|
||||
}
|
||||
|
||||
if (deliveryDateObj < today) {
|
||||
toast.error("Нельзя выбрать прошедшую дату поставки");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingSupply(true);
|
||||
|
||||
@ -948,7 +991,17 @@ export function CreateConsumablesSupplyPage() {
|
||||
<Input
|
||||
type="date"
|
||||
value={deliveryDate}
|
||||
onChange={(e) => setDeliveryDate(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const selectedDate = new Date(e.target.value);
|
||||
const today = new Date();
|
||||
today.setHours(0,0,0,0);
|
||||
|
||||
if (selectedDate < today) {
|
||||
toast.error("Нельзя выбрать прошедшую дату поставки");
|
||||
return;
|
||||
}
|
||||
setDeliveryDate(e.target.value);
|
||||
}}
|
||||
className="bg-white/10 border-white/20 text-white h-8 text-sm"
|
||||
min={new Date().toISOString().split("T")[0]}
|
||||
required
|
||||
|
1210
src/components/supplies/create-suppliers-supply-page.tsx
Normal file
1210
src/components/supplies/create-suppliers-supply-page.tsx
Normal file
@ -0,0 +1,1210 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useMutation } from "@apollo/client";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { useSidebar } from "@/hooks/useSidebar";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Building2,
|
||||
MapPin,
|
||||
Phone,
|
||||
Mail,
|
||||
Star,
|
||||
Search,
|
||||
Package,
|
||||
Plus,
|
||||
Minus,
|
||||
ShoppingCart,
|
||||
Calendar,
|
||||
Truck,
|
||||
Box,
|
||||
FileText,
|
||||
AlertCircle,
|
||||
Settings,
|
||||
DollarSign,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
GET_MY_COUNTERPARTIES,
|
||||
GET_ORGANIZATION_PRODUCTS,
|
||||
} from "@/graphql/queries";
|
||||
import { CREATE_SUPPLY_ORDER } from "@/graphql/mutations";
|
||||
import { OrganizationAvatar } from "@/components/market/organization-avatar";
|
||||
import { AddGoodsModal } from "./add-goods-modal";
|
||||
import { toast } from "sonner";
|
||||
import Image from "next/image";
|
||||
|
||||
// Интерфейсы согласно rules2.md 9.7
|
||||
interface GoodsSupplier {
|
||||
id: string;
|
||||
inn: string;
|
||||
name?: string;
|
||||
fullName?: string;
|
||||
type: "FULFILLMENT" | "SELLER" | "LOGIST" | "WHOLESALE";
|
||||
address?: string;
|
||||
phones?: Array<{ value: string }>;
|
||||
emails?: Array<{ value: string }>;
|
||||
users?: Array<{ id: string; avatar?: string; managerName?: string }>;
|
||||
createdAt: string;
|
||||
rating?: number;
|
||||
}
|
||||
|
||||
interface GoodsProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
price: number;
|
||||
category?: { name: string };
|
||||
images: string[];
|
||||
mainImage?: string;
|
||||
article: string; // Артикул поставщика
|
||||
organization: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
quantity?: number;
|
||||
unit?: string;
|
||||
weight?: number;
|
||||
dimensions?: {
|
||||
length: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface SelectedGoodsItem {
|
||||
id: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
price: number;
|
||||
selectedQuantity: number;
|
||||
unit?: string;
|
||||
category?: string;
|
||||
supplierId: string;
|
||||
supplierName: string;
|
||||
completeness?: string; // Комплектность согласно rules2.md 9.7.2
|
||||
recipe?: string; // Рецептура/состав
|
||||
specialRequirements?: string; // Особые требования
|
||||
parameters?: Array<{ name: string; value: string }>; // Параметры товара
|
||||
}
|
||||
|
||||
interface LogisticsCompany {
|
||||
id: string;
|
||||
name: string;
|
||||
estimatedCost: number;
|
||||
deliveryDays: number;
|
||||
type: "EXPRESS" | "STANDARD" | "ECONOMY";
|
||||
}
|
||||
|
||||
export function CreateSuppliersSupplyPage() {
|
||||
const router = useRouter();
|
||||
const { user } = useAuth();
|
||||
const { getSidebarMargin } = useSidebar();
|
||||
|
||||
// Основные состояния
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<GoodsSupplier | null>(null);
|
||||
const [selectedGoods, setSelectedGoods] = useState<SelectedGoodsItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [productSearchQuery, setProductSearchQuery] = useState("");
|
||||
|
||||
// Обязательные поля согласно rules2.md 9.7.8
|
||||
const [deliveryDate, setDeliveryDate] = useState("");
|
||||
|
||||
// Выбор логистики согласно rules2.md 9.7.7
|
||||
const [selectedLogistics, setSelectedLogistics] = useState<string>("auto"); // "auto" или ID компании
|
||||
|
||||
// Выбор фулфилмента согласно rules2.md 9.7.2
|
||||
const [selectedFulfillment, setSelectedFulfillment] = useState<string>("");
|
||||
|
||||
// Модальное окно для детального добавления товара
|
||||
const [selectedProductForModal, setSelectedProductForModal] = useState<GoodsProduct | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const [isCreatingSupply, setIsCreatingSupply] = useState(false);
|
||||
|
||||
// Состояние количества товаров для карточек согласно rules2.md 13.3
|
||||
const [productQuantities, setProductQuantities] = useState<Record<string, number>>({});
|
||||
|
||||
// Загружаем партнеров-поставщиков согласно rules2.md 13.3
|
||||
const { data: counterpartiesData, loading: counterpartiesLoading, error: counterpartiesError } = useQuery(
|
||||
GET_MY_COUNTERPARTIES,
|
||||
{
|
||||
errorPolicy: 'all', // Показываем все ошибки, но не прерываем работу
|
||||
onError: (error) => {
|
||||
try {
|
||||
console.log("🚨 GET_MY_COUNTERPARTIES ERROR:", {
|
||||
errorMessage: error?.message || "Unknown error",
|
||||
hasGraphQLErrors: !!error?.graphQLErrors?.length,
|
||||
hasNetworkError: !!error?.networkError,
|
||||
});
|
||||
} catch (logError) {
|
||||
console.log("❌ Error in counterparties error handler:", logError);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Загружаем каталог товаров согласно rules2.md 13.3
|
||||
// Товары поставщика загружаются из Product таблицы where organizationId = поставщик.id
|
||||
const { data: productsData, loading: productsLoading, error: productsError } = useQuery(
|
||||
GET_ORGANIZATION_PRODUCTS,
|
||||
{
|
||||
variables: {
|
||||
organizationId: selectedSupplier?.id || "", // Избегаем undefined для обязательного параметра
|
||||
search: productSearchQuery, // Используем поисковый запрос для фильтрации
|
||||
category: "", // Пока без фильтра по категории
|
||||
type: "PRODUCT", // КРИТИЧЕСКИ ВАЖНО: показываем только PRODUCT, не CONSUMABLE согласно development-checklist.md
|
||||
},
|
||||
skip: !selectedSupplier || !selectedSupplier.id, // Более строгая проверка
|
||||
fetchPolicy: 'network-only', // Обходим кеш для получения актуальных данных
|
||||
errorPolicy: 'all', // Показываем все ошибки, но не прерываем работу
|
||||
onError: (error) => {
|
||||
try {
|
||||
console.log("🚨 GET_ORGANIZATION_PRODUCTS ERROR:", {
|
||||
errorMessage: error?.message || "Unknown error",
|
||||
hasGraphQLErrors: !!error?.graphQLErrors?.length,
|
||||
hasNetworkError: !!error?.networkError,
|
||||
variables: {
|
||||
organizationId: selectedSupplier?.id || "not_selected",
|
||||
search: productSearchQuery || "empty",
|
||||
category: "",
|
||||
type: "PRODUCT",
|
||||
},
|
||||
selectedSupplier: selectedSupplier ? {
|
||||
id: selectedSupplier.id,
|
||||
name: selectedSupplier.name || selectedSupplier.fullName || "Unknown",
|
||||
} : "not_selected",
|
||||
});
|
||||
} catch (logError) {
|
||||
console.log("❌ Error in error handler:", logError);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Мутация создания поставки
|
||||
const [createSupplyOrder] = useMutation(CREATE_SUPPLY_ORDER);
|
||||
|
||||
// Фильтруем только партнеров-поставщиков согласно rules2.md 13.3
|
||||
const allCounterparties = counterpartiesData?.myCounterparties || [];
|
||||
|
||||
// Показываем только партнеров с типом WHOLESALE согласно rules2.md 13.3
|
||||
const wholesaleSuppliers = allCounterparties.filter((cp: any) => {
|
||||
try {
|
||||
return cp && cp.type === "WHOLESALE";
|
||||
} catch (error) {
|
||||
console.log("❌ Error filtering wholesale suppliers:", error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const suppliers = wholesaleSuppliers.filter((cp: GoodsSupplier) => {
|
||||
try {
|
||||
if (!cp) return false;
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
return (
|
||||
cp.name?.toLowerCase().includes(searchLower) ||
|
||||
cp.fullName?.toLowerCase().includes(searchLower) ||
|
||||
cp.inn?.includes(searchQuery) ||
|
||||
cp.phones?.some(phone => phone.value?.includes(searchQuery))
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("❌ Error filtering suppliers by search:", error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const isLoading = counterpartiesLoading;
|
||||
|
||||
// Получаем товары выбранного поставщика согласно rules2.md 13.3
|
||||
// Теперь фильтрация происходит на сервере через GraphQL запрос
|
||||
const products = (productsData?.organizationProducts || []).filter((product: any) => {
|
||||
try {
|
||||
return product && product.id && product.name;
|
||||
} catch (error) {
|
||||
console.log("❌ Error filtering products:", error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Отладочные логи согласно development-checklist.md
|
||||
console.log("🛒 CREATE_SUPPLIERS_SUPPLY DEBUG:", {
|
||||
selectedSupplier: selectedSupplier ? {
|
||||
id: selectedSupplier.id,
|
||||
name: selectedSupplier.name,
|
||||
type: selectedSupplier.type,
|
||||
} : null,
|
||||
counterpartiesStatus: {
|
||||
loading: counterpartiesLoading,
|
||||
error: counterpartiesError?.message,
|
||||
dataCount: counterpartiesData?.myCounterparties?.length || 0,
|
||||
},
|
||||
productsStatus: {
|
||||
loading: productsLoading,
|
||||
error: productsError?.message,
|
||||
dataCount: products.length,
|
||||
hasData: !!productsData?.organizationProducts,
|
||||
productSample: products.slice(0, 3).map(p => ({ id: p.id, name: p.name, article: p.article })),
|
||||
},
|
||||
});
|
||||
|
||||
// Моковые логистические компании согласно rules2.md 9.7.7
|
||||
const logisticsCompanies: LogisticsCompany[] = [
|
||||
{ id: "express", name: "Экспресс доставка", estimatedCost: 2500, deliveryDays: 1, type: "EXPRESS" },
|
||||
{ id: "standard", name: "Стандартная доставка", estimatedCost: 1200, deliveryDays: 3, type: "STANDARD" },
|
||||
{ id: "economy", name: "Экономичная доставка", estimatedCost: 800, deliveryDays: 7, type: "ECONOMY" },
|
||||
];
|
||||
|
||||
// Моковые фулфилмент-центры согласно rules2.md 9.7.2
|
||||
const fulfillmentCenters = [
|
||||
{ id: "ff1", name: "СФ Центр Москва", address: "г. Москва, ул. Складская 10" },
|
||||
{ id: "ff2", name: "СФ Центр СПб", address: "г. Санкт-Петербург, пр. Логистический 5" },
|
||||
{ id: "ff3", name: "СФ Центр Екатеринбург", address: "г. Екатеринбург, ул. Промышленная 15" },
|
||||
];
|
||||
|
||||
// Функции для работы с количеством товаров в карточках согласно rules2.md 13.3
|
||||
const getProductQuantity = (productId: string): number => {
|
||||
return productQuantities[productId] || 0;
|
||||
};
|
||||
|
||||
const setProductQuantity = (productId: string, quantity: number): void => {
|
||||
setProductQuantities(prev => ({
|
||||
...prev,
|
||||
[productId]: Math.max(0, quantity)
|
||||
}));
|
||||
};
|
||||
|
||||
const updateProductQuantity = (productId: string, delta: number): void => {
|
||||
const currentQuantity = getProductQuantity(productId);
|
||||
const newQuantity = currentQuantity + delta;
|
||||
setProductQuantity(productId, newQuantity);
|
||||
};
|
||||
|
||||
// Добавление товара в корзину из карточки с заданным количеством
|
||||
const addToCart = (product: GoodsProduct) => {
|
||||
const quantity = getProductQuantity(product.id);
|
||||
if (quantity <= 0) {
|
||||
toast.error("Укажите количество товара");
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверка остатков согласно rules2.md 9.7.9
|
||||
if (product.quantity !== undefined && quantity > product.quantity) {
|
||||
toast.error(`Недостаточно товара на складе. Доступно: ${product.quantity} ${product.unit || 'шт'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedSupplier) {
|
||||
toast.error("Не выбран поставщик");
|
||||
return;
|
||||
}
|
||||
|
||||
const newGoodsItem: SelectedGoodsItem = {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
sku: product.article,
|
||||
price: product.price,
|
||||
selectedQuantity: quantity,
|
||||
unit: product.unit,
|
||||
category: product.category?.name,
|
||||
supplierId: selectedSupplier.id,
|
||||
supplierName: selectedSupplier.name || selectedSupplier.fullName || "Неизвестный поставщик",
|
||||
};
|
||||
|
||||
// Проверяем, есть ли уже такой товар в корзине
|
||||
const existingItemIndex = selectedGoods.findIndex(item => item.id === product.id);
|
||||
|
||||
if (existingItemIndex >= 0) {
|
||||
// Обновляем количество существующего товара
|
||||
const updatedGoods = [...selectedGoods];
|
||||
updatedGoods[existingItemIndex] = {
|
||||
...updatedGoods[existingItemIndex],
|
||||
selectedQuantity: quantity
|
||||
};
|
||||
setSelectedGoods(updatedGoods);
|
||||
toast.success(`Количество товара "${product.name}" обновлено в корзине`);
|
||||
} else {
|
||||
// Добавляем новый товар
|
||||
setSelectedGoods(prev => [...prev, newGoodsItem]);
|
||||
toast.success(`Товар "${product.name}" добавлен в корзину`);
|
||||
}
|
||||
|
||||
// Сбрасываем количество в карточке
|
||||
setProductQuantity(product.id, 0);
|
||||
};
|
||||
|
||||
// Открытие модального окна для детального добавления
|
||||
const openAddModal = (product: GoodsProduct) => {
|
||||
setSelectedProductForModal(product);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// Добавление товара в корзину из модального окна с дополнительными данными
|
||||
const addToCartFromModal = (
|
||||
product: GoodsProduct,
|
||||
quantity: number,
|
||||
additionalData?: {
|
||||
completeness?: string;
|
||||
recipe?: string;
|
||||
specialRequirements?: string;
|
||||
parameters?: Array<{ name: string; value: string }>;
|
||||
customPrice?: number;
|
||||
}
|
||||
) => {
|
||||
// Проверка остатков согласно rules2.md 9.7.9
|
||||
if (product.quantity !== undefined && quantity > product.quantity) {
|
||||
toast.error(`Недостаточно товара на складе. Доступно: ${product.quantity} ${product.unit || 'шт'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingItem = selectedGoods.find(item => item.id === product.id);
|
||||
const finalPrice = additionalData?.customPrice || product.price;
|
||||
|
||||
if (existingItem) {
|
||||
// Обновляем существующий товар
|
||||
setSelectedGoods(prev =>
|
||||
prev.map(item =>
|
||||
item.id === product.id
|
||||
? {
|
||||
...item,
|
||||
selectedQuantity: quantity,
|
||||
price: finalPrice,
|
||||
completeness: additionalData?.completeness,
|
||||
recipe: additionalData?.recipe,
|
||||
specialRequirements: additionalData?.specialRequirements,
|
||||
parameters: additionalData?.parameters,
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// Добавляем новый товар
|
||||
const newItem: SelectedGoodsItem = {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
sku: product.article,
|
||||
price: finalPrice,
|
||||
selectedQuantity: quantity,
|
||||
unit: product.unit,
|
||||
category: product.category?.name,
|
||||
supplierId: selectedSupplier!.id,
|
||||
supplierName: selectedSupplier!.name || selectedSupplier!.fullName || '',
|
||||
completeness: additionalData?.completeness,
|
||||
recipe: additionalData?.recipe,
|
||||
specialRequirements: additionalData?.specialRequirements,
|
||||
parameters: additionalData?.parameters,
|
||||
};
|
||||
|
||||
setSelectedGoods(prev => [...prev, newItem]);
|
||||
}
|
||||
|
||||
toast.success("Товар добавлен в корзину");
|
||||
};
|
||||
|
||||
// Удаление из корзины
|
||||
const removeFromCart = (productId: string) => {
|
||||
setSelectedGoods(prev => prev.filter(item => item.id !== productId));
|
||||
toast.success("Товар удален из корзины");
|
||||
};
|
||||
|
||||
// Расчеты согласно rules2.md 9.7.6
|
||||
const totalGoodsAmount = selectedGoods.reduce((sum, item) => sum + (item.price * item.selectedQuantity), 0);
|
||||
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 totalAmount = totalGoodsAmount + fulfillmentFee + logisticsCost;
|
||||
|
||||
// Валидация формы согласно rules2.md 9.7.6
|
||||
const isFormValid = selectedSupplier && selectedGoods.length > 0 && deliveryDate && selectedFulfillment;
|
||||
|
||||
// Создание поставки
|
||||
const handleCreateSupply = async () => {
|
||||
if (!isFormValid) {
|
||||
toast.error("Заполните все обязательные поля");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingSupply(true);
|
||||
try {
|
||||
await createSupplyOrder({
|
||||
variables: {
|
||||
supplierId: selectedSupplier!.id,
|
||||
fulfillmentCenterId: selectedFulfillment,
|
||||
items: selectedGoods.map(item => ({
|
||||
productId: item.id,
|
||||
quantity: item.selectedQuantity,
|
||||
price: item.price,
|
||||
completeness: item.completeness,
|
||||
recipe: item.recipe,
|
||||
specialRequirements: item.specialRequirements,
|
||||
parameters: item.parameters,
|
||||
})),
|
||||
deliveryDate,
|
||||
logisticsCompany: selectedLogistics === "auto" ? null : selectedLogistics,
|
||||
type: "ТОВАР",
|
||||
creationMethod: "suppliers",
|
||||
},
|
||||
});
|
||||
|
||||
toast.success("Поставка успешно создана");
|
||||
router.push("/supplies?tab=goods&subTab=suppliers");
|
||||
} catch (error) {
|
||||
console.error("Ошибка создания поставки:", error);
|
||||
toast.error("Ошибка при создании поставки");
|
||||
} finally {
|
||||
setIsCreatingSupply(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Получение минимальной и максимальной даты согласно rules2.md 9.7.8
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const maxDate = new Date();
|
||||
maxDate.setDate(maxDate.getDate() + 90);
|
||||
|
||||
const minDateString = tomorrow.toISOString().split('T')[0];
|
||||
const maxDateString = maxDate.toISOString().split('T')[0];
|
||||
|
||||
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">
|
||||
|
||||
{/* СТРУКТУРА ИЗ 3 БЛОКОВ согласно rules1.md 19.2.1 - блоки точно по уровню сайдбара */}
|
||||
<div className="flex-1 flex gap-2 min-h-0">
|
||||
|
||||
{/* ЛЕВЫЙ БЛОК: ПОСТАВЩИКИ И ТОВАРЫ */}
|
||||
<div className="flex-1 flex flex-col gap-2 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"
|
||||
style={{ minHeight: '120px', maxHeight: suppliers.length > 4 ? '200px' : 'auto' }}>
|
||||
<div className="p-4 flex-shrink-0">
|
||||
|
||||
{/* Навигация и заголовок в одном блоке */}
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/supplies?tab=goods&subTab=suppliers")}
|
||||
className="glass-secondary hover:text-white/90 gap-2 transition-all duration-200 -ml-2"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Назад
|
||||
</Button>
|
||||
<div className="h-4 w-px bg-white/20"></div>
|
||||
<div className="p-2 bg-green-400/10 rounded-lg border border-green-400/20">
|
||||
<Building2 className="h-4 w-4 text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">Поставщики товаров</h2>
|
||||
<Badge className="bg-purple-500/20 text-purple-300 border border-purple-500/30 text-xs font-medium mt-0.5">
|
||||
Создание поставки
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 max-w-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/40 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Поиск..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="bg-white/5 border-white/10 text-white placeholder:text-white/50 pl-10 h-9 text-sm transition-all duration-200 focus:border-white/20"
|
||||
/>
|
||||
</div>
|
||||
{allCounterparties.length === 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push("/market")}
|
||||
className="glass-secondary hover:text-white/90 transition-all duration-200 mt-2 w-full"
|
||||
>
|
||||
<Building2 className="h-3 w-3 mr-2" />
|
||||
Найти поставщиков в маркете
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Список поставщиков согласно visual-design-rules.md */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60"></div>
|
||||
<span className="ml-3 text-white/70">Загрузка поставщиков...</span>
|
||||
</div>
|
||||
) : suppliers.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="w-12 h-12 mx-auto bg-white/5 rounded-full flex items-center justify-center">
|
||||
<Building2 className="h-6 w-6 text-white/40" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-medium text-white mb-1">Поставщики товаров не найдены</h3>
|
||||
<p className="text-white/60 text-xs max-w-md mx-auto">
|
||||
{allCounterparties.length === 0
|
||||
? "У вас нет партнеров. Найдите поставщиков в маркете или добавьте их через раздел 'Партнеры'"
|
||||
: wholesaleSuppliers.length === 0
|
||||
? `Найдено ${allCounterparties.length} партнеров, но среди них нет поставщиков (тип WHOLESALE)`
|
||||
: searchQuery && suppliers.length === 0
|
||||
? "Поставщики-партнеры не найдены по вашему запросу"
|
||||
: `Найдено ${suppliers.length} поставщиков-партнеров`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`gap-2 overflow-y-auto ${
|
||||
suppliers.length <= 2
|
||||
? 'flex flex-wrap'
|
||||
: suppliers.length <= 4
|
||||
? 'grid grid-cols-2'
|
||||
: 'grid grid-cols-1 md:grid-cols-2 max-h-32'
|
||||
}`}>
|
||||
{suppliers.map((supplier: GoodsSupplier) => (
|
||||
<div
|
||||
key={supplier.id}
|
||||
onClick={() => setSelectedSupplier(supplier)}
|
||||
className={`p-3 rounded-lg cursor-pointer group transition-all duration-200 ${
|
||||
selectedSupplier?.id === supplier.id
|
||||
? "bg-white/15 border border-white/40 shadow-lg"
|
||||
: "bg-white/5 border border-white/10 hover:border-white/20 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-shrink-0">
|
||||
<OrganizationAvatar organization={supplier} size="sm" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="text-white font-medium text-sm truncate group-hover:text-white transition-colors">
|
||||
{supplier.name || supplier.fullName}
|
||||
</h4>
|
||||
{supplier.rating && (
|
||||
<div className="flex items-center gap-1 bg-yellow-400/10 px-2 py-0.5 rounded-full">
|
||||
<Star className="h-3 w-3 text-yellow-400 fill-current" />
|
||||
<span className="text-yellow-300 text-xs font-medium">{supplier.rating}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="text-white/60 text-xs font-mono">ИНН: {supplier.inn}</p>
|
||||
<Badge className={`text-xs font-medium ${
|
||||
supplier.type === "WHOLESALE"
|
||||
? "bg-green-500/20 text-green-300 border border-green-500/30"
|
||||
: "bg-yellow-500/20 text-yellow-300 border border-yellow-500/30"
|
||||
}`}>
|
||||
{supplier.type === "WHOLESALE" ? "Поставщик" : supplier.type}
|
||||
</Badge>
|
||||
</div>
|
||||
{supplier.address && (
|
||||
<p className="text-white/50 text-xs line-clamp-1">{supplier.address}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* БЛОК 2: ТОВАРЫ - зависимый блок согласно rules1.md 19.2.1 */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl flex-1 min-h-0 flex flex-col">
|
||||
<div className="p-6 border-b border-white/10 flex-shrink-0">
|
||||
<div className="flex items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-400/10 rounded-lg border border-blue-400/20">
|
||||
<Package className="h-6 w-6 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-white">
|
||||
{selectedSupplier ? `Товары ${selectedSupplier.name || selectedSupplier.fullName}` : "Каталог товаров"}
|
||||
</h3>
|
||||
{selectedSupplier && (
|
||||
<p className="text-white/60 text-sm mt-1">
|
||||
Выберите товары для добавления в корзину
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{selectedSupplier && (
|
||||
<div className="flex-1 max-w-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/40 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Поиск товаров..."
|
||||
value={productSearchQuery}
|
||||
onChange={(e) => setProductSearchQuery(e.target.value)}
|
||||
className="glass-input text-white placeholder:text-white/50 pl-10 h-10 transition-all duration-200 focus-visible:ring-ring/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{!selectedSupplier ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-24 h-24 mx-auto bg-blue-400/5 rounded-full flex items-center justify-center">
|
||||
<Building2 className="h-12 w-12 text-blue-400/50" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xl font-medium text-white mb-2">Выберите поставщика</h4>
|
||||
<p className="text-white/60 max-w-sm mx-auto">
|
||||
Для просмотра каталога товаров сначала выберите поставщика из списка выше
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : productsError ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto bg-red-500/10 rounded-full flex items-center justify-center mb-4">
|
||||
<AlertCircle className="h-8 w-8 text-red-400" />
|
||||
</div>
|
||||
<h4 className="text-xl font-medium text-white mb-2">Ошибка загрузки товаров</h4>
|
||||
<p className="text-red-400 text-sm">
|
||||
{productsError.message || "Произошла ошибка при загрузке каталога"}
|
||||
</p>
|
||||
</div>
|
||||
) : productsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-400"></div>
|
||||
<span className="ml-3 text-white/70">Загрузка товаров...</span>
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto bg-white/5 rounded-full flex items-center justify-center mb-4">
|
||||
<Package className="h-8 w-8 text-white/40" />
|
||||
</div>
|
||||
<h4 className="text-xl font-medium text-white mb-2">
|
||||
{productSearchQuery ? "Товары не найдены" : "Каталог пуст"}
|
||||
</h4>
|
||||
<p className="text-white/60">
|
||||
{productSearchQuery
|
||||
? "Попробуйте изменить поисковый запрос"
|
||||
: "У выбранного поставщика нет товаров"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{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"
|
||||
>
|
||||
<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="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>
|
||||
{product.quantity !== undefined && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
product.quantity > 0 ? 'bg-green-400' : 'bg-red-400'
|
||||
}`}></div>
|
||||
<p className={`text-xs font-medium ${
|
||||
product.quantity > 0 ? 'text-green-400' : 'text-red-400'
|
||||
}`}>
|
||||
{product.quantity > 0
|
||||
? `Доступно: ${product.quantity}`
|
||||
: 'Нет в наличии'
|
||||
}
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<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)}
|
||||
>
|
||||
<ShoppingCart className="h-3 w-3 mr-1" />
|
||||
В корзину
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* БЛОК 3: КОРЗИНА - правый блок согласно rules1.md 19.2.1 */}
|
||||
<div className="w-96 flex-shrink-0 flex flex-col min-h-0">
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl flex-1 flex flex-col min-h-0">
|
||||
|
||||
{/* ЗАГОЛОВОК И СТАТИСТИКА */}
|
||||
<div className="p-4 border-b border-white/10 flex-shrink-0">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-purple-400/10 rounded-lg border border-purple-400/20">
|
||||
<ShoppingCart className="h-5 w-5 text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Корзина и настройки поставки</h3>
|
||||
<p className="text-white/60 text-xs mt-1">
|
||||
Управление заказом и параметрами доставки
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* СТАТИСТИКА ПОСТАВКИ */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-white/5 border border-white/10 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Поставщиков</p>
|
||||
<p className="text-lg font-semibold text-white">{selectedSupplier ? 1 : 0}</p>
|
||||
</div>
|
||||
<Building2 className="h-4 w-4 text-green-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/10 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Товаров</p>
|
||||
<p className="text-lg font-semibold text-white">{selectedGoods.length}</p>
|
||||
</div>
|
||||
<Package className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/10 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Количество</p>
|
||||
<p className="text-lg font-semibold text-white">{totalQuantity} шт</p>
|
||||
</div>
|
||||
<Box className="h-4 w-4 text-orange-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 border border-white/10 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-white/60">Сумма</p>
|
||||
<p className="text-lg font-semibold text-white">{totalAmount.toLocaleString('ru-RU')} ₽</p>
|
||||
</div>
|
||||
<DollarSign className="h-4 w-4 text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* НАСТРОЙКИ ПОСТАВКИ */}
|
||||
<div className="p-4 border-b border-white/10 flex-shrink-0">
|
||||
<h4 className="text-sm font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-blue-400" />
|
||||
Настройки поставки
|
||||
</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Выбор фулфилмент-центра */}
|
||||
<div>
|
||||
<label className="text-white/70 text-xs font-medium mb-2 flex items-center gap-2">
|
||||
<Building2 className="h-3 w-3 text-green-400" />
|
||||
Фулфилмент-центр *
|
||||
</label>
|
||||
<select
|
||||
value={selectedFulfillment}
|
||||
onChange={(e) => setSelectedFulfillment(e.target.value)}
|
||||
className="w-full bg-white/5 border-white/10 text-white h-8 text-sm rounded-lg hover:border-white/30 focus:border-green-400/50 transition-all duration-200"
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">Выберите фулфилмент-центр</option>
|
||||
{fulfillmentCenters.map((center) => (
|
||||
<option key={center.id} value={center.id} className="bg-gray-800 text-white">
|
||||
{center.name} - {center.address}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Дата поставки */}
|
||||
<div>
|
||||
<label className="text-white/70 text-xs font-medium mb-2 flex items-center gap-2">
|
||||
<Calendar className="h-3 w-3 text-blue-400" />
|
||||
Желаемая дата поставки *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/40 h-3 w-3 z-10" />
|
||||
<Input
|
||||
type="date"
|
||||
value={deliveryDate}
|
||||
onChange={(e) => setDeliveryDate(e.target.value)}
|
||||
min={minDateString}
|
||||
max={maxDateString}
|
||||
className="bg-white/5 border-white/10 text-white pl-9 h-8 text-sm hover:border-white/30 focus:border-blue-400/50 transition-all duration-200"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Выбор логистики */}
|
||||
<div>
|
||||
<label className="text-white/70 text-xs font-medium mb-2 flex items-center gap-2">
|
||||
<Truck className="h-3 w-3 text-orange-400" />
|
||||
Логистическая компания
|
||||
</label>
|
||||
<select
|
||||
value={selectedLogistics}
|
||||
onChange={(e) => setSelectedLogistics(e.target.value)}
|
||||
className="w-full bg-white/5 border-white/10 text-white h-8 text-sm rounded-lg hover:border-white/30 focus:border-orange-400/50 transition-all duration-200"
|
||||
>
|
||||
<option value="auto" className="bg-gray-800 text-white">Автоматический выбор</option>
|
||||
{logisticsCompanies.map((company) => (
|
||||
<option key={company.id} value={company.id} className="bg-gray-800 text-white">
|
||||
{company.name} (~{company.estimatedCost} ₽, {company.deliveryDays} дн.)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ТОВАРЫ В КОРЗИНЕ */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{selectedGoods.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-400/5 rounded-full flex items-center justify-center">
|
||||
<ShoppingCart className="h-8 w-8 text-purple-400/50" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-base font-medium text-white mb-2">Корзина пуста</h4>
|
||||
<p className="text-white/60 text-sm">
|
||||
Добавьте товары из каталога поставщика
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{selectedGoods.map((item) => (
|
||||
<div key={item.id} className="glass-card hover:border-white/20 transition-all duration-200 group">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-white font-semibold text-base truncate group-hover:text-white transition-colors">
|
||||
{item.name}
|
||||
</h4>
|
||||
<p className="text-white/60 text-sm font-mono mt-1">Артикул: {item.sku}</p>
|
||||
{item.category && (
|
||||
<Badge className="bg-blue-500/20 text-blue-300 border border-blue-500/30 text-xs font-medium mt-2">
|
||||
{item.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeFromCart(item.id)}
|
||||
className="text-red-400 hover:text-red-300 hover:bg-red-500/20 border border-transparent hover:border-red-500/30 p-2 transition-all duration-200"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/70 text-xs font-medium">Количество:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newQuantity = Math.max(1, item.selectedQuantity - 1);
|
||||
addToCart(
|
||||
{
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
sku: item.sku,
|
||||
price: item.price,
|
||||
category: { name: item.category || '' },
|
||||
images: [],
|
||||
organization: { id: item.supplierId, name: item.supplierName },
|
||||
unit: item.unit,
|
||||
} as GoodsProduct,
|
||||
newQuantity
|
||||
);
|
||||
}}
|
||||
className="h-7 w-7 p-0 border border-white/20 text-white/70 hover:text-white hover:bg-white/10 transition-all duration-200"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={item.selectedQuantity}
|
||||
onChange={(e) => {
|
||||
const newQuantity = parseInt(e.target.value) || 1;
|
||||
addToCart(
|
||||
{
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
sku: item.sku,
|
||||
price: item.price,
|
||||
category: { name: item.category || '' },
|
||||
images: [],
|
||||
organization: { id: item.supplierId, name: item.supplierName },
|
||||
unit: item.unit,
|
||||
} as GoodsProduct,
|
||||
newQuantity
|
||||
);
|
||||
}}
|
||||
className="glass-input text-white w-16 h-7 text-center text-xs font-medium"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newQuantity = item.selectedQuantity + 1;
|
||||
addToCart(
|
||||
{
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
sku: item.sku,
|
||||
price: item.price,
|
||||
category: { name: item.category || '' },
|
||||
images: [],
|
||||
organization: { id: item.supplierId, name: item.supplierName },
|
||||
unit: item.unit,
|
||||
} as GoodsProduct,
|
||||
newQuantity
|
||||
);
|
||||
}}
|
||||
className="h-7 w-7 p-0 border border-white/20 text-white/70 hover:text-white hover:bg-white/10 transition-all duration-200"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/70 text-xs font-medium">Цена за {item.unit || 'шт'}:</span>
|
||||
<span className="text-white text-xs font-semibold">{item.price.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-white/5 rounded-lg border border-white/10">
|
||||
<span className="text-white/80 text-sm font-medium">Сумма:</span>
|
||||
<span className="text-green-400 text-base font-bold">
|
||||
{(item.price * item.selectedQuantity).toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Дополнительная информация */}
|
||||
{(item.completeness || item.recipe || item.specialRequirements || item.parameters) && (
|
||||
<div className="mt-2 pt-2 border-t border-white/10 space-y-1">
|
||||
{item.completeness && (
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="h-3 w-3 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-blue-300 text-xs font-medium">Комплектность: </span>
|
||||
<span className="text-white/80 text-xs">{item.completeness}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.recipe && (
|
||||
<div className="flex items-start gap-2">
|
||||
<Settings className="h-3 w-3 text-purple-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-purple-300 text-xs font-medium">Рецептура: </span>
|
||||
<span className="text-white/80 text-xs">{item.recipe}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.specialRequirements && (
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-3 w-3 text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-yellow-300 text-xs font-medium">Требования: </span>
|
||||
<span className="text-white/80 text-xs">{item.specialRequirements}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.parameters && item.parameters.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Settings className="h-3 w-3 text-green-400" />
|
||||
<span className="text-green-300 text-xs font-medium">Параметры:</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{item.parameters.map((param, idx) => (
|
||||
<Badge key={idx} className="bg-green-500/10 text-green-300 border border-green-500/20 text-xs">
|
||||
{param.name}: {param.value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ИТОГИ И КНОПКА СОЗДАНИЯ */}
|
||||
<div className="p-4 border-t border-white/10 space-y-4 flex-shrink-0">
|
||||
{/* Детальные итоги */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-white/70 text-xs font-medium">Товаров:</span>
|
||||
<span className="text-white text-xs font-semibold">{totalQuantity} шт</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-white/70 text-xs font-medium">Стоимость товаров:</span>
|
||||
<span className="text-white text-xs font-semibold">{totalGoodsAmount.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-purple-300 text-xs font-medium">Фулфилмент (8%):</span>
|
||||
<span className="text-purple-300 text-xs font-semibold">{fulfillmentFee.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-orange-300 text-xs font-medium">Логистика:</span>
|
||||
<span className="text-orange-300 text-xs font-semibold">
|
||||
{selectedLogistics === "auto" ? "~" : ""}{logisticsCost.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-2 bg-gradient-to-r from-green-500/10 to-emerald-500/10 border border-green-500/20 rounded-lg">
|
||||
<span className="text-white text-sm font-semibold">Итого к оплате:</span>
|
||||
<span className="text-green-400 text-lg font-bold">{totalAmount.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопка создания поставки */}
|
||||
<Button
|
||||
onClick={handleCreateSupply}
|
||||
disabled={!isFormValid || isCreatingSupply}
|
||||
className="w-full bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white font-semibold py-3 text-sm border border-green-500/30 hover:border-green-400/50 transition-all duration-300 disabled:opacity-50"
|
||||
>
|
||||
{isCreatingSupply ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
<span>Создание поставки...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
<span>Продолжить оформление</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Сообщения об ошибках валидации */}
|
||||
{!isFormValid && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-2 mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-3 w-3 text-red-400 flex-shrink-0" />
|
||||
<p className="text-red-300 text-xs font-medium">
|
||||
{!selectedSupplier && "Выберите поставщика"}
|
||||
{selectedSupplier && selectedGoods.length === 0 && "Добавьте товары в корзину"}
|
||||
{selectedSupplier && selectedGoods.length > 0 && !deliveryDate && "Укажите дату поставки"}
|
||||
{selectedSupplier && selectedGoods.length > 0 && deliveryDate && !selectedFulfillment && "Выберите фулфилмент-центр"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Модальное окно для детального добавления товара */}
|
||||
<AddGoodsModal
|
||||
product={selectedProductForModal}
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedProductForModal(null);
|
||||
}}
|
||||
onAdd={addToCartFromModal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -2,40 +2,48 @@
|
||||
|
||||
import React from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { FulfillmentGoodsTab } from "./fulfillment-goods-tab";
|
||||
import { RealSupplyOrdersTab } from "./real-supply-orders-tab";
|
||||
import { SellerSupplyOrdersTab } from "./seller-supply-orders-tab";
|
||||
import { GoodsSuppliesTable } from "../goods-supplies-table";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Package } from "lucide-react";
|
||||
|
||||
interface AllSuppliesTabProps {
|
||||
pendingSupplyOrders?: number;
|
||||
goodsSupplies?: any[]; // Данные поставок товаров из обоих источников
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function AllSuppliesTab({
|
||||
pendingSupplyOrders = 0,
|
||||
goodsSupplies = [],
|
||||
loading = false
|
||||
}: AllSuppliesTabProps) {
|
||||
const { user } = useAuth();
|
||||
|
||||
// Определяем тип организации для выбора правильного компонента
|
||||
const isWholesale = user?.organization?.type === "WHOLESALE";
|
||||
|
||||
// ✅ ЕДИНАЯ ТАБЛИЦА ПОСТАВОК ТОВАРОВ согласно rules2.md 9.5.3
|
||||
return (
|
||||
<div className="h-full overflow-hidden space-y-4">
|
||||
{/* Секция товаров */}
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-4">
|
||||
<h3 className="text-white font-semibold text-lg mb-3">Товары</h3>
|
||||
<div className="h-64 overflow-hidden">
|
||||
<FulfillmentGoodsTab />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Секция расходников */}
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-4">
|
||||
<h3 className="text-white font-semibold text-lg mb-3">Расходники</h3>
|
||||
<div className="h-64 overflow-hidden">
|
||||
{isWholesale ? <RealSupplyOrdersTab /> : <SellerSupplyOrdersTab />}
|
||||
</div>
|
||||
</Card>
|
||||
<div className="h-full">
|
||||
{goodsSupplies.length === 0 && !loading ? (
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-8">
|
||||
<div className="text-center">
|
||||
<Package className="h-16 w-16 mx-auto mb-4 text-white/30" />
|
||||
<h3 className="text-xl font-semibold text-white mb-2">
|
||||
Поставки товаров
|
||||
</h3>
|
||||
<p className="text-white/60 mb-4">
|
||||
Здесь отображаются все поставки товаров, созданные через карточки и у поставщиков
|
||||
</p>
|
||||
<div className="text-sm text-white/50">
|
||||
<p>• Карточки - импорт через WB API</p>
|
||||
<p>• Поставщики - прямой заказ с рецептурой</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<GoodsSuppliesTable
|
||||
supplies={goodsSupplies}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
735
src/components/supplies/goods-supplies-table.tsx
Normal file
735
src/components/supplies/goods-supplies-table.tsx
Normal file
@ -0,0 +1,735 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Package,
|
||||
Building2,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Search,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Smartphone,
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
MapPin,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
Warehouse,
|
||||
} from "lucide-react";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
|
||||
// Простые компоненты таблицы
|
||||
const Table = ({ children, ...props }: any) => (
|
||||
<div className="w-full overflow-auto" {...props}>
|
||||
<table className="w-full">{children}</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TableHeader = ({ children, ...props }: any) => <thead {...props}>{children}</thead>;
|
||||
const TableBody = ({ children, ...props }: any) => <tbody {...props}>{children}</tbody>;
|
||||
const TableRow = ({ children, className, ...props }: any) => (
|
||||
<tr className={className} {...props}>{children}</tr>
|
||||
);
|
||||
const TableHead = ({ children, className, ...props }: any) => (
|
||||
<th className={`px-4 py-3 text-left font-medium ${className}`} {...props}>{children}</th>
|
||||
);
|
||||
const TableCell = ({ children, className, ...props }: any) => (
|
||||
<td className={`px-4 py-3 ${className}`} {...props}>{children}</td>
|
||||
);
|
||||
|
||||
// Расширенные типы данных для детальной структуры поставок
|
||||
interface ProductParameter {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
interface GoodsSupplyProduct {
|
||||
id: string;
|
||||
name: string;
|
||||
sku: string;
|
||||
category: string;
|
||||
plannedQty: number;
|
||||
actualQty: number;
|
||||
defectQty: number;
|
||||
productPrice: number;
|
||||
parameters: ProductParameter[];
|
||||
}
|
||||
|
||||
interface GoodsSupplyWholesaler {
|
||||
id: string;
|
||||
name: string;
|
||||
inn: string;
|
||||
contact: string;
|
||||
address: string;
|
||||
products: GoodsSupplyProduct[];
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
interface GoodsSupplyRoute {
|
||||
id: string;
|
||||
from: string;
|
||||
fromAddress: string;
|
||||
to: string;
|
||||
toAddress: string;
|
||||
wholesalers: GoodsSupplyWholesaler[];
|
||||
totalProductPrice: number;
|
||||
fulfillmentServicePrice: number;
|
||||
logisticsPrice: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
// Основной интерфейс поставки товаров согласно rules2.md 9.5.4
|
||||
interface GoodsSupply {
|
||||
id: string;
|
||||
number: string;
|
||||
creationMethod: 'cards' | 'suppliers'; // 📱 карточки / 🏢 поставщик
|
||||
deliveryDate: string;
|
||||
createdAt: string;
|
||||
status: string;
|
||||
|
||||
// Агрегированные данные
|
||||
plannedTotal: number;
|
||||
actualTotal: number;
|
||||
defectTotal: number;
|
||||
totalProductPrice: number;
|
||||
totalFulfillmentPrice: number;
|
||||
totalLogisticsPrice: number;
|
||||
grandTotal: number;
|
||||
|
||||
// Детальная структура
|
||||
routes: GoodsSupplyRoute[];
|
||||
|
||||
// Для обратной совместимости
|
||||
goodsCount?: number;
|
||||
totalAmount?: number;
|
||||
supplier?: string;
|
||||
items?: GoodsSupplyItem[];
|
||||
}
|
||||
|
||||
// Простой интерфейс товара для базовой детализации
|
||||
interface GoodsSupplyItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
interface GoodsSuppliesTableProps {
|
||||
supplies?: GoodsSupply[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// Компонент для иконки способа создания
|
||||
function CreationMethodIcon({ method }: { method: 'cards' | 'suppliers' }) {
|
||||
if (method === 'cards') {
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-blue-400">
|
||||
<Smartphone className="h-3 w-3" />
|
||||
<span className="text-xs hidden sm:inline">Карточки</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-green-400">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span className="text-xs hidden sm:inline">Поставщик</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Компонент для статуса поставки
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'pending': return 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30';
|
||||
case 'supplier_approved': return 'bg-blue-500/20 text-blue-300 border-blue-500/30';
|
||||
case 'confirmed': return 'bg-purple-500/20 text-purple-300 border-purple-500/30';
|
||||
case 'shipped': return 'bg-orange-500/20 text-orange-300 border-orange-500/30';
|
||||
case 'in_transit': return 'bg-indigo-500/20 text-indigo-300 border-indigo-500/30';
|
||||
case 'delivered': return 'bg-green-500/20 text-green-300 border-green-500/30';
|
||||
default: return 'bg-gray-500/20 text-gray-300 border-gray-500/30';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'pending': return 'Ожидает';
|
||||
case 'supplier_approved': return 'Одобрена';
|
||||
case 'confirmed': return 'Подтверждена';
|
||||
case 'shipped': return 'Отгружена';
|
||||
case 'in_transit': return 'В пути';
|
||||
case 'delivered': return 'Доставлена';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge className={`${getStatusColor(status)} border text-xs`}>
|
||||
{getStatusText(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function GoodsSuppliesTable({ supplies = [], loading = false }: GoodsSuppliesTableProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedMethod, setSelectedMethod] = useState<string>("all");
|
||||
const [selectedStatus, setSelectedStatus] = useState<string>("all");
|
||||
const [expandedSupplies, setExpandedSupplies] = useState<Set<string>>(new Set());
|
||||
const [expandedRoutes, setExpandedRoutes] = useState<Set<string>>(new Set());
|
||||
const [expandedWholesalers, setExpandedWholesalers] = useState<Set<string>>(new Set());
|
||||
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(new Set());
|
||||
|
||||
// Фильтрация согласно rules2.md 9.5.4 с поддержкой расширенной структуры
|
||||
const filteredSupplies = supplies.filter(supply => {
|
||||
const matchesSearch = supply.number.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(supply.supplier && supply.supplier.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
||||
(supply.routes && supply.routes.some(route =>
|
||||
route.wholesalers.some(wholesaler =>
|
||||
wholesaler.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
));
|
||||
const matchesMethod = selectedMethod === "all" || supply.creationMethod === selectedMethod;
|
||||
const matchesStatus = selectedStatus === "all" || supply.status === selectedStatus;
|
||||
|
||||
return matchesSearch && matchesMethod && matchesStatus;
|
||||
});
|
||||
|
||||
const toggleSupplyExpansion = (supplyId: string) => {
|
||||
const newExpanded = new Set(expandedSupplies);
|
||||
if (newExpanded.has(supplyId)) {
|
||||
newExpanded.delete(supplyId);
|
||||
} else {
|
||||
newExpanded.add(supplyId);
|
||||
}
|
||||
setExpandedSupplies(newExpanded);
|
||||
};
|
||||
|
||||
const toggleRouteExpansion = (routeId: string) => {
|
||||
const newExpanded = new Set(expandedRoutes);
|
||||
if (newExpanded.has(routeId)) {
|
||||
newExpanded.delete(routeId);
|
||||
} else {
|
||||
newExpanded.add(routeId);
|
||||
}
|
||||
setExpandedRoutes(newExpanded);
|
||||
};
|
||||
|
||||
const toggleWholesalerExpansion = (wholesalerId: string) => {
|
||||
const newExpanded = new Set(expandedWholesalers);
|
||||
if (newExpanded.has(wholesalerId)) {
|
||||
newExpanded.delete(wholesalerId);
|
||||
} else {
|
||||
newExpanded.add(wholesalerId);
|
||||
}
|
||||
setExpandedWholesalers(newExpanded);
|
||||
};
|
||||
|
||||
const toggleProductExpansion = (productId: string) => {
|
||||
const newExpanded = new Set(expandedProducts);
|
||||
if (newExpanded.has(productId)) {
|
||||
newExpanded.delete(productId);
|
||||
} else {
|
||||
newExpanded.add(productId);
|
||||
}
|
||||
setExpandedProducts(newExpanded);
|
||||
};
|
||||
|
||||
// Вспомогательные функции
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusMap = {
|
||||
pending: { label: "Ожидает", color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30" },
|
||||
supplier_approved: { label: "Одобрена", color: "bg-blue-500/20 text-blue-300 border-blue-500/30" },
|
||||
confirmed: { label: "Подтверждена", color: "bg-purple-500/20 text-purple-300 border-purple-500/30" },
|
||||
shipped: { label: "Отгружена", color: "bg-orange-500/20 text-orange-300 border-orange-500/30" },
|
||||
in_transit: { label: "В пути", color: "bg-indigo-500/20 text-indigo-300 border-indigo-500/30" },
|
||||
delivered: { label: "Доставлена", color: "bg-green-500/20 text-green-300 border-green-500/30" },
|
||||
planned: { label: "Запланирована", color: "bg-blue-500/20 text-blue-300 border-blue-500/30" },
|
||||
completed: { label: "Завершена", color: "bg-purple-500/20 text-purple-300 border-purple-500/30" },
|
||||
};
|
||||
const statusInfo = statusMap[status as keyof typeof statusMap] || { label: status, color: "bg-gray-500/20 text-gray-300 border-gray-500/30" };
|
||||
return <Badge className={`${statusInfo.color} border`}>{statusInfo.label}</Badge>;
|
||||
};
|
||||
|
||||
const getEfficiencyBadge = (planned: number, actual: number, defect: number) => {
|
||||
const efficiency = ((actual - defect) / planned) * 100;
|
||||
if (efficiency >= 95) {
|
||||
return <Badge className="bg-green-500/20 text-green-300 border-green-500/30 border">Отлично</Badge>;
|
||||
} else if (efficiency >= 90) {
|
||||
return <Badge className="bg-yellow-500/20 text-yellow-300 border-yellow-500/30 border">Хорошо</Badge>;
|
||||
} else {
|
||||
return <Badge className="bg-red-500/20 text-red-300 border-red-500/30 border">Проблемы</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const calculateProductTotal = (product: GoodsSupplyProduct) => {
|
||||
return product.actualQty * product.productPrice;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-white/10 rounded w-1/4"></div>
|
||||
<div className="space-y-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="h-12 bg-white/5 rounded"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Фильтры */}
|
||||
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Поиск */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/40 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="Поиск по номеру или поставщику..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="bg-white/10 border-white/20 text-white placeholder-white/50 pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Фильтр по способу создания */}
|
||||
<select
|
||||
value={selectedMethod}
|
||||
onChange={(e) => setSelectedMethod(e.target.value)}
|
||||
className="bg-white/10 border border-white/20 text-white rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">Все способы</option>
|
||||
<option value="cards">Карточки</option>
|
||||
<option value="suppliers">Поставщики</option>
|
||||
</select>
|
||||
|
||||
{/* Фильтр по статусу */}
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
className="bg-white/10 border border-white/20 text-white rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="pending">Ожидает</option>
|
||||
<option value="supplier_approved">Одобрена</option>
|
||||
<option value="confirmed">Подтверждена</option>
|
||||
<option value="shipped">Отгружена</option>
|
||||
<option value="in_transit">В пути</option>
|
||||
<option value="delivered">Доставлена</option>
|
||||
</select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Таблица поставок согласно rules2.md 9.5.4 */}
|
||||
<Card className="bg-white/10 backdrop-blur border-white/20 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-white/10 hover:bg-white/5">
|
||||
<TableHead className="text-white/70">№</TableHead>
|
||||
<TableHead className="text-white/70">
|
||||
<span className="hidden sm:inline">Дата поставки</span>
|
||||
<span className="sm:hidden">Поставка</span>
|
||||
</TableHead>
|
||||
<TableHead className="text-white/70 hidden lg:table-cell">Создана</TableHead>
|
||||
<TableHead className="text-white/70">План</TableHead>
|
||||
<TableHead className="text-white/70">Факт</TableHead>
|
||||
<TableHead className="text-white/70">Брак</TableHead>
|
||||
<TableHead className="text-white/70">
|
||||
<span className="hidden md:inline">Цена товаров</span>
|
||||
<span className="md:hidden">Цена</span>
|
||||
</TableHead>
|
||||
<TableHead className="text-white/70 hidden lg:table-cell">ФФ</TableHead>
|
||||
<TableHead className="text-white/70 hidden lg:table-cell">Логистика</TableHead>
|
||||
<TableHead className="text-white/70">Итого</TableHead>
|
||||
<TableHead className="text-white/70">Статус</TableHead>
|
||||
<TableHead className="text-white/70">Способ</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredSupplies.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={12} className="text-center py-8 text-white/60">
|
||||
{searchQuery || selectedMethod !== "all" || selectedStatus !== "all"
|
||||
? "Поставки не найдены по заданным фильтрам"
|
||||
: "Поставки товаров отсутствуют"
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredSupplies.map((supply) => {
|
||||
const isSupplyExpanded = expandedSupplies.has(supply.id);
|
||||
|
||||
return (
|
||||
<React.Fragment key={supply.id}>
|
||||
{/* Основная строка поставки */}
|
||||
<TableRow
|
||||
className="border-white/10 hover:bg-white/5 cursor-pointer transition-colors bg-purple-500/10"
|
||||
onClick={() => toggleSupplyExpansion(supply.id)}
|
||||
>
|
||||
<TableCell className="text-white font-mono text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
{isSupplyExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-white/40" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-white/40" />
|
||||
)}
|
||||
{supply.number}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Calendar className="h-3 w-3 text-white/40" />
|
||||
<span className="text-white font-semibold text-sm">
|
||||
{formatDate(supply.deliveryDate)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<span className="text-white/80 text-sm">
|
||||
{formatDate(supply.createdAt)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white font-semibold text-sm">
|
||||
{supply.plannedTotal || supply.goodsCount || 0}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white font-semibold text-sm">
|
||||
{supply.actualTotal || supply.goodsCount || 0}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`font-semibold text-sm ${
|
||||
(supply.defectTotal || 0) > 0 ? "text-red-400" : "text-white"
|
||||
}`}>
|
||||
{supply.defectTotal || 0}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-green-400 font-semibold text-sm">
|
||||
{formatCurrency(supply.totalProductPrice || supply.totalAmount || 0)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<span className="text-blue-400 font-semibold text-sm">
|
||||
{formatCurrency(supply.totalFulfillmentPrice || 0)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<span className="text-purple-400 font-semibold text-sm">
|
||||
{formatCurrency(supply.totalLogisticsPrice || 0)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-1">
|
||||
<DollarSign className="h-3 w-3 text-white/40" />
|
||||
<span className="text-white font-bold text-sm">
|
||||
{formatCurrency(supply.grandTotal || supply.totalAmount || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(supply.status)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CreationMethodIcon method={supply.creationMethod} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Развернутые уровни - маршруты, поставщики, товары */}
|
||||
{isSupplyExpanded && supply.routes && supply.routes.map((route) => {
|
||||
const isRouteExpanded = expandedRoutes.has(route.id);
|
||||
return (
|
||||
<React.Fragment key={route.id}>
|
||||
<TableRow
|
||||
className="border-white/10 hover:bg-white/5 cursor-pointer transition-colors bg-blue-500/10"
|
||||
onClick={() => toggleRouteExpansion(route.id)}
|
||||
>
|
||||
<TableCell className="relative">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1 h-1 rounded-full bg-blue-400 mr-1"></div>
|
||||
<MapPin className="h-3 w-3 text-blue-400" />
|
||||
<span className="text-white font-medium text-sm">Маршрут</span>
|
||||
</div>
|
||||
<div className="absolute left-0 top-0 w-0.5 h-full bg-blue-400/30"></div>
|
||||
</TableCell>
|
||||
<TableCell colSpan={1}>
|
||||
<div className="text-white">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<span className="font-medium text-sm">{route.from}</span>
|
||||
<span className="text-white/60">→</span>
|
||||
<span className="font-medium text-sm">{route.to}</span>
|
||||
</div>
|
||||
<div className="text-xs text-white/60 hidden sm:block">
|
||||
{route.fromAddress} → {route.toAddress}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell"></TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">
|
||||
{route.wholesalers.reduce((sum, w) =>
|
||||
sum + w.products.reduce((pSum, p) => pSum + p.plannedQty, 0), 0
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">
|
||||
{route.wholesalers.reduce((sum, w) =>
|
||||
sum + w.products.reduce((pSum, p) => pSum + p.actualQty, 0), 0
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">
|
||||
{route.wholesalers.reduce((sum, w) =>
|
||||
sum + w.products.reduce((pSum, p) => pSum + p.defectQty, 0), 0
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-green-400 font-medium text-sm">
|
||||
{formatCurrency(route.totalProductPrice)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<span className="text-blue-400 font-medium text-sm">
|
||||
{formatCurrency(route.fulfillmentServicePrice)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<span className="text-purple-400 font-medium text-sm">
|
||||
{formatCurrency(route.logisticsPrice)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white font-semibold text-sm">
|
||||
{formatCurrency(route.totalAmount)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Поставщики в маршруте */}
|
||||
{isRouteExpanded && route.wholesalers.map((wholesaler) => {
|
||||
const isWholesalerExpanded = expandedWholesalers.has(wholesaler.id);
|
||||
return (
|
||||
<React.Fragment key={wholesaler.id}>
|
||||
<TableRow
|
||||
className="border-white/10 hover:bg-white/5 cursor-pointer transition-colors bg-green-500/10"
|
||||
onClick={() => toggleWholesalerExpansion(wholesaler.id)}
|
||||
>
|
||||
<TableCell className="relative">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1 h-1 rounded-full bg-green-400 mr-1"></div>
|
||||
<div className="w-1 h-1 rounded-full bg-green-400 mr-1"></div>
|
||||
<Building2 className="h-3 w-3 text-green-400" />
|
||||
<span className="text-white font-medium text-sm">Поставщик</span>
|
||||
</div>
|
||||
<div className="absolute left-0 top-0 w-0.5 h-full bg-green-400/30"></div>
|
||||
</TableCell>
|
||||
<TableCell colSpan={1}>
|
||||
<div className="text-white">
|
||||
<div className="font-medium mb-1 text-sm">{wholesaler.name}</div>
|
||||
<div className="text-xs text-white/60 mb-1 hidden sm:block">
|
||||
ИНН: {wholesaler.inn}
|
||||
</div>
|
||||
<div className="text-xs text-white/60 mb-1 hidden lg:block">
|
||||
{wholesaler.address}
|
||||
</div>
|
||||
<div className="text-xs text-white/60 hidden sm:block">
|
||||
{wholesaler.contact}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell"></TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">
|
||||
{wholesaler.products.reduce((sum, p) => sum + p.plannedQty, 0)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">
|
||||
{wholesaler.products.reduce((sum, p) => sum + p.actualQty, 0)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white/80 text-sm">
|
||||
{wholesaler.products.reduce((sum, p) => sum + p.defectQty, 0)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-green-400 font-medium text-sm">
|
||||
{formatCurrency(wholesaler.products.reduce((sum, p) => sum + calculateProductTotal(p), 0))}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell" colSpan={2}></TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white font-semibold text-sm">
|
||||
{formatCurrency(wholesaler.totalAmount)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Товары поставщика */}
|
||||
{isWholesalerExpanded && wholesaler.products.map((product) => {
|
||||
const isProductExpanded = expandedProducts.has(product.id);
|
||||
return (
|
||||
<React.Fragment key={product.id}>
|
||||
<TableRow
|
||||
className="border-white/10 hover:bg-white/5 cursor-pointer transition-colors bg-yellow-500/10"
|
||||
onClick={() => toggleProductExpansion(product.id)}
|
||||
>
|
||||
<TableCell className="relative">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-1 h-1 rounded-full bg-yellow-400 mr-1"></div>
|
||||
<div className="w-1 h-1 rounded-full bg-yellow-400 mr-1"></div>
|
||||
<div className="w-1 h-1 rounded-full bg-yellow-400 mr-1"></div>
|
||||
<Package className="h-3 w-3 text-yellow-400" />
|
||||
<span className="text-white font-medium text-sm">Товар</span>
|
||||
</div>
|
||||
<div className="absolute left-0 top-0 w-0.5 h-full bg-yellow-400/30"></div>
|
||||
</TableCell>
|
||||
<TableCell colSpan={1}>
|
||||
<div className="text-white">
|
||||
<div className="font-medium mb-1 text-sm">{product.name}</div>
|
||||
<div className="text-xs text-white/60 mb-1 hidden sm:block">
|
||||
Артикул: {product.sku}
|
||||
</div>
|
||||
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30 border text-xs hidden sm:inline-flex">
|
||||
{product.category}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell"></TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white font-semibold text-sm">{product.plannedQty}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white font-semibold text-sm">{product.actualQty}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`font-semibold text-sm ${
|
||||
product.defectQty > 0 ? "text-red-400" : "text-white"
|
||||
}`}>
|
||||
{product.defectQty}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-white">
|
||||
<div className="font-medium text-sm">
|
||||
{formatCurrency(calculateProductTotal(product))}
|
||||
</div>
|
||||
<div className="text-xs text-white/60 hidden sm:block">
|
||||
{formatCurrency(product.productPrice)} за шт.
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell" colSpan={2}>
|
||||
{getEfficiencyBadge(product.plannedQty, product.actualQty, product.defectQty)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-white font-semibold text-sm">
|
||||
{formatCurrency(calculateProductTotal(product))}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell colSpan={2}></TableCell>
|
||||
</TableRow>
|
||||
|
||||
{/* Параметры товара */}
|
||||
{isProductExpanded && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={12} className="p-0">
|
||||
<div className="bg-white/5 border-t border-white/10">
|
||||
<div className="p-4">
|
||||
<h4 className="text-white font-medium mb-3 flex items-center space-x-2">
|
||||
<span className="text-xs text-white/60">📋 Параметры товара:</span>
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{product.parameters.map((param) => (
|
||||
<div key={param.id} className="bg-white/5 rounded-lg p-3">
|
||||
<div className="text-white/80 text-xs font-medium mb-1">
|
||||
{param.name}
|
||||
</div>
|
||||
<div className="text-white text-sm">
|
||||
{param.value} {param.unit || ""}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Базовая детализация для поставок без маршрутов */}
|
||||
{isSupplyExpanded && supply.items && !supply.routes && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={12} className="bg-white/5 border-white/5">
|
||||
<div className="p-4 space-y-4">
|
||||
<h4 className="text-white font-medium">Детализация товаров:</h4>
|
||||
<div className="grid gap-2">
|
||||
{supply.items.map((item) => (
|
||||
<div key={item.id} className="flex justify-between items-center py-2 px-3 bg-white/5 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<span className="text-white text-sm">{item.name}</span>
|
||||
{item.category && (
|
||||
<span className="text-white/60 text-xs ml-2">({item.category})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-white/80">{item.quantity} шт</span>
|
||||
<span className="text-white/80">{formatCurrency(item.price)}</span>
|
||||
<span className="text-white font-medium">{formatCurrency(item.price * item.quantity)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,12 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
// Убираем Tabs - используем кнопочную логику как в fulfillment-supplies
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Sidebar } from "@/components/dashboard/sidebar";
|
||||
import { useSidebar } from "@/hooks/useSidebar";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import {
|
||||
Plus,
|
||||
@ -18,12 +17,11 @@ import {
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { GET_PENDING_SUPPLIES_COUNT } from "@/graphql/queries";
|
||||
import { FulfillmentGoodsTab } from "./fulfillment-supplies/fulfillment-goods-tab";
|
||||
import { RealSupplyOrdersTab } from "./fulfillment-supplies/real-supply-orders-tab";
|
||||
import { SellerSupplyOrdersTab } from "./fulfillment-supplies/seller-supply-orders-tab";
|
||||
import { AllSuppliesTab } from "./fulfillment-supplies/all-supplies-tab";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
// Убираем DropdownMenu - больше не используется
|
||||
import { SuppliesStatistics } from "./supplies-statistics";
|
||||
|
||||
// Компонент для отображения бейджа с уведомлениями
|
||||
function NotificationBadge({ count }: { count: number }) {
|
||||
@ -39,10 +37,12 @@ function NotificationBadge({ count }: { count: number }) {
|
||||
export function SuppliesDashboard() {
|
||||
const { getSidebarMargin } = useSidebar();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState("fulfillment");
|
||||
const [activeSubTab, setActiveSubTab] = useState("goods");
|
||||
const [activeThirdTab, setActiveThirdTab] = useState("cards");
|
||||
const { user } = useAuth();
|
||||
const [statisticsData, setStatisticsData] = useState<any>(null);
|
||||
|
||||
// Загружаем счетчик поставок, требующих одобрения
|
||||
const { data: pendingData } = useQuery(GET_PENDING_SUPPLIES_COUNT, {
|
||||
@ -95,7 +95,7 @@ export function SuppliesDashboard() {
|
||||
<main
|
||||
className={`flex-1 ${getSidebarMargin()} px-2 py-2 overflow-hidden transition-all duration-300`}
|
||||
>
|
||||
<div className="h-full">
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Уведомляющий баннер */}
|
||||
{hasPendingItems && (
|
||||
<Alert className="mb-4 bg-blue-500/20 border-blue-400/30 text-blue-300 animate-pulse">
|
||||
@ -132,8 +132,8 @@ export function SuppliesDashboard() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* БЛОК ВСЕХ ТАБОВ */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6">
|
||||
{/* БЛОК 1: ТАБЫ (навигация) */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6 flex-shrink-0">
|
||||
{/* УРОВЕНЬ 1: Главные табы */}
|
||||
<div className="mb-4">
|
||||
<div className="grid w-full grid-cols-2 bg-white/15 backdrop-blur border-white/30 rounded-xl h-11 p-2">
|
||||
@ -218,7 +218,7 @@ export function SuppliesDashboard() {
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.location.href = "/supplies/create-consumables";
|
||||
router.push("/supplies/create-consumables");
|
||||
}}
|
||||
className="h-6 px-2 py-1 bg-white/10 border border-white/20 hover:bg-white/20 text-xs font-medium text-white/80 hover:text-white rounded-md transition-all duration-150 flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
@ -257,7 +257,7 @@ export function SuppliesDashboard() {
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.location.href = "/supplies/create-wildberries";
|
||||
router.push("/supplies/create-wildberries");
|
||||
}}
|
||||
className="h-6 px-2 py-1 bg-white/10 border border-white/20 hover:bg-white/20 text-xs font-medium text-white/80 hover:text-white rounded-md transition-all duration-150 flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
@ -285,7 +285,7 @@ export function SuppliesDashboard() {
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.location.href = "/supplies/create-ozon";
|
||||
router.push("/supplies/create-ozon");
|
||||
}}
|
||||
className="h-6 px-2 py-1 bg-white/10 border border-white/20 hover:bg-white/20 text-xs font-medium text-white/80 hover:text-white rounded-md transition-all duration-150 flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
@ -324,7 +324,7 @@ export function SuppliesDashboard() {
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.location.href = "/supplies/create-cards";
|
||||
router.push("/supplies/create-cards");
|
||||
}}
|
||||
className="h-5 px-1.5 py-0.5 bg-white/8 border border-white/15 hover:bg-white/15 text-xs font-normal text-white/60 hover:text-white/80 rounded-sm transition-all duration-150 flex items-center gap-0.5 cursor-pointer"
|
||||
>
|
||||
@ -352,7 +352,7 @@ export function SuppliesDashboard() {
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.location.href = "/supplies/create-suppliers";
|
||||
router.push("/supplies/create-suppliers");
|
||||
}}
|
||||
className="h-5 px-1.5 py-0.5 bg-white/8 border border-white/15 hover:bg-white/15 text-xs font-normal text-white/60 hover:text-white/80 rounded-sm transition-all duration-150 flex items-center gap-0.5 cursor-pointer"
|
||||
>
|
||||
@ -367,65 +367,77 @@ export function SuppliesDashboard() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* РАБОЧЕЕ ПРОСТРАНСТВО */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl flex-1 overflow-hidden p-6">
|
||||
{/* СОДЕРЖИМОЕ ПОСТАВОК НА ФУЛФИЛМЕНТ */}
|
||||
{activeTab === "fulfillment" && (
|
||||
<div className="h-full">
|
||||
{/* ТОВАР */}
|
||||
{activeSubTab === "goods" && (
|
||||
<div className="h-full">
|
||||
{/* КАРТОЧКИ */}
|
||||
{activeThirdTab === "cards" && <FulfillmentGoodsTab />}
|
||||
{/* БЛОК 2: СТАТИСТИКА (метрики) */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl p-6 mt-4 flex-shrink-0">
|
||||
<SuppliesStatistics
|
||||
activeTab={activeTab}
|
||||
activeSubTab={activeSubTab}
|
||||
activeThirdTab={activeThirdTab}
|
||||
data={statisticsData}
|
||||
loading={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ПОСТАВЩИКИ */}
|
||||
{activeThirdTab === "suppliers" && (
|
||||
<AllSuppliesTab
|
||||
pendingSupplyOrders={pendingCount?.supplyOrders || 0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* БЛОК 3: ОСНОВНОЙ КОНТЕНТ (сохраняем весь функционал) */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl mt-4 flex-1 min-h-0">
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
{/* СОДЕРЖИМОЕ ПОСТАВОК НА ФУЛФИЛМЕНТ */}
|
||||
{activeTab === "fulfillment" && (
|
||||
<div className="h-full">
|
||||
{/* ТОВАР */}
|
||||
{activeSubTab === "goods" && (
|
||||
<div className="h-full">
|
||||
{/* ✅ ЕДИНАЯ ЛОГИКА для табов "Карточки" и "Поставщики" согласно rules2.md 9.5.3 */}
|
||||
{(activeThirdTab === "cards" || activeThirdTab === "suppliers") && (
|
||||
<AllSuppliesTab
|
||||
pendingSupplyOrders={pendingCount?.supplyOrders || 0}
|
||||
goodsSupplies={[]} // TODO: Подключить реальные данные поставок товаров из всех источников
|
||||
loading={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* РАСХОДНИКИ СЕЛЛЕРА */}
|
||||
{activeSubTab === "consumables" && (
|
||||
<div className="h-full">
|
||||
{isWholesale ? (
|
||||
<RealSupplyOrdersTab />
|
||||
) : (
|
||||
<SellerSupplyOrdersTab />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* РАСХОДНИКИ СЕЛЛЕРА - сохраняем весь функционал */}
|
||||
{activeSubTab === "consumables" && (
|
||||
<div className="h-full">
|
||||
{isWholesale ? (
|
||||
<RealSupplyOrdersTab />
|
||||
) : (
|
||||
<SellerSupplyOrdersTab />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* СОДЕРЖИМОЕ ПОСТАВОК НА МАРКЕТПЛЕЙСЫ */}
|
||||
{activeTab === "marketplace" && (
|
||||
<div className="h-full">
|
||||
{/* WILDBERRIES */}
|
||||
{activeSubTab === "wildberries" && (
|
||||
<div className="text-white/70 text-center py-8">
|
||||
<Package className="h-16 w-16 mx-auto mb-4 text-white/30" />
|
||||
<h3 className="text-xl font-semibold mb-2">
|
||||
Поставки на Wildberries
|
||||
</h3>
|
||||
<p>Раздел находится в разработке</p>
|
||||
</div>
|
||||
)}
|
||||
{/* СОДЕРЖИМОЕ ПОСТАВОК НА МАРКЕТПЛЕЙСЫ */}
|
||||
{activeTab === "marketplace" && (
|
||||
<div className="h-full">
|
||||
{/* WILDBERRIES - плейсхолдер */}
|
||||
{activeSubTab === "wildberries" && (
|
||||
<div className="text-white/70 text-center py-8">
|
||||
<Package className="h-16 w-16 mx-auto mb-4 text-white/30" />
|
||||
<h3 className="text-xl font-semibold mb-2">
|
||||
Поставки на Wildberries
|
||||
</h3>
|
||||
<p>Раздел находится в разработке</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OZON */}
|
||||
{activeSubTab === "ozon" && (
|
||||
<div className="text-white/70 text-center py-8">
|
||||
<Package className="h-16 w-16 mx-auto mb-4 text-white/30" />
|
||||
<h3 className="text-xl font-semibold mb-2">
|
||||
Поставки на Ozon
|
||||
</h3>
|
||||
<p>Раздел находится в разработке</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* OZON - плейсхолдер */}
|
||||
{activeSubTab === "ozon" && (
|
||||
<div className="text-white/70 text-center py-8">
|
||||
<Package className="h-16 w-16 mx-auto mb-4 text-white/30" />
|
||||
<h3 className="text-xl font-semibold mb-2">
|
||||
Поставки на Ozon
|
||||
</h3>
|
||||
<p>Раздел находится в разработке</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
211
src/components/supplies/supplies-statistics.tsx
Normal file
211
src/components/supplies/supplies-statistics.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Package,
|
||||
TrendingUp,
|
||||
DollarSign,
|
||||
Truck,
|
||||
AlertTriangle,
|
||||
BarChart,
|
||||
ShoppingCart,
|
||||
Undo2
|
||||
} from "lucide-react";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
|
||||
interface StatisticCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ReactNode;
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
};
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function StatisticCard({ title, value, icon, trend, loading }: StatisticCardProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-white/5 backdrop-blur border-white/10 p-4">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-3 bg-white/10 rounded w-24 mb-2"></div>
|
||||
<div className="h-6 bg-white/10 rounded w-32"></div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-white/5 backdrop-blur border-white/10 p-4 hover:border-white/20 transition-all">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs text-white/60 mb-1">{title}</p>
|
||||
<p className="text-xl font-semibold text-white">
|
||||
{typeof value === 'number' && title.includes('Сумма')
|
||||
? formatCurrency(value)
|
||||
: value}
|
||||
</p>
|
||||
{trend && (
|
||||
<div className={`flex items-center mt-1 text-xs ${
|
||||
trend.isPositive ? 'text-green-400' : 'text-red-400'
|
||||
}`}>
|
||||
<TrendingUp className={`h-3 w-3 mr-1 ${!trend.isPositive && 'rotate-180'}`} />
|
||||
<span>{Math.abs(trend.value)}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-white/40">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface SuppliesStatisticsProps {
|
||||
activeTab: string;
|
||||
activeSubTab: string;
|
||||
activeThirdTab?: string;
|
||||
data?: any; // Данные будут приходить из родительского компонента
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function SuppliesStatistics({
|
||||
activeTab,
|
||||
activeSubTab,
|
||||
activeThirdTab,
|
||||
data,
|
||||
loading = false
|
||||
}: SuppliesStatisticsProps) {
|
||||
|
||||
// Определяем какие метрики показывать в зависимости от активных табов
|
||||
const getStatistics = () => {
|
||||
// ✅ Фулфилмент → Товар → Карточки/Поставщики - ОБЩИЕ МЕТРИКИ согласно rules2.md 9.5.2
|
||||
if (activeTab === "fulfillment" && activeSubTab === "goods") {
|
||||
return [
|
||||
{
|
||||
title: "Всего поставок товаров",
|
||||
value: data?.totalGoodsSupplies || 0,
|
||||
icon: <Package className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: "Активных поставок",
|
||||
value: data?.activeGoodsSupplies || 0,
|
||||
icon: <TrendingUp className="h-5 w-5" />,
|
||||
trend: data?.activeGoodsSuppliesTrend
|
||||
},
|
||||
{
|
||||
title: "Сумма активных",
|
||||
value: data?.activeGoodsSuppliesSum || 0,
|
||||
icon: <DollarSign className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: "Товаров в пути",
|
||||
value: data?.goodsInTransit || 0,
|
||||
icon: <Truck className="h-5 w-5" />,
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Фулфилмент → Расходники селлера
|
||||
if (activeTab === "fulfillment" && activeSubTab === "consumables") {
|
||||
return [
|
||||
{
|
||||
title: "Всего поставок",
|
||||
value: data?.totalSupplies || 0,
|
||||
icon: <Package className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: "Активных поставок",
|
||||
value: data?.activeSupplies || 0,
|
||||
icon: <TrendingUp className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: "Видов расходников",
|
||||
value: data?.consumableTypes || 0,
|
||||
icon: <BarChart className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: "Критические остатки",
|
||||
value: data?.criticalStock || 0,
|
||||
icon: <AlertTriangle className="h-5 w-5" />,
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Маркетплейсы → Wildberries
|
||||
if (activeTab === "marketplace" && activeSubTab === "wildberries") {
|
||||
return [
|
||||
{
|
||||
title: "Поставок на WB",
|
||||
value: data?.totalWbSupplies || 0,
|
||||
icon: <ShoppingCart className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: "Товаров отправлено",
|
||||
value: data?.sentProducts || 0,
|
||||
icon: <Package className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: "Возвраты за неделю",
|
||||
value: data?.weeklyReturns || 0,
|
||||
icon: <Undo2 className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: "Эффективность",
|
||||
value: `${data?.efficiency || 0}%`,
|
||||
icon: <TrendingUp className="h-5 w-5" />,
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Маркетплейсы → Ozon
|
||||
if (activeTab === "marketplace" && activeSubTab === "ozon") {
|
||||
return [
|
||||
{
|
||||
title: "Поставок на Ozon",
|
||||
value: data?.totalOzonSupplies || 0,
|
||||
icon: <ShoppingCart className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: "Товаров отправлено",
|
||||
value: data?.sentProducts || 0,
|
||||
icon: <Package className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: "Возвраты за неделю",
|
||||
value: data?.weeklyReturns || 0,
|
||||
icon: <Undo2 className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
title: "Эффективность",
|
||||
value: `${data?.efficiency || 0}%`,
|
||||
icon: <TrendingUp className="h-5 w-5" />,
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const statistics = getStatistics();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{statistics.map((stat, index) => (
|
||||
<StatisticCard
|
||||
key={index}
|
||||
title={stat.title}
|
||||
value={stat.value}
|
||||
icon={stat.icon}
|
||||
trend={stat.trend}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -595,6 +595,51 @@ export const GET_ALL_PRODUCTS = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
// Запрос товаров конкретной организации (для формы создания поставки)
|
||||
export const GET_ORGANIZATION_PRODUCTS = gql`
|
||||
query GetOrganizationProducts($organizationId: ID!, $search: String, $category: String, $type: String) {
|
||||
organizationProducts(organizationId: $organizationId, search: $search, category: $category, type: $type) {
|
||||
id
|
||||
name
|
||||
article
|
||||
description
|
||||
price
|
||||
quantity
|
||||
type
|
||||
category {
|
||||
id
|
||||
name
|
||||
}
|
||||
brand
|
||||
color
|
||||
size
|
||||
weight
|
||||
dimensions
|
||||
material
|
||||
images
|
||||
mainImage
|
||||
isActive
|
||||
createdAt
|
||||
updatedAt
|
||||
organization {
|
||||
id
|
||||
inn
|
||||
name
|
||||
fullName
|
||||
type
|
||||
address
|
||||
phones
|
||||
emails
|
||||
users {
|
||||
id
|
||||
avatar
|
||||
managerName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const GET_MY_CART = gql`
|
||||
query GetMyCart {
|
||||
myCart {
|
||||
|
@ -1748,6 +1748,76 @@ export const resolvers = {
|
||||
return products;
|
||||
},
|
||||
|
||||
// Товары конкретной организации (для формы создания поставки)
|
||||
organizationProducts: async (
|
||||
_: unknown,
|
||||
args: { organizationId: string; search?: string; category?: string; type?: string },
|
||||
context: Context
|
||||
) => {
|
||||
console.log("🏢 ORGANIZATION_PRODUCTS RESOLVER - ВЫЗВАН:", {
|
||||
userId: context.user?.id,
|
||||
organizationId: args.organizationId,
|
||||
search: args.search,
|
||||
category: args.category,
|
||||
type: args.type,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (!context.user) {
|
||||
throw new GraphQLError("Требуется авторизация", {
|
||||
extensions: { code: "UNAUTHENTICATED" },
|
||||
});
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
isActive: true, // Показываем только активные товары
|
||||
organizationId: args.organizationId, // Фильтруем по конкретной организации
|
||||
type: args.type || "ТОВАР", // Показываем только товары по умолчанию, НЕ расходники согласно development-checklist.md
|
||||
};
|
||||
|
||||
if (args.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: args.search, mode: "insensitive" } },
|
||||
{ article: { contains: args.search, mode: "insensitive" } },
|
||||
{ description: { contains: args.search, mode: "insensitive" } },
|
||||
{ brand: { contains: args.search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
if (args.category) {
|
||||
where.categoryId = args.category;
|
||||
}
|
||||
|
||||
const products = await prisma.product.findMany({
|
||||
where,
|
||||
include: {
|
||||
category: true,
|
||||
organization: {
|
||||
include: {
|
||||
users: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100, // Ограничиваем количество результатов
|
||||
});
|
||||
|
||||
console.log("🔥 ORGANIZATION_PRODUCTS RESOLVER DEBUG:", {
|
||||
organizationId: args.organizationId,
|
||||
searchArgs: args,
|
||||
whereCondition: where,
|
||||
totalProducts: products.length,
|
||||
productTypes: products.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
type: p.type,
|
||||
isActive: p.isActive,
|
||||
})),
|
||||
});
|
||||
|
||||
return products;
|
||||
},
|
||||
|
||||
// Все категории
|
||||
categories: async (_: unknown, __: unknown, context: Context) => {
|
||||
if (!context.user && !context.admin) {
|
||||
|
@ -70,6 +70,9 @@ export const typeDefs = gql`
|
||||
# Все товары всех поставщиков для маркета
|
||||
allProducts(search: String, category: String): [Product!]!
|
||||
|
||||
# Товары конкретной организации (для формы создания поставки)
|
||||
organizationProducts(organizationId: ID!, search: String, category: String, type: String): [Product!]!
|
||||
|
||||
# Все категории
|
||||
categories: [Category!]!
|
||||
|
||||
@ -467,10 +470,8 @@ export const typeDefs = gql`
|
||||
OZON
|
||||
}
|
||||
|
||||
enum ProductType {
|
||||
PRODUCT
|
||||
CONSUMABLE
|
||||
}
|
||||
# ProductType теперь String, чтобы поддерживать кириллические значения из БД
|
||||
# Возможные значения: "ТОВАР", "БРАК", "РАСХОДНИКИ", "ПРОДУКТ"
|
||||
|
||||
enum CounterpartyRequestStatus {
|
||||
PENDING
|
||||
@ -746,7 +747,7 @@ export const typeDefs = gql`
|
||||
inTransit: Int
|
||||
stock: Int
|
||||
sold: Int
|
||||
type: ProductType
|
||||
type: String
|
||||
category: Category
|
||||
brand: String
|
||||
color: String
|
||||
@ -774,7 +775,7 @@ export const typeDefs = gql`
|
||||
inTransit: Int
|
||||
stock: Int
|
||||
sold: Int
|
||||
type: ProductType
|
||||
type: String
|
||||
categoryId: ID
|
||||
brand: String
|
||||
color: String
|
||||
|
@ -48,25 +48,58 @@ const authLink = setContext((operation, { headers }) => {
|
||||
// Error Link для обработки ошибок с детальным логированием
|
||||
const errorLink = onError(
|
||||
({ graphQLErrors, networkError, operation, forward }) => {
|
||||
if (graphQLErrors) {
|
||||
graphQLErrors.forEach(({ message, locations, path, extensions }) => {
|
||||
console.error("🚨 GraphQL Error:", {
|
||||
message,
|
||||
locations,
|
||||
path,
|
||||
extensions,
|
||||
operation: operation.operationName,
|
||||
variables: operation.variables,
|
||||
try {
|
||||
// Расширенная отладочная информация для всех ошибок
|
||||
const debugInfo = {
|
||||
hasGraphQLErrors: !!graphQLErrors,
|
||||
graphQLErrorsLength: graphQLErrors?.length || 0,
|
||||
hasNetworkError: !!networkError,
|
||||
operationName: operation?.operationName || "Unknown",
|
||||
operationType: operation?.query?.definitions?.[0]?.operation || "Unknown",
|
||||
variables: operation?.variables || {},
|
||||
};
|
||||
|
||||
console.log("🎯 APOLLO ERROR LINK TRIGGERED:", debugInfo);
|
||||
|
||||
// Безопасная обработка GraphQL ошибок
|
||||
if (graphQLErrors && Array.isArray(graphQLErrors) && graphQLErrors.length > 0) {
|
||||
console.log("📊 GRAPHQL ERRORS COUNT:", graphQLErrors.length);
|
||||
|
||||
graphQLErrors.forEach((error, index) => {
|
||||
try {
|
||||
// Безопасная деструктуризация
|
||||
const message = error?.message || "No message";
|
||||
const locations = error?.locations || [];
|
||||
const path = error?.path || [];
|
||||
const extensions = error?.extensions || {};
|
||||
|
||||
console.log(`🚨 GraphQL Error #${index + 1}:`, {
|
||||
message,
|
||||
locations,
|
||||
path,
|
||||
extensions,
|
||||
operation: operation?.operationName || "Unknown",
|
||||
});
|
||||
} catch (innerError) {
|
||||
console.log(`❌ Error processing GraphQL error #${index + 1}:`, innerError);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (networkError) {
|
||||
console.error("🌐 Network Error:", {
|
||||
error: networkError,
|
||||
operation: operation.operationName,
|
||||
variables: operation.variables,
|
||||
});
|
||||
// Безопасная обработка Network ошибок
|
||||
if (networkError) {
|
||||
try {
|
||||
console.log("🌐 Network Error:", {
|
||||
message: networkError.message || "No message",
|
||||
statusCode: networkError.statusCode || "No status",
|
||||
operation: operation?.operationName || "Unknown",
|
||||
});
|
||||
} catch (innerError) {
|
||||
console.log("❌ Error processing network error:", innerError);
|
||||
}
|
||||
}
|
||||
} catch (outerError) {
|
||||
console.log("❌ Critical error in Apollo error link:", outerError);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -23,3 +23,13 @@ export function formatPhone(phone: string): string {
|
||||
// Форматируем как +7 (999) 999-99-99
|
||||
return `+7 (${normalizedDigits.slice(1, 4)}) ${normalizedDigits.slice(4, 7)}-${normalizedDigits.slice(7, 9)}-${normalizedDigits.slice(9, 11)}`
|
||||
}
|
||||
|
||||
// Функция для форматирования валюты
|
||||
export function formatCurrency(amount: number, currency: string = 'RUB'): string {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
Reference in New Issue
Block a user