Files
vidconf/frontend/src/components/admin/AdminUsersTab.tsx
Max Ronzhin c4721fcf99 feat(admin): поиск пользователей по названию команды
AdminUserRepository.list_paginated ищет теперь и по названию команды
(LEFT JOIN teams, как и раньше) — пользователи без команды по-прежнему
не пропадают из общей выдачи, просто не совпадают с этой частью поиска.
Плейсхолдер поля поиска в админке обновлён под новое поведение.
2026-07-27 21:04:26 +03:00

263 lines
11 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.
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Ban, CheckCircle2, Search, UserPlus } from 'lucide-react'
import { listAdminTeams, listAdminUsers, updateAdminUser, type AdminUserOut } from '@/api/admin'
import { ApiError } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { AdminUserCreateDialog } from '@/components/admin/AdminUserCreateDialog'
import { AdminUserProfileDialog } from '@/components/admin/AdminUserProfileDialog'
import { Avatar } from '@/components/ui/Avatar'
import { useToast } from '@/components/ui/ToastProvider'
const PAGE_SIZE = 10
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте. */
const TEAMS_LIMIT = 200
const STATUS_FILTERS: { value: 'active' | 'blocked' | 'all'; label: string }[] = [
{ value: 'active', label: 'Активные' },
{ value: 'blocked', label: 'Заблокированные' },
{ value: 'all', label: 'Все' },
]
/**
* Вкладка «Пользователи» админки — список с поиском, пагинацией, сменой
* роли, блокировкой и командой (design/mockups/admin.html,
* «table-card»/«user-cell»). Собственная учётная запись администратора
* защищена от самоизменения на уровне UI (disabled) и backend (409 — на
* случай гонки в двух вкладках) — но это касается только роли и блокировки:
* смену собственной команды запрет не затрагивает, селект «Команда» для
* своей строки не дизейблится.
*
* Клик по имени пользователя открывает карточку
* профиля (`AdminUserProfileDialog`) — те же поля, что в собственном
* профиле, плюс существующие действия роль/блокировка.
*/
export function AdminUsersTab() {
const { user: currentUser } = useAuth()
const [statusFilter, setStatusFilter] = useState<'active' | 'blocked' | 'all'>('all')
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0)
const [viewingUserId, setViewingUserId] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const queryClient = useQueryClient()
const toast = useToast()
useEffect(() => {
const timer = setTimeout(() => {
setSearch(searchInput.trim())
setOffset(0)
}, 300)
return () => clearTimeout(timer)
}, [searchInput])
const { data, isLoading } = useQuery({
queryKey: ['admin', 'users', statusFilter, search, offset],
queryFn: () =>
listAdminUsers({
status: statusFilter === 'all' ? undefined : statusFilter,
q: search || undefined,
limit: PAGE_SIZE,
offset,
}),
})
const { data: teamsData } = useQuery({
queryKey: ['admin', 'teams', 'all'],
queryFn: () => listAdminTeams({ limit: TEAMS_LIMIT, offset: 0 }),
})
const teams = teamsData?.items ?? []
const mutation = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: Parameters<typeof updateAdminUser>[1] }) => updateAdminUser(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
toast.show('Нельзя изменить собственную учётную запись', 'error')
} else {
toast.show('Не удалось сохранить изменения', 'error')
}
},
})
function handleRoleChange(target: AdminUserOut, role: 'admin' | 'user') {
if (role === target.role) return
mutation.mutate({ id: target.id, payload: { role } })
}
function handleToggleBlock(target: AdminUserOut) {
mutation.mutate({ id: target.id, payload: { is_blocked: !target.is_blocked } })
}
function handleTeamChange(target: AdminUserOut, teamId: string) {
const nextTeamId = teamId === '' ? null : teamId
if (nextTeamId === target.team_id) return
mutation.mutate({ id: target.id, payload: { team_id: nextTeamId } })
}
const items = data?.items ?? []
const total = data?.total ?? 0
return (
<>
<div className="section-tabs-inline">
{STATUS_FILTERS.map((f) => (
<button
key={f.value}
type="button"
className={`chip${statusFilter === f.value ? ' is-active' : ''}`}
onClick={() => {
setStatusFilter(f.value)
setOffset(0)
}}
>
{f.label}
</button>
))}
</div>
<div className="toolbar-row">
<div className="toolbar-left">
<div className="search-wrap">
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
<input
type="text"
placeholder="Поиск по имени, email или команде…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</div>
<button type="button" className="btn btn-primary" onClick={() => setCreating(true)}>
<UserPlus style={{ width: 16, height: 16 }} aria-hidden="true" />
Добавить пользователя
</button>
</div>
<span className="toolbar-count">
{isLoading ? 'Загрузка…' : `Показано ${items.length ? offset + 1 : 0}${offset + items.length} из ${total}`}
</span>
</div>
<div className="table-card users-table">
<table>
<thead>
<tr>
<th>Пользователь</th>
<th>Роль</th>
<th>Команда</th>
<th>Регистрация</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody>
{items.length === 0 && !isLoading && (
<tr>
<td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-ink-500)' }}>
Пользователи не найдены
</td>
</tr>
)}
{items.map((u) => {
const isSelf = u.id === currentUser?.id
return (
<tr key={u.id}>
<td>
<div className="user-cell">
<Avatar name={u.name_user} avatarUrl={u.avatar_url} size={32} />
<div>
<button type="button" className="user-cell-name" onClick={() => setViewingUserId(u.id)}>
{u.name_user}
</button>
<span>{u.email}</span>
</div>
</div>
</td>
<td>
<select
value={u.role}
disabled={isSelf || mutation.isPending}
onChange={(e) => handleRoleChange(u, e.target.value as 'admin' | 'user')}
aria-label="Роль пользователя"
>
<option value="user">Пользователь</option>
<option value="admin">Администратор</option>
</select>
</td>
<td>
<select
value={u.team_id ?? ''}
disabled={mutation.isPending}
onChange={(e) => handleTeamChange(u, e.target.value)}
aria-label="Команда пользователя"
>
<option value="">Без команды</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
</td>
<td>{new Date(u.created_at).toLocaleDateString('ru-RU')}</td>
<td>
{u.is_blocked ? (
<span className="badge badge-busy">
<Ban style={{ width: 12, height: 12 }} aria-hidden="true" />
Заблокирован
</span>
) : (
<span className="badge badge-free">
<CheckCircle2 style={{ width: 12, height: 12 }} aria-hidden="true" />
Активен
</span>
)}
</td>
<td>
<div className="row-actions">
<button
type="button"
className={`icon-btn${u.is_blocked ? '' : ' danger'}`}
aria-label={u.is_blocked ? 'Разблокировать' : 'Заблокировать'}
disabled={isSelf || mutation.isPending}
title={isSelf ? 'Нельзя изменить собственную учётную запись' : undefined}
onClick={() => handleToggleBlock(u)}
>
{u.is_blocked ? (
<CheckCircle2 style={{ width: 16, height: 16 }} aria-hidden="true" />
) : (
<Ban style={{ width: 16, height: 16 }} aria-hidden="true" />
)}
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div className="pagination-row">
<button type="button" className="btn btn-secondary" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>
Назад
</button>
<button
type="button"
className="btn btn-secondary"
disabled={offset + PAGE_SIZE >= total}
onClick={() => setOffset(offset + PAGE_SIZE)}
>
Далее
</button>
</div>
)}
{viewingUserId && <AdminUserProfileDialog userId={viewingUserId} onClose={() => setViewingUserId(null)} />}
{creating && <AdminUserCreateDialog onClose={() => setCreating(false)} />}
</>
)
}