Добавлены модели и функциональность для управления администраторами, включая авторизацию через JWT, запросы и мутации для получения информации об администраторах и управления пользователями. Обновлены стили и логика работы с токенами в Apollo Client. Улучшен интерфейс взаимодействия с пользователем.

This commit is contained in:
Bivekich
2025-07-19 14:53:45 +03:00
parent f24c015021
commit 6287449521
26 changed files with 3931 additions and 19 deletions

View File

@ -0,0 +1,286 @@
"use client"
import { useState, useEffect } from 'react'
import { useQuery } from '@apollo/client'
import { gql } from '@apollo/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Search, Phone, Building, Calendar, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
// 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
}
}
export function UsersSection() {
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 = data?.allUsers?.users || []
const total = data?.allUsers?.total || 0
const hasMore = data?.allUsers?.hasMore || false
const totalPages = Math.ceil(total / limit)
const getOrganizationTypeBadge = (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 = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const getInitials = (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 = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1)
}
}
const handleNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1)
}
}
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) => setSearch(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>
)
}