Files
sfera-new/src/components/admin/users-section.tsx
Veronika Smirnova bf27f3ba29 Оптимизирована производительность React компонентов с помощью мемоизации
КРИТИЧНЫЕ КОМПОНЕНТЫ ОПТИМИЗИРОВАНЫ:
• 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>
2025-08-06 13:18:45 +03:00

297 lines
10 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, 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 }