Обновлен интерфейс панели поставок: заменены вкладки товаров и расходников на вкладки для выполнения и маркетплейсов. Изменен активный таб на 'fulfillment', обновлены названия вкладок для улучшения понимания. Оптимизирован код для лучшей читаемости.

This commit is contained in:
Veronika Smirnova
2025-07-21 15:24:12 +03:00
parent b935807cc2
commit 248548a4b4
10 changed files with 3820 additions and 12 deletions

View File

@ -0,0 +1,852 @@
"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 { StatsCard } from "../ui/stats-card";
import { StatsGrid } from "../ui/stats-grid";
import {
ChevronDown,
ChevronRight,
Calendar,
Package,
MapPin,
Building2,
TrendingUp,
AlertTriangle,
DollarSign,
Warehouse,
} from "lucide-react";
// Типы данных для товаров ФФ
interface ProductParameter {
id: string;
name: string;
value: string;
unit?: string;
}
interface Product {
id: string;
name: string;
sku: string;
category: string;
plannedQty: number;
actualQty: number;
defectQty: number;
productPrice: number;
parameters: ProductParameter[];
}
interface Wholesaler {
id: string;
name: string;
inn: string;
contact: string;
address: string;
products: Product[];
totalAmount: number;
}
interface Route {
id: string;
from: string;
fromAddress: string;
to: string;
toAddress: string;
wholesalers: Wholesaler[];
totalProductPrice: number;
fulfillmentServicePrice: number;
logisticsPrice: number;
totalAmount: number;
}
interface FulfillmentSupply {
id: string;
number: number;
deliveryDate: string;
createdDate: string;
routes: Route[];
plannedTotal: number;
actualTotal: number;
defectTotal: number;
totalProductPrice: number;
totalFulfillmentPrice: number;
totalLogisticsPrice: number;
grandTotal: number;
status: "planned" | "in-transit" | "delivered" | "completed";
}
// Моковые данные для товаров ФФ
const mockFulfillmentGoods: FulfillmentSupply[] = [
{
id: "ff1",
number: 1001,
deliveryDate: "2024-01-15",
createdDate: "2024-01-10",
status: "delivered",
plannedTotal: 180,
actualTotal: 173,
defectTotal: 2,
totalProductPrice: 3750000,
totalFulfillmentPrice: 43000,
totalLogisticsPrice: 27000,
grandTotal: 3820000,
routes: [
{
id: "ffr1",
from: "Садовод",
fromAddress: "Москва, 14-й км МКАД",
to: "SFERAV Logistics ФФ",
toAddress: "Москва, ул. Складская, 15",
totalProductPrice: 3600000,
fulfillmentServicePrice: 25000,
logisticsPrice: 15000,
totalAmount: 3640000,
wholesalers: [
{
id: "ffw1",
name: 'ООО "ТехноСнаб"',
inn: "7701234567",
contact: "+7 (495) 123-45-67",
address: "Москва, ул. Торговая, 1",
totalAmount: 3600000,
products: [
{
id: "ffp1",
name: "Смартфон iPhone 15 Pro",
sku: "APL-IP15P-256",
category: "Электроника",
plannedQty: 50,
actualQty: 48,
defectQty: 2,
productPrice: 75000,
parameters: [
{ id: "param1", name: "Цвет", value: "Титановый" },
{ id: "param2", name: "Память", value: "256", unit: "ГБ" },
{ id: "param3", name: "Гарантия", value: "12", unit: "мес" },
],
},
],
},
],
},
],
},
{
id: "ff2",
number: 1002,
deliveryDate: "2024-01-20",
createdDate: "2024-01-12",
status: "in-transit",
plannedTotal: 30,
actualTotal: 30,
defectTotal: 0,
totalProductPrice: 750000,
totalFulfillmentPrice: 18000,
totalLogisticsPrice: 12000,
grandTotal: 780000,
routes: [
{
id: "ffr2",
from: "Садовод",
fromAddress: "Москва, 14-й км МКАД",
to: "MegaFulfillment",
toAddress: "Подольск, ул. Складская, 25",
totalProductPrice: 750000,
fulfillmentServicePrice: 18000,
logisticsPrice: 12000,
totalAmount: 780000,
wholesalers: [
{
id: "ffw2",
name: 'ООО "АудиоТех"',
inn: "7702345678",
contact: "+7 (495) 555-12-34",
address: "Москва, ул. Звуковая, 8",
totalAmount: 750000,
products: [
{
id: "ffp2",
name: "Наушники AirPods Pro 2",
sku: "APL-AP-PRO2-USB",
category: "Аудио",
plannedQty: 30,
actualQty: 30,
defectQty: 0,
productPrice: 25000,
parameters: [
{ id: "param4", name: "Тип", value: "Беспроводные" },
{ id: "param5", name: "Шумоподавление", value: "Активное" },
{ id: "param6", name: "Время работы", value: "6", unit: "ч" },
],
},
],
},
],
},
],
},
];
export function FulfillmentGoodsTab() {
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()
);
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: FulfillmentSupply["status"]) => {
const statusMap = {
planned: {
label: "Запланирована",
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
},
"in-transit": {
label: "В пути",
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
},
delivered: {
label: "Доставлена",
color: "bg-green-500/20 text-green-300 border-green-500/30",
},
completed: {
label: "Завершена",
color: "bg-purple-500/20 text-purple-300 border-purple-500/30",
},
};
const { label, color } = statusMap[status];
return <Badge className={`${color} border`}>{label}</Badge>;
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
};
const calculateProductTotal = (product: Product) => {
return product.actualQty * product.productPrice;
};
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>
);
}
};
return (
<div className="space-y-6">
{/* Статистика товаров ФФ */}
<StatsGrid>
<StatsCard
title="Поставок товаров ФФ"
value={mockFulfillmentGoods.length}
icon={Warehouse}
iconColor="text-purple-400"
iconBg="bg-purple-500/20"
trend={{ value: 12, isPositive: true }}
subtitle="За текущий месяц"
/>
<StatsCard
title="Сумма товаров ФФ"
value={formatCurrency(
mockFulfillmentGoods.reduce(
(sum, supply) => sum + supply.grandTotal,
0
)
)}
icon={TrendingUp}
iconColor="text-green-400"
iconBg="bg-green-500/20"
trend={{ value: 8, isPositive: true }}
subtitle="Общая стоимость"
/>
<StatsCard
title="В пути"
value={
mockFulfillmentGoods.filter(
(supply) => supply.status === "in-transit"
).length
}
icon={Calendar}
iconColor="text-yellow-400"
iconBg="bg-yellow-500/20"
subtitle="Активные поставки"
/>
<StatsCard
title="С браком"
value={
mockFulfillmentGoods.filter((supply) => supply.defectTotal > 0)
.length
}
icon={AlertTriangle}
iconColor="text-red-400"
iconBg="bg-red-500/20"
trend={{ value: 3, isPositive: false }}
subtitle="Требуют внимания"
/>
</StatsGrid>
{/* Таблица поставок товаров ФФ */}
<Card className="bg-white/10 backdrop-blur border-white/20 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-white/20">
<th className="text-left p-4 text-white font-semibold"></th>
<th className="text-left p-4 text-white font-semibold">
Дата поставки
</th>
<th className="text-left p-4 text-white font-semibold">
Дата создания
</th>
<th className="text-left p-4 text-white font-semibold">План</th>
<th className="text-left p-4 text-white font-semibold">Факт</th>
<th className="text-left p-4 text-white font-semibold">Брак</th>
<th className="text-left p-4 text-white font-semibold">
Цена товаров
</th>
<th className="text-left p-4 text-white font-semibold">
Услуги ФФ
</th>
<th className="text-left p-4 text-white font-semibold">
Логистика до ФФ
</th>
<th className="text-left p-4 text-white font-semibold">
Итого сумма
</th>
<th className="text-left p-4 text-white font-semibold">
Статус
</th>
</tr>
</thead>
<tbody>
{mockFulfillmentGoods.map((supply) => {
const isSupplyExpanded = expandedSupplies.has(supply.id);
return (
<React.Fragment key={supply.id}>
{/* Основная строка поставки */}
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-purple-500/10">
<td className="p-4">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleSupplyExpansion(supply.id)}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isSupplyExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<span className="text-white font-bold text-lg">
#{supply.number}
</span>
</div>
</td>
<td className="p-4">
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-white/40" />
<span className="text-white font-semibold">
{formatDate(supply.deliveryDate)}
</span>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{formatDate(supply.createdDate)}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{supply.plannedTotal}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{supply.actualTotal}
</span>
</td>
<td className="p-4">
<span
className={`font-semibold ${
supply.defectTotal > 0
? "text-red-400"
: "text-white"
}`}
>
{supply.defectTotal}
</span>
</td>
<td className="p-4">
<span className="text-green-400 font-semibold">
{formatCurrency(supply.totalProductPrice)}
</span>
</td>
<td className="p-4">
<span className="text-blue-400 font-semibold">
{formatCurrency(supply.totalFulfillmentPrice)}
</span>
</td>
<td className="p-4">
<span className="text-purple-400 font-semibold">
{formatCurrency(supply.totalLogisticsPrice)}
</span>
</td>
<td className="p-4">
<div className="flex items-center space-x-2">
<DollarSign className="h-4 w-4 text-white/40" />
<span className="text-white font-bold text-lg">
{formatCurrency(supply.grandTotal)}
</span>
</div>
</td>
<td className="p-4">{getStatusBadge(supply.status)}</td>
</tr>
{/* Развернутые уровни - аналогично оригинальному коду */}
{isSupplyExpanded &&
supply.routes.map((route) => {
const isRouteExpanded = expandedRoutes.has(route.id);
return (
<React.Fragment key={route.id}>
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-blue-500/10">
<td className="p-4 pl-12">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
toggleRouteExpansion(route.id)
}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isRouteExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<MapPin className="h-4 w-4 text-blue-400" />
<span className="text-white font-medium">
Маршрут
</span>
</div>
</td>
<td className="p-4" colSpan={2}>
<div className="text-white">
<div className="flex items-center space-x-2 mb-1">
<span className="font-medium">
{route.from}
</span>
<span className="text-white/60"></span>
<span className="font-medium">
{route.to}
</span>
</div>
<div className="text-xs text-white/60">
{route.fromAddress} {route.toAddress}
</div>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{route.wholesalers.reduce(
(sum, w) =>
sum +
w.products.reduce(
(pSum, p) => pSum + p.plannedQty,
0
),
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{route.wholesalers.reduce(
(sum, w) =>
sum +
w.products.reduce(
(pSum, p) => pSum + p.actualQty,
0
),
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{route.wholesalers.reduce(
(sum, w) =>
sum +
w.products.reduce(
(pSum, p) => pSum + p.defectQty,
0
),
0
)}
</span>
</td>
<td className="p-4">
<span className="text-green-400 font-medium">
{formatCurrency(route.totalProductPrice)}
</span>
</td>
<td className="p-4">
<span className="text-blue-400 font-medium">
{formatCurrency(
route.fulfillmentServicePrice
)}
</span>
</td>
<td className="p-4">
<span className="text-purple-400 font-medium">
{formatCurrency(route.logisticsPrice)}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{formatCurrency(route.totalAmount)}
</span>
</td>
<td className="p-4"></td>
</tr>
{/* Остальные уровни развертывания аналогично */}
{isRouteExpanded &&
route.wholesalers.map((wholesaler) => {
const isWholesalerExpanded =
expandedWholesalers.has(wholesaler.id);
return (
<React.Fragment key={wholesaler.id}>
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-green-500/10">
<td className="p-4 pl-20">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
toggleWholesalerExpansion(
wholesaler.id
)
}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isWholesalerExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<Building2 className="h-4 w-4 text-green-400" />
<span className="text-white font-medium">
Оптовик
</span>
</div>
</td>
<td className="p-4" colSpan={2}>
<div className="text-white">
<div className="font-medium mb-1">
{wholesaler.name}
</div>
<div className="text-xs text-white/60 mb-1">
ИНН: {wholesaler.inn}
</div>
<div className="text-xs text-white/60 mb-1">
{wholesaler.address}
</div>
<div className="text-xs text-white/60">
{wholesaler.contact}
</div>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{wholesaler.products.reduce(
(sum, p) => sum + p.plannedQty,
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{wholesaler.products.reduce(
(sum, p) => sum + p.actualQty,
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{wholesaler.products.reduce(
(sum, p) => sum + p.defectQty,
0
)}
</span>
</td>
<td className="p-4">
<span className="text-green-400 font-medium">
{formatCurrency(
wholesaler.products.reduce(
(sum, p) =>
sum + calculateProductTotal(p),
0
)
)}
</span>
</td>
<td className="p-4" colSpan={2}></td>
<td className="p-4">
<span className="text-white font-semibold">
{formatCurrency(
wholesaler.totalAmount
)}
</span>
</td>
<td className="p-4"></td>
</tr>
{/* Товары */}
{isWholesalerExpanded &&
wholesaler.products.map((product) => {
const isProductExpanded =
expandedProducts.has(product.id);
return (
<React.Fragment key={product.id}>
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-yellow-500/10">
<td className="p-4 pl-28">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
toggleProductExpansion(
product.id
)
}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isProductExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<Package className="h-4 w-4 text-yellow-400" />
<span className="text-white font-medium">
Товар
</span>
</div>
</td>
<td className="p-4" colSpan={2}>
<div className="text-white">
<div className="font-medium mb-1">
{product.name}
</div>
<div className="text-xs text-white/60 mb-1">
Артикул: {product.sku}
</div>
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30 border text-xs">
{product.category}
</Badge>
</div>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{product.plannedQty}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{product.actualQty}
</span>
</td>
<td className="p-4">
<span
className={`font-semibold ${
product.defectQty > 0
? "text-red-400"
: "text-white"
}`}
>
{product.defectQty}
</span>
</td>
<td className="p-4">
<div className="text-white">
<div className="font-medium">
{formatCurrency(
calculateProductTotal(
product
)
)}
</div>
<div className="text-xs text-white/60">
{formatCurrency(
product.productPrice
)}{" "}
за шт.
</div>
</div>
</td>
<td className="p-4" colSpan={2}>
{getEfficiencyBadge(
product.plannedQty,
product.actualQty,
product.defectQty
)}
</td>
<td className="p-4">
<span className="text-white font-semibold">
{formatCurrency(
calculateProductTotal(
product
)
)}
</span>
</td>
<td className="p-4"></td>
</tr>
{/* Параметры товара */}
{isProductExpanded && (
<tr>
<td
colSpan={11}
className="p-0"
>
<div className="bg-white/5 border-t border-white/10">
<div className="p-4 pl-36">
<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>
</td>
</tr>
)}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@ -0,0 +1,447 @@
"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 { StatsCard } from "../ui/stats-card";
import { StatsGrid } from "../ui/stats-grid";
import {
ChevronDown,
ChevronRight,
Calendar,
MapPin,
Building2,
TrendingUp,
AlertTriangle,
DollarSign,
Wrench,
Box,
Package2,
Tags,
} from "lucide-react";
// Типы данных для расходников ФФ
interface ConsumableParameter {
id: string;
name: string;
value: string;
unit?: string;
}
interface Consumable {
id: string;
name: string;
sku: string;
category: string;
type: "packaging" | "labels" | "protective" | "tools" | "other";
plannedQty: number;
actualQty: number;
defectQty: number;
unitPrice: number;
parameters: ConsumableParameter[];
}
interface ConsumableSupplier {
id: string;
name: string;
inn: string;
contact: string;
address: string;
consumables: Consumable[];
totalAmount: number;
}
interface ConsumableRoute {
id: string;
from: string;
fromAddress: string;
to: string;
toAddress: string;
suppliers: ConsumableSupplier[];
totalConsumablesPrice: number;
logisticsPrice: number;
totalAmount: number;
}
interface FulfillmentConsumableSupply {
id: string;
number: number;
deliveryDate: string;
createdDate: string;
routes: ConsumableRoute[];
plannedTotal: number;
actualTotal: number;
defectTotal: number;
totalConsumablesPrice: number;
totalLogisticsPrice: number;
grandTotal: number;
status: "planned" | "in-transit" | "delivered" | "completed";
}
// Моковые данные для расходников ФФ
const mockFulfillmentConsumables: FulfillmentConsumableSupply[] = [
{
id: "ffc1",
number: 2001,
deliveryDate: "2024-01-18",
createdDate: "2024-01-14",
status: "delivered",
plannedTotal: 5000,
actualTotal: 4950,
defectTotal: 50,
totalConsumablesPrice: 125000,
totalLogisticsPrice: 8000,
grandTotal: 133000,
routes: [
{
id: "ffcr1",
from: "Склад расходников",
fromAddress: "Москва, ул. Промышленная, 12",
to: "SFERAV Logistics ФФ",
toAddress: "Москва, ул. Складская, 15",
totalConsumablesPrice: 125000,
logisticsPrice: 8000,
totalAmount: 133000,
suppliers: [
{
id: "ffcs1",
name: 'ООО "УпакСервис ФФ"',
inn: "7703456789",
contact: "+7 (495) 777-88-99",
address: "Москва, ул. Упаковочная, 5",
totalAmount: 75000,
consumables: [
{
id: "ffcons1",
name: "Коробки для ФФ 40x30x15",
sku: "BOX-FF-403015",
category: "Упаковка ФФ",
type: "packaging",
plannedQty: 2000,
actualQty: 1980,
defectQty: 20,
unitPrice: 45,
parameters: [
{
id: "ffcp1",
name: "Размер",
value: "40x30x15",
unit: "см",
},
{
id: "ffcp2",
name: "Материал",
value: "Гофрокартон усиленный",
},
{ id: "ffcp3", name: "Плотность", value: "5", unit: "слоев" },
],
},
],
},
],
},
],
},
];
export function FulfillmentSuppliesTab() {
const [expandedSupplies, setExpandedSupplies] = useState<Set<string>>(
new Set()
);
const [expandedRoutes, setExpandedRoutes] = useState<Set<string>>(new Set());
const [expandedSuppliers, setExpandedSuppliers] = useState<Set<string>>(
new Set()
);
const [expandedConsumables, setExpandedConsumables] = useState<Set<string>>(
new Set()
);
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 toggleSupplierExpansion = (supplierId: string) => {
const newExpanded = new Set(expandedSuppliers);
if (newExpanded.has(supplierId)) {
newExpanded.delete(supplierId);
} else {
newExpanded.add(supplierId);
}
setExpandedSuppliers(newExpanded);
};
const toggleConsumableExpansion = (consumableId: string) => {
const newExpanded = new Set(expandedConsumables);
if (newExpanded.has(consumableId)) {
newExpanded.delete(consumableId);
} else {
newExpanded.add(consumableId);
}
setExpandedConsumables(newExpanded);
};
const getStatusBadge = (status: FulfillmentConsumableSupply["status"]) => {
const statusMap = {
planned: {
label: "Запланирована",
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
},
"in-transit": {
label: "В пути",
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
},
delivered: {
label: "Доставлена",
color: "bg-green-500/20 text-green-300 border-green-500/30",
},
completed: {
label: "Завершена",
color: "bg-purple-500/20 text-purple-300 border-purple-500/30",
},
};
const { label, color } = statusMap[status];
return <Badge className={`${color} border`}>{label}</Badge>;
};
const getTypeBadge = (type: Consumable["type"]) => {
const typeMap = {
packaging: {
label: "Упаковка",
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
},
labels: {
label: "Этикетки",
color: "bg-green-500/20 text-green-300 border-green-500/30",
},
protective: {
label: "Защитная",
color: "bg-orange-500/20 text-orange-300 border-orange-500/30",
},
tools: {
label: "Инструменты",
color: "bg-purple-500/20 text-purple-300 border-purple-500/30",
},
other: {
label: "Прочее",
color: "bg-gray-500/20 text-gray-300 border-gray-500/30",
},
};
const { label, color } = typeMap[type];
return <Badge className={`${color} border text-xs`}>{label}</Badge>;
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
};
const calculateConsumableTotal = (consumable: Consumable) => {
return consumable.actualQty * consumable.unitPrice;
};
return (
<div className="space-y-6">
{/* Статистика расходников ФФ */}
<StatsGrid>
<StatsCard
title="Расходники ФФ"
value={mockFulfillmentConsumables.length}
icon={Package2}
iconColor="text-orange-400"
iconBg="bg-orange-500/20"
trend={{ value: 5, isPositive: true }}
subtitle="Поставки материалов"
/>
<StatsCard
title="Сумма расходников ФФ"
value={formatCurrency(
mockFulfillmentConsumables.reduce(
(sum, supply) => sum + supply.grandTotal,
0
)
)}
icon={TrendingUp}
iconColor="text-green-400"
iconBg="bg-green-500/20"
trend={{ value: 15, isPositive: true }}
subtitle="Общая стоимость"
/>
<StatsCard
title="В пути"
value={
mockFulfillmentConsumables.filter(
(supply) => supply.status === "in-transit"
).length
}
icon={Calendar}
iconColor="text-yellow-400"
iconBg="bg-yellow-500/20"
subtitle="Активные поставки"
/>
<StatsCard
title="С браком"
value={
mockFulfillmentConsumables.filter(
(supply) => supply.defectTotal > 0
).length
}
icon={AlertTriangle}
iconColor="text-red-400"
iconBg="bg-red-500/20"
trend={{ value: 2, isPositive: false }}
subtitle="Дефектные материалы"
/>
</StatsGrid>
{/* Таблица поставок расходников ФФ */}
<Card className="bg-white/10 backdrop-blur border-white/20 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-white/20">
<th className="text-left p-4 text-white font-semibold"></th>
<th className="text-left p-4 text-white font-semibold">
Дата поставки
</th>
<th className="text-left p-4 text-white font-semibold">
Дата создания
</th>
<th className="text-left p-4 text-white font-semibold">План</th>
<th className="text-left p-4 text-white font-semibold">Факт</th>
<th className="text-left p-4 text-white font-semibold">Брак</th>
<th className="text-left p-4 text-white font-semibold">
Цена расходников
</th>
<th className="text-left p-4 text-white font-semibold">
Логистика
</th>
<th className="text-left p-4 text-white font-semibold">
Итого сумма
</th>
<th className="text-left p-4 text-white font-semibold">
Статус
</th>
</tr>
</thead>
<tbody>
{mockFulfillmentConsumables.map((supply) => {
const isSupplyExpanded = expandedSupplies.has(supply.id);
return (
<React.Fragment key={supply.id}>
{/* Основная строка поставки расходников ФФ */}
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-orange-500/10">
<td className="p-4">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleSupplyExpansion(supply.id)}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isSupplyExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<span className="text-white font-bold text-lg">
#{supply.number}
</span>
</div>
</td>
<td className="p-4">
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-white/40" />
<span className="text-white font-semibold">
{formatDate(supply.deliveryDate)}
</span>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{formatDate(supply.createdDate)}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{supply.plannedTotal}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{supply.actualTotal}
</span>
</td>
<td className="p-4">
<span
className={`font-semibold ${
supply.defectTotal > 0
? "text-red-400"
: "text-white"
}`}
>
{supply.defectTotal}
</span>
</td>
<td className="p-4">
<span className="text-green-400 font-semibold">
{formatCurrency(supply.totalConsumablesPrice)}
</span>
</td>
<td className="p-4">
<span className="text-purple-400 font-semibold">
{formatCurrency(supply.totalLogisticsPrice)}
</span>
</td>
<td className="p-4">
<div className="flex items-center space-x-2">
<DollarSign className="h-4 w-4 text-white/40" />
<span className="text-white font-bold text-lg">
{formatCurrency(supply.grandTotal)}
</span>
</div>
</td>
<td className="p-4">{getStatusBadge(supply.status)}</td>
</tr>
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@ -0,0 +1,55 @@
"use client";
import React, { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { FulfillmentGoodsTab } from "./fulfillment-goods-tab";
import { FulfillmentSuppliesTab as FulfillmentSuppliesSubTab } from "./fulfillment-supplies-sub-tab";
import { PvzReturnsTab } from "./pvz-returns-tab";
export function FulfillmentSuppliesTab() {
const [activeSubTab, setActiveSubTab] = useState("goods");
return (
<div className="h-full">
<Tabs
value={activeSubTab}
onValueChange={setActiveSubTab}
className="w-full h-full flex flex-col"
>
{/* Подвкладки для ФФ */}
<TabsList className="grid grid-cols-3 bg-white/5 backdrop-blur border-white/10 mb-4 w-fit">
<TabsTrigger
value="goods"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/60 px-6"
>
Товар
</TabsTrigger>
<TabsTrigger
value="supplies"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/60 px-6"
>
Расходники
</TabsTrigger>
<TabsTrigger
value="returns"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/60 px-6"
>
Возвраты с ПВЗ
</TabsTrigger>
</TabsList>
<TabsContent value="goods" className="mt-0 flex-1">
<FulfillmentGoodsTab />
</TabsContent>
<TabsContent value="supplies" className="mt-0 flex-1">
<FulfillmentSuppliesSubTab />
</TabsContent>
<TabsContent value="returns" className="mt-0 flex-1">
<PvzReturnsTab />
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,741 @@
"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 { StatsCard } from "../ui/stats-card";
import { StatsGrid } from "../ui/stats-grid";
import {
ChevronDown,
ChevronRight,
Calendar,
Package,
MapPin,
Building2,
TrendingDown,
AlertTriangle,
DollarSign,
RotateCcw,
RefreshCcw,
} from "lucide-react";
// Типы данных для возвратов с ПВЗ
interface ReturnProduct {
id: string;
name: string;
sku: string;
category: string;
returnQty: number;
defectQty: number;
returnPrice: number;
returnReason:
| "customer_return"
| "defect"
| "damage"
| "wrong_item"
| "other";
}
interface PvzPoint {
id: string;
name: string;
address: string;
contact: string;
products: ReturnProduct[];
totalAmount: number;
}
interface ReturnRoute {
id: string;
from: string;
fromAddress: string;
to: string;
toAddress: string;
pvzPoints: PvzPoint[];
totalReturnPrice: number;
logisticsPrice: number;
totalAmount: number;
}
interface PvzReturnSupply {
id: string;
number: number;
returnDate: string;
createdDate: string;
routes: ReturnRoute[];
totalReturnQty: number;
totalDefectQty: number;
totalReturnPrice: number;
totalLogisticsPrice: number;
grandTotal: number;
status: "planned" | "in-transit" | "delivered" | "completed";
}
// Моковые данные для возвратов с ПВЗ
const mockPvzReturns: PvzReturnSupply[] = [
{
id: "pvz1",
number: 3001,
returnDate: "2024-01-20",
createdDate: "2024-01-15",
status: "delivered",
totalReturnQty: 45,
totalDefectQty: 12,
totalReturnPrice: 890000,
totalLogisticsPrice: 15000,
grandTotal: 905000,
routes: [
{
id: "pvzr1",
from: "ПВЗ Сеть",
fromAddress: "Москва, различные точки",
to: "SFERAV Logistics ФФ",
toAddress: "Москва, ул. Складская, 15",
totalReturnPrice: 890000,
logisticsPrice: 15000,
totalAmount: 905000,
pvzPoints: [
{
id: "pvzp1",
name: 'ПВЗ "На Тверской"',
address: "Москва, ул. Тверская, 15",
contact: "+7 (495) 123-45-67",
totalAmount: 450000,
products: [
{
id: "pvzprod1",
name: "Смартфон iPhone 15 (возврат)",
sku: "APL-IP15-128-RET",
category: "Электроника",
returnQty: 15,
defectQty: 3,
returnPrice: 70000,
returnReason: "customer_return",
},
{
id: "pvzprod2",
name: "Наушники AirPods (брак)",
sku: "APL-AP-PRO2-DEF",
category: "Аудио",
returnQty: 8,
defectQty: 8,
returnPrice: 22000,
returnReason: "defect",
},
],
},
{
id: "pvzp2",
name: 'ПВЗ "Арбатский"',
address: "Москва, ул. Арбат, 25",
contact: "+7 (495) 987-65-43",
totalAmount: 440000,
products: [
{
id: "pvzprod3",
name: "Планшет iPad Air (повреждение)",
sku: "APL-IPAD-AIR-DMG",
category: "Планшеты",
returnQty: 12,
defectQty: 1,
returnPrice: 35000,
returnReason: "damage",
},
],
},
],
},
],
},
{
id: "pvz2",
number: 3002,
returnDate: "2024-01-25",
createdDate: "2024-01-18",
status: "in-transit",
totalReturnQty: 28,
totalDefectQty: 5,
totalReturnPrice: 560000,
totalLogisticsPrice: 12000,
grandTotal: 572000,
routes: [
{
id: "pvzr2",
from: "ПВЗ Сеть Подольск",
fromAddress: "Подольск, различные точки",
to: "MegaFulfillment",
toAddress: "Подольск, ул. Складская, 25",
totalReturnPrice: 560000,
logisticsPrice: 12000,
totalAmount: 572000,
pvzPoints: [
{
id: "pvzp3",
name: 'ПВЗ "Центральный"',
address: "Подольск, ул. Центральная, 10",
contact: "+7 (4967) 55-66-77",
totalAmount: 560000,
products: [
{
id: "pvzprod4",
name: "Ноутбук MacBook (возврат)",
sku: "APL-MBP-14-RET",
category: "Компьютеры",
returnQty: 8,
defectQty: 2,
returnPrice: 180000,
returnReason: "customer_return",
},
],
},
],
},
],
},
];
export function PvzReturnsTab() {
const [expandedSupplies, setExpandedSupplies] = useState<Set<string>>(
new Set()
);
const [expandedRoutes, setExpandedRoutes] = useState<Set<string>>(new Set());
const [expandedPvzPoints, setExpandedPvzPoints] = useState<Set<string>>(
new Set()
);
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(
new Set()
);
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 togglePvzPointExpansion = (pvzPointId: string) => {
const newExpanded = new Set(expandedPvzPoints);
if (newExpanded.has(pvzPointId)) {
newExpanded.delete(pvzPointId);
} else {
newExpanded.add(pvzPointId);
}
setExpandedPvzPoints(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: PvzReturnSupply["status"]) => {
const statusMap = {
planned: {
label: "Запланирован",
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
},
"in-transit": {
label: "В пути",
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
},
delivered: {
label: "Доставлен",
color: "bg-green-500/20 text-green-300 border-green-500/30",
},
completed: {
label: "Завершен",
color: "bg-purple-500/20 text-purple-300 border-purple-500/30",
},
};
const { label, color } = statusMap[status];
return <Badge className={`${color} border`}>{label}</Badge>;
};
const getReturnReasonBadge = (reason: ReturnProduct["returnReason"]) => {
const reasonMap = {
customer_return: {
label: "Возврат клиента",
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
},
defect: {
label: "Брак",
color: "bg-red-500/20 text-red-300 border-red-500/30",
},
damage: {
label: "Повреждение",
color: "bg-orange-500/20 text-orange-300 border-orange-500/30",
},
wrong_item: {
label: "Неправильный товар",
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
},
other: {
label: "Прочее",
color: "bg-gray-500/20 text-gray-300 border-gray-500/30",
},
};
const { label, color } = reasonMap[reason];
return <Badge className={`${color} border text-xs`}>{label}</Badge>;
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
};
const calculateProductTotal = (product: ReturnProduct) => {
return product.returnQty * product.returnPrice;
};
return (
<div className="space-y-6">
{/* Статистика возвратов с ПВЗ */}
<StatsGrid>
<StatsCard
title="Возвратов с ПВЗ"
value={mockPvzReturns.length}
icon={RefreshCcw}
iconColor="text-red-400"
iconBg="bg-red-500/20"
trend={{ value: 7, isPositive: false }}
subtitle="Обработка возвратов"
/>
<StatsCard
title="Сумма возвратов"
value={formatCurrency(
mockPvzReturns.reduce((sum, supply) => sum + supply.grandTotal, 0)
)}
icon={TrendingDown}
iconColor="text-orange-400"
iconBg="bg-orange-500/20"
trend={{ value: 4, isPositive: false }}
subtitle="Общая стоимость"
/>
<StatsCard
title="В пути"
value={
mockPvzReturns.filter((supply) => supply.status === "in-transit")
.length
}
icon={Calendar}
iconColor="text-yellow-400"
iconBg="bg-yellow-500/20"
subtitle="Активные возвраты"
/>
<StatsCard
title="С дефектами"
value={
mockPvzReturns.filter((supply) => supply.totalDefectQty > 0).length
}
icon={AlertTriangle}
iconColor="text-red-400"
iconBg="bg-red-500/20"
trend={{ value: 15, isPositive: false }}
subtitle="Бракованные товары"
/>
</StatsGrid>
{/* Таблица возвратов с ПВЗ */}
<Card className="bg-white/10 backdrop-blur border-white/20 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-white/20">
<th className="text-left p-4 text-white font-semibold"></th>
<th className="text-left p-4 text-white font-semibold">
Дата возврата
</th>
<th className="text-left p-4 text-white font-semibold">
Дата создания
</th>
<th className="text-left p-4 text-white font-semibold">
Возвратов
</th>
<th className="text-left p-4 text-white font-semibold">
Дефектов
</th>
<th className="text-left p-4 text-white font-semibold">
Сумма возвратов
</th>
<th className="text-left p-4 text-white font-semibold">
Логистика
</th>
<th className="text-left p-4 text-white font-semibold">
Итого сумма
</th>
<th className="text-left p-4 text-white font-semibold">
Статус
</th>
</tr>
</thead>
<tbody>
{mockPvzReturns.map((supply) => {
const isSupplyExpanded = expandedSupplies.has(supply.id);
return (
<React.Fragment key={supply.id}>
{/* Основная строка возврата */}
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-red-500/10">
<td className="p-4">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleSupplyExpansion(supply.id)}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isSupplyExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<span className="text-white font-bold text-lg">
#{supply.number}
</span>
</div>
</td>
<td className="p-4">
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-white/40" />
<span className="text-white font-semibold">
{formatDate(supply.returnDate)}
</span>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{formatDate(supply.createdDate)}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{supply.totalReturnQty}
</span>
</td>
<td className="p-4">
<span
className={`font-semibold ${
supply.totalDefectQty > 0
? "text-red-400"
: "text-white"
}`}
>
{supply.totalDefectQty}
</span>
</td>
<td className="p-4">
<span className="text-orange-400 font-semibold">
{formatCurrency(supply.totalReturnPrice)}
</span>
</td>
<td className="p-4">
<span className="text-purple-400 font-semibold">
{formatCurrency(supply.totalLogisticsPrice)}
</span>
</td>
<td className="p-4">
<div className="flex items-center space-x-2">
<DollarSign className="h-4 w-4 text-white/40" />
<span className="text-white font-bold text-lg">
{formatCurrency(supply.grandTotal)}
</span>
</div>
</td>
<td className="p-4">{getStatusBadge(supply.status)}</td>
</tr>
{/* Развернутые уровни - аналогично другим компонентам */}
{isSupplyExpanded &&
supply.routes.map((route) => {
const isRouteExpanded = expandedRoutes.has(route.id);
return (
<React.Fragment key={route.id}>
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-blue-500/10">
<td className="p-4 pl-12">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
toggleRouteExpansion(route.id)
}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isRouteExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<MapPin className="h-4 w-4 text-blue-400" />
<span className="text-white font-medium">
Маршрут возврата
</span>
</div>
</td>
<td className="p-4" colSpan={2}>
<div className="text-white">
<div className="flex items-center space-x-2 mb-1">
<span className="font-medium">
{route.from}
</span>
<span className="text-white/60"></span>
<span className="font-medium">
{route.to}
</span>
</div>
<div className="text-xs text-white/60">
{route.fromAddress} {route.toAddress}
</div>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{route.pvzPoints.reduce(
(sum, p) =>
sum +
p.products.reduce(
(pSum, prod) => pSum + prod.returnQty,
0
),
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{route.pvzPoints.reduce(
(sum, p) =>
sum +
p.products.reduce(
(pSum, prod) => pSum + prod.defectQty,
0
),
0
)}
</span>
</td>
<td className="p-4">
<span className="text-orange-400 font-medium">
{formatCurrency(route.totalReturnPrice)}
</span>
</td>
<td className="p-4">
<span className="text-purple-400 font-medium">
{formatCurrency(route.logisticsPrice)}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{formatCurrency(route.totalAmount)}
</span>
</td>
<td className="p-4"></td>
</tr>
{/* ПВЗ точки */}
{isRouteExpanded &&
route.pvzPoints.map((pvzPoint) => {
const isPvzPointExpanded =
expandedPvzPoints.has(pvzPoint.id);
return (
<React.Fragment key={pvzPoint.id}>
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-green-500/10">
<td className="p-4 pl-20">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
togglePvzPointExpansion(
pvzPoint.id
)
}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isPvzPointExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<Building2 className="h-4 w-4 text-green-400" />
<span className="text-white font-medium">
ПВЗ
</span>
</div>
</td>
<td className="p-4" colSpan={2}>
<div className="text-white">
<div className="font-medium mb-1">
{pvzPoint.name}
</div>
<div className="text-xs text-white/60 mb-1">
{pvzPoint.address}
</div>
<div className="text-xs text-white/60">
{pvzPoint.contact}
</div>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{pvzPoint.products.reduce(
(sum, p) => sum + p.returnQty,
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{pvzPoint.products.reduce(
(sum, p) => sum + p.defectQty,
0
)}
</span>
</td>
<td className="p-4">
<span className="text-orange-400 font-medium">
{formatCurrency(
pvzPoint.products.reduce(
(sum, p) =>
sum + calculateProductTotal(p),
0
)
)}
</span>
</td>
<td className="p-4"></td>
<td className="p-4">
<span className="text-white font-semibold">
{formatCurrency(pvzPoint.totalAmount)}
</span>
</td>
<td className="p-4"></td>
</tr>
{/* Возвращенные товары */}
{isPvzPointExpanded &&
pvzPoint.products.map((product) => (
<tr
key={product.id}
className="border-b border-white/10 hover:bg-white/5 transition-colors bg-yellow-500/10"
>
<td className="p-4 pl-28">
<div className="flex items-center space-x-2">
<Package className="h-4 w-4 text-yellow-400" />
<span className="text-white font-medium">
Возврат
</span>
</div>
</td>
<td className="p-4" colSpan={2}>
<div className="text-white">
<div className="font-medium mb-1">
{product.name}
</div>
<div className="text-xs text-white/60 mb-1">
Артикул: {product.sku}
</div>
<div className="flex gap-2">
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30 border text-xs">
{product.category}
</Badge>
{getReturnReasonBadge(
product.returnReason
)}
</div>
</div>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{product.returnQty}
</span>
</td>
<td className="p-4">
<span
className={`font-semibold ${
product.defectQty > 0
? "text-red-400"
: "text-white"
}`}
>
{product.defectQty}
</span>
</td>
<td className="p-4">
<div className="text-white">
<div className="font-medium">
{formatCurrency(
calculateProductTotal(product)
)}
</div>
<div className="text-xs text-white/60">
{formatCurrency(
product.returnPrice
)}{" "}
за шт.
</div>
</div>
</td>
<td className="p-4"></td>
<td className="p-4">
<span className="text-white font-semibold">
{formatCurrency(
calculateProductTotal(product)
)}
</span>
</td>
<td className="p-4"></td>
</tr>
))}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@ -0,0 +1,44 @@
"use client";
import React, { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { WildberriesSuppliesTab } from "./wildberries-supplies-tab";
import { OzonSuppliesTab } from "./ozon-supplies-tab";
export function MarketplaceSuppliesTab() {
const [activeSubTab, setActiveSubTab] = useState("wildberries");
return (
<div className="h-full">
<Tabs
value={activeSubTab}
onValueChange={setActiveSubTab}
className="w-full h-full flex flex-col"
>
{/* Подвкладки для Маркетплейсов */}
<TabsList className="grid grid-cols-2 bg-white/5 backdrop-blur border-white/10 mb-4 w-fit">
<TabsTrigger
value="wildberries"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/60 px-6"
>
Поставки на Wildberries
</TabsTrigger>
<TabsTrigger
value="ozon"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-white/60 px-6"
>
Поставки на Ozon
</TabsTrigger>
</TabsList>
<TabsContent value="wildberries" className="mt-0 flex-1">
<WildberriesSuppliesTab />
</TabsContent>
<TabsContent value="ozon" className="mt-0 flex-1">
<OzonSuppliesTab />
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,779 @@
"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 { StatsCard } from "../ui/stats-card";
import { StatsGrid } from "../ui/stats-grid";
import {
ChevronDown,
ChevronRight,
Calendar,
Package,
MapPin,
TrendingUp,
AlertTriangle,
DollarSign,
Truck,
Store,
} from "lucide-react";
// Типы данных для поставок на Ozon
interface OzonProduct {
id: string;
name: string;
sku: string;
offerId: string;
category: string;
plannedQty: number;
actualQty: number;
defectQty: number;
productPrice: number;
}
interface OzonWarehouse {
id: string;
name: string;
address: string;
warehouseId: number;
products: OzonProduct[];
totalAmount: number;
}
interface OzonRoute {
id: string;
from: string;
fromAddress: string;
to: string;
toAddress: string;
warehouses: OzonWarehouse[];
totalProductPrice: number;
logisticsPrice: number;
totalAmount: number;
}
interface OzonSupply {
id: string;
number: number;
supplyId: string;
deliveryDate: string;
createdDate: string;
routes: OzonRoute[];
plannedTotal: number;
actualTotal: number;
defectTotal: number;
totalProductPrice: number;
totalLogisticsPrice: number;
grandTotal: number;
status: "planned" | "in-transit" | "delivered" | "completed";
}
// Моковые данные для поставок на Ozon
const mockOzonSupplies: OzonSupply[] = [
{
id: "oz1",
number: 5001,
supplyId: "OZ24010001",
deliveryDate: "2024-01-25",
createdDate: "2024-01-18",
status: "delivered",
plannedTotal: 90,
actualTotal: 87,
defectTotal: 3,
totalProductPrice: 1950000,
totalLogisticsPrice: 22000,
grandTotal: 1972000,
routes: [
{
id: "ozr1",
from: "ТЯК Москва",
fromAddress: "Москва, Алтуфьевское шоссе, 27",
to: "Ozon Тверь",
toAddress: "Тверь, ул. Складская, 45",
totalProductPrice: 1950000,
logisticsPrice: 22000,
totalAmount: 1972000,
warehouses: [
{
id: "ozw1",
name: "Склад Ozon Тверь",
address: "Тверь, ул. Складская, 45",
warehouseId: 22341172,
totalAmount: 1950000,
products: [
{
id: "ozp1",
name: "Ноутбук ASUS VivoBook",
sku: "ASUS-VB-15-512",
offerId: "ASUS-001",
category: "Ноутбуки",
plannedQty: 15,
actualQty: 14,
defectQty: 1,
productPrice: 85000,
},
{
id: "ozp2",
name: "Мышь беспроводная Logitech",
sku: "LOG-MX3-BLK",
offerId: "LOG-002",
category: "Компьютерные аксессуары",
plannedQty: 75,
actualQty: 73,
defectQty: 2,
productPrice: 4500,
},
],
},
],
},
],
},
{
id: "oz2",
number: 5002,
supplyId: "OZ24010002",
deliveryDate: "2024-01-30",
createdDate: "2024-01-22",
status: "in-transit",
plannedTotal: 45,
actualTotal: 45,
defectTotal: 0,
totalProductPrice: 1125000,
totalLogisticsPrice: 18000,
grandTotal: 1143000,
routes: [
{
id: "ozr2",
from: "Садовод",
fromAddress: "Москва, 14-й км МКАД",
to: "Ozon Рязань",
toAddress: "Рязань, ул. Промышленная, 15",
totalProductPrice: 1125000,
logisticsPrice: 18000,
totalAmount: 1143000,
warehouses: [
{
id: "ozw2",
name: "Склад Ozon Рязань",
address: "Рязань, ул. Промышленная, 15",
warehouseId: 22341173,
totalAmount: 1125000,
products: [
{
id: "ozp3",
name: "Планшет iPad Air",
sku: "APL-IPAD-AIR-64",
offerId: "APL-003",
category: "Планшеты",
plannedQty: 20,
actualQty: 20,
defectQty: 0,
productPrice: 45000,
},
{
id: "ozp4",
name: "Клавиатура механическая",
sku: "KEYB-MECH-RGB",
offerId: "KEYB-004",
category: "Клавиатуры",
plannedQty: 25,
actualQty: 25,
defectQty: 0,
productPrice: 12000,
},
],
},
],
},
],
},
];
export function OzonSuppliesTab() {
const [expandedSupplies, setExpandedSupplies] = useState<Set<string>>(
new Set()
);
const [expandedRoutes, setExpandedRoutes] = useState<Set<string>>(new Set());
const [expandedWarehouses, setExpandedWarehouses] = useState<Set<string>>(
new Set()
);
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(
new Set()
);
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 toggleWarehouseExpansion = (warehouseId: string) => {
const newExpanded = new Set(expandedWarehouses);
if (newExpanded.has(warehouseId)) {
newExpanded.delete(warehouseId);
} else {
newExpanded.add(warehouseId);
}
setExpandedWarehouses(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: OzonSupply["status"]) => {
const statusMap = {
planned: {
label: "Запланирована",
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
},
"in-transit": {
label: "В пути",
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
},
delivered: {
label: "Доставлена",
color: "bg-green-500/20 text-green-300 border-green-500/30",
},
completed: {
label: "Завершена",
color: "bg-purple-500/20 text-purple-300 border-purple-500/30",
},
};
const { label, color } = statusMap[status];
return <Badge className={`${color} border`}>{label}</Badge>;
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
};
const calculateProductTotal = (product: OzonProduct) => {
return product.actualQty * product.productPrice;
};
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>
);
}
};
return (
<div className="space-y-6">
{/* Статистика поставок на Ozon */}
<StatsGrid>
<StatsCard
title="Поставок на Ozon"
value={mockOzonSupplies.length}
icon={Store}
iconColor="text-blue-400"
iconBg="bg-blue-500/20"
trend={{ value: 14, isPositive: true }}
subtitle="Ozon поставки"
/>
<StatsCard
title="Сумма Ozon поставок"
value={formatCurrency(
mockOzonSupplies.reduce((sum, supply) => sum + supply.grandTotal, 0)
)}
icon={TrendingUp}
iconColor="text-green-400"
iconBg="bg-green-500/20"
trend={{ value: 19, isPositive: true }}
subtitle="Общая стоимость"
/>
<StatsCard
title="В пути"
value={
mockOzonSupplies.filter((supply) => supply.status === "in-transit")
.length
}
icon={Calendar}
iconColor="text-yellow-400"
iconBg="bg-yellow-500/20"
subtitle="Активные поставки"
/>
<StatsCard
title="С браком"
value={
mockOzonSupplies.filter((supply) => supply.defectTotal > 0).length
}
icon={AlertTriangle}
iconColor="text-red-400"
iconBg="bg-red-500/20"
trend={{ value: 2, isPositive: false }}
subtitle="Требуют проверки"
/>
</StatsGrid>
{/* Таблица поставок на Ozon */}
<Card className="bg-white/10 backdrop-blur border-white/20 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-white/20">
<th className="text-left p-4 text-white font-semibold"></th>
<th className="text-left p-4 text-white font-semibold">
ID поставки
</th>
<th className="text-left p-4 text-white font-semibold">
Дата поставки
</th>
<th className="text-left p-4 text-white font-semibold">
Дата создания
</th>
<th className="text-left p-4 text-white font-semibold">План</th>
<th className="text-left p-4 text-white font-semibold">Факт</th>
<th className="text-left p-4 text-white font-semibold">Брак</th>
<th className="text-left p-4 text-white font-semibold">
Цена товаров
</th>
<th className="text-left p-4 text-white font-semibold">
Логистика
</th>
<th className="text-left p-4 text-white font-semibold">
Итого сумма
</th>
<th className="text-left p-4 text-white font-semibold">
Статус
</th>
</tr>
</thead>
<tbody>
{mockOzonSupplies.map((supply) => {
const isSupplyExpanded = expandedSupplies.has(supply.id);
return (
<React.Fragment key={supply.id}>
{/* Основная строка поставки на Ozon */}
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-blue-500/10">
<td className="p-4">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleSupplyExpansion(supply.id)}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isSupplyExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<span className="text-white font-bold text-lg">
#{supply.number}
</span>
</div>
</td>
<td className="p-4">
<span className="text-blue-300 font-mono text-sm">
{supply.supplyId}
</span>
</td>
<td className="p-4">
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-white/40" />
<span className="text-white font-semibold">
{formatDate(supply.deliveryDate)}
</span>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{formatDate(supply.createdDate)}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{supply.plannedTotal}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{supply.actualTotal}
</span>
</td>
<td className="p-4">
<span
className={`font-semibold ${
supply.defectTotal > 0
? "text-red-400"
: "text-white"
}`}
>
{supply.defectTotal}
</span>
</td>
<td className="p-4">
<span className="text-green-400 font-semibold">
{formatCurrency(supply.totalProductPrice)}
</span>
</td>
<td className="p-4">
<span className="text-purple-400 font-semibold">
{formatCurrency(supply.totalLogisticsPrice)}
</span>
</td>
<td className="p-4">
<div className="flex items-center space-x-2">
<DollarSign className="h-4 w-4 text-white/40" />
<span className="text-white font-bold text-lg">
{formatCurrency(supply.grandTotal)}
</span>
</div>
</td>
<td className="p-4">{getStatusBadge(supply.status)}</td>
</tr>
{/* Развернутые уровни */}
{isSupplyExpanded &&
supply.routes.map((route) => {
const isRouteExpanded = expandedRoutes.has(route.id);
return (
<React.Fragment key={route.id}>
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-cyan-500/10">
<td className="p-4 pl-12">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
toggleRouteExpansion(route.id)
}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isRouteExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<MapPin className="h-4 w-4 text-cyan-400" />
<span className="text-white font-medium">
Маршрут
</span>
</div>
</td>
<td className="p-4" colSpan={3}>
<div className="text-white">
<div className="flex items-center space-x-2 mb-1">
<span className="font-medium">
{route.from}
</span>
<span className="text-white/60"></span>
<span className="font-medium">
{route.to}
</span>
</div>
<div className="text-xs text-white/60">
{route.fromAddress} {route.toAddress}
</div>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{route.warehouses.reduce(
(sum, w) =>
sum +
w.products.reduce(
(pSum, p) => pSum + p.plannedQty,
0
),
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{route.warehouses.reduce(
(sum, w) =>
sum +
w.products.reduce(
(pSum, p) => pSum + p.actualQty,
0
),
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{route.warehouses.reduce(
(sum, w) =>
sum +
w.products.reduce(
(pSum, p) => pSum + p.defectQty,
0
),
0
)}
</span>
</td>
<td className="p-4">
<span className="text-green-400 font-medium">
{formatCurrency(route.totalProductPrice)}
</span>
</td>
<td className="p-4">
<span className="text-purple-400 font-medium">
{formatCurrency(route.logisticsPrice)}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{formatCurrency(route.totalAmount)}
</span>
</td>
<td className="p-4"></td>
</tr>
{/* Склады Ozon */}
{isRouteExpanded &&
route.warehouses.map((warehouse) => {
const isWarehouseExpanded =
expandedWarehouses.has(warehouse.id);
return (
<React.Fragment key={warehouse.id}>
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-indigo-500/10">
<td className="p-4 pl-20">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
toggleWarehouseExpansion(
warehouse.id
)
}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isWarehouseExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<Truck className="h-4 w-4 text-indigo-400" />
<span className="text-white font-medium">
Склад Ozon
</span>
</div>
</td>
<td className="p-4" colSpan={3}>
<div className="text-white">
<div className="font-medium mb-1">
{warehouse.name}
</div>
<div className="text-xs text-white/60 mb-1">
ID: {warehouse.warehouseId}
</div>
<div className="text-xs text-white/60">
{warehouse.address}
</div>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{warehouse.products.reduce(
(sum, p) => sum + p.plannedQty,
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{warehouse.products.reduce(
(sum, p) => sum + p.actualQty,
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{warehouse.products.reduce(
(sum, p) => sum + p.defectQty,
0
)}
</span>
</td>
<td className="p-4">
<span className="text-green-400 font-medium">
{formatCurrency(
warehouse.products.reduce(
(sum, p) =>
sum + calculateProductTotal(p),
0
)
)}
</span>
</td>
<td className="p-4"></td>
<td className="p-4">
<span className="text-white font-semibold">
{formatCurrency(
warehouse.totalAmount
)}
</span>
</td>
<td className="p-4"></td>
</tr>
{/* Товары Ozon */}
{isWarehouseExpanded &&
warehouse.products.map((product) => (
<tr
key={product.id}
className="border-b border-white/10 hover:bg-white/5 transition-colors bg-teal-500/10"
>
<td className="p-4 pl-28">
<div className="flex items-center space-x-2">
<Package className="h-4 w-4 text-teal-400" />
<span className="text-white font-medium">
Товар Ozon
</span>
</div>
</td>
<td className="p-4" colSpan={3}>
<div className="text-white">
<div className="font-medium mb-1">
{product.name}
</div>
<div className="text-xs text-white/60 mb-1">
Артикул: {product.sku}
</div>
<div className="text-xs text-white/60 mb-1">
Offer ID: {product.offerId}
</div>
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30 border text-xs">
{product.category}
</Badge>
</div>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{product.plannedQty}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{product.actualQty}
</span>
</td>
<td className="p-4">
<span
className={`font-semibold ${
product.defectQty > 0
? "text-red-400"
: "text-white"
}`}
>
{product.defectQty}
</span>
</td>
<td className="p-4">
<div className="text-white">
<div className="font-medium">
{formatCurrency(
calculateProductTotal(product)
)}
</div>
<div className="text-xs text-white/60">
{formatCurrency(
product.productPrice
)}{" "}
за шт.
</div>
</div>
</td>
<td className="p-4">
{getEfficiencyBadge(
product.plannedQty,
product.actualQty,
product.defectQty
)}
</td>
<td className="p-4">
<span className="text-white font-semibold">
{formatCurrency(
calculateProductTotal(product)
)}
</span>
</td>
<td className="p-4"></td>
</tr>
))}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@ -0,0 +1,779 @@
"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 { StatsCard } from "../ui/stats-card";
import { StatsGrid } from "../ui/stats-grid";
import {
ChevronDown,
ChevronRight,
Calendar,
Package,
MapPin,
TrendingUp,
AlertTriangle,
DollarSign,
Truck,
ShoppingBag,
} from "lucide-react";
// Типы данных для поставок на Wildberries
interface WbProduct {
id: string;
name: string;
sku: string;
nmId: number;
category: string;
plannedQty: number;
actualQty: number;
defectQty: number;
productPrice: number;
}
interface WbWarehouse {
id: string;
name: string;
address: string;
warehouseId: number;
products: WbProduct[];
totalAmount: number;
}
interface WbRoute {
id: string;
from: string;
fromAddress: string;
to: string;
toAddress: string;
warehouses: WbWarehouse[];
totalProductPrice: number;
logisticsPrice: number;
totalAmount: number;
}
interface WbSupply {
id: string;
number: number;
supplyId: string;
deliveryDate: string;
createdDate: string;
routes: WbRoute[];
plannedTotal: number;
actualTotal: number;
defectTotal: number;
totalProductPrice: number;
totalLogisticsPrice: number;
grandTotal: number;
status: "planned" | "in-transit" | "delivered" | "completed";
}
// Моковые данные для поставок на Wildberries
const mockWbSupplies: WbSupply[] = [
{
id: "wb1",
number: 4001,
supplyId: "WB24010001",
deliveryDate: "2024-01-22",
createdDate: "2024-01-16",
status: "delivered",
plannedTotal: 120,
actualTotal: 118,
defectTotal: 2,
totalProductPrice: 2400000,
totalLogisticsPrice: 18000,
grandTotal: 2418000,
routes: [
{
id: "wbr1",
from: "Садовод",
fromAddress: "Москва, 14-й км МКАД",
to: "WB Подольск",
toAddress: "Подольск, ул. Складская, 25",
totalProductPrice: 2400000,
logisticsPrice: 18000,
totalAmount: 2418000,
warehouses: [
{
id: "wbw1",
name: "Склад WB Подольск",
address: "Подольск, ул. Складская, 25",
warehouseId: 117501,
totalAmount: 2400000,
products: [
{
id: "wbp1",
name: "Смартфон Samsung Galaxy S24",
sku: "SAMS-GS24-256",
nmId: 123456789,
category: "Смартфоны и гаджеты",
plannedQty: 40,
actualQty: 39,
defectQty: 1,
productPrice: 65000,
},
{
id: "wbp2",
name: "Чехол для Samsung Galaxy S24",
sku: "CASE-GS24-BLK",
nmId: 987654321,
category: "Аксессуары для телефонов",
plannedQty: 80,
actualQty: 79,
defectQty: 1,
productPrice: 1200,
},
],
},
],
},
],
},
{
id: "wb2",
number: 4002,
supplyId: "WB24010002",
deliveryDate: "2024-01-28",
createdDate: "2024-01-20",
status: "in-transit",
plannedTotal: 60,
actualTotal: 60,
defectTotal: 0,
totalProductPrice: 1800000,
totalLogisticsPrice: 15000,
grandTotal: 1815000,
routes: [
{
id: "wbr2",
from: "ТЯК Москва",
fromAddress: "Москва, Алтуфьевское шоссе, 27",
to: "WB Электросталь",
toAddress: "Электросталь, ул. Промышленная, 10",
totalProductPrice: 1800000,
logisticsPrice: 15000,
totalAmount: 1815000,
warehouses: [
{
id: "wbw2",
name: "Склад WB Электросталь",
address: "Электросталь, ул. Промышленная, 10",
warehouseId: 117986,
totalAmount: 1800000,
products: [
{
id: "wbp3",
name: "Наушники Sony WH-1000XM5",
sku: "SONY-WH1000XM5",
nmId: 555666777,
category: "Наушники и аудио",
plannedQty: 30,
actualQty: 30,
defectQty: 0,
productPrice: 35000,
},
{
id: "wbp4",
name: "Кабель USB-C",
sku: "CABLE-USBC-2M",
nmId: 111222333,
category: "Кабели и адаптеры",
plannedQty: 30,
actualQty: 30,
defectQty: 0,
productPrice: 800,
},
],
},
],
},
],
},
];
export function WildberriesSuppliesTab() {
const [expandedSupplies, setExpandedSupplies] = useState<Set<string>>(
new Set()
);
const [expandedRoutes, setExpandedRoutes] = useState<Set<string>>(new Set());
const [expandedWarehouses, setExpandedWarehouses] = useState<Set<string>>(
new Set()
);
const [expandedProducts, setExpandedProducts] = useState<Set<string>>(
new Set()
);
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 toggleWarehouseExpansion = (warehouseId: string) => {
const newExpanded = new Set(expandedWarehouses);
if (newExpanded.has(warehouseId)) {
newExpanded.delete(warehouseId);
} else {
newExpanded.add(warehouseId);
}
setExpandedWarehouses(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: WbSupply["status"]) => {
const statusMap = {
planned: {
label: "Запланирована",
color: "bg-blue-500/20 text-blue-300 border-blue-500/30",
},
"in-transit": {
label: "В пути",
color: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
},
delivered: {
label: "Доставлена",
color: "bg-green-500/20 text-green-300 border-green-500/30",
},
completed: {
label: "Завершена",
color: "bg-purple-500/20 text-purple-300 border-purple-500/30",
},
};
const { label, color } = statusMap[status];
return <Badge className={`${color} border`}>{label}</Badge>;
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
minimumFractionDigits: 0,
}).format(amount);
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
};
const calculateProductTotal = (product: WbProduct) => {
return product.actualQty * product.productPrice;
};
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>
);
}
};
return (
<div className="space-y-6">
{/* Статистика поставок на Wildberries */}
<StatsGrid>
<StatsCard
title="Поставок на WB"
value={mockWbSupplies.length}
icon={ShoppingBag}
iconColor="text-purple-400"
iconBg="bg-purple-500/20"
trend={{ value: 18, isPositive: true }}
subtitle="Wildberries поставки"
/>
<StatsCard
title="Сумма WB поставок"
value={formatCurrency(
mockWbSupplies.reduce((sum, supply) => sum + supply.grandTotal, 0)
)}
icon={TrendingUp}
iconColor="text-green-400"
iconBg="bg-green-500/20"
trend={{ value: 22, isPositive: true }}
subtitle="Общая стоимость"
/>
<StatsCard
title="В пути"
value={
mockWbSupplies.filter((supply) => supply.status === "in-transit")
.length
}
icon={Calendar}
iconColor="text-yellow-400"
iconBg="bg-yellow-500/20"
subtitle="Активные поставки"
/>
<StatsCard
title="С браком"
value={
mockWbSupplies.filter((supply) => supply.defectTotal > 0).length
}
icon={AlertTriangle}
iconColor="text-red-400"
iconBg="bg-red-500/20"
trend={{ value: 1, isPositive: false }}
subtitle="Требуют проверки"
/>
</StatsGrid>
{/* Таблица поставок на Wildberries */}
<Card className="bg-white/10 backdrop-blur border-white/20 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-white/20">
<th className="text-left p-4 text-white font-semibold"></th>
<th className="text-left p-4 text-white font-semibold">
ID поставки
</th>
<th className="text-left p-4 text-white font-semibold">
Дата поставки
</th>
<th className="text-left p-4 text-white font-semibold">
Дата создания
</th>
<th className="text-left p-4 text-white font-semibold">План</th>
<th className="text-left p-4 text-white font-semibold">Факт</th>
<th className="text-left p-4 text-white font-semibold">Брак</th>
<th className="text-left p-4 text-white font-semibold">
Цена товаров
</th>
<th className="text-left p-4 text-white font-semibold">
Логистика
</th>
<th className="text-left p-4 text-white font-semibold">
Итого сумма
</th>
<th className="text-left p-4 text-white font-semibold">
Статус
</th>
</tr>
</thead>
<tbody>
{mockWbSupplies.map((supply) => {
const isSupplyExpanded = expandedSupplies.has(supply.id);
return (
<React.Fragment key={supply.id}>
{/* Основная строка поставки на WB */}
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-purple-500/10">
<td className="p-4">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleSupplyExpansion(supply.id)}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isSupplyExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<span className="text-white font-bold text-lg">
#{supply.number}
</span>
</div>
</td>
<td className="p-4">
<span className="text-purple-300 font-mono text-sm">
{supply.supplyId}
</span>
</td>
<td className="p-4">
<div className="flex items-center space-x-2">
<Calendar className="h-4 w-4 text-white/40" />
<span className="text-white font-semibold">
{formatDate(supply.deliveryDate)}
</span>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{formatDate(supply.createdDate)}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{supply.plannedTotal}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{supply.actualTotal}
</span>
</td>
<td className="p-4">
<span
className={`font-semibold ${
supply.defectTotal > 0
? "text-red-400"
: "text-white"
}`}
>
{supply.defectTotal}
</span>
</td>
<td className="p-4">
<span className="text-green-400 font-semibold">
{formatCurrency(supply.totalProductPrice)}
</span>
</td>
<td className="p-4">
<span className="text-purple-400 font-semibold">
{formatCurrency(supply.totalLogisticsPrice)}
</span>
</td>
<td className="p-4">
<div className="flex items-center space-x-2">
<DollarSign className="h-4 w-4 text-white/40" />
<span className="text-white font-bold text-lg">
{formatCurrency(supply.grandTotal)}
</span>
</div>
</td>
<td className="p-4">{getStatusBadge(supply.status)}</td>
</tr>
{/* Развернутые уровни */}
{isSupplyExpanded &&
supply.routes.map((route) => {
const isRouteExpanded = expandedRoutes.has(route.id);
return (
<React.Fragment key={route.id}>
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-blue-500/10">
<td className="p-4 pl-12">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
toggleRouteExpansion(route.id)
}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isRouteExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<MapPin className="h-4 w-4 text-blue-400" />
<span className="text-white font-medium">
Маршрут
</span>
</div>
</td>
<td className="p-4" colSpan={3}>
<div className="text-white">
<div className="flex items-center space-x-2 mb-1">
<span className="font-medium">
{route.from}
</span>
<span className="text-white/60"></span>
<span className="font-medium">
{route.to}
</span>
</div>
<div className="text-xs text-white/60">
{route.fromAddress} {route.toAddress}
</div>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{route.warehouses.reduce(
(sum, w) =>
sum +
w.products.reduce(
(pSum, p) => pSum + p.plannedQty,
0
),
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{route.warehouses.reduce(
(sum, w) =>
sum +
w.products.reduce(
(pSum, p) => pSum + p.actualQty,
0
),
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{route.warehouses.reduce(
(sum, w) =>
sum +
w.products.reduce(
(pSum, p) => pSum + p.defectQty,
0
),
0
)}
</span>
</td>
<td className="p-4">
<span className="text-green-400 font-medium">
{formatCurrency(route.totalProductPrice)}
</span>
</td>
<td className="p-4">
<span className="text-purple-400 font-medium">
{formatCurrency(route.logisticsPrice)}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{formatCurrency(route.totalAmount)}
</span>
</td>
<td className="p-4"></td>
</tr>
{/* Склады WB */}
{isRouteExpanded &&
route.warehouses.map((warehouse) => {
const isWarehouseExpanded =
expandedWarehouses.has(warehouse.id);
return (
<React.Fragment key={warehouse.id}>
<tr className="border-b border-white/10 hover:bg-white/5 transition-colors bg-green-500/10">
<td className="p-4 pl-20">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
toggleWarehouseExpansion(
warehouse.id
)
}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isWarehouseExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<Truck className="h-4 w-4 text-green-400" />
<span className="text-white font-medium">
Склад WB
</span>
</div>
</td>
<td className="p-4" colSpan={3}>
<div className="text-white">
<div className="font-medium mb-1">
{warehouse.name}
</div>
<div className="text-xs text-white/60 mb-1">
ID: {warehouse.warehouseId}
</div>
<div className="text-xs text-white/60">
{warehouse.address}
</div>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{warehouse.products.reduce(
(sum, p) => sum + p.plannedQty,
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{warehouse.products.reduce(
(sum, p) => sum + p.actualQty,
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{warehouse.products.reduce(
(sum, p) => sum + p.defectQty,
0
)}
</span>
</td>
<td className="p-4">
<span className="text-green-400 font-medium">
{formatCurrency(
warehouse.products.reduce(
(sum, p) =>
sum + calculateProductTotal(p),
0
)
)}
</span>
</td>
<td className="p-4"></td>
<td className="p-4">
<span className="text-white font-semibold">
{formatCurrency(
warehouse.totalAmount
)}
</span>
</td>
<td className="p-4"></td>
</tr>
{/* Товары WB */}
{isWarehouseExpanded &&
warehouse.products.map((product) => (
<tr
key={product.id}
className="border-b border-white/10 hover:bg-white/5 transition-colors bg-yellow-500/10"
>
<td className="p-4 pl-28">
<div className="flex items-center space-x-2">
<Package className="h-4 w-4 text-yellow-400" />
<span className="text-white font-medium">
Товар WB
</span>
</div>
</td>
<td className="p-4" colSpan={3}>
<div className="text-white">
<div className="font-medium mb-1">
{product.name}
</div>
<div className="text-xs text-white/60 mb-1">
Артикул: {product.sku}
</div>
<div className="text-xs text-white/60 mb-1">
NM ID: {product.nmId}
</div>
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30 border text-xs">
{product.category}
</Badge>
</div>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{product.plannedQty}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{product.actualQty}
</span>
</td>
<td className="p-4">
<span
className={`font-semibold ${
product.defectQty > 0
? "text-red-400"
: "text-white"
}`}
>
{product.defectQty}
</span>
</td>
<td className="p-4">
<div className="text-white">
<div className="font-medium">
{formatCurrency(
calculateProductTotal(product)
)}
</div>
<div className="text-xs text-white/60">
{formatCurrency(
product.productPrice
)}{" "}
за шт.
</div>
</div>
</td>
<td className="p-4">
{getEfficiencyBadge(
product.plannedQty,
product.actualQty,
product.defectQty
)}
</td>
<td className="p-4">
<span className="text-white font-semibold">
{formatCurrency(
calculateProductTotal(product)
)}
</span>
</td>
<td className="p-4"></td>
</tr>
))}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@ -6,12 +6,12 @@ import { Button } from "@/components/ui/button";
import { Sidebar } from "@/components/dashboard/sidebar";
import { useSidebar } from "@/hooks/useSidebar";
import { Plus } from "lucide-react";
import { SuppliesGoodsTab } from "./goods-supplies/goods-supplies-tab";
import { SuppliesConsumablesTab } from "./consumables-supplies/consumables-supplies-tab";
import { FulfillmentSuppliesTab } from "./fulfillment-supplies/fulfillment-supplies-tab";
import { MarketplaceSuppliesTab } from "./marketplace-supplies/marketplace-supplies-tab";
export function SuppliesDashboard() {
const { getSidebarMargin } = useSidebar();
const [activeTab, setActiveTab] = useState("goods");
const [activeTab, setActiveTab] = useState("fulfillment");
return (
<div className="h-screen flex overflow-hidden">
@ -20,7 +20,7 @@ export function SuppliesDashboard() {
className={`flex-1 ${getSidebarMargin()} px-4 py-4 overflow-hidden transition-all duration-300`}
>
<div className="h-full">
{/* Вкладки с кнопкой создания */}
{/* Главные вкладки с кнопкой создания */}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
@ -29,16 +29,16 @@ export function SuppliesDashboard() {
<div className="flex items-center justify-between mb-6">
<TabsList className="grid grid-cols-2 bg-white/10 backdrop-blur border-white/20 w-fit">
<TabsTrigger
value="goods"
value="fulfillment"
className="data-[state=active]:bg-gradient-to-r data-[state=active]:from-purple-500 data-[state=active]:to-pink-500 data-[state=active]:text-white text-white/60 px-8"
>
Товар
Поставки на ФФ
</TabsTrigger>
<TabsTrigger
value="consumables"
value="marketplace"
className="data-[state=active]:bg-gradient-to-r data-[state=active]:from-purple-500 data-[state=active]:to-pink-500 data-[state=active]:text-white text-white/60 px-8"
>
Расходники
Поставки на Маркетплейсы
</TabsTrigger>
</TabsList>
@ -53,12 +53,12 @@ export function SuppliesDashboard() {
</Button>
</div>
<TabsContent value="goods" className="mt-0 flex-1">
<SuppliesGoodsTab />
<TabsContent value="fulfillment" className="mt-0 flex-1">
<FulfillmentSuppliesTab />
</TabsContent>
<TabsContent value="consumables" className="mt-0 flex-1">
<SuppliesConsumablesTab />
<TabsContent value="marketplace" className="mt-0 flex-1">
<MarketplaceSuppliesTab />
</TabsContent>
</Tabs>
</div>

View File

@ -0,0 +1,83 @@
"use client";
import React from "react";
import { Card } from "@/components/ui/card";
import { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
interface StatsCardProps {
title: string;
value: string | number;
icon: LucideIcon;
iconColor: string;
iconBg: string;
trend?: {
value: number;
isPositive: boolean;
};
subtitle?: string;
className?: string;
}
export function StatsCard({
title,
value,
icon: Icon,
iconColor,
iconBg,
trend,
subtitle,
className,
}: StatsCardProps) {
return (
<Card
className={cn(
"bg-white/10 backdrop-blur border-white/20 p-4 hover:bg-white/15 transition-all duration-300 hover:scale-105 hover:shadow-lg",
className
)}
>
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3 flex-1">
<div className={cn("p-2.5 rounded-xl", iconBg)}>
<Icon className={cn("h-5 w-5", iconColor)} />
</div>
<div className="flex-1 min-w-0">
<p className="text-white/60 text-xs font-medium truncate">
{title}
</p>
<p
className="text-xl font-bold text-white mt-1 truncate"
title={value.toString()}
>
{value}
</p>
{subtitle && (
<p className="text-white/40 text-xs mt-1 truncate">{subtitle}</p>
)}
</div>
</div>
{trend && (
<div
className={cn(
"flex items-center space-x-1 px-2 py-1 rounded-full text-xs font-medium",
trend.isPositive
? "bg-green-500/20 text-green-300"
: "bg-red-500/20 text-red-300"
)}
>
<span
className={cn(
"text-xs",
trend.isPositive ? "text-green-400" : "text-red-400"
)}
>
{trend.isPositive ? "↗" : "↘"}
</span>
<span>{Math.abs(trend.value)}%</span>
</div>
)}
</div>
</Card>
);
}

View File

@ -0,0 +1,28 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
interface StatsGridProps {
children: React.ReactNode;
columns?: 2 | 3 | 4;
className?: string;
}
export function StatsGrid({
children,
columns = 4,
className,
}: StatsGridProps) {
const gridCols = {
2: "grid-cols-1 md:grid-cols-2",
3: "grid-cols-1 md:grid-cols-3",
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
};
return (
<div className={cn("grid gap-4 mb-6", gridCols[columns], className)}>
{children}
</div>
);
}