Files
sfera-new/src/components/fulfillment-warehouse/fulfillment-supplies-page.tsx
Veronika Smirnova b405daa1be fix(fulfillment): исправить группировку поставок и синхронизацию статистики
- Исправить группировку в истории поставок - теперь 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>
2025-08-27 12:26:16 +03:00

415 lines
15 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 { 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>
)
}