Files
vidconf/frontend/src/components/admin/AdminConferencesTab.tsx
Max Ronzhin dccc369d0b
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
fix(admin): дефолтные подвкладки конференций и пользователей
Конференции открывались на «Все» — неинформативная сборная вкладка вместо
актуальных «Запланированные». Заодно перенесена вкладка «Все» в конец
списка фильтров (после «Завершённые»), чтобы порядок шёл от актуального
к общему.

Пользователи открывались на «Все» вместо «Активные» — админ по умолчанию
видел вперемешку с заблокированными.
2026-07-28 00:21:06 +03:00

496 lines
21 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 { 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: 'scheduled', label: 'Запланированные' },
{ value: 'active', label: 'Активные' },
{ value: 'ended', label: 'Завершённые' },
{ value: 'all', 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,
onDelete,
deleting,
}: {
conference: AdminConferenceOut
onClose: () => void
/** Удаление конференции — та же мутация, что и у корзины в строке таблицы (см. AdminConferencesTab). */
onDelete: () => void
deleting: boolean
}) {
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 [confirmDelete, setConfirmDelete] = useState(false)
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 modal-actions--split">
{confirmDelete ? (
<div className="row-actions">
<span className="field-hint">Удалить без возможности восстановления?</span>
<button type="button" className="btn btn-secondary" onClick={() => setConfirmDelete(false)}>
Нет
</button>
<button type="button" className="btn btn-danger" disabled={deleting} onClick={onDelete}>
{deleting ? 'Удаляем…' : 'Да, удалить'}
</button>
</div>
) : (
<button type="button" className="btn btn-danger" onClick={() => setConfirmDelete(true)}>
<Trash2 style={{ width: 16, height: 16 }} aria-hidden="true" />
Удалить
</button>
)}
<div className="modal-actions-group">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Отмена
</button>
<button type="submit" className="btn btn-primary" disabled={mutation.isPending}>
{mutation.isPending ? 'Сохраняем…' : 'Сохранить'}
</button>
</div>
</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'>('scheduled')
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)
// Удаление могло прийти и из модалки редактирования (кнопка «Удалить»
// там дёргает эту же мутацию) — закрываем её тоже, если открыта.
setEditingConference(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 conferences-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((conf) => (
<tr key={conf.id}>
<td>
{/* На мобильном это единственный вход в редактирование строки (см. .conferences-table
в admin.css — остальные колонки там скрыты), на десктопе — то же самое, что кнопка
«Редактировать» правее, просто более крупная область клика. */}
<button type="button" className="conf-cell-name" onClick={() => setEditingConference(conf)}>
{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>
</button>
</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)}
onDelete={() => deleteMutation.mutate(editingConference.id)}
deleting={deleteMutation.isPending}
/>
)}
{invitingConference && <InviteModal conference={invitingConference} onClose={() => setInvitingConference(null)} />}
</>
)
}