Files
vidconf/frontend/src/components/admin/AdminConferencesTab.tsx

451 lines
19 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: '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)} />}
</>
)
}