core/panel/routes/invites.tsx

578 lines
20 KiB
TypeScript
Raw Normal View History

2025-06-30 19:19:46 +00:00
import { Component, createSignal, For, onMount, Show } from 'solid-js'
2025-07-01 06:32:22 +00:00
import { ADMIN_DELETE_INVITE_MUTATION, ADMIN_DELETE_INVITES_BATCH_MUTATION } from '../graphql/mutations'
2025-06-30 19:19:46 +00:00
import { ADMIN_GET_INVITES_QUERY } from '../graphql/queries'
import styles from '../styles/Table.module.css'
import Button from '../ui/Button'
import Modal from '../ui/Modal'
import Pagination from '../ui/Pagination'
2025-07-02 19:30:21 +00:00
import TableControls from '../ui/TableControls'
2025-06-30 20:10:48 +00:00
import { getAuthTokenFromCookie } from '../utils/auth'
2025-06-30 19:19:46 +00:00
/**
* Интерфейсы для приглашений
*/
interface Author {
id: number
name: string
email: string
slug: string
}
interface Shout {
id: number
title: string
slug: string
created_by: Author
}
interface Invite {
inviter_id: number
author_id: number
shout_id: number
status: 'PENDING' | 'ACCEPTED' | 'REJECTED'
inviter: Author
author: Author
shout: Shout
created_at?: number
}
interface InvitesRouteProps {
onError: (error: string) => void
onSuccess: (message: string) => void
}
2025-06-30 22:20:48 +00:00
// Добавляю типы для сортировки
type SortField = 'inviter_name' | 'author_name' | 'shout_title' | 'status' | 'created_at'
type SortDirection = 'asc' | 'desc'
interface SortState {
field: SortField | null
direction: SortDirection
}
2025-06-30 19:19:46 +00:00
/**
* Компонент для управления приглашениями
*/
const InvitesRoute: Component<InvitesRouteProps> = (props) => {
const [invites, setInvites] = createSignal<Invite[]>([])
const [loading, setLoading] = createSignal(false)
const [search, setSearch] = createSignal('')
const [statusFilter, setStatusFilter] = createSignal('all')
const [pagination, setPagination] = createSignal({
page: 1,
2025-07-02 19:30:21 +00:00
perPage: 20,
2025-06-30 19:19:46 +00:00
total: 0,
totalPages: 1
})
2025-06-30 20:37:21 +00:00
// Состояние для выбранных приглашений
const [selectedInvites, setSelectedInvites] = createSignal<Record<string, boolean>>({})
const [selectAll, setSelectAll] = createSignal(false)
// Состояние для модального окна подтверждения удаления
2025-07-02 19:30:21 +00:00
const [deleteModal, setDeleteModal] = createSignal<{
show: boolean
invite: Invite | null
}>({
2025-06-30 19:19:46 +00:00
show: false,
invite: null
})
2025-06-30 20:37:21 +00:00
// Состояние для модального окна подтверждения пакетного удаления
2025-07-02 19:30:21 +00:00
const [batchDeleteModal, setBatchDeleteModal] = createSignal<{
show: boolean
}>({
2025-06-30 19:19:46 +00:00
show: false
})
2025-06-30 22:20:48 +00:00
// Добавляю состояние сортировки
2025-07-02 19:30:21 +00:00
const [sortState, setSortState] = createSignal<SortState>({
field: null,
direction: 'asc'
})
2025-06-30 22:20:48 +00:00
2025-06-30 19:19:46 +00:00
/**
* Загружает список приглашений с учетом фильтров и пагинации
*/
2025-06-30 20:10:48 +00:00
const loadInvites = async (page = 1) => {
2025-06-30 19:19:46 +00:00
setLoading(true)
try {
const limit = pagination().perPage
const offset = (page - 1) * limit
2025-06-30 20:10:48 +00:00
// Получаем токен авторизации из localStorage или cookie
const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie()
console.log(`[InvitesRoute] Загрузка приглашений, токен: ${authToken ? 'найден' : 'не найден'}`)
2025-06-30 19:19:46 +00:00
const response = await fetch('/graphql', {
method: 'POST',
headers: {
2025-06-30 20:10:48 +00:00
'Content-Type': 'application/json',
Authorization: authToken ? `Bearer ${authToken}` : ''
2025-06-30 19:19:46 +00:00
},
body: JSON.stringify({
query: ADMIN_GET_INVITES_QUERY,
variables: {
limit,
offset,
search: search().trim() || null,
status: statusFilter() === 'all' ? null : statusFilter()
}
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
const data = result.data.adminGetInvites
setInvites(data.invites || [])
setPagination({
page: data.page || 1,
2025-07-02 19:30:21 +00:00
perPage: data.perPage || 20,
2025-06-30 19:19:46 +00:00
total: data.total || 0,
totalPages: data.totalPages || 1
})
2025-06-30 20:37:21 +00:00
// Сбрасываем выбранные приглашения при загрузке новых данных
setSelectedInvites({})
setSelectAll(false)
2025-06-30 19:19:46 +00:00
} catch (error) {
props.onError(`Ошибка загрузки приглашений: ${(error as Error).message}`)
} finally {
setLoading(false)
}
}
/**
* Обработчик изменения страницы
*/
const handlePageChange = (page: number) => {
void loadInvites(page)
}
/**
* Обработчик поиска
*/
const handleSearch = () => {
void loadInvites(1) // Сброс на первую страницу при поиске
}
/**
* Обработчик изменения фильтра статуса
*/
const handleStatusFilterChange = (status: string) => {
setStatusFilter(status)
void loadInvites(1)
}
/**
* Получает отображаемое название статуса
*/
const getStatusDisplay = (status: string) => {
switch (status) {
case 'PENDING':
return { text: 'Ожидает', badge: 'warning' }
case 'ACCEPTED':
return { text: 'Принято', badge: 'success' }
case 'REJECTED':
return { text: 'Отклонено', badge: 'error' }
default:
return { text: status, badge: 'secondary' }
}
}
/**
2025-06-30 20:37:21 +00:00
* Удаляет приглашение
2025-06-30 19:19:46 +00:00
*/
2025-06-30 20:37:21 +00:00
const deleteInvite = async (invite: Invite) => {
2025-06-30 19:19:46 +00:00
try {
2025-06-30 20:10:48 +00:00
// Получаем токен авторизации из localStorage или cookie
const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie()
2025-06-30 20:37:21 +00:00
console.log(`[InvitesRoute] Удаление приглашения, токен: ${authToken ? 'найден' : 'не найден'}`)
2025-06-30 20:10:48 +00:00
2025-06-30 19:19:46 +00:00
const response = await fetch('/graphql', {
method: 'POST',
headers: {
2025-06-30 20:10:48 +00:00
'Content-Type': 'application/json',
Authorization: authToken ? `Bearer ${authToken}` : ''
2025-06-30 19:19:46 +00:00
},
body: JSON.stringify({
2025-06-30 20:37:21 +00:00
query: ADMIN_DELETE_INVITE_MUTATION,
variables: {
inviter_id: invite.inviter_id,
author_id: invite.author_id,
shout_id: invite.shout_id
}
2025-06-30 19:19:46 +00:00
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
2025-06-30 20:37:21 +00:00
if (!result.data.adminDeleteInvite.success) {
throw new Error(result.data.adminDeleteInvite.error || 'Неизвестная ошибка')
2025-06-30 19:19:46 +00:00
}
2025-06-30 20:37:21 +00:00
props.onSuccess('Приглашение успешно удалено')
setDeleteModal({ show: false, invite: null })
2025-06-30 19:19:46 +00:00
await loadInvites(pagination().page)
} catch (error) {
2025-06-30 20:37:21 +00:00
props.onError(`Ошибка удаления приглашения: ${(error as Error).message}`)
2025-06-30 19:19:46 +00:00
}
}
/**
2025-06-30 20:37:21 +00:00
* Пакетное удаление выбранных приглашений
2025-06-30 19:19:46 +00:00
*/
2025-06-30 20:37:21 +00:00
const deleteSelectedInvites = async () => {
2025-06-30 19:19:46 +00:00
try {
2025-06-30 20:37:21 +00:00
const selected = selectedInvites()
2025-07-01 06:32:22 +00:00
const invitesToDelete = invites().filter((invite) => {
2025-06-30 20:37:21 +00:00
const key = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}`
return selected[key]
})
if (invitesToDelete.length === 0) {
props.onError('Не выбрано ни одного приглашения для удаления')
return
}
2025-06-30 20:10:48 +00:00
// Получаем токен авторизации из localStorage или cookie
const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie()
2025-07-01 06:32:22 +00:00
console.log(
`[InvitesRoute] Пакетное удаление приглашений, токен: ${authToken ? 'найден' : 'не найден'}`
)
2025-06-30 20:10:48 +00:00
2025-06-30 19:19:46 +00:00
const response = await fetch('/graphql', {
method: 'POST',
headers: {
2025-06-30 20:10:48 +00:00
'Content-Type': 'application/json',
Authorization: authToken ? `Bearer ${authToken}` : ''
2025-06-30 19:19:46 +00:00
},
body: JSON.stringify({
2025-06-30 20:37:21 +00:00
query: ADMIN_DELETE_INVITES_BATCH_MUTATION,
2025-06-30 19:19:46 +00:00
variables: {
2025-07-01 06:32:22 +00:00
invites: invitesToDelete.map((invite) => ({
2025-06-30 20:37:21 +00:00
inviter_id: invite.inviter_id,
author_id: invite.author_id,
shout_id: invite.shout_id
}))
2025-06-30 19:19:46 +00:00
}
})
})
const result = await response.json()
if (result.errors) {
throw new Error(result.errors[0].message)
}
2025-06-30 20:37:21 +00:00
const deleteResult = result.data.adminDeleteInvitesBatch
if (!deleteResult.success) {
throw new Error(deleteResult.error || 'Неизвестная ошибка')
2025-06-30 19:19:46 +00:00
}
2025-06-30 20:37:21 +00:00
props.onSuccess(`Успешно удалено ${invitesToDelete.length} приглашений`)
setBatchDeleteModal({ show: false })
setSelectedInvites({})
setSelectAll(false)
2025-06-30 19:19:46 +00:00
await loadInvites(pagination().page)
} catch (error) {
2025-06-30 20:37:21 +00:00
props.onError(`Ошибка пакетного удаления приглашений: ${(error as Error).message}`)
2025-06-30 19:19:46 +00:00
}
}
2025-06-30 20:37:21 +00:00
/**
* Обработчик выбора/снятия выбора с приглашения
*/
const handleSelectInvite = (invite: Invite, checked: boolean) => {
const key = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}`
2025-07-01 06:32:22 +00:00
setSelectedInvites((prev) => ({ ...prev, [key]: checked }))
2025-06-30 20:37:21 +00:00
// Если снимаем выбор с элемента, то снимаем и "выбрать все"
if (!checked && selectAll()) {
setSelectAll(false)
}
}
/**
* Обработчик выбора/снятия выбора со всех приглашений
*/
const handleSelectAll = (checked: boolean) => {
setSelectAll(checked)
const newSelected: Record<string, boolean> = {}
if (checked) {
// Выбираем все приглашения на текущей странице
2025-07-01 06:32:22 +00:00
invites().forEach((invite) => {
2025-06-30 20:37:21 +00:00
const key = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}`
newSelected[key] = true
})
}
setSelectedInvites(newSelected)
}
/**
* Получает количество выбранных приглашений
*/
const getSelectedCount = () => {
return Object.values(selectedInvites()).filter(Boolean).length
}
2025-06-30 22:20:48 +00:00
/**
* Обработчик клика по заголовку колонки для сортировки
*/
const handleSort = (field: SortField) => {
const current = sortState()
let newDirection: SortDirection = 'asc'
if (current.field === field) {
// Если кликнули по той же колонке, меняем направление
newDirection = current.direction === 'asc' ? 'desc' : 'asc'
}
setSortState({ field, direction: newDirection })
// Здесь можно добавить логику сортировки на сервере или клиенте
console.log(`Сортировка по ${field} в направлении ${newDirection}`)
}
/**
* Получает иконку сортировки для колонки
*/
const getSortIcon = (field: SortField) => {
const current = sortState()
if (current.field !== field) {
return '↕️' // Неактивная сортировка
}
return current.direction === 'asc' ? '↑' : '↓'
}
2025-06-30 19:19:46 +00:00
// Загружаем приглашения при монтировании компонента
onMount(() => {
void loadInvites()
})
return (
<div class={styles.container}>
2025-07-02 19:30:21 +00:00
<TableControls
searchValue={search()}
onSearchChange={(value) => setSearch(value)}
onSearch={handleSearch}
searchPlaceholder="Поиск по приглашающему, приглашаемому, публикации..."
isLoading={loading()}
actions={
2025-06-30 20:37:21 +00:00
<Show when={getSelectedCount() > 0}>
<button
2025-07-02 19:30:21 +00:00
class={`${styles.button} ${styles.danger}`}
2025-06-30 20:37:21 +00:00
onClick={() => setBatchDeleteModal({ show: true })}
title="Удалить выбранные приглашения"
>
2025-07-02 19:30:21 +00:00
Удалить выбранные ({getSelectedCount()})
2025-06-30 20:37:21 +00:00
</button>
</Show>
2025-07-02 19:30:21 +00:00
}
>
<select
value={statusFilter()}
onChange={(e) => handleStatusFilterChange(e.target.value)}
class={styles.statusFilter}
>
<option value="all">Все статусы</option>
<option value="pending">Ожидает ответа</option>
<option value="accepted">Принято</option>
<option value="rejected">Отклонено</option>
</select>
</TableControls>
{/* Панель выбора всех */}
<Show when={!loading() && invites().length > 0}>
<div class={styles['select-all-container']} style={{ 'margin-bottom': '10px' }}>
<input
type="checkbox"
id="select-all"
checked={selectAll()}
onChange={(e) => handleSelectAll(e.target.checked)}
class={styles.checkbox}
/>
<label for="select-all" class={styles['select-all-label']}>
Выбрать все
</label>
2025-06-30 20:37:21 +00:00
</div>
</Show>
2025-06-30 19:19:46 +00:00
<Show when={loading()}>
<div class={styles.loading}>Загрузка приглашений...</div>
</Show>
<Show when={!loading() && invites().length === 0}>
<div class={styles.empty}>Приглашения не найдены</div>
</Show>
<Show when={!loading() && invites().length > 0}>
<div class={styles.tableContainer}>
<table class={styles.table}>
<thead>
<tr>
2025-07-01 06:32:22 +00:00
<th class={styles['checkbox-column']} />
2025-06-30 22:20:48 +00:00
<th class={styles.sortableHeader} onClick={() => handleSort('inviter_name')}>
<span class={styles.headerContent}>
Приглашающий
<span class={styles.sortIcon}>{getSortIcon('inviter_name')}</span>
</span>
</th>
<th class={styles.sortableHeader} onClick={() => handleSort('author_name')}>
<span class={styles.headerContent}>
Приглашаемый
<span class={styles.sortIcon}>{getSortIcon('author_name')}</span>
</span>
</th>
<th class={styles.sortableHeader} onClick={() => handleSort('shout_title')}>
<span class={styles.headerContent}>
Публикация
<span class={styles.sortIcon}>{getSortIcon('shout_title')}</span>
</span>
</th>
<th class={styles.sortableHeader} onClick={() => handleSort('status')}>
<span class={styles.headerContent}>
Статус
<span class={styles.sortIcon}>{getSortIcon('status')}</span>
</span>
</th>
2025-06-30 19:19:46 +00:00
<th>Действия</th>
</tr>
</thead>
<tbody>
<For each={invites()}>
{(invite) => {
const statusDisplay = getStatusDisplay(invite.status)
2025-06-30 20:37:21 +00:00
const inviteKey = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}`
const isSelected = selectedInvites()[inviteKey] || false
2025-06-30 19:19:46 +00:00
return (
2025-06-30 20:37:21 +00:00
<tr>
<td class={styles['checkbox-column']}>
<input
type="checkbox"
checked={isSelected}
onChange={(e) => handleSelectInvite(invite, e.target.checked)}
class={styles.checkbox}
onClick={(e) => e.stopPropagation()}
/>
</td>
2025-06-30 19:19:46 +00:00
<td>
<div>
<strong>{invite.inviter.name || 'Без имени'}</strong>
</div>
<div class={styles.subtitle}>{invite.inviter.email}</div>
<div class={styles.subtitle}>ID: {invite.inviter_id}</div>
</td>
<td>
<div>
<strong>{invite.author.name || 'Без имени'}</strong>
</div>
<div class={styles.subtitle}>{invite.author.email}</div>
<div class={styles.subtitle}>ID: {invite.author_id}</div>
</td>
<td>
<div>
<strong>{invite.shout.title}</strong>
</div>
<div class={styles.subtitle}>Автор: {invite.shout.created_by.name}</div>
<div class={styles.subtitle}>ID: {invite.shout_id}</div>
</td>
<td>
<span class={`${styles.badge} ${styles[statusDisplay.badge]}`}>
{statusDisplay.text}
</span>
</td>
<td>
<button
class={styles.deleteButton}
2025-06-30 20:37:21 +00:00
onClick={() => setDeleteModal({ show: true, invite })}
2025-06-30 19:19:46 +00:00
title="Удалить приглашение"
>
×
</button>
</td>
</tr>
)
}}
</For>
</tbody>
</table>
</div>
<Pagination
currentPage={pagination().page}
totalPages={pagination().totalPages}
total={pagination().total}
limit={pagination().perPage}
onPageChange={handlePageChange}
/>
</Show>
{/* Модальное окно подтверждения удаления */}
<Modal
isOpen={deleteModal().show}
onClose={() => setDeleteModal({ show: false, invite: null })}
title="Подтверждение удаления"
size="small"
>
<div class={styles.deleteConfirmation}>
<p>
Вы действительно хотите удалить приглашение от{' '}
<strong>{deleteModal().invite?.inviter.name}</strong> для{' '}
<strong>{deleteModal().invite?.author.name}</strong> к публикации{' '}
<strong>"{deleteModal().invite?.shout.title}"</strong>?
</p>
<div class={styles.modalActions}>
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, invite: null })}>
Отмена
</Button>
<Button
variant="danger"
onClick={() => deleteModal().invite && deleteInvite(deleteModal().invite!)}
>
Удалить
</Button>
</div>
</div>
</Modal>
2025-06-30 20:37:21 +00:00
{/* Модальное окно подтверждения пакетного удаления */}
<Modal
isOpen={batchDeleteModal().show}
onClose={() => setBatchDeleteModal({ show: false })}
title="Подтверждение пакетного удаления"
size="small"
>
<div class={styles.deleteConfirmation}>
<p>
Вы действительно хотите удалить <strong>{getSelectedCount()}</strong> выбранных приглашений?
<br />
Это действие нельзя отменить.
</p>
<div class={styles.modalActions}>
<Button variant="secondary" onClick={() => setBatchDeleteModal({ show: false })}>
Отмена
</Button>
2025-07-01 06:32:22 +00:00
<Button variant="danger" onClick={deleteSelectedInvites}>
2025-06-30 20:37:21 +00:00
Удалить выбранные
</Button>
</div>
</div>
</Modal>
2025-06-30 19:19:46 +00:00
</div>
)
}
export default InvitesRoute