Первоначальная версия 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,450 @@
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, Clock, Lock, Mail, Pencil, Play, Repeat, Search, Send, Trash2, X } from 'lucide-react'
import {
deleteAdminConference,
listAdminConferences,
sendConferenceInvitations,
updateAdminConference,
type AdminConferenceOut,
type AdminConferenceUpdatePayload,
} from '@/api/admin'
import type { ConferenceStatus, SummaryRecipientsMode } from '@/api/conferences'
import { ApiError } from '@/api/client'
import { useToast } from '@/components/ui/ToastProvider'
import { formatLocalDateTime, localDateTimeToIso, toLocalDateTimeParts } from '@/lib/localTime'
import { formatRecurrenceSummary } from '@/lib/recurrenceFormat'
const PAGE_SIZE = 10
const STATUS_FILTERS: { value: ConferenceStatus | 'all'; label: string }[] = [
{ value: 'all', label: 'Все' },
{ value: 'scheduled', label: 'Запланированные' },
{ value: 'active', label: 'Активные' },
{ value: 'ended', label: 'Завершённые' },
]
function StatusBadge({ conference }: { conference: AdminConferenceOut }) {
if (conference.status === 'active') {
return (
<span className="badge badge-free">
<Play style={{ width: 12, height: 12 }} aria-hidden="true" />
Идёт сейчас
</span>
)
}
if (conference.status === 'ended') {
return (
<span className="badge badge-locked">
<Check style={{ width: 12, height: 12 }} aria-hidden="true" />
Завершена
</span>
)
}
return (
<span className="badge badge-booked">
<Clock style={{ width: 12, height: 12 }} aria-hidden="true" />
Запланирована
</span>
)
}
/** Форма быстрого редактирования конференции из админки (title/время/пароль/рассылка). */
function EditConferenceModal({ conference, onClose }: { conference: AdminConferenceOut; onClose: () => void }) {
const queryClient = useQueryClient()
const toast = useToast()
const initialSlot = conference.scheduled_at ? toLocalDateTimeParts(conference.scheduled_at) : { date: '', time: '' }
const [title, setTitle] = useState(conference.title ?? '')
const [dateStr, setDateStr] = useState(initialSlot.date)
const [timeStr, setTimeStr] = useState(initialSlot.time)
const [durationMinutes, setDurationMinutes] = useState(conference.duration_minutes ?? 30)
const [isClosed, setIsClosed] = useState(conference.is_closed)
const [password, setPassword] = useState('')
const [summaryRecipients, setSummaryRecipients] = useState<'' | SummaryRecipientsMode>(
conference.summary_recipients ?? '',
)
const mutation = useMutation({
mutationFn: (payload: AdminConferenceUpdatePayload) => updateAdminConference(conference.id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'conferences'] })
toast.show('Изменения сохранены', 'success')
onClose()
},
onError: () => toast.show('Не удалось сохранить изменения', 'error'),
})
function handleSubmit() {
const payload: AdminConferenceUpdatePayload = {
title: title.trim(),
duration_minutes: durationMinutes,
is_closed: isClosed,
summary_recipients: summaryRecipients === '' ? null : summaryRecipients,
}
if (dateStr && timeStr) payload.scheduled_at = localDateTimeToIso(dateStr, timeStr)
if (isClosed && password) payload.password = password
mutation.mutate(payload)
}
return (
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={onClose}>
<div className="modal-panel is-wide" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2>Редактирование конференции</h2>
<button type="button" className="modal-close" aria-label="Закрыть" onClick={onClose}>
<X style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
{conference.is_pinned && conference.recurrence && (
<p className="field-hint" style={{ marginBottom: 'var(--space-4)' }}>
<Repeat style={{ width: 14, height: 14, verticalAlign: '-2px' }} aria-hidden="true" /> Закреплена:{' '}
{formatRecurrenceSummary(conference.recurrence)} повторение редактируется владельцем в календаре.
</p>
)}
<form
onSubmit={(e) => {
e.preventDefault()
handleSubmit()
}}
>
<div className="field">
<label htmlFor="admin-conf-title">Название</label>
<input id="admin-conf-title" type="text" value={title} onChange={(e) => setTitle(e.target.value)} maxLength={255} />
</div>
{!conference.is_pinned && (
<div className="field-row">
<div className="field">
<label htmlFor="admin-conf-date">Дата</label>
<input id="admin-conf-date" type="date" value={dateStr} onChange={(e) => setDateStr(e.target.value)} />
</div>
<div className="field">
<label htmlFor="admin-conf-time">Время</label>
<input id="admin-conf-time" type="time" value={timeStr} onChange={(e) => setTimeStr(e.target.value)} />
</div>
</div>
)}
<div className="field">
<label htmlFor="admin-conf-duration">Длительность, мин</label>
<input
id="admin-conf-duration"
type="number"
min={5}
step={5}
value={durationMinutes}
onChange={(e) => setDurationMinutes(Number(e.target.value) || 0)}
/>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Закрытая по паролю</strong>
<span>Вход только по паролю, включая гостей</span>
</div>
<label className="switch">
<input type="checkbox" checked={isClosed} onChange={(e) => setIsClosed(e.target.checked)} />
<span className="slider" />
</label>
</div>
{isClosed && (
<div className="field">
<label htmlFor="admin-conf-password">
<Lock style={{ width: 14, height: 14 }} aria-hidden="true" />
Новый пароль (оставьте пустым, чтобы не менять)
</label>
<input
id="admin-conf-password"
type="password"
placeholder="Минимум 4 символа"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
)}
<div className="field">
<label htmlFor="admin-conf-recipients">Рассылка саммари</label>
<select
id="admin-conf-recipients"
value={summaryRecipients}
onChange={(e) => setSummaryRecipients(e.target.value as '' | SummaryRecipientsMode)}
>
<option value="">По умолчанию (настройки инстанса)</option>
<option value="all">Всем участникам</option>
<option value="owner">Только организатору</option>
</select>
</div>
<div className="modal-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Отмена
</button>
<button type="submit" className="btn btn-primary" disabled={mutation.isPending}>
{mutation.isPending ? 'Сохраняем…' : 'Сохранить'}
</button>
</div>
</form>
</div>
</div>
)
}
/** Модалка ручной рассылки .ics-приглашений по конкретной конференции. */
function InviteModal({ conference, onClose }: { conference: AdminConferenceOut; onClose: () => void }) {
const [emailsText, setEmailsText] = useState('')
const toast = useToast()
const mutation = useMutation({
mutationFn: () => {
const emails = emailsText
.split(/[\n,;]+/)
.map((e) => e.trim())
.filter(Boolean)
return sendConferenceInvitations(conference.id, emails.length > 0 ? emails : undefined)
},
onSuccess: () => {
toast.show('Рассылка приглашений поставлена в очередь', 'success')
onClose()
},
onError: () => toast.show('Не удалось поставить рассылку', 'error'),
})
return (
<div className="modal-overlay" role="dialog" aria-modal="true" onClick={onClose}>
<div className="modal-panel is-wide" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2>Рассылка приглашений</h2>
<button type="button" className="modal-close" aria-label="Закрыть" onClick={onClose}>
<X style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
<p className="field-hint" style={{ marginBottom: 'var(--space-4)' }}>
«{conference.title ?? conference.number}» оставьте поле пустым, чтобы разослать по умолчанию (владельцу и
участникам прошлых сеансов).
</p>
<div className="field">
<label htmlFor="admin-invite-emails">Адреса (через запятую или с новой строки)</label>
<textarea
id="admin-invite-emails"
rows={5}
placeholder="i.sokolov@company.ru&#10;o.panina@company.ru"
value={emailsText}
onChange={(e) => setEmailsText(e.target.value)}
/>
</div>
<div className="modal-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Отмена
</button>
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={() => mutation.mutate()}>
<Send style={{ width: 16, height: 16 }} aria-hidden="true" />
{mutation.isPending ? 'Отправляем…' : 'Отправить'}
</button>
</div>
</div>
</div>
)
}
/**
* Вкладка «Конференции» админки — список запланированных/закреплённых
* конференций с поиском, фильтром по статусу, пагинацией, редактированием,
* удалением и ручной рассылкой приглашений (design/mockups/admin.html,
* блок «table-card», адаптированный под сущность «конференция»).
*/
export function AdminConferencesTab() {
const [statusFilter, setStatusFilter] = useState<ConferenceStatus | 'all'>('all')
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0)
const [editingConference, setEditingConference] = useState<AdminConferenceOut | null>(null)
const [invitingConference, setInvitingConference] = useState<AdminConferenceOut | null>(null)
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
const queryClient = useQueryClient()
const toast = useToast()
// Дебаунс поискового ввода — не дёргаем API на каждое нажатие клавиши.
useEffect(() => {
const timer = setTimeout(() => {
setSearch(searchInput.trim())
setOffset(0)
}, 300)
return () => clearTimeout(timer)
}, [searchInput])
const { data, isLoading } = useQuery({
queryKey: ['admin', 'conferences', statusFilter, search, offset],
queryFn: () =>
listAdminConferences({
status: statusFilter === 'all' ? undefined : statusFilter,
q: search || undefined,
limit: PAGE_SIZE,
offset,
}),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminConference(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'conferences'] })
toast.show('Конференция удалена', 'success')
setConfirmingDeleteId(null)
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
toast.show('Нельзя удалить — конференция сейчас идёт', 'error')
} else {
toast.show('Не удалось удалить конференцию', 'error')
}
setConfirmingDeleteId(null)
},
})
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="search-wrap">
<Search className="icon" style={{ width: 16, height: 16 }} aria-hidden="true" />
<input
type="text"
placeholder="Поиск по названию или номеру…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</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((conf) => (
<tr key={conf.id}>
<td>
{conf.is_closed && <Lock style={{ width: 14, height: 14, marginRight: 6, verticalAlign: '-2px' }} aria-hidden="true" />}
{conf.title ?? 'Без названия'}
<span style={{ display: 'block', font: 'var(--text-caption)', color: 'var(--color-ink-500)' }}>
{conf.number}
</span>
</td>
<td>
{conf.owner_name ?? '—'}
{conf.owner_email && (
<span style={{ display: 'block', font: 'var(--text-caption)', color: 'var(--color-ink-500)' }}>
{conf.owner_email}
</span>
)}
</td>
<td>
<StatusBadge conference={conf} />
</td>
<td>
{conf.is_pinned ? (
<span className="badge badge-locked">
<Repeat style={{ width: 12, height: 12 }} aria-hidden="true" />
Закреплена
</span>
) : (
'Разовая'
)}
</td>
<td>{conf.next_occurrence ? formatLocalDateTime(conf.next_occurrence) : conf.scheduled_at ? formatLocalDateTime(conf.scheduled_at) : '—'}</td>
<td>
{confirmingDeleteId === conf.id ? (
<div className="row-actions">
<button
type="button"
className="icon-btn danger"
aria-label="Подтвердить удаление"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(conf.id)}
>
<Check style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<button type="button" className="icon-btn" aria-label="Отмена" onClick={() => setConfirmingDeleteId(null)}>
<X style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
) : (
<div className="row-actions">
<button type="button" className="icon-btn" aria-label="Рассылка приглашений" onClick={() => setInvitingConference(conf)}>
<Mail style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<button type="button" className="icon-btn" aria-label="Редактировать" onClick={() => setEditingConference(conf)}>
<Pencil style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<button type="button" className="icon-btn danger" aria-label="Удалить" onClick={() => setConfirmingDeleteId(conf.id)}>
<Trash2 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>
)}
{editingConference && <EditConferenceModal conference={editingConference} onClose={() => setEditingConference(null)} />}
{invitingConference && <InviteModal conference={invitingConference} onClose={() => setInvitingConference(null)} />}
</>
)
}

