Files
sfera/src/components/supplies/consumables-supplies/consumables-supplies-tab.tsx

940 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 {
ChevronDown,
ChevronRight,
Calendar,
Package,
MapPin,
Building2,
TrendingUp,
AlertTriangle,
DollarSign,
Wrench,
Box,
} 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 ConsumableSupply {
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 mockConsumableSupplies: ConsumableSupply[] = [
{
id: "c1",
number: 101,
deliveryDate: "2024-01-18",
createdDate: "2024-01-14",
status: "delivered",
plannedTotal: 5000,
actualTotal: 4950,
defectTotal: 50,
totalConsumablesPrice: 125000,
totalLogisticsPrice: 8000,
grandTotal: 133000,
routes: [
{
id: "cr1",
from: "Склад расходников",
fromAddress: "Москва, ул. Промышленная, 12",
to: "SFERAV Logistics",
toAddress: "Москва, ул. Складская, 15",
totalConsumablesPrice: 125000,
logisticsPrice: 8000,
totalAmount: 133000,
suppliers: [
{
id: "cs1",
name: 'ООО "УпакСервис"',
inn: "7703456789",
contact: "+7 (495) 777-88-99",
address: "Москва, ул. Упаковочная, 5",
totalAmount: 75000,
consumables: [
{
id: "cons1",
name: "Коробки картонные 30x20x10",
sku: "BOX-302010",
category: "Упаковка",
type: "packaging",
plannedQty: 2000,
actualQty: 1980,
defectQty: 20,
unitPrice: 35,
parameters: [
{ id: "cp1", name: "Размер", value: "30x20x10", unit: "см" },
{ id: "cp2", name: "Материал", value: "Гофрокартон" },
{ id: "cp3", name: "Плотность", value: "3", unit: "слоя" },
],
},
{
id: "cons2",
name: "Пузырчатая пленка",
sku: "BUBBLE-100",
category: "Защитная упаковка",
type: "protective",
plannedQty: 1000,
actualQty: 1000,
defectQty: 0,
unitPrice: 25,
parameters: [
{ id: "cp4", name: "Ширина", value: "100", unit: "см" },
{ id: "cp5", name: "Толщина", value: "0.1", unit: "мм" },
{ id: "cp6", name: "Длина рулона", value: "50", unit: "м" },
],
},
],
},
{
id: "cs2",
name: "ИП Маркин С.А.",
inn: "123456789013",
contact: "+7 (499) 111-22-33",
address: "Москва, пр-т Маркировочный, 8",
totalAmount: 50000,
consumables: [
{
id: "cons3",
name: "Этикетки самоклеящиеся",
sku: "LABEL-5030",
category: "Маркировка",
type: "labels",
plannedQty: 2000,
actualQty: 1970,
defectQty: 30,
unitPrice: 15,
parameters: [
{ id: "cp7", name: "Размер", value: "50x30", unit: "мм" },
{ id: "cp8", name: "Материал", value: "Полипропилен" },
{ id: "cp9", name: "Клей", value: "Акриловый" },
],
},
],
},
],
},
],
},
{
id: "c2",
number: 102,
deliveryDate: "2024-01-22",
createdDate: "2024-01-16",
status: "in-transit",
plannedTotal: 1500,
actualTotal: 1500,
defectTotal: 0,
totalConsumablesPrice: 45000,
totalLogisticsPrice: 3000,
grandTotal: 48000,
routes: [
{
id: "cr2",
from: "Инструментальный склад",
fromAddress: "Подольск, ул. Индустриальная, 25",
to: "WB Подольск",
toAddress: "Подольск, ул. Складская, 25",
totalConsumablesPrice: 45000,
logisticsPrice: 3000,
totalAmount: 48000,
suppliers: [
{
id: "cs3",
name: 'ООО "ИнструментПро"',
inn: "5001234567",
contact: "+7 (4967) 55-66-77",
address: "Подольск, ул. Инструментальная, 15",
totalAmount: 45000,
consumables: [
{
id: "cons4",
name: "Сканер штрих-кодов",
sku: "SCANNER-2D",
category: "Оборудование",
type: "tools",
plannedQty: 5,
actualQty: 5,
defectQty: 0,
unitPrice: 8000,
parameters: [
{ id: "cp10", name: "Тип", value: "2D сканер" },
{ id: "cp11", name: "Интерфейс", value: "USB" },
{ id: "cp12", name: "Дальность", value: "30", unit: "см" },
],
},
{
id: "cons5",
name: "Термопринтер этикеток",
sku: "PRINTER-THERMAL",
category: "Оборудование",
type: "tools",
plannedQty: 2,
actualQty: 2,
defectQty: 0,
unitPrice: 2500,
parameters: [
{
id: "cp13",
name: "Ширина печати",
value: "108",
unit: "мм",
},
{ id: "cp14", name: "Разрешение", value: "203", unit: "dpi" },
{ id: "cp15", name: "Скорость", value: "102", unit: "мм/с" },
],
},
],
},
],
},
],
},
];
export function SuppliesConsumablesTab() {
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: ConsumableSupply["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;
};
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">
{/* Статистика расходников */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card className="bg-white/10 backdrop-blur border-white/20 p-4">
<div className="flex items-center space-x-3">
<div className="p-2 bg-orange-500/20 rounded-lg">
<Box className="h-5 w-5 text-orange-400" />
</div>
<div>
<p className="text-white/60 text-xs">Поставок расходников</p>
<p className="text-xl font-bold text-white">
{mockConsumableSupplies.length}
</p>
</div>
</div>
</Card>
<Card className="bg-white/10 backdrop-blur border-white/20 p-4">
<div className="flex items-center space-x-3">
<div className="p-2 bg-green-500/20 rounded-lg">
<TrendingUp className="h-5 w-5 text-green-400" />
</div>
<div>
<p className="text-white/60 text-xs">Сумма расходников</p>
<p className="text-xl font-bold text-white">
{formatCurrency(
mockConsumableSupplies.reduce(
(sum, supply) => sum + supply.grandTotal,
0
)
)}
</p>
</div>
</div>
</Card>
<Card className="bg-white/10 backdrop-blur border-white/20 p-4">
<div className="flex items-center space-x-3">
<div className="p-2 bg-yellow-500/20 rounded-lg">
<Calendar className="h-5 w-5 text-yellow-400" />
</div>
<div>
<p className="text-white/60 text-xs">В пути</p>
<p className="text-xl font-bold text-white">
{
mockConsumableSupplies.filter(
(supply) => supply.status === "in-transit"
).length
}
</p>
</div>
</div>
</Card>
<Card className="bg-white/10 backdrop-blur border-white/20 p-4">
<div className="flex items-center space-x-3">
<div className="p-2 bg-red-500/20 rounded-lg">
<AlertTriangle className="h-5 w-5 text-red-400" />
</div>
<div>
<p className="text-white/60 text-xs">С браком</p>
<p className="text-xl font-bold text-white">
{
mockConsumableSupplies.filter(
(supply) => supply.defectTotal > 0
).length
}
</p>
</div>
</div>
</Card>
</div>
{/* Таблица поставок расходников */}
<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>
{mockConsumableSupplies.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 cursor-pointer"
onClick={() => toggleSupplyExpansion(supply.id)}
>
<td className="p-4">
<div className="flex items-center space-x-2">
<span className="text-white font-normal 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>
{/* Развернутые уровни */}
{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.suppliers.reduce(
(sum, s) =>
sum +
s.consumables.reduce(
(cSum, c) => cSum + c.plannedQty,
0
),
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{route.suppliers.reduce(
(sum, s) =>
sum +
s.consumables.reduce(
(cSum, c) => cSum + c.actualQty,
0
),
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{route.suppliers.reduce(
(sum, s) =>
sum +
s.consumables.reduce(
(cSum, c) => cSum + c.defectQty,
0
),
0
)}
</span>
</td>
<td className="p-4">
<span className="text-green-400 font-medium">
{formatCurrency(route.totalConsumablesPrice)}
</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.suppliers.map((supplier) => {
const isSupplierExpanded =
expandedSuppliers.has(supplier.id);
return (
<React.Fragment key={supplier.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={() =>
toggleSupplierExpansion(
supplier.id
)
}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isSupplierExpanded ? (
<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">
{supplier.name}
</div>
<div className="text-xs text-white/60 mb-1">
ИНН: {supplier.inn}
</div>
<div className="text-xs text-white/60 mb-1">
{supplier.address}
</div>
<div className="text-xs text-white/60">
{supplier.contact}
</div>
</div>
</td>
<td className="p-4">
<span className="text-white/80">
{supplier.consumables.reduce(
(sum, c) => sum + c.plannedQty,
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{supplier.consumables.reduce(
(sum, c) => sum + c.actualQty,
0
)}
</span>
</td>
<td className="p-4">
<span className="text-white/80">
{supplier.consumables.reduce(
(sum, c) => sum + c.defectQty,
0
)}
</span>
</td>
<td className="p-4">
<span className="text-green-400 font-medium">
{formatCurrency(
supplier.consumables.reduce(
(sum, c) =>
sum +
calculateConsumableTotal(c),
0
)
)}
</span>
</td>
<td className="p-4"></td>
<td className="p-4">
<span className="text-white font-semibold">
{formatCurrency(supplier.totalAmount)}
</span>
</td>
<td className="p-4"></td>
</tr>
{/* Расходники */}
{isSupplierExpanded &&
supplier.consumables.map((consumable) => {
const isConsumableExpanded =
expandedConsumables.has(
consumable.id
);
return (
<React.Fragment key={consumable.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={() =>
toggleConsumableExpansion(
consumable.id
)
}
className="h-6 w-6 p-0 text-white/60 hover:text-white hover:bg-white/10"
>
{isConsumableExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<Wrench 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">
{consumable.name}
</div>
<div className="text-xs text-white/60 mb-1">
Артикул: {consumable.sku}
</div>
<div className="flex gap-2">
<Badge className="bg-gray-500/20 text-gray-300 border-gray-500/30 border text-xs">
{consumable.category}
</Badge>
{getTypeBadge(
consumable.type
)}
</div>
</div>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{consumable.plannedQty}
</span>
</td>
<td className="p-4">
<span className="text-white font-semibold">
{consumable.actualQty}
</span>
</td>
<td className="p-4">
<span
className={`font-semibold ${
consumable.defectQty > 0
? "text-red-400"
: "text-white"
}`}
>
{consumable.defectQty}
</span>
</td>
<td className="p-4">
<div className="text-white">
<div className="font-medium">
{formatCurrency(
calculateConsumableTotal(
consumable
)
)}
</div>
<div className="text-xs text-white/60">
{formatCurrency(
consumable.unitPrice
)}{" "}
за шт.
</div>
</div>
</td>
<td className="p-4">
{getEfficiencyBadge(
consumable.plannedQty,
consumable.actualQty,
consumable.defectQty
)}
</td>
<td className="p-4">
<span className="text-white font-semibold">
{formatCurrency(
calculateConsumableTotal(
consumable
)
)}
</span>
</td>
<td className="p-4"></td>
</tr>
{/* Параметры расходника */}
{isConsumableExpanded && (
<tr>
<td
colSpan={10}
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">
{consumable.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>
);
}