# УПРАВЛЕНИЕ СОСТОЯНИЕМ ТАБЛИЦ SFERA ## 📊 СПЕЦИФИКА ТАБЛИЦ В РАЗДЕЛЕ "МОИ ПОСТАВКИ" Раздел "Мои поставки" в кабинете селлера использует сложные многоуровневые таблицы с уникальными требованиями к управлению состоянием. ## 🏗️ АРХИТЕКТУРА МНОГОУРОВНЕВЫХ ТАБЛИЦ ### Структура уровней: #### MultiLevelSuppliesTable (3 уровня): ``` Поставка (Supply) ├── Маршрут (Route) │ └── Товары (Items) └── Итоговые суммы и статусы ``` #### GoodsSuppliesTable (4 уровня): ``` Поставка (Supply) ├── Маршрут (Route) │ ├── Поставщик (Wholesaler) │ │ └── Товары (Products) │ └── Логистика и расценки └── Общие итоги ``` ## 🎯 ПАТТЕРНЫ УПРАВЛЕНИЯ СОСТОЯНИЕМ ### 1. Hook для управления раскрытием уровней ```typescript interface MultiLevelTableState { expandedSupplies: Record expandedRoutes: Record expandedWholesalers: Record selectedItems: Set } const useMultiLevelTableState = (initialData: T[]): UseMultiLevelTableReturn => { // Состояние раскрытых элементов const [expandedSupplies, setExpandedSupplies] = useState>({}) const [expandedRoutes, setExpandedRoutes] = useState>({}) const [expandedWholesalers, setExpandedWholesalers] = useState>({}) // Состояние выбранных элементов const [selectedItems, setSelectedItems] = useState>(new Set()) // Функции управления раскрытием const toggleSupply = useCallback((supplyId: string) => { setExpandedSupplies((prev) => ({ ...prev, [supplyId]: !prev[supplyId], })) }, []) const toggleRoute = useCallback((routeId: string) => { setExpandedRoutes((prev) => ({ ...prev, [routeId]: !prev[routeId], })) }, []) const toggleWholesaler = useCallback((wholesalerId: string) => { setExpandedWholesalers((prev) => ({ ...prev, [wholesalerId]: !prev[wholesalerId], })) }, []) // Массовые операции const expandAll = useCallback(() => { const allSupplyIds = initialData.reduce( (acc, item) => ({ ...acc, [item.id]: true, }), {}, ) setExpandedSupplies(allSupplyIds) }, [initialData]) const collapseAll = useCallback(() => { setExpandedSupplies({}) setExpandedRoutes({}) setExpandedWholesalers({}) }, []) // Управление выделением const toggleSelection = useCallback((itemId: string) => { setSelectedItems((prev) => { const newSet = new Set(prev) if (newSet.has(itemId)) { newSet.delete(itemId) } else { newSet.add(itemId) } return newSet }) }, []) const selectAll = useCallback(() => { const allIds = initialData.map((item) => item.id) setSelectedItems(new Set(allIds)) }, [initialData]) const clearSelection = useCallback(() => { setSelectedItems(new Set()) }, []) // Вычисляемые свойства const isAllExpanded = useMemo( () => Object.keys(expandedSupplies).length === initialData.length && Object.values(expandedSupplies).every(Boolean), [expandedSupplies, initialData.length], ) const isAllSelected = useMemo( () => selectedItems.size === initialData.length && initialData.length > 0, [selectedItems.size, initialData.length], ) const hasSelection = selectedItems.size > 0 return { // Состояния expandedSupplies, expandedRoutes, expandedWholesalers, selectedItems, // Действия toggleSupply, toggleRoute, toggleWholesaler, expandAll, collapseAll, toggleSelection, selectAll, clearSelection, // Вычисляемые isAllExpanded, isAllSelected, hasSelection, } } ``` ### 2. Фильтрация и сортировка ```typescript interface SupplyFilters { status: SupplyStatus | 'all' dateRange: [Date | null, Date | null] search: string creationMethod?: 'cards' | 'suppliers' | 'all' fulfillmentCenter?: string | 'all' logisticsPartner?: string | 'all' } const useSupplyFilters = (supplies: Supply[]) => { const [filters, setFilters] = useState({ status: 'all', dateRange: [null, null], search: '', creationMethod: 'all', fulfillmentCenter: 'all', logisticsPartner: 'all', }) const [sortConfig, setSortConfig] = useState<{ key: keyof Supply direction: 'asc' | 'desc' }>({ key: 'createdAt', direction: 'desc', }) // Фильтрация const filteredSupplies = useMemo(() => { return supplies.filter((supply) => { // Статус if (filters.status !== 'all' && supply.status !== filters.status) { return false } // Дата const supplyDate = new Date(supply.deliveryDate) if (filters.dateRange[0] && supplyDate < filters.dateRange[0]) { return false } if (filters.dateRange[1] && supplyDate > filters.dateRange[1]) { return false } // Поиск if (filters.search) { const searchLower = filters.search.toLowerCase() const searchableFields = [ supply.number, supply.partner?.name, supply.partner?.inn, supply.fulfillmentCenter?.name, ...supply.items.map((item) => item.product.name), ].filter(Boolean) if (!searchableFields.some((field) => field!.toLowerCase().includes(searchLower))) { return false } } // Метод создания if (filters.creationMethod !== 'all' && supply.creationMethod !== filters.creationMethod) { return false } // Фулфилмент центр if (filters.fulfillmentCenter !== 'all' && supply.fulfillmentCenterId !== filters.fulfillmentCenter) { return false } // Логистический партнер if (filters.logisticsPartner !== 'all' && supply.logisticsPartnerId !== filters.logisticsPartner) { return false } return true }) }, [supplies, filters]) // Сортировка const sortedSupplies = useMemo(() => { return [...filteredSupplies].sort((a, b) => { const aValue = a[sortConfig.key] const bValue = b[sortConfig.key] if (aValue === null || aValue === undefined) return 1 if (bValue === null || bValue === undefined) return -1 const comparison = aValue < bValue ? -1 : aValue > bValue ? 1 : 0 return sortConfig.direction === 'asc' ? comparison : -comparison }) }, [filteredSupplies, sortConfig]) const updateFilter = useCallback((key: K, value: SupplyFilters[K]) => { setFilters((prev) => ({ ...prev, [key]: value })) }, []) const resetFilters = useCallback(() => { setFilters({ status: 'all', dateRange: [null, null], search: '', creationMethod: 'all', fulfillmentCenter: 'all', logisticsPartner: 'all', }) }, []) const toggleSort = useCallback((key: keyof Supply) => { setSortConfig((prev) => ({ key, direction: prev.key === key && prev.direction === 'asc' ? 'desc' : 'asc', })) }, []) return { filters, sortConfig, filteredSupplies: sortedSupplies, updateFilter, resetFilters, toggleSort, hasActiveFilters: filters.status !== 'all' || filters.search !== '' || filters.dateRange[0] !== null || filters.dateRange[1] !== null, } } ``` ### 3. Интеграция с GraphQL ```typescript const MySuppliesPage: FC = () => { // GraphQL запросы const { data, loading, error, refetch } = useQuery(GET_MY_SUPPLIES, { fetchPolicy: 'cache-and-network', errorPolicy: 'all', }) // Подписка на real-time обновления const handleRealtimeEvent = useCallback((event: RealtimeEvent) => { switch (event.type) { case 'supply_status_changed': // Обновляем кеш Apollo apolloClient.cache.modify({ id: apolloClient.cache.identify({ __typename: 'SupplyOrder', id: event.payload.supplyId }), fields: { status: () => event.payload.newStatus } }) break case 'new_supply_created': // Перезапрашиваем список refetch() toast.success('Создана новая поставка') break case 'supply_cancelled': // Обновляем UI refetch() toast.info(`Поставка #${event.payload.number} отменена`) break } }, [refetch]) useRealtime({ onEvent: handleRealtimeEvent }) // Hooks для состояния таблицы const { expandedSupplies, expandedRoutes, toggleSupply, toggleRoute, selectedItems, toggleSelection, selectAll, clearSelection, hasSelection, } = useMultiLevelTableState(data?.supplyOrders || []) // Фильтрация и сортировка const { filters, sortConfig, filteredSupplies, updateFilter, resetFilters, toggleSort, hasActiveFilters, } = useSupplyFilters(data?.supplyOrders || []) // Массовые операции const [isBulkOperating, setIsBulkOperating] = useState(false) const handleBulkOperation = useCallback(async (operation: string) => { if (!hasSelection) return setIsBulkOperating(true) const selectedIds = Array.from(selectedItems) try { switch (operation) { case 'cancel': await apolloClient.mutate({ mutation: CANCEL_SUPPLIES, variables: { supplyIds: selectedIds } }) toast.success(`${selectedIds.length} поставок отменено`) break case 'export': const exportData = filteredSupplies .filter(supply => selectedItems.has(supply.id)) .map(supply => ({ number: supply.number, status: supply.status, partner: supply.partner.name, deliveryDate: supply.deliveryDate, totalAmount: supply.totalAmount, })) downloadAsExcel(exportData, 'supplies-export') toast.success('Данные экспортированы') break case 'print': const printIds = selectedIds.join(',') window.open(`/print/supplies?ids=${printIds}`, '_blank') break } clearSelection() refetch() } catch (error) { toast.error('Ошибка при выполнении операции') console.error(error) } finally { setIsBulkOperating(false) } }, [selectedItems, hasSelection, filteredSupplies, clearSelection, refetch]) return (
{/* Заголовок и действия */}

Мои поставки

{/* Панель фильтров */} {/* Панель массовых операций */} {hasSelection && ( )} {/* Таблица */} {loading && !data ? ( ) : error ? ( ) : filteredSupplies.length === 0 ? ( Сбросить фильтры ) : ( )} /> ) : ( )}
) } ``` ## 🚀 ОПТИМИЗАЦИЯ ПРОИЗВОДИТЕЛЬНОСТИ ### 1. Виртуализация для больших списков ```typescript import { VariableSizeList } from 'react-window' const VirtualizedSuppliesTable: FC = ({ supplies, expandedSupplies, onToggleSupply, }) => { const listRef = useRef(null) // Кеш высот строк const rowHeights = useRef>({}) // Базовые высоты const SUPPLY_ROW_HEIGHT = 64 const ROUTE_ROW_HEIGHT = 56 const ITEM_ROW_HEIGHT = 48 // Вычисление высоты строки с учетом раскрытия const getItemSize = useCallback((index: number) => { const supply = supplies[index] const supplyId = supply.id // Если есть кешированная высота, используем её if (rowHeights.current[supplyId]) { return rowHeights.current[supplyId] } let height = SUPPLY_ROW_HEIGHT if (expandedSupplies[supplyId]) { // Добавляем высоту маршрутов height += supply.routes.length * ROUTE_ROW_HEIGHT // Добавляем высоту товаров для раскрытых маршрутов supply.routes.forEach(route => { if (expandedRoutes[route.id]) { height += route.items.length * ITEM_ROW_HEIGHT } }) } // Кешируем высоту rowHeights.current[supplyId] = height return height }, [supplies, expandedSupplies, expandedRoutes]) // Сброс кеша при изменении состояния раскрытия useEffect(() => { rowHeights.current = {} listRef.current?.resetAfterIndex(0) }, [expandedSupplies, expandedRoutes]) const Row = ({ index, style }) => { const supply = supplies[index] return (
onToggleSupply(supply.id)} renderRoutes={() => ( expandedSupplies[supply.id] && supply.routes.map(route => ( onToggleRoute(route.id)} /> )) )} />
) } return ( {Row} ) } ``` ### 2. Дебаунс для поиска ```typescript const useDebounce = (value: T, delay: number): T => { const [debouncedValue, setDebouncedValue] = useState(value) useEffect(() => { const timer = setTimeout(() => { setDebouncedValue(value) }, delay) return () => { clearTimeout(timer) } }, [value, delay]) return debouncedValue } // Использование в компоненте фильтров const SearchFilter: FC<{ onSearch: (value: string) => void }> = ({ onSearch }) => { const [searchTerm, setSearchTerm] = useState('') const debouncedSearch = useDebounce(searchTerm, 300) useEffect(() => { onSearch(debouncedSearch) }, [debouncedSearch, onSearch]) return ( setSearchTerm(e.target.value)} className="w-full max-w-sm" /> ) } ``` ### 3. Мемоизация тяжелых вычислений ```typescript const useSupplyStatistics = (supplies: Supply[]) => { // Статистика по статусам const statusStats = useMemo(() => { return supplies.reduce( (acc, supply) => { acc[supply.status] = (acc[supply.status] || 0) + 1 return acc }, {} as Record, ) }, [supplies]) // Суммарные показатели const totals = useMemo(() => { return supplies.reduce( (acc, supply) => { acc.totalAmount += supply.totalAmount acc.totalItems += supply.totalItems acc.totalRoutes += supply.routes.length return acc }, { totalAmount: 0, totalItems: 0, totalRoutes: 0, }, ) }, [supplies]) // Статистика по периодам const periodStats = useMemo(() => { const now = new Date() const stats = { today: 0, thisWeek: 0, thisMonth: 0, overdue: 0, } supplies.forEach((supply) => { const deliveryDate = new Date(supply.deliveryDate) const diffTime = deliveryDate.getTime() - now.getTime() const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) if (diffDays < 0 && supply.status !== 'completed') { stats.overdue++ } else if (diffDays === 0) { stats.today++ } else if (diffDays <= 7) { stats.thisWeek++ } else if (diffDays <= 30) { stats.thisMonth++ } }) return stats }, [supplies]) return { statusStats, totals, periodStats, } } ``` ## 🎨 ВИЗУАЛЬНЫЕ ИНДИКАТОРЫ СОСТОЯНИЯ ### Цветовая схема статусов ```typescript const STATUS_CONFIG = { new: { label: 'Новая', color: 'bg-blue-100 text-blue-700', icon: Package, }, confirmed: { label: 'Подтверждена', color: 'bg-green-100 text-green-700', icon: CheckCircle, }, in_transit: { label: 'В пути', color: 'bg-purple-100 text-purple-700', icon: Truck, }, at_fulfillment: { label: 'На фулфилменте', color: 'bg-orange-100 text-orange-700', icon: Warehouse, }, in_processing: { label: 'В обработке', color: 'bg-yellow-100 text-yellow-700', icon: Clock, }, completed: { label: 'Завершена', color: 'bg-gray-100 text-gray-700', icon: CheckSquare, }, cancelled: { label: 'Отменена', color: 'bg-red-100 text-red-700', icon: XCircle, }, issue: { label: 'Проблема', color: 'bg-red-100 text-red-700', icon: AlertTriangle, }, } as const const StatusBadge: FC<{ status: SupplyStatus }> = ({ status }) => { const config = STATUS_CONFIG[status] const Icon = config.icon return ( {config.label} ) } ``` ### Анимации раскрытия ```typescript import { motion, AnimatePresence } from 'framer-motion' const ExpandableRow: FC = ({ children, expanded, level = 0, }) => { return ( {expanded && ( {children} )} ) } ``` --- _Документ описывает специфику управления состоянием таблиц в разделе "Мои поставки"_ _Версия: 2025-08-21_ _Основа: React Hooks + Apollo Client + TypeScript + Performance Optimization_