Первоначальная версия VidConf
This commit is contained in:
233
frontend/src/components/admin/AdminUsersTab.tsx
Normal file
233
frontend/src/components/admin/AdminUsersTab.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* Вкладка «Пользователи» админки — список с поиском, пагинацией, сменой
|
||||
* роли, блокировкой и командой (design/mockups/admin.html,
|
||||
* «table-card»/«user-cell»). Собственная учётная запись администратора
|
||||
* защищена от самоизменения на уровне UI (disabled) и backend (409 — на
|
||||
* случай гонки в двух вкладках) — но это касается только роли и блокировки:
|
||||
* смену собственной команды запрет не затрагивает, селект «Команда» для
|
||||
* своей строки не дизейблится.
|
||||
*
|
||||
* Клик по имени пользователя открывает карточку
|
||||
* профиля (`AdminUserProfileDialog`) — те же поля, что в собственном
|
||||
* профиле, плюс существующие действия роль/блокировка.
|
||||
*/
|
||||
export function AdminUsersTab() {
|
||||
const { user: currentUser } = useAuth()
|
||||
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', search, offset],
|
||||
queryFn: () => listAdminUsers({ 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="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">
|
||||
<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)} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user