View File

@@ -0,0 +1,278 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react'
import { getAdminSettings, updateAdminSettings, type AiLevel, type SettingsOut, type SettingsUpdateIn } from '@/api/admin'
import type { SummaryRecipientsMode } from '@/api/conferences'
import { ApiError, errorDetail } from '@/api/client'
import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider'
const SUMMARY_RECIPIENTS_OPTIONS = [
{ value: 'all', label: 'Всем участникам' },
{ value: 'owner', label: 'Только организатору' },
]
const AI_LEVEL_LABEL: Record<AiLevel, string> = {
min: 'Минимальный (CPU, faster-whisper small + Qwen2.5-3B)',
medium: 'Средний',
max: 'Максимальный',
}
/**
* Вкладка «Настройки» админки — раздела нет в макете `admin.html`, построена
* по паттерну того же макета (`section-tab`/`table-card` → карточки-секции
* с `toggle-row`/radio).
* Тумблер «Транскрибация и суммаризация» — единый переключатель AI-модуля
* (см. `SettingsOut.transcription_enabled`).
*
* Форма (`AdminSettingsForm`) вынесена отдельно и монтируется только после
* загрузки `data` — локальное состояние инициализируется прямо из пропсов
* при монтировании, без `useEffect`-синхронизации (react-hooks/set-state-in-effect).
*/
export function AdminSettingsTab() {
const { data, isLoading } = useQuery({ queryKey: ['admin', 'settings'], queryFn: getAdminSettings })
if (isLoading || !data) {
return <p className="field-hint">Загрузка настроек</p>
}
return <AdminSettingsForm data={data} />
}
function AdminSettingsForm({ data }: { data: SettingsOut }) {
const queryClient = useQueryClient()
const toast = useToast()
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level)
const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients)
const [timezone, setTimezone] = useState(data.display_timezone)
const [teamChoiceEnabled, setTeamChoiceEnabled] = useState(data.registration_team_choice)
const [domainVerificationEnabled, setDomainVerificationEnabled] = useState(data.registration_email_domain_enabled)
const [emailDomain, setEmailDomain] = useState(data.registration_email_domain ?? '')
const mutation = useMutation({
mutationFn: (payload: SettingsUpdateIn) => updateAdminSettings(payload),
onSuccess: (result) => {
queryClient.setQueryData(['admin', 'settings'], result)
toast.show('Настройки сохранены', 'success')
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 400) {
toast.show(errorDetail(err) ?? 'Недоступное значение — проверьте уровень AI, таймзону и домен почты', 'error')
} else {
toast.show('Не удалось сохранить настройки', 'error')
}
},
})
function handleSave() {
mutation.mutate({
chat_enabled: chatEnabled,
transcription_enabled: aiEnabled,
ai_level: aiLevel,
summary_recipients: recipients,
display_timezone: timezone.trim(),
registration_team_choice: teamChoiceEnabled,
registration_email_domain_enabled: domainVerificationEnabled,
registration_email_domain: emailDomain.trim() || null,
})
}
return (
<div className="settings-grid">
<section className="settings-card">
<h2>Модули</h2>
<p className="desc">Включение и отключение возможностей инстанса переключатели плагинов из поставки.</p>
<div className="settings-card-body settings-card-body--spread">
<div className="toggle-row" style={{ borderTop: 'none', paddingTop: 0 }}>
<div className="toggle-copy">
<strong>Чат конференции</strong>
<span>Текстовый чат внутри комнаты для участников</span>
</div>
<label className="switch">
<input type="checkbox" checked={chatEnabled} onChange={(e) => setChatEnabled(e.target.checked)} />
<span className="slider" />
</label>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Транскрибация и суммаризация (AI)</strong>
<span>Батч-обработка записи после конференции: расшифровка речи и саммари</span>
{aiEnabled && !data.transcription_queue_served && (
<span className="ai-level-reason">
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" />
AI включён, но обработка очереди транскрибации недоступна сеансы не будут обработаны
</span>
)}
</div>
<label className="switch">
<input type="checkbox" checked={aiEnabled} onChange={(e) => setAiEnabled(e.target.checked)} />
<span className="slider" />
</label>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Разрешить выбор команды при регистрации</strong>
<span>На экране регистрации появится поле «Команда» со списком команд инстанса</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={teamChoiceEnabled}
onChange={(e) => setTeamChoiceEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Верификация по домену почты</strong>
<span>Регистрация только с email эталонного домена (см. плитку «Эталон mail-домена»)</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={domainVerificationEnabled}
onChange={(e) => setDomainVerificationEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
{/*
Анонс записи конференций. Чекбокс не имеет состояния и не
участвует в сохранении формы — функция появится в одной из
следующих версий, здесь только заглушка-анонс в общем стиле
«Модулей».
*/}
<div className="toggle-row">
<div className="toggle-copy">
<strong>Возможность записи конференций</strong>
<span>Функция появится в ближайших версиях</span>
</div>
<label className="switch">
<input type="checkbox" checked={false} disabled readOnly />
<span className="slider" />
</label>
</div>
</div>
</section>
<div className="settings-stack">
<section className="settings-card">
<h2>Уровень AI</h2>
<p className="desc">Качество распознавания и суммаризации определяет размер моделей и требования к железу.</p>
<div className="settings-card-body">
<div className="ai-level-list">
{data.ai_levels.map((levelStatus) => (
<label
key={levelStatus.level}
className={`ai-level-option${!levelStatus.available ? ' is-disabled' : ''}`}
>
<input
type="radio"
name="ai-level"
checked={aiLevel === levelStatus.level}
disabled={!levelStatus.available || !aiEnabled}
onChange={() => setAiLevel(levelStatus.level)}
/>
<div>
<strong>{AI_LEVEL_LABEL[levelStatus.level]}</strong>
{!levelStatus.available && levelStatus.reason && (
<span className="ai-level-reason">
<AlertTriangle style={{ width: 13, height: 13 }} aria-hidden="true" />
{levelStatus.reason}
</span>
)}
</div>
</label>
))}
</div>
</div>
</section>
<section className="settings-card">
<h2>Эталон mail-домена</h2>
<p className="desc">Домен, с которым сверяется email при регистрации, если включена верификация в «Модулях».</p>
<div className="settings-card-body settings-card-body--center">
<div className="field" style={{ marginBottom: 0 }}>
<label htmlFor="settings-email-domain">Домен корпоративной почты</label>
<input
id="settings-email-domain"
type="text"
placeholder="company.ru"
value={emailDomain}
disabled={!domainVerificationEnabled}
onChange={(e) => setEmailDomain(e.target.value)}
/>
</div>
</div>
</section>
</div>
<section className="settings-card">
<h2>Рассылка саммари</h2>
<p className="desc">Кому по умолчанию отправляется письмо с саммари после конференции (переопределяется в карточке конференции).</p>
<div className="settings-card-body settings-card-body--center">
<div className="field" style={{ marginBottom: 0 }}>
<label id="settings-recipients-label" htmlFor="settings-recipients">
Получатели по умолчанию
</label>
<Select
id="settings-recipients"
aria-labelledby="settings-recipients-label"
value={recipients}
onChange={(v) => setRecipients(v as SummaryRecipientsMode)}
options={SUMMARY_RECIPIENTS_OPTIONS}
/>
</div>
</div>
</section>
<section className="settings-card">
<h2>Часовой пояс отображения</h2>
<p className="desc">IANA-таймзона для времени в письмах и .ics-приглашениях (в БД время всегда хранится в UTC).</p>
<div className="settings-card-body settings-card-body--center">
<div className="field" style={{ marginBottom: 0 }}>
<label htmlFor="settings-timezone">Таймзона</label>
<input
id="settings-timezone"
type="text"
list="timezone-options"
placeholder="Europe/Moscow"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
/>
<datalist id="timezone-options">
<option value="Europe/Moscow" />
<option value="Europe/Kaliningrad" />
<option value="Europe/Samara" />
<option value="Asia/Yekaterinburg" />
<option value="Asia/Novosibirsk" />
<option value="Asia/Krasnoyarsk" />
<option value="Asia/Irkutsk" />
<option value="Asia/Vladivostok" />
<option value="UTC" />
</datalist>
</div>
</div>
</section>
<div className="settings-actions">
<button type="button" className="btn btn-primary" disabled={mutation.isPending} onClick={handleSave}>
{mutation.isPending ? 'Сохраняем…' : 'Сохранить настройки'}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,241 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Check, Pencil, Plus, Trash2, X } from 'lucide-react'
import { createAdminTeam, deleteAdminTeam, listAdminTeams, renameAdminTeam, type TeamOut } from '@/api/admin'
import { ApiError } from '@/api/client'
import { useToast } from '@/components/ui/ToastProvider'
const PAGE_SIZE = 20
/**
* Вкладка «Команды» админки — список команд с датой создания, добавление,
* инлайн-переименование и удаление с подтверждением. В макете
* `design/mockups/admin.html` отдельного экрана для команд нет — вкладка
* построена по паттерну `table-card`/`toolbar-row`/`row-actions` того же
* макета и `admin.css` (тот же приём, что и вкладка «Настройки»,
* см. комментарий в `AdminPage.tsx`).
*/
export function AdminTeamsTab() {
const [offset, setOffset] = useState(0)
const [newName, setNewName] = useState('')
const [editingId, setEditingId] = useState<string | null>(null)
const [editingName, setEditingName] = useState('')
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
const queryClient = useQueryClient()
const toast = useToast()
const { data, isLoading } = useQuery({
queryKey: ['admin', 'teams', offset],
queryFn: () => listAdminTeams({ limit: PAGE_SIZE, offset }),
})
function invalidate() {
queryClient.invalidateQueries({ queryKey: ['admin', 'teams'] })
}
const createMutation = useMutation({
mutationFn: (name: string) => createAdminTeam(name),
onSuccess: () => {
invalidate()
toast.show('Команда добавлена', 'success')
setNewName('')
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
toast.show('Команда с таким названием уже есть', 'error')
} else {
toast.show('Не удалось добавить команду', 'error')
}
},
})
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => renameAdminTeam(id, name),
onSuccess: () => {
invalidate()
toast.show('Команда переименована', 'success')
setEditingId(null)
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
toast.show('Команда с таким названием уже есть', 'error')
} else {
toast.show('Не удалось переименовать команду', 'error')
}
},
})
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteAdminTeam(id),
onSuccess: () => {
invalidate()
toast.show('Команда удалена', 'success')
setConfirmingDeleteId(null)
},
onError: () => {
toast.show('Не удалось удалить команду', 'error')
setConfirmingDeleteId(null)
},
})
function handleCreate() {
const name = newName.trim()
if (!name) return
createMutation.mutate(name)
}
function startEditing(team: TeamOut) {
setEditingId(team.id)
setEditingName(team.name)
}
function handleRenameSubmit(id: string) {
const name = editingName.trim()
if (!name) return
renameMutation.mutate({ id, name })
}
const items = data?.items ?? []
const total = data?.total ?? 0
return (
<>
<div className="toolbar-row">
<form
className="team-add-form"
onSubmit={(e) => {
e.preventDefault()
handleCreate()
}}
>
<input
type="text"
placeholder="Название новой команды…"
value={newName}
maxLength={255}
onChange={(e) => setNewName(e.target.value)}
aria-label="Название новой команды"
/>
<button type="submit" className="btn btn-primary" disabled={createMutation.isPending || !newName.trim()}>
<Plus style={{ width: 16, height: 16 }} aria-hidden="true" />
Добавить команду
</button>
</form>
<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>
</tr>
</thead>
<tbody>
{items.length === 0 && !isLoading && (
<tr>
<td colSpan={3} style={{ textAlign: 'center', color: 'var(--color-ink-500)' }}>
Команды не найдены
</td>
</tr>
)}
{items.map((team) => (
<tr key={team.id}>
<td>
{editingId === team.id ? (
<form
className="team-rename-form"
onSubmit={(e) => {
e.preventDefault()
handleRenameSubmit(team.id)
}}
>
<input
type="text"
value={editingName}
maxLength={255}
autoFocus
onChange={(e) => setEditingName(e.target.value)}
aria-label="Новое название команды"
/>
</form>
) : (
team.name
)}
</td>
<td>{new Date(team.created_at).toLocaleDateString('ru-RU')}</td>
<td>
{editingId === team.id ? (
<div className="row-actions">
<button
type="button"
className="icon-btn"
aria-label="Сохранить название"
disabled={renameMutation.isPending}
onClick={() => handleRenameSubmit(team.id)}
>
<Check style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<button type="button" className="icon-btn" aria-label="Отмена" onClick={() => setEditingId(null)}>
<X style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
) : confirmingDeleteId === team.id ? (
<div className="row-actions">
<button
type="button"
className="icon-btn danger"
aria-label="Подтвердить удаление"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(team.id)}
>
<Check style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<button type="button" className="icon-btn" aria-label="Отмена" onClick={() => setConfirmingDeleteId(null)}>
<X style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
) : (
<div className="row-actions">
<button type="button" className="icon-btn" aria-label="Переименовать" onClick={() => startEditing(team)}>
<Pencil style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
<button
type="button"
className="icon-btn danger"
aria-label="Удалить"
onClick={() => setConfirmingDeleteId(team.id)}
>
<Trash2 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>
)}
</>
)
}

View File

@@ -0,0 +1,148 @@
import { useState, type FormEvent } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { X } from 'lucide-react'
import { createAdminUser, listAdminTeams } from '@/api/admin'
import { ApiError, errorDetail } from '@/api/client'
import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider'
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте (как в AdminUsersTab). */
const TEAMS_LIMIT = 200
interface AdminUserCreateDialogProps {
onClose: () => void
}
/**
* Диалог «Добавить пользователя» в админке (вкладка «Пользователи») —
* по образцу `AdminUserProfileDialog`. ФИО, email,
* команда (справочник команд) и пароль; создание сразу с подтверждённой
* почтой (`email_verified = true` выставляет backend). Роль остаётся
* дефолтной («Пользователь») — сменить её можно потом из таблицы.
*/
export function AdminUserCreateDialog({ onClose }: AdminUserCreateDialogProps) {
const toast = useToast()
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [teamId, setTeamId] = useState('')
const [emailError, setEmailError] = useState<string | null>(null)
const { data: teamsData } = useQuery({
queryKey: ['admin', 'teams', 'all'],
queryFn: () => listAdminTeams({ limit: TEAMS_LIMIT, offset: 0 }),
})
const teams = teamsData?.items ?? []
const mutation = useMutation({
mutationFn: () =>
createAdminUser({ name_user: name.trim(), email: email.trim(), password, team_id: teamId === '' ? null : teamId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
toast.show('Пользователь создан', 'success')
onClose()
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
setEmailError('Email уже зарегистрирован')
} else if (err instanceof ApiError && err.status === 404 && errorDetail(err) === 'team_not_found') {
toast.show('Выбранная команда не найдена — обновите список команд', 'error')
} else {
toast.show('Не удалось создать пользователя', 'error')
}
},
})
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setEmailError(null)
mutation.mutate()
}
const canSubmit = name.trim().length > 0 && email.trim().length > 0 && password.length >= 8
return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="admin-user-create-title" onClick={onClose}>
<div className="modal-panel" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2 id="admin-user-create-title">Добавить пользователя</h2>
<button type="button" className="modal-close" aria-label="Закрыть" onClick={onClose}>
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="admin-create-name">ФИО</label>
<input
id="admin-create-name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={255}
/>
</div>
<div className={`field${emailError ? ' has-error' : ''}`}>
<label htmlFor="admin-create-email">Email</label>
<input
id="admin-create-email"
type="email"
required
value={email}
onChange={(e) => {
setEmail(e.target.value)
setEmailError(null)
}}
/>
{emailError && (
<p className="field-hint" style={{ color: 'var(--color-danger)' }}>
{emailError}
</p>
)}
</div>
<div className="field">
<label id="admin-create-team-label" htmlFor="admin-create-team">
Команда
</label>
<Select
id="admin-create-team"
aria-labelledby="admin-create-team-label"
value={teamId}
onChange={setTeamId}
options={[{ value: '', label: 'Без команды' }, ...teams.map((t) => ({ value: t.id, label: t.name }))]}
/>
</div>
<div className="field">
<label htmlFor="admin-create-password">Пароль</label>
<input
id="admin-create-password"
type="password"
autoComplete="new-password"
required
minLength={8}
placeholder="Минимум 8 символов"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<p className="field-hint">Пользователь сможет сменить пароль после входа, в своём профиле</p>
</div>
<div className="modal-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Отмена
</button>
<button type="submit" className="btn btn-primary" disabled={!canSubmit || mutation.isPending}>
{mutation.isPending ? 'Создаём…' : 'Создать'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,219 @@
import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Ban, CheckCircle2, Upload, X } from 'lucide-react'
import {
getAdminUser,
listAdminTeams,
updateAdminUser,
uploadAdminUserAvatar,
type AdminUserOut,
} from '@/api/admin'
import { ApiError } from '@/api/client'
import { useAuth } from '@/auth/useAuth'
import { Avatar } from '@/components/ui/Avatar'
import { Select } from '@/components/ui/Select'
import { useToast } from '@/components/ui/ToastProvider'
/** Разрешённые типы/лимит размера файла аватара — те же ограничения, что и на ProfilePage/backend. */
const ALLOWED_AVATAR_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const MAX_AVATAR_BYTES = 2 * 1024 * 1024
/** Верхний предел выборки команд для селекта — без отдельной пагинации в этом контексте (как в AdminUsersTab). */
const TEAMS_LIMIT = 200
interface AdminUserProfileDialogProps {
userId: string
onClose: () => void
}
/**
* Карточка профиля пользователя в админке — открывается
* кликом по имени в таблице «Пользователи» (`AdminUsersTab.tsx`). Те же поля,
* что и в собственном профиле (`ProfilePage.tsx`) плюс уже существующие
* админ-действия «роль»/«блокировка» — та же логика прав (собственная учётная
* запись администратора защищена от самоизменения роли/блокировки).
*/
export function AdminUserProfileDialog({ userId, onClose }: AdminUserProfileDialogProps) {
const { user: currentUser } = useAuth()
const toast = useToast()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const [avatarError, setAvatarError] = useState<string | null>(null)
const [name, setName] = useState<string | null>(null)
const { data, isLoading } = useQuery({ queryKey: ['admin', 'users', userId], queryFn: () => getAdminUser(userId) })
const { data: teamsData } = useQuery({
queryKey: ['admin', 'teams', 'all'],
queryFn: () => listAdminTeams({ limit: TEAMS_LIMIT, offset: 0 }),
})
const teams = teamsData?.items ?? []
const isSelf = userId === currentUser?.id
function invalidate() {
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
}
const mutation = useMutation({
mutationFn: (payload: Parameters<typeof updateAdminUser>[1]) => updateAdminUser(userId, payload),
onSuccess: () => {
invalidate()
queryClient.invalidateQueries({ queryKey: ['admin', 'users', userId] })
toast.show('Изменения сохранены', 'success')
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 409) {
toast.show('Нельзя изменить собственную учётную запись', 'error')
} else {
toast.show('Не удалось сохранить изменения', 'error')
}
},
})
const avatarMutation = useMutation({
mutationFn: (file: File) => uploadAdminUserAvatar(userId, file),
onSuccess: () => {
invalidate()
queryClient.invalidateQueries({ queryKey: ['admin', 'users', userId] })
toast.show('Аватар обновлён', 'success')
},
onError: (err: unknown) => {
if (err instanceof ApiError && err.status === 413) toast.show('Файл слишком большой — максимум 2 МБ', 'error')
else if (err instanceof ApiError && err.status === 415) toast.show('Недопустимый формат — только JPEG, PNG или WEBP', 'error')
else toast.show('Не удалось загрузить аватар', 'error')
},
})
function handleFileChange(file: File | null) {
setAvatarError(null)
if (!file) return
if (!ALLOWED_AVATAR_TYPES.includes(file.type)) {
setAvatarError('Недопустимый формат — только JPEG, PNG или WEBP')
return
}
if (file.size > MAX_AVATAR_BYTES) {
setAvatarError('Файл слишком большой — максимум 2 МБ')
return
}
avatarMutation.mutate(file)
}
function handleRoleChange(target: AdminUserOut, role: 'admin' | 'user') {
if (role === target.role) return
mutation.mutate({ role })
}
function handleToggleBlock(target: AdminUserOut) {
mutation.mutate({ is_blocked: !target.is_blocked })
}
function handleTeamChange(teamId: string) {
mutation.mutate({ team_id: teamId === '' ? null : teamId })
}
function handleSaveName() {
if (name === null) return
mutation.mutate({ name_user: name.trim() })
}
const displayName = name ?? data?.name_user ?? ''
return (
<div className="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="admin-user-dialog-title" onClick={onClose}>
<div className="modal-panel" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h2 id="admin-user-dialog-title">Профиль пользователя</h2>
<button type="button" className="modal-close" aria-label="Закрыть" onClick={onClose}>
<X className="lucide" style={{ width: 16, height: 16 }} aria-hidden="true" />
</button>
</div>
{isLoading || !data ? (
<p className="field-hint">Загрузка</p>
) : (
<>
<div className="profile-avatar-block">
<Avatar name={data.name_user} avatarUrl={data.avatar_url} size={72} />
<div className="profile-avatar-actions">
<button type="button" className="btn btn-secondary" onClick={() => fileInputRef.current?.click()} disabled={avatarMutation.isPending}>
<Upload style={{ width: 16, height: 16 }} aria-hidden="true" />
{avatarMutation.isPending ? 'Загружаем…' : 'Загрузить фото'}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
style={{ display: 'none' }}
onChange={(e) => {
handleFileChange(e.target.files?.[0] ?? null)
e.target.value = ''
}}
/>
</div>
{avatarError && <p className="field-hint" style={{ color: 'var(--color-danger)' }}>{avatarError}</p>}
</div>
<div className="field">
<label htmlFor="admin-user-name">ФИО</label>
<input id="admin-user-name" type="text" value={displayName} onChange={(e) => setName(e.target.value)} maxLength={255} />
</div>
<div className="field">
<label htmlFor="admin-user-email">Email</label>
<input id="admin-user-email" type="email" value={data.email} disabled readOnly />
</div>
<div className="field">
<label id="admin-user-team-label" htmlFor="admin-user-team">
Команда
</label>
<Select
id="admin-user-team"
aria-labelledby="admin-user-team-label"
value={data.team_id ?? ''}
onChange={handleTeamChange}
disabled={mutation.isPending}
options={[{ value: '', label: 'Без команды' }, ...teams.map((t) => ({ value: t.id, label: t.name }))]}
/>
</div>
<div className="field">
<label id="admin-user-role-label" htmlFor="admin-user-role">
Роль
</label>
<Select
id="admin-user-role"
aria-labelledby="admin-user-role-label"
value={data.role}
onChange={(v) => handleRoleChange(data, v as 'admin' | 'user')}
disabled={isSelf || mutation.isPending}
options={[
{ value: 'user', label: 'Пользователь' },
{ value: 'admin', label: 'Администратор' },
]}
/>
</div>
<div className="modal-actions">
<button type="button" className="btn btn-primary" onClick={handleSaveName} disabled={mutation.isPending || !displayName.trim()}>
Сохранить ФИО
</button>
<button
type="button"
className="btn btn-secondary"
disabled={isSelf || mutation.isPending}
title={isSelf ? 'Нельзя изменить собственную учётную запись' : undefined}
onClick={() => handleToggleBlock(data)}
>
{data.is_blocked ? (
<CheckCircle2 style={{ width: 16, height: 16 }} aria-hidden="true" />
) : (
<Ban style={{ width: 16, height: 16 }} aria-hidden="true" />
)}
{data.is_blocked ? 'Разблокировать' : 'Заблокировать'}
</button>
</div>
</>
)}
</div>
</div>
)
}

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