588 lines
21 KiB
TypeScript
588 lines
21 KiB
TypeScript
import { Component, createSignal, For, onMount, Show } from 'solid-js'
|
||
import { ADMIN_DELETE_INVITE_MUTATION, ADMIN_DELETE_INVITES_BATCH_MUTATION } from '../graphql/mutations'
|
||
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'
|
||
import { getAuthTokenFromCookie } from '../utils/auth'
|
||
|
||
/**
|
||
* Интерфейсы для приглашений
|
||
*/
|
||
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
|
||
}
|
||
|
||
// Добавляю типы для сортировки
|
||
type SortField = 'inviter_name' | 'author_name' | 'shout_title' | 'status' | 'created_at'
|
||
type SortDirection = 'asc' | 'desc'
|
||
|
||
interface SortState {
|
||
field: SortField | null
|
||
direction: SortDirection
|
||
}
|
||
|
||
/**
|
||
* Компонент для управления приглашениями
|
||
*/
|
||
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,
|
||
perPage: 10,
|
||
total: 0,
|
||
totalPages: 1
|
||
})
|
||
|
||
// Состояние для выбранных приглашений
|
||
const [selectedInvites, setSelectedInvites] = createSignal<Record<string, boolean>>({})
|
||
const [selectAll, setSelectAll] = createSignal(false)
|
||
|
||
// Состояние для модального окна подтверждения удаления
|
||
const [deleteModal, setDeleteModal] = createSignal<{ show: boolean; invite: Invite | null }>({
|
||
show: false,
|
||
invite: null
|
||
})
|
||
|
||
// Состояние для модального окна подтверждения пакетного удаления
|
||
const [batchDeleteModal, setBatchDeleteModal] = createSignal<{ show: boolean }>({
|
||
show: false
|
||
})
|
||
|
||
// Добавляю состояние сортировки
|
||
const [sortState, setSortState] = createSignal<SortState>({ field: null, direction: 'asc' })
|
||
|
||
/**
|
||
* Загружает список приглашений с учетом фильтров и пагинации
|
||
*/
|
||
const loadInvites = async (page = 1) => {
|
||
setLoading(true)
|
||
try {
|
||
const limit = pagination().perPage
|
||
const offset = (page - 1) * limit
|
||
|
||
// Получаем токен авторизации из localStorage или cookie
|
||
const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie()
|
||
console.log(`[InvitesRoute] Загрузка приглашений, токен: ${authToken ? 'найден' : 'не найден'}`)
|
||
|
||
const response = await fetch('/graphql', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||
},
|
||
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,
|
||
perPage: data.perPage || 10,
|
||
total: data.total || 0,
|
||
totalPages: data.totalPages || 1
|
||
})
|
||
|
||
// Сбрасываем выбранные приглашения при загрузке новых данных
|
||
setSelectedInvites({})
|
||
setSelectAll(false)
|
||
} 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' }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Удаляет приглашение
|
||
*/
|
||
const deleteInvite = async (invite: Invite) => {
|
||
try {
|
||
// Получаем токен авторизации из localStorage или cookie
|
||
const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie()
|
||
console.log(`[InvitesRoute] Удаление приглашения, токен: ${authToken ? 'найден' : 'не найден'}`)
|
||
|
||
const response = await fetch('/graphql', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||
},
|
||
body: JSON.stringify({
|
||
query: ADMIN_DELETE_INVITE_MUTATION,
|
||
variables: {
|
||
inviter_id: invite.inviter_id,
|
||
author_id: invite.author_id,
|
||
shout_id: invite.shout_id
|
||
}
|
||
})
|
||
})
|
||
|
||
const result = await response.json()
|
||
|
||
if (result.errors) {
|
||
throw new Error(result.errors[0].message)
|
||
}
|
||
|
||
if (!result.data.adminDeleteInvite.success) {
|
||
throw new Error(result.data.adminDeleteInvite.error || 'Неизвестная ошибка')
|
||
}
|
||
|
||
props.onSuccess('Приглашение успешно удалено')
|
||
setDeleteModal({ show: false, invite: null })
|
||
await loadInvites(pagination().page)
|
||
} catch (error) {
|
||
props.onError(`Ошибка удаления приглашения: ${(error as Error).message}`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Пакетное удаление выбранных приглашений
|
||
*/
|
||
const deleteSelectedInvites = async () => {
|
||
try {
|
||
const selected = selectedInvites()
|
||
const invitesToDelete = invites().filter((invite) => {
|
||
const key = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}`
|
||
return selected[key]
|
||
})
|
||
|
||
if (invitesToDelete.length === 0) {
|
||
props.onError('Не выбрано ни одного приглашения для удаления')
|
||
return
|
||
}
|
||
|
||
// Получаем токен авторизации из localStorage или cookie
|
||
const authToken = localStorage.getItem('auth_token') || getAuthTokenFromCookie()
|
||
console.log(
|
||
`[InvitesRoute] Пакетное удаление приглашений, токен: ${authToken ? 'найден' : 'не найден'}`
|
||
)
|
||
|
||
const response = await fetch('/graphql', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: authToken ? `Bearer ${authToken}` : ''
|
||
},
|
||
body: JSON.stringify({
|
||
query: ADMIN_DELETE_INVITES_BATCH_MUTATION,
|
||
variables: {
|
||
invites: invitesToDelete.map((invite) => ({
|
||
inviter_id: invite.inviter_id,
|
||
author_id: invite.author_id,
|
||
shout_id: invite.shout_id
|
||
}))
|
||
}
|
||
})
|
||
})
|
||
|
||
const result = await response.json()
|
||
|
||
if (result.errors) {
|
||
throw new Error(result.errors[0].message)
|
||
}
|
||
|
||
const deleteResult = result.data.adminDeleteInvitesBatch
|
||
|
||
if (!deleteResult.success) {
|
||
throw new Error(deleteResult.error || 'Неизвестная ошибка')
|
||
}
|
||
|
||
props.onSuccess(`Успешно удалено ${invitesToDelete.length} приглашений`)
|
||
setBatchDeleteModal({ show: false })
|
||
setSelectedInvites({})
|
||
setSelectAll(false)
|
||
await loadInvites(pagination().page)
|
||
} catch (error) {
|
||
props.onError(`Ошибка пакетного удаления приглашений: ${(error as Error).message}`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Обработчик выбора/снятия выбора с приглашения
|
||
*/
|
||
const handleSelectInvite = (invite: Invite, checked: boolean) => {
|
||
const key = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}`
|
||
setSelectedInvites((prev) => ({ ...prev, [key]: checked }))
|
||
|
||
// Если снимаем выбор с элемента, то снимаем и "выбрать все"
|
||
if (!checked && selectAll()) {
|
||
setSelectAll(false)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Обработчик выбора/снятия выбора со всех приглашений
|
||
*/
|
||
const handleSelectAll = (checked: boolean) => {
|
||
setSelectAll(checked)
|
||
|
||
const newSelected: Record<string, boolean> = {}
|
||
if (checked) {
|
||
// Выбираем все приглашения на текущей странице
|
||
invites().forEach((invite) => {
|
||
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
|
||
}
|
||
|
||
/**
|
||
* Обработчик клика по заголовку колонки для сортировки
|
||
*/
|
||
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' ? '↑' : '↓'
|
||
}
|
||
|
||
// Загружаем приглашения при монтировании компонента
|
||
onMount(() => {
|
||
void loadInvites()
|
||
})
|
||
|
||
return (
|
||
<div class={styles.container}>
|
||
{/* Новая компактная панель поиска и фильтров */}
|
||
<div class={styles.searchSection}>
|
||
<div class={styles.searchRow}>
|
||
<input
|
||
type="text"
|
||
placeholder="Поиск по приглашающему, приглашаемому, публикации..."
|
||
value={search()}
|
||
onInput={(e) => setSearch(e.target.value)}
|
||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||
class={styles.fullWidthSearch}
|
||
/>
|
||
</div>
|
||
|
||
<div class={styles.filtersRow}>
|
||
<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>
|
||
|
||
<Button onClick={handleSearch} disabled={loading()}>
|
||
🔍 Поиск
|
||
</Button>
|
||
|
||
<Button onClick={() => loadInvites(pagination().page)} disabled={loading()}>
|
||
{loading() ? 'Загрузка...' : '🔄 Обновить'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Панель пакетных действий */}
|
||
<Show when={!loading() && invites().length > 0}>
|
||
<div class={styles['batch-actions']}>
|
||
<div class={styles['select-all-container']}>
|
||
<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>
|
||
</div>
|
||
|
||
<Show when={getSelectedCount() > 0}>
|
||
<div class={styles['selected-count']}>Выбрано: {getSelectedCount()}</div>
|
||
|
||
<button
|
||
class={styles['batch-delete-button']}
|
||
onClick={() => setBatchDeleteModal({ show: true })}
|
||
title="Удалить выбранные приглашения"
|
||
>
|
||
Удалить выбранные
|
||
</button>
|
||
</Show>
|
||
</div>
|
||
</Show>
|
||
|
||
<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>
|
||
<th class={styles['checkbox-column']} />
|
||
<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>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<For each={invites()}>
|
||
{(invite) => {
|
||
const statusDisplay = getStatusDisplay(invite.status)
|
||
const inviteKey = `${invite.inviter_id}-${invite.author_id}-${invite.shout_id}`
|
||
const isSelected = selectedInvites()[inviteKey] || false
|
||
|
||
return (
|
||
<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>
|
||
<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}
|
||
onClick={() => setDeleteModal({ show: true, invite })}
|
||
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>
|
||
|
||
{/* Модальное окно подтверждения пакетного удаления */}
|
||
<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>
|
||
<Button variant="danger" onClick={deleteSelectedInvites}>
|
||
Удалить выбранные
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default InvitesRoute
|