
- Исправить группировку в истории поставок - теперь 3 отдельные поставки показываются как 3 строки - Добавить фильтрацию по productId вместо name для корректной связи данных - Исправить отображение статусов поставок (toLowerCase для UPPERCASE статусов) - Заменить карточку "Мало на складе" на "Остаток" с общей суммой currentStock - Добавить функцию getAggregatedSupplyData для корректного подсчёта из истории - Синхронизировать fetchPolicy: 'cache-and-network' для связанных компонентов 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
415 lines
15 KiB
TypeScript
415 lines
15 KiB
TypeScript
'use client'
|
||
|
||
import { useQuery } from '@apollo/client'
|
||
import { AlertTriangle, CheckCircle } from 'lucide-react'
|
||
import React, { useState, useMemo, useCallback } from 'react'
|
||
import { toast } from 'sonner'
|
||
|
||
import { Sidebar } from '@/components/dashboard/sidebar'
|
||
import { GET_MY_FULFILLMENT_SUPPLIES } from '@/graphql/queries'
|
||
import { GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES } from '@/graphql/queries/fulfillment-consumables-v2'
|
||
import { useSidebar } from '@/hooks/useSidebar'
|
||
|
||
import { SuppliesGrid } from './supplies-grid'
|
||
import { SuppliesHeader } from './supplies-header'
|
||
import { SuppliesList } from './supplies-list'
|
||
import { SuppliesStats } from './supplies-stats'
|
||
import { Supply, FilterState, SortState, ViewMode, GroupBy, StatusConfig } from './types'
|
||
|
||
// Статусы расходников с цветами
|
||
const STATUS_CONFIG = {
|
||
'На складе': {
|
||
label: 'На складе',
|
||
color: 'bg-green-500/20 text-green-300',
|
||
icon: CheckCircle,
|
||
},
|
||
Недоступен: {
|
||
label: 'Недоступен',
|
||
color: 'bg-red-500/20 text-red-300',
|
||
icon: AlertTriangle,
|
||
},
|
||
// Обратная совместимость
|
||
available: {
|
||
label: 'Доступен',
|
||
color: 'bg-green-500/20 text-green-300',
|
||
icon: CheckCircle,
|
||
},
|
||
unavailable: {
|
||
label: 'Недоступен',
|
||
color: 'bg-red-500/20 text-red-300',
|
||
icon: AlertTriangle,
|
||
},
|
||
} as const
|
||
|
||
export function FulfillmentSuppliesPage() {
|
||
const { getSidebarMargin } = useSidebar()
|
||
|
||
// Состояния
|
||
const [viewMode, setViewMode] = useState<ViewMode>('list')
|
||
const [filters, setFilters] = useState<FilterState>({
|
||
search: '',
|
||
category: '',
|
||
status: '',
|
||
supplier: '',
|
||
lowStock: false,
|
||
})
|
||
const [sort, setSort] = useState<SortState>({
|
||
field: 'name',
|
||
direction: 'asc',
|
||
})
|
||
const [showFilters, setShowFilters] = useState(false)
|
||
const [groupBy, setGroupBy] = useState<GroupBy>('none')
|
||
const [expandedSupplies, setExpandedSupplies] = useState<Set<string>>(new Set())
|
||
|
||
// Загрузка данных складских остатков
|
||
const {
|
||
data: suppliesData,
|
||
loading,
|
||
error,
|
||
refetch,
|
||
} = useQuery(GET_MY_FULFILLMENT_SUPPLIES, {
|
||
fetchPolicy: 'cache-and-network',
|
||
onError: (error) => {
|
||
toast.error('Ошибка загрузки расходников фулфилмента: ' + error.message)
|
||
},
|
||
})
|
||
|
||
// Загрузка истории поставок для детализации
|
||
const { data: deliveriesData, loading: deliveriesLoading } = useQuery(GET_MY_FULFILLMENT_CONSUMABLE_SUPPLIES, {
|
||
fetchPolicy: 'cache-and-network',
|
||
onError: (error) => {
|
||
console.warn('Ошибка загрузки истории поставок:', error.message)
|
||
},
|
||
})
|
||
|
||
const supplies = useMemo(() => suppliesData?.myFulfillmentSupplies || [], [suppliesData])
|
||
const allDeliveries = useMemo(() => deliveriesData?.myFulfillmentConsumableSupplies || [], [deliveriesData])
|
||
|
||
// Функция для корректировки агрегированных данных на основе истории поставок
|
||
const getAggregatedSupplyData = useCallback(
|
||
(supply: Supply) => {
|
||
if (!supply.productId || !allDeliveries.length) return supply
|
||
|
||
// Получаем все поставки этого товара
|
||
const productDeliveries = allDeliveries.filter((delivery) =>
|
||
delivery.items?.some((item) => item.productId === supply.productId),
|
||
)
|
||
|
||
if (!productDeliveries.length) return supply
|
||
|
||
// Вычисляем суммы из истории поставок
|
||
let totalReceived = 0
|
||
let totalShipped = 0
|
||
|
||
productDeliveries.forEach((delivery) => {
|
||
const item = delivery.items?.find((item) => item.productId === supply.productId)
|
||
if (item) {
|
||
totalReceived += item.receivedQuantity || 0
|
||
totalShipped += item.shippedQuantity || 0
|
||
}
|
||
})
|
||
|
||
// Возвращаем supply с исправленными данными
|
||
return {
|
||
...supply,
|
||
quantity: totalReceived, // ПОСТАВЛЕНО = сумма всех receivedQuantity
|
||
currentStock: Math.max(0, totalReceived - totalShipped), // ОСТАТОК = получено - отгружено
|
||
shippedQuantity: totalShipped, // ОТПРАВЛЕНО = сумма всех shippedQuantity
|
||
}
|
||
},
|
||
[allDeliveries],
|
||
)
|
||
|
||
// Функции
|
||
const getStatusConfig = useCallback((supply: Supply): StatusConfig => {
|
||
return supply.currentStock > 0 ? STATUS_CONFIG.available : STATUS_CONFIG.unavailable
|
||
}, [])
|
||
|
||
const getSupplyDeliveries = useCallback(
|
||
(supply: Supply): Supply[] => {
|
||
if (!supply.productId || !allDeliveries.length) return []
|
||
|
||
// Фильтруем поставки по productId товара
|
||
return allDeliveries
|
||
.filter((delivery) => delivery.items?.some((item) => item.productId === supply.productId))
|
||
.map((delivery) => {
|
||
// Преобразуем поставку в формат Supply для DeliveryDetails
|
||
const item = delivery.items?.find((item) => item.productId === supply.productId)
|
||
if (!item) return null
|
||
|
||
return {
|
||
id: delivery.id,
|
||
productId: supply.productId,
|
||
name: supply.name,
|
||
description: supply.description,
|
||
price: item.unitPrice || 0,
|
||
quantity: item.requestedQuantity || 0,
|
||
unit: supply.unit,
|
||
category: supply.category,
|
||
status: delivery.status ? delivery.status.toLowerCase() : 'pending',
|
||
date: delivery.createdAt || '',
|
||
supplier: delivery.supplier?.name || supply.supplier,
|
||
minStock: supply.minStock,
|
||
currentStock: item.receivedQuantity || 0,
|
||
imageUrl: supply.imageUrl,
|
||
createdAt: delivery.createdAt || '',
|
||
updatedAt: delivery.updatedAt || '',
|
||
shippedQuantity: item.shippedQuantity || 0,
|
||
}
|
||
})
|
||
.filter(Boolean) as Supply[]
|
||
},
|
||
[allDeliveries],
|
||
)
|
||
|
||
// V2 система уже возвращает агрегированные данные, дополнительная консолидация не нужна
|
||
const consolidatedSupplies = useMemo(() => {
|
||
// Корректируем агрегированные данные на основе истории поставок для точности
|
||
return supplies.map((supply) => {
|
||
const correctedSupply = getAggregatedSupplyData(supply)
|
||
return {
|
||
...correctedSupply,
|
||
// Переопределяем статус на основе исправленных остатков
|
||
status: correctedSupply.currentStock > 0 ? 'На складе' : 'Недоступен',
|
||
}
|
||
})
|
||
}, [supplies, getAggregatedSupplyData])
|
||
|
||
// Фильтрация и сортировка
|
||
const filteredAndSortedSupplies = useMemo(() => {
|
||
const filtered = consolidatedSupplies.filter((supply) => {
|
||
const matchesSearch =
|
||
supply.name.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||
supply.description.toLowerCase().includes(filters.search.toLowerCase())
|
||
const matchesCategory = !filters.category || supply.category === filters.category
|
||
const matchesStatus =
|
||
!filters.status ||
|
||
(filters.status === 'На складе' && supply.currentStock > 0) ||
|
||
(filters.status === 'Недоступен' && supply.currentStock === 0) ||
|
||
// Обратная совместимость
|
||
(filters.status === 'available' && supply.currentStock > 0) ||
|
||
(filters.status === 'unavailable' && supply.currentStock === 0)
|
||
const matchesSupplier =
|
||
!filters.supplier || supply.supplier.toLowerCase().includes(filters.supplier.toLowerCase())
|
||
const matchesLowStock = !filters.lowStock || (supply.currentStock <= supply.minStock && supply.currentStock > 0)
|
||
|
||
return matchesSearch && matchesCategory && matchesStatus && matchesSupplier && matchesLowStock
|
||
})
|
||
|
||
// Сортировка
|
||
filtered.sort((a, b) => {
|
||
let aValue: string | number = a[sort.field]
|
||
let bValue: string | number = b[sort.field]
|
||
|
||
if (typeof aValue === 'string') {
|
||
aValue = aValue.toLowerCase()
|
||
bValue = (bValue as string).toLowerCase()
|
||
}
|
||
|
||
if (sort.direction === 'asc') {
|
||
return aValue > bValue ? 1 : -1
|
||
} else {
|
||
return aValue < bValue ? 1 : -1
|
||
}
|
||
})
|
||
|
||
return filtered
|
||
}, [consolidatedSupplies, filters, sort])
|
||
|
||
// Группировка
|
||
const groupedSupplies = useMemo(() => {
|
||
if (groupBy === 'none') return { 'Все расходники': filteredAndSortedSupplies }
|
||
|
||
return filteredAndSortedSupplies.reduce(
|
||
(acc, supply) => {
|
||
let key: string
|
||
if (groupBy === 'status') {
|
||
key = supply.currentStock > 0 ? 'На складе' : 'Недоступен'
|
||
} else {
|
||
key = supply[groupBy] || 'Без категории'
|
||
}
|
||
if (!acc[key]) acc[key] = []
|
||
acc[key].push(supply)
|
||
return acc
|
||
},
|
||
{} as Record<string, Supply[]>,
|
||
)
|
||
}, [filteredAndSortedSupplies, groupBy])
|
||
|
||
// Обработчики
|
||
const handleSort = useCallback((field: SortState['field']) => {
|
||
setSort((prev) => ({
|
||
field,
|
||
direction: prev.field === field && prev.direction === 'asc' ? 'desc' : 'asc',
|
||
}))
|
||
}, [])
|
||
|
||
const toggleSupplyExpansion = useCallback((supplyId: string) => {
|
||
setExpandedSupplies((prev) => {
|
||
const newSet = new Set(prev)
|
||
if (newSet.has(supplyId)) {
|
||
newSet.delete(supplyId)
|
||
} else {
|
||
newSet.add(supplyId)
|
||
}
|
||
return newSet
|
||
})
|
||
}, [])
|
||
|
||
const handleExport = useCallback(() => {
|
||
const csvData = filteredAndSortedSupplies.map((supply) => ({
|
||
Название: supply.name,
|
||
Описание: supply.description,
|
||
Категория: supply.category,
|
||
Статус: getStatusConfig(supply).label,
|
||
'Текущий остаток': supply.currentStock,
|
||
'Минимальный остаток': supply.minStock,
|
||
Единица: supply.unit,
|
||
Цена: supply.price,
|
||
Поставщик: supply.supplier,
|
||
'Дата создания': new Date(supply.createdAt).toLocaleDateString('ru-RU'),
|
||
}))
|
||
|
||
const csv = [Object.keys(csvData[0]).join(','), ...csvData.map((row) => Object.values(row).join(','))].join('\n')
|
||
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
||
const link = document.createElement('a')
|
||
link.href = URL.createObjectURL(blob)
|
||
link.download = `расходники_фулфилмента_${new Date().toISOString().split('T')[0]}.csv`
|
||
link.click()
|
||
|
||
toast.success('Данные экспортированы в CSV')
|
||
}, [filteredAndSortedSupplies, getStatusConfig])
|
||
|
||
const handleRefresh = useCallback(() => {
|
||
refetch()
|
||
toast.success('Данные обновлены')
|
||
}, [refetch])
|
||
|
||
if (loading || deliveriesLoading) {
|
||
return (
|
||
<div className="h-screen flex overflow-hidden">
|
||
<Sidebar />
|
||
<main
|
||
className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}
|
||
>
|
||
<div className="flex-1 overflow-y-auto flex items-center justify-center">
|
||
<div className="text-white">Загрузка...</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="h-screen flex overflow-hidden">
|
||
<Sidebar />
|
||
<main
|
||
className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}
|
||
>
|
||
<div className="flex-1 overflow-y-auto flex items-center justify-center">
|
||
<div className="text-red-300">Ошибка загрузки: {error.message}</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="h-screen flex overflow-hidden">
|
||
<Sidebar />
|
||
<main
|
||
className={`flex-1 ${getSidebarMargin()} px-4 py-3 flex flex-col transition-all duration-300 overflow-hidden`}
|
||
>
|
||
<div className="flex-1 overflow-y-auto space-y-6">
|
||
{/* Заголовок и фильтры */}
|
||
<SuppliesHeader
|
||
viewMode={viewMode}
|
||
onViewModeChange={setViewMode}
|
||
groupBy={groupBy}
|
||
onGroupByChange={setGroupBy}
|
||
filters={filters}
|
||
onFiltersChange={setFilters}
|
||
showFilters={showFilters}
|
||
onToggleFilters={() => setShowFilters(!showFilters)}
|
||
onExport={handleExport}
|
||
onRefresh={handleRefresh}
|
||
/>
|
||
|
||
{/* Статистика */}
|
||
<SuppliesStats supplies={consolidatedSupplies} />
|
||
|
||
{/* Основной контент */}
|
||
<div className="space-y-6">
|
||
{groupBy === 'none' ? (
|
||
// Без группировки
|
||
<>
|
||
{viewMode === 'grid' && (
|
||
<SuppliesGrid
|
||
supplies={filteredAndSortedSupplies}
|
||
expandedSupplies={expandedSupplies}
|
||
onToggleExpansion={toggleSupplyExpansion}
|
||
getSupplyDeliveries={getSupplyDeliveries}
|
||
getStatusConfig={getStatusConfig}
|
||
/>
|
||
)}
|
||
|
||
{viewMode === 'list' && (
|
||
<SuppliesList
|
||
supplies={filteredAndSortedSupplies}
|
||
expandedSupplies={expandedSupplies}
|
||
onToggleExpansion={toggleSupplyExpansion}
|
||
getSupplyDeliveries={getSupplyDeliveries}
|
||
getStatusConfig={getStatusConfig}
|
||
sort={sort}
|
||
onSort={handleSort}
|
||
/>
|
||
)}
|
||
|
||
{viewMode === 'analytics' && (
|
||
<div className="text-center text-white/60 py-12">Аналитический режим будет добавлен позже</div>
|
||
)}
|
||
</>
|
||
) : (
|
||
// С группировкой
|
||
<div className="space-y-6">
|
||
{Object.entries(groupedSupplies).map(([groupName, groupSupplies]) => (
|
||
<div key={groupName} className="space-y-4">
|
||
<h3 className="text-lg font-semibold text-white flex items-center space-x-2">
|
||
<span>{groupName}</span>
|
||
<span className="text-sm text-white/60">({groupSupplies.length})</span>
|
||
</h3>
|
||
|
||
{viewMode === 'grid' && (
|
||
<SuppliesGrid
|
||
supplies={groupSupplies}
|
||
expandedSupplies={expandedSupplies}
|
||
onToggleExpansion={toggleSupplyExpansion}
|
||
getSupplyDeliveries={getSupplyDeliveries}
|
||
getStatusConfig={getStatusConfig}
|
||
/>
|
||
)}
|
||
|
||
{viewMode === 'list' && (
|
||
<SuppliesList
|
||
supplies={groupSupplies}
|
||
expandedSupplies={expandedSupplies}
|
||
onToggleExpansion={toggleSupplyExpansion}
|
||
getSupplyDeliveries={getSupplyDeliveries}
|
||
getStatusConfig={getStatusConfig}
|
||
sort={sort}
|
||
onSort={handleSort}
|
||
/>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|