Конференции открывались на «Все» — неинформативная сборная вкладка вместо актуальных «Запланированные». Заодно перенесена вкладка «Все» в конец списка фильтров (после «Завершённые»), чтобы порядок шёл от актуального к общему. Пользователи открывались на «Все» вместо «Активные» — админ по умолчанию видел вперемешку с заблокированными.
263 lines
11 KiB
TypeScript
263 lines
11 KiB
TypeScript
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'>('active')
|
||
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)} />}
|
||
</>
|
||
)
|
||
}
|