Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View 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)} />}
</>
)
}