
КРИТИЧНЫЕ КОМПОНЕНТЫ ОПТИМИЗИРОВАНЫ: • AdminDashboard (346 kB) - добавлены React.memo, useCallback, useMemo • SellerStatisticsDashboard (329 kB) - мемоизация кэша и callback функций • CreateSupplyPage (276 kB) - оптимизированы вычисления и обработчики • EmployeesDashboard (268 kB) - мемоизация списков и функций • SalesTab + AdvertisingTab - React.memo обертка ТЕХНИЧЕСКИЕ УЛУЧШЕНИЯ: ✅ React.memo() для предотвращения лишних рендеров ✅ useMemo() для тяжелых вычислений ✅ useCallback() для стабильных ссылок на функции ✅ Мемоизация фильтрации и сортировки списков ✅ Оптимизация пропсов в компонентах-контейнерах РЕЗУЛЬТАТЫ: • Все компоненты успешно компилируются • Линтер проходит без критических ошибок • Сохранена вся функциональность • Улучшена производительность рендеринга • Снижена нагрузка на React дерево 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
336 lines
14 KiB
TypeScript
336 lines
14 KiB
TypeScript
'use client'
|
||
|
||
import { useQuery } from '@apollo/client'
|
||
import { Wrench, Plus, Calendar, TrendingUp, AlertCircle, Search, Filter } from 'lucide-react'
|
||
import { useState } from 'react'
|
||
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Card } from '@/components/ui/card'
|
||
import { Input } from '@/components/ui/input'
|
||
import { GET_MY_SUPPLIES } from '@/graphql/queries'
|
||
|
||
interface MaterialSupply {
|
||
id: string
|
||
name: string
|
||
description?: string
|
||
price: number
|
||
quantity: number
|
||
unit?: string
|
||
category?: string
|
||
status?: string
|
||
date: string
|
||
supplier?: string
|
||
minStock?: number
|
||
currentStock?: number
|
||
imageUrl?: string
|
||
createdAt: string
|
||
updatedAt: string
|
||
}
|
||
|
||
export function MaterialsSuppliesTab() {
|
||
const [searchTerm, setSearchTerm] = useState('')
|
||
const [categoryFilter, setCategoryFilter] = useState<string>('all')
|
||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||
|
||
// Загружаем расходники из GraphQL
|
||
const { data, loading, error, refetch } = useQuery(GET_MY_SUPPLIES, {
|
||
fetchPolicy: 'cache-and-network', // Всегда проверяем сервер
|
||
errorPolicy: 'all', // Показываем ошибки
|
||
})
|
||
|
||
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 getStatusBadge = (status: string) => {
|
||
const statusConfig = {
|
||
planned: { variant: 'outline' as const, color: 'text-blue-300 border-blue-400/30', label: 'Запланировано' },
|
||
'in-transit': { variant: 'outline' as const, color: 'text-yellow-300 border-yellow-400/30', label: 'В пути' },
|
||
delivered: { variant: 'outline' as const, color: 'text-green-300 border-green-400/30', label: 'Доставлено' },
|
||
'in-stock': { variant: 'outline' as const, color: 'text-purple-300 border-purple-400/30', label: 'На складе' },
|
||
}
|
||
|
||
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.planned
|
||
|
||
return (
|
||
<Badge variant={config.variant} className={`glass-secondary ${config.color}`}>
|
||
{config.label}
|
||
</Badge>
|
||
)
|
||
}
|
||
|
||
const getStockStatusBadge = (currentStock: number, minStock: number) => {
|
||
if (currentStock <= minStock) {
|
||
return (
|
||
<Badge variant="outline" className="glass-secondary text-red-300 border-red-400/30">
|
||
Низкий остаток
|
||
</Badge>
|
||
)
|
||
} else if (currentStock <= minStock * 1.5) {
|
||
return (
|
||
<Badge variant="outline" className="glass-secondary text-yellow-300 border-yellow-400/30">
|
||
Требует заказа
|
||
</Badge>
|
||
)
|
||
}
|
||
return (
|
||
<Badge variant="outline" className="glass-secondary text-green-300 border-green-400/30">
|
||
В норме
|
||
</Badge>
|
||
)
|
||
}
|
||
|
||
// Обрабатываем данные из GraphQL
|
||
const supplies: MaterialSupply[] = data?.mySupplies || []
|
||
|
||
const filteredSupplies = supplies.filter((supply: MaterialSupply) => {
|
||
const matchesSearch =
|
||
supply.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
(supply.category || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
(supply.supplier || '').toLowerCase().includes(searchTerm.toLowerCase())
|
||
|
||
const matchesCategory = categoryFilter === 'all' || supply.category === categoryFilter
|
||
const matchesStatus = statusFilter === 'all' || supply.status === statusFilter
|
||
|
||
return matchesSearch && matchesCategory && matchesStatus
|
||
})
|
||
|
||
const getTotalAmount = () => {
|
||
return filteredSupplies.reduce((sum: number, supply: MaterialSupply) => sum + supply.price * supply.quantity, 0)
|
||
}
|
||
|
||
const getTotalQuantity = () => {
|
||
return filteredSupplies.reduce((sum: number, supply: MaterialSupply) => sum + supply.quantity, 0)
|
||
}
|
||
|
||
const getLowStockCount = () => {
|
||
return supplies.filter((supply: MaterialSupply) => (supply.currentStock || 0) <= (supply.minStock || 0)).length
|
||
}
|
||
|
||
const categories = Array.from(new Set(supplies.map((supply: MaterialSupply) => supply.category)))
|
||
|
||
// Показываем индикатор загрузки
|
||
if (loading) {
|
||
return (
|
||
<div className="h-full flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-4"></div>
|
||
<p className="text-white/60">Загрузка расходников...</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Показываем ошибку
|
||
if (error) {
|
||
return (
|
||
<div className="h-full flex items-center justify-center">
|
||
<div className="text-center">
|
||
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||
<p className="text-white/60">Ошибка загрузки данных</p>
|
||
<p className="text-white/40 text-sm mt-2">{error.message}</p>
|
||
<Button onClick={() => refetch()} className="mt-4 bg-blue-500 hover:bg-blue-600">
|
||
Попробовать снова
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="h-full flex flex-col space-y-4 p-4">
|
||
{/* Статистика с кнопкой заказа */}
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 flex-1">
|
||
<Card className="glass-card p-3 h-[60px]">
|
||
<div className="flex items-center space-x-2 h-full">
|
||
<div className="p-1.5 bg-purple-500/20 rounded">
|
||
<Wrench className="h-3 w-3 text-purple-400" />
|
||
</div>
|
||
<div>
|
||
<p className="text-white/60 text-xs">Поставок</p>
|
||
<p className="text-lg font-bold text-white">{filteredSupplies.length}</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card className="glass-card p-3 h-[60px]">
|
||
<div className="flex items-center space-x-2 h-full">
|
||
<div className="p-1.5 bg-green-500/20 rounded">
|
||
<TrendingUp className="h-3 w-3 text-green-400" />
|
||
</div>
|
||
<div>
|
||
<p className="text-white/60 text-xs">Сумма</p>
|
||
<p className="text-lg font-bold text-white">{formatCurrency(getTotalAmount())}</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card className="glass-card p-3 h-[60px]">
|
||
<div className="flex items-center space-x-2 h-full">
|
||
<div className="p-1.5 bg-blue-500/20 rounded">
|
||
<AlertCircle className="h-3 w-3 text-blue-400" />
|
||
</div>
|
||
<div>
|
||
<p className="text-white/60 text-xs">Единиц</p>
|
||
<p className="text-lg font-bold text-white">{getTotalQuantity()}</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card className="glass-card p-3 h-[60px]">
|
||
<div className="flex items-center space-x-2 h-full">
|
||
<div className="p-1.5 bg-red-500/20 rounded">
|
||
<Calendar className="h-3 w-3 text-red-400" />
|
||
</div>
|
||
<div>
|
||
<p className="text-white/60 text-xs">Низкий остаток</p>
|
||
<p className="text-lg font-bold text-white">{getLowStockCount()}</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Кнопка заказа */}
|
||
<Button
|
||
size="sm"
|
||
onClick={() => (window.location.href = '/fulfillment-supplies/materials/order')}
|
||
className="bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 text-white text-sm px-6 h-[60px] whitespace-nowrap"
|
||
>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
Заказать
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Компактный поиск и фильтры */}
|
||
<div className="flex flex-col sm:flex-row gap-2">
|
||
<div className="relative flex-1">
|
||
<Search className="absolute left-2 top-2 h-3 w-3 text-white/40" />
|
||
<Input
|
||
placeholder="Поиск..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="pl-7 h-8 text-sm glass-input text-white placeholder:text-white/40"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex gap-1">
|
||
<select
|
||
value={categoryFilter}
|
||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||
className="px-2 py-1 h-8 bg-white/5 border border-white/20 rounded text-white text-xs focus:outline-none focus:ring-1 focus:ring-purple-500"
|
||
>
|
||
<option value="all">Все категории</option>
|
||
{categories.map((category) => (
|
||
<option key={category} value={category}>
|
||
{category}
|
||
</option>
|
||
))}
|
||
</select>
|
||
|
||
<select
|
||
value={statusFilter}
|
||
onChange={(e) => setStatusFilter(e.target.value)}
|
||
className="px-2 py-1 h-8 bg-white/5 border border-white/20 rounded text-white text-xs focus:outline-none focus:ring-1 focus:ring-purple-500"
|
||
>
|
||
<option value="all">Все статусы</option>
|
||
<option value="planned">Запланировано</option>
|
||
<option value="in-transit">В пути</option>
|
||
<option value="delivered">Доставлено</option>
|
||
<option value="in-stock">На складе</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Компактная таблица расходников */}
|
||
<Card className="glass-card flex-1 overflow-hidden">
|
||
<div className="p-3 h-full flex flex-col">
|
||
<div className="overflow-x-auto flex-1">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-white/20">
|
||
<th className="text-left p-2 text-white font-medium text-xs">Наименование</th>
|
||
<th className="text-left p-2 text-white font-medium text-xs">Категория</th>
|
||
<th className="text-left p-2 text-white font-medium text-xs">Кол-во</th>
|
||
<th className="text-left p-2 text-white font-medium text-xs">Остаток</th>
|
||
<th className="text-left p-2 text-white font-medium text-xs">Поставщик</th>
|
||
<th className="text-left p-2 text-white font-medium text-xs">Дата</th>
|
||
<th className="text-left p-2 text-white font-medium text-xs">Сумма</th>
|
||
<th className="text-left p-2 text-white font-medium text-xs">Статус</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredSupplies.map((supply) => (
|
||
<tr key={supply.id} className="border-b border-white/10 hover:bg-white/5 transition-colors">
|
||
<td className="p-2">
|
||
<div>
|
||
<span className="text-white font-medium text-sm">{supply.name}</span>
|
||
{supply.description && <p className="text-white/60 text-xs mt-0.5">{supply.description}</p>}
|
||
</div>
|
||
</td>
|
||
<td className="p-2">
|
||
<span className="text-white/80 text-sm">{supply.category || 'Не указано'}</span>
|
||
</td>
|
||
<td className="p-2">
|
||
<span className="text-white font-semibold text-sm">
|
||
{supply.quantity} {supply.unit || 'шт'}
|
||
</span>
|
||
</td>
|
||
<td className="p-2">
|
||
<div className="flex flex-col gap-0.5">
|
||
<span className="text-white font-semibold text-sm">
|
||
{supply.currentStock || 0} {supply.unit || 'шт'}
|
||
</span>
|
||
{getStockStatusBadge(supply.currentStock || 0, supply.minStock || 0)}
|
||
</div>
|
||
</td>
|
||
<td className="p-2">
|
||
<span className="text-white/80 text-sm">{supply.supplier || 'Не указан'}</span>
|
||
</td>
|
||
<td className="p-2">
|
||
<span className="text-white/80 text-sm">
|
||
{supply.date ? formatDate(supply.date) : 'Не указано'}
|
||
</span>
|
||
</td>
|
||
<td className="p-2">
|
||
<span className="text-white font-semibold text-sm">
|
||
{formatCurrency(supply.price * supply.quantity)}
|
||
</span>
|
||
</td>
|
||
<td className="p-2">{getStatusBadge(supply.status || 'planned')}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
|
||
{filteredSupplies.length === 0 && (
|
||
<div className="flex items-center justify-center p-8">
|
||
<div className="text-center">
|
||
<Wrench className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||
<p className="text-white/60">Расходники не найдены</p>
|
||
<p className="text-white/40 text-sm mt-2">
|
||
Попробуйте изменить параметры поиска или создать новый заказ
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|