
КРИТИЧНЫЕ КОМПОНЕНТЫ ОПТИМИЗИРОВАНЫ: • 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>
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
'use client'
|
||
|
||
import { useQuery, gql } from '@apollo/client'
|
||
import { Search, Phone, Building, Calendar, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||
|
||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Card, CardContent } from '@/components/ui/card'
|
||
import { Input } from '@/components/ui/input'
|
||
|
||
// GraphQL запрос для получения пользователей
|
||
const ALL_USERS = gql`
|
||
query AllUsers($search: String, $limit: Int, $offset: Int) {
|
||
allUsers(search: $search, limit: $limit, offset: $offset) {
|
||
users {
|
||
id
|
||
phone
|
||
managerName
|
||
avatar
|
||
createdAt
|
||
updatedAt
|
||
organization {
|
||
id
|
||
inn
|
||
name
|
||
fullName
|
||
type
|
||
status
|
||
createdAt
|
||
}
|
||
}
|
||
total
|
||
hasMore
|
||
}
|
||
}
|
||
`
|
||
|
||
interface User {
|
||
id: string
|
||
phone: string
|
||
managerName?: string
|
||
avatar?: string
|
||
createdAt: string
|
||
updatedAt: string
|
||
organization?: {
|
||
id: string
|
||
inn: string
|
||
name?: string
|
||
fullName?: string
|
||
type: string
|
||
status?: string
|
||
createdAt: string
|
||
}
|
||
}
|
||
|
||
const UsersSection = React.memo(() => {
|
||
const [search, setSearch] = useState('')
|
||
const [currentPage, setCurrentPage] = useState(1)
|
||
const [searchQuery, setSearchQuery] = useState('')
|
||
const limit = 20
|
||
|
||
const { data, loading, error, refetch } = useQuery(ALL_USERS, {
|
||
variables: {
|
||
search: searchQuery || undefined,
|
||
limit,
|
||
offset: (currentPage - 1) * limit,
|
||
},
|
||
fetchPolicy: 'cache-and-network',
|
||
})
|
||
|
||
// Обновляем запрос при изменении поиска с дебаунсом
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
setSearchQuery(search)
|
||
setCurrentPage(1) // Сбрасываем на первую страницу при поиске
|
||
}, 500)
|
||
|
||
return () => clearTimeout(timer)
|
||
}, [search])
|
||
|
||
const users = useMemo(() => data?.allUsers?.users || [], [data?.allUsers?.users])
|
||
const total = useMemo(() => data?.allUsers?.total || 0, [data?.allUsers?.total])
|
||
const _hasMore = useMemo(() => data?.allUsers?.hasMore || false, [data?.allUsers?.hasMore])
|
||
const totalPages = useMemo(() => Math.ceil(total / limit), [total, limit])
|
||
|
||
const getOrganizationTypeBadge = useCallback((type: string) => {
|
||
const typeMap = {
|
||
FULFILLMENT: { label: 'Фулфилмент', variant: 'default' as const },
|
||
SELLER: { label: 'Селлер', variant: 'secondary' as const },
|
||
LOGIST: { label: 'Логистика', variant: 'outline' as const },
|
||
WHOLESALE: { label: 'Поставщик', variant: 'destructive' as const },
|
||
}
|
||
return typeMap[type as keyof typeof typeMap] || { label: type, variant: 'outline' as const }
|
||
}, [])
|
||
|
||
const formatDate = useCallback((dateString: string) => {
|
||
try {
|
||
const date = new Date(dateString)
|
||
if (isNaN(date.getTime())) {
|
||
return 'Неизвестно'
|
||
}
|
||
return date.toLocaleDateString('ru-RU', {
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
} catch {
|
||
return 'Неизвестно'
|
||
}
|
||
}, [])
|
||
|
||
const getInitials = useCallback((name?: string, phone?: string) => {
|
||
if (name) {
|
||
return name
|
||
.split(' ')
|
||
.map((n) => n[0])
|
||
.join('')
|
||
.toUpperCase()
|
||
.slice(0, 2)
|
||
}
|
||
if (phone) {
|
||
return phone.slice(-2)
|
||
}
|
||
return 'У'
|
||
}, [])
|
||
|
||
const handlePrevPage = useCallback(() => {
|
||
if (currentPage > 1) {
|
||
setCurrentPage(currentPage - 1)
|
||
}
|
||
}, [currentPage])
|
||
|
||
const handleNextPage = useCallback(() => {
|
||
if (currentPage < totalPages) {
|
||
setCurrentPage(currentPage + 1)
|
||
}
|
||
}, [currentPage, totalPages])
|
||
|
||
const handleSearchChange = useCallback((value: string) => {
|
||
setSearch(value)
|
||
}, [])
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="p-8">
|
||
<div className="glass-card p-6 text-center">
|
||
<p className="text-red-400">Ошибка загрузки пользователей: {error.message}</p>
|
||
<Button onClick={() => refetch()} className="mt-4 glass-button">
|
||
Попробовать снова
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="p-8">
|
||
<div className="mb-8">
|
||
<h1 className="text-3xl font-bold text-white mb-2">Пользователи</h1>
|
||
<p className="text-white/70">Управление пользователями системы</p>
|
||
</div>
|
||
|
||
{/* Поиск и статистика */}
|
||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||
<div className="flex-1 relative">
|
||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-white/50 h-4 w-4" />
|
||
<Input
|
||
type="text"
|
||
placeholder="Поиск по телефону, имени, ИНН организации..."
|
||
value={search}
|
||
onChange={(e) => handleSearchChange(e.target.value)}
|
||
className="pl-10 glass-input text-white placeholder:text-white/50"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center space-x-4 text-white/70 text-sm">
|
||
<span>Всего: {total}</span>
|
||
{searchQuery && <span>Найдено: {users.length}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Список пользователей */}
|
||
{loading ? (
|
||
<div className="glass-card p-8 text-center">
|
||
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4 text-white/70" />
|
||
<p className="text-white/70">Загрузка пользователей...</p>
|
||
</div>
|
||
) : users.length === 0 ? (
|
||
<div className="glass-card p-8 text-center">
|
||
<p className="text-white/70">{searchQuery ? 'Пользователи не найдены' : 'Пользователи отсутствуют'}</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{users.map((user: User) => (
|
||
<Card key={user.id} className="glass-card border-white/10 hover:border-white/20 transition-all">
|
||
<CardContent className="p-6">
|
||
<div className="flex items-start space-x-4">
|
||
{/* Аватар */}
|
||
<Avatar className="h-12 w-12 bg-white/20">
|
||
<AvatarImage src={user.avatar} />
|
||
<AvatarFallback className="bg-white/20 text-white text-sm font-semibold">
|
||
{getInitials(user.managerName, user.phone)}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
|
||
{/* Основная информация */}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-start justify-between mb-2">
|
||
<div>
|
||
<h3 className="text-white font-medium">{user.managerName || 'Без имени'}</h3>
|
||
<div className="flex items-center text-white/70 text-sm mt-1">
|
||
<Phone className="h-3 w-3 mr-1" />
|
||
{user.phone}
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="flex items-center text-white/70 text-sm">
|
||
<Calendar className="h-3 w-3 mr-1" />
|
||
{formatDate(user.createdAt)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Организация */}
|
||
{user.organization && (
|
||
<div className="bg-white/5 rounded-lg p-3 mt-3">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center space-x-2 mb-1">
|
||
<Building className="h-4 w-4 text-white/70" />
|
||
<span className="text-white font-medium truncate">
|
||
{user.organization.name || user.organization.fullName || 'Без названия'}
|
||
</span>
|
||
<Badge {...getOrganizationTypeBadge(user.organization.type)}>
|
||
{getOrganizationTypeBadge(user.organization.type).label}
|
||
</Badge>
|
||
</div>
|
||
<p className="text-white/70 text-sm">ИНН: {user.organization.inn}</p>
|
||
{user.organization.status && (
|
||
<p className="text-white/60 text-xs">Статус: {user.organization.status}</p>
|
||
)}
|
||
</div>
|
||
<div className="text-right">
|
||
<p className="text-white/60 text-xs">
|
||
Зарегистрировано: {formatDate(user.organization.createdAt)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Пагинация */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-between mt-8 pt-6 border-t border-white/10">
|
||
<div className="text-white/70 text-sm">
|
||
Страница {currentPage} из {totalPages}
|
||
</div>
|
||
<div className="flex space-x-2">
|
||
<Button
|
||
onClick={handlePrevPage}
|
||
disabled={currentPage === 1}
|
||
variant="ghost"
|
||
className="glass-button text-white disabled:opacity-50"
|
||
>
|
||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||
Назад
|
||
</Button>
|
||
<Button
|
||
onClick={handleNextPage}
|
||
disabled={currentPage === totalPages}
|
||
variant="ghost"
|
||
className="glass-button text-white disabled:opacity-50"
|
||
>
|
||
Вперед
|
||
<ChevronRight className="h-4 w-4 ml-1" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})
|
||
|
||
UsersSection.displayName = 'UsersSection'
|
||
|
||
export { UsersSection }
|