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