Files
sfera/src/components/supplies/supplies-dashboard.tsx

724 lines
33 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 { Sidebar } from '@/components/dashboard/sidebar'
import {
ChevronDown,
ChevronRight,
Plus,
Calendar,
Package,
MapPin,
Building2,
TrendingUp,
AlertTriangle,
DollarSign
} from 'lucide-react'
// Типы данных для 5-уровневой структуры
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 Supply {
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'
}
// Моковые данные для 5-уровневой структуры
const mockSupplies: Supply[] = [
{
id: '1',
number: 1,
deliveryDate: '2024-01-15',
createdDate: '2024-01-10',
status: 'delivered',
plannedTotal: 180,
actualTotal: 173,
defectTotal: 2,
totalProductPrice: 3750000,
totalFulfillmentPrice: 43000,
totalLogisticsPrice: 27000,
grandTotal: 3820000,
routes: [
{
id: 'r1',
from: 'Садовод',
fromAddress: 'Москва, 14-й км МКАД',
to: 'SFERAV Logistics',
toAddress: 'Москва, ул. Складская, 15',
totalProductPrice: 3600000,
fulfillmentServicePrice: 25000,
logisticsPrice: 15000,
totalAmount: 3640000,
wholesalers: [
{
id: 'w1',
name: 'ООО "ТехноСнаб"',
inn: '7701234567',
contact: '+7 (495) 123-45-67',
address: 'Москва, ул. Торговая, 1',
totalAmount: 3600000,
products: [
{
id: 'p1',
name: 'Смартфон iPhone 15',
sku: 'APL-IP15-128',
category: 'Электроника',
plannedQty: 50,
actualQty: 48,
defectQty: 2,
productPrice: 75000,
parameters: [
{ id: 'param1', name: 'Цвет', value: 'Черный' },
{ id: 'param2', name: 'Память', value: '128', unit: 'ГБ' },
{ id: 'param3', name: 'Гарантия', value: '12', unit: 'мес' }
]
}
]
}
]
},
{
id: 'r2',
from: 'ТЯК Москва',
fromAddress: 'Москва, Алтуфьевское шоссе, 27',
to: 'MegaFulfillment',
toAddress: 'Подольск, ул. Индустриальная, 42',
totalProductPrice: 150000,
fulfillmentServicePrice: 18000,
logisticsPrice: 12000,
totalAmount: 180000,
wholesalers: [
{
id: 'w2',
name: 'ИП Петров А.В.',
inn: '123456789012',
contact: '+7 (499) 987-65-43',
address: 'Москва, пр-т Мира, 45',
totalAmount: 150000,
products: [
{
id: 'p2',
name: 'Чехол для iPhone 15',
sku: 'ACC-IP15-CASE',
category: 'Аксессуары',
plannedQty: 100,
actualQty: 95,
defectQty: 0,
productPrice: 1500,
parameters: [
{ id: 'param4', name: 'Материал', value: 'Силикон' },
{ id: 'param5', name: 'Цвет', value: 'Прозрачный' }
]
}
]
}
]
}
]
},
{
id: '2',
number: 2,
deliveryDate: '2024-01-20',
createdDate: '2024-01-12',
status: 'in-transit',
plannedTotal: 30,
actualTotal: 30,
defectTotal: 0,
totalProductPrice: 750000,
totalFulfillmentPrice: 18000,
totalLogisticsPrice: 12000,
grandTotal: 780000,
routes: [
{
id: 'r3',
from: 'Садовод',
fromAddress: 'Москва, 14-й км МКАД',
to: 'WB Подольск',
toAddress: 'Подольск, ул. Складская, 25',
totalProductPrice: 750000,
fulfillmentServicePrice: 18000,
logisticsPrice: 12000,
totalAmount: 780000,
wholesalers: [
{
id: 'w3',
name: 'ООО "АудиоТех"',
inn: '7702345678',
contact: '+7 (495) 555-12-34',
address: 'Москва, ул. Звуковая, 8',
totalAmount: 750000,
products: [
{
id: 'p3',
name: 'Наушники AirPods Pro',
sku: 'APL-AP-PRO2',
category: 'Аудио',
plannedQty: 30,
actualQty: 30,
defectQty: 0,
productPrice: 25000,
parameters: [
{ id: 'param6', name: 'Тип', value: 'Беспроводные' },
{ id: 'param7', name: 'Шумоподавление', value: 'Активное' },
{ id: 'param8', name: 'Время работы', value: '6', unit: 'ч' }
]
}
]
}
]
}
]
}
]
export function SuppliesDashboard() {
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: Supply['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="min-h-screen bg-gradient-smooth flex">
<Sidebar />
<main className="flex-1 ml-56">
<div className="p-8">
{/* Заголовок */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Поставки</h1>
<p className="text-white/60">Управление поставками товаров</p>
</div>
<Button
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white shadow-lg"
onClick={() => {
window.location.href = '/supplies/create'
}}
>
<Plus className="h-4 w-4 mr-2" />
Создать поставку
</Button>
</div>
{/* Статистика */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card className="bg-white/10 backdrop-blur border-white/20 p-6">
<div className="flex items-center space-x-3">
<div className="p-3 bg-blue-500/20 rounded-lg">
<Package className="h-6 w-6 text-blue-400" />
</div>
<div>
<p className="text-white/60 text-sm">Всего поставок</p>
<p className="text-2xl font-bold text-white">{mockSupplies.length}</p>
</div>
</div>
</Card>
<Card className="bg-white/10 backdrop-blur border-white/20 p-6">
<div className="flex items-center space-x-3">
<div className="p-3 bg-green-500/20 rounded-lg">
<TrendingUp className="h-6 w-6 text-green-400" />
</div>
<div>
<p className="text-white/60 text-sm">Общая сумма</p>
<p className="text-2xl font-bold text-white">
{formatCurrency(mockSupplies.reduce((sum, supply) => sum + supply.grandTotal, 0))}
</p>
</div>
</div>
</Card>
<Card className="bg-white/10 backdrop-blur border-white/20 p-6">
<div className="flex items-center space-x-3">
<div className="p-3 bg-yellow-500/20 rounded-lg">
<Calendar className="h-6 w-6 text-yellow-400" />
</div>
<div>
<p className="text-white/60 text-sm">В пути</p>
<p className="text-2xl font-bold text-white">
{mockSupplies.filter(supply => supply.status === 'in-transit').length}
</p>
</div>
</div>
</Card>
<Card className="bg-white/10 backdrop-blur border-white/20 p-6">
<div className="flex items-center space-x-3">
<div className="p-3 bg-red-500/20 rounded-lg">
<AlertTriangle className="h-6 w-6 text-red-400" />
</div>
<div>
<p className="text-white/60 text-sm">С браком</p>
<p className="text-2xl font-bold text-white">
{mockSupplies.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>
<th className="text-left p-4 text-white font-semibold">Статус</th>
</tr>
</thead>
<tbody>
{mockSupplies.map((supply) => {
const isSupplyExpanded = expandedSupplies.has(supply.id)
return (
<React.Fragment key={supply.id}>
{/* Уровень 1: Основная строка поставки */}
<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>
{/* Уровень 2: Маршруты */}
{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>
{/* Уровень 3: Оптовики */}
{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>
{/* Уровень 4: Товары */}
{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>
{/* Уровень 5: Параметры товара */}
{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>
</main>
</div>
)
}