simpler-parent-select
This commit is contained in:
@@ -43,6 +43,15 @@ interface InvitesRouteProps {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Компонент для управления приглашениями
|
||||
*/
|
||||
@@ -73,6 +82,9 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
||||
show: false
|
||||
})
|
||||
|
||||
// Добавляю состояние сортировки
|
||||
const [sortState, setSortState] = createSignal<SortState>({ field: null, direction: 'asc' })
|
||||
|
||||
/**
|
||||
* Загружает список приглашений с учетом фильтров и пагинации
|
||||
*/
|
||||
@@ -307,6 +319,34 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
||||
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()
|
||||
@@ -314,20 +354,20 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
||||
|
||||
return (
|
||||
<div class={styles.container}>
|
||||
<div class={styles.header}>
|
||||
<div class={styles.controls}>
|
||||
{/* Новая компактная панель поиска и фильтров */}
|
||||
<div class={styles.searchSection}>
|
||||
<div class={styles.searchRow}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск приглашений..."
|
||||
placeholder="Поиск по приглашающему, приглашаемому, публикации..."
|
||||
value={search()}
|
||||
onInput={(e) => setSearch(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
class={styles.searchInput}
|
||||
class={styles.fullWidthSearch}
|
||||
/>
|
||||
<Button onClick={handleSearch} disabled={loading()}>
|
||||
🔍
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class={styles.filtersRow}>
|
||||
<select
|
||||
value={statusFilter()}
|
||||
onChange={(e) => handleStatusFilterChange(e.target.value)}
|
||||
@@ -339,8 +379,12 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
||||
<option value="rejected">Отклонено</option>
|
||||
</select>
|
||||
|
||||
<Button onClick={handleSearch} disabled={loading()}>
|
||||
🔍 Поиск
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => loadInvites(pagination().page)} disabled={loading()}>
|
||||
{loading() ? 'Загрузка...' : 'Обновить'}
|
||||
{loading() ? 'Загрузка...' : '🔄 Обновить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,10 +435,30 @@ const InvitesRoute: Component<InvitesRouteProps> = (props) => {
|
||||
<thead>
|
||||
<tr>
|
||||
<th class={styles['checkbox-column']}></th>
|
||||
<th>Приглашающий</th>
|
||||
<th>Приглашаемый</th>
|
||||
<th>Публикация</th>
|
||||
<th>Статус</th>
|
||||
<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>
|
||||
|
@@ -9,6 +9,8 @@ import type { Query } from '../graphql/generated/schema'
|
||||
import { CREATE_TOPIC_MUTATION, DELETE_TOPIC_MUTATION, UPDATE_TOPIC_MUTATION } from '../graphql/mutations'
|
||||
import { GET_TOPICS_QUERY } from '../graphql/queries'
|
||||
import TopicEditModal from '../modals/TopicEditModal'
|
||||
import TopicMergeModal from '../modals/TopicMergeModal'
|
||||
import TopicSimpleParentModal from '../modals/TopicSimpleParentModal'
|
||||
import styles from '../styles/Table.module.css'
|
||||
import Button from '../ui/Button'
|
||||
import Modal from '../ui/Modal'
|
||||
@@ -56,6 +58,15 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||
const [createModal, setCreateModal] = createSignal<{ show: boolean }>({
|
||||
show: false
|
||||
})
|
||||
const [selectedTopics, setSelectedTopics] = createSignal<number[]>([])
|
||||
const [groupAction, setGroupAction] = createSignal<'delete' | 'merge' | ''>('')
|
||||
const [mergeModal, setMergeModal] = createSignal<{ show: boolean }>({
|
||||
show: false
|
||||
})
|
||||
const [simpleParentModal, setSimpleParentModal] = createSignal<{ show: boolean; topic: Topic | null }>({
|
||||
show: false,
|
||||
topic: null
|
||||
})
|
||||
|
||||
/**
|
||||
* Загружает список всех топиков
|
||||
@@ -186,19 +197,22 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||
const result: JSX.Element[] = []
|
||||
|
||||
topics.forEach((topic) => {
|
||||
const isSelected = selectedTopics().includes(topic.id)
|
||||
|
||||
result.push(
|
||||
<tr
|
||||
onClick={() => setEditModal({ show: true, topic })}
|
||||
style={{ cursor: 'pointer' }}
|
||||
class={styles['clickable-row']}
|
||||
>
|
||||
<tr class={styles['clickable-row']}>
|
||||
<td>{topic.id}</td>
|
||||
<td style={{ 'padding-left': `${(topic.level || 0) * 20}px` }}>
|
||||
<td
|
||||
style={{ 'padding-left': `${(topic.level || 0) * 20}px`, cursor: 'pointer' }}
|
||||
onClick={() => setEditModal({ show: true, topic })}
|
||||
>
|
||||
{topic.level! > 0 && '└─ '}
|
||||
{topic.title}
|
||||
</td>
|
||||
<td>{topic.slug}</td>
|
||||
<td>
|
||||
<td onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
|
||||
{topic.slug}
|
||||
</td>
|
||||
<td onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
|
||||
<div
|
||||
style={{
|
||||
'max-width': '200px',
|
||||
@@ -211,20 +225,22 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||
{truncateText(topic.body?.replace(/<[^>]*>/g, '') || '', 100)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{topic.community}</td>
|
||||
<td>{topic.parent_ids?.join(', ') || '—'}</td>
|
||||
<td onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
|
||||
{topic.community}
|
||||
</td>
|
||||
<td onClick={() => setEditModal({ show: true, topic })} style={{ cursor: 'pointer' }}>
|
||||
{topic.parent_ids?.join(', ') || '—'}
|
||||
</td>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteModal({ show: true, topic })
|
||||
handleTopicSelect(topic.id, e.target.checked)
|
||||
}}
|
||||
class={styles['delete-button']}
|
||||
title="Удалить топик"
|
||||
aria-label="Удалить топик"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
@@ -305,6 +321,90 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик выбора/снятия выбора топика
|
||||
*/
|
||||
const handleTopicSelect = (topicId: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedTopics(prev => [...prev, topicId])
|
||||
} else {
|
||||
setSelectedTopics(prev => prev.filter(id => id !== topicId))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик выбора/снятия выбора всех топиков
|
||||
*/
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const allTopicIds = rawTopics().map(topic => topic.id)
|
||||
setSelectedTopics(allTopicIds)
|
||||
} else {
|
||||
setSelectedTopics([])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет выбраны ли все топики
|
||||
*/
|
||||
const isAllSelected = () => {
|
||||
const allIds = rawTopics().map(topic => topic.id)
|
||||
const selected = selectedTopics()
|
||||
return allIds.length > 0 && allIds.every(id => selected.includes(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет выбран ли хотя бы один топик
|
||||
*/
|
||||
const hasSelectedTopics = () => selectedTopics().length > 0
|
||||
|
||||
/**
|
||||
* Выполняет групповое действие
|
||||
*/
|
||||
const executeGroupAction = () => {
|
||||
const action = groupAction()
|
||||
const selected = selectedTopics()
|
||||
|
||||
if (!action || selected.length === 0) {
|
||||
props.onError('Выберите действие и топики')
|
||||
return
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
// Групповое удаление
|
||||
const selectedTopicsData = rawTopics().filter(t => selected.includes(t.id))
|
||||
setDeleteModal({ show: true, topic: selectedTopicsData[0] }) // Используем первый для отображения
|
||||
} else if (action === 'merge') {
|
||||
// Слияние топиков
|
||||
if (selected.length < 2) {
|
||||
props.onError('Для слияния нужно выбрать минимум 2 темы')
|
||||
return
|
||||
}
|
||||
setMergeModal({ show: true })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Групповое удаление выбранных топиков
|
||||
*/
|
||||
const deleteSelectedTopics = async () => {
|
||||
const selected = selectedTopics()
|
||||
if (selected.length === 0) return
|
||||
|
||||
try {
|
||||
// Удаляем по одному (можно оптимизировать пакетным удалением)
|
||||
for (const topicId of selected) {
|
||||
await deleteTopic(topicId)
|
||||
}
|
||||
|
||||
setSelectedTopics([])
|
||||
setGroupAction('')
|
||||
props.onSuccess(`Успешно удалено ${selected.length} тем`)
|
||||
} catch (error) {
|
||||
props.onError(`Ошибка группового удаления: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет топик
|
||||
*/
|
||||
@@ -378,6 +478,21 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||
<Button variant="primary" onClick={() => setCreateModal({ show: true })}>
|
||||
Создать тему
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
if (selectedTopics().length === 1) {
|
||||
const selectedTopic = rawTopics().find(t => t.id === selectedTopics()[0])
|
||||
if (selectedTopic) {
|
||||
setSimpleParentModal({ show: true, topic: selectedTopic })
|
||||
}
|
||||
} else {
|
||||
props.onError('Выберите одну тему для назначения родителя')
|
||||
}
|
||||
}}
|
||||
>
|
||||
🏠 Назначить родителя
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -399,7 +514,53 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||
<th>Описание</th>
|
||||
<th>Сообщество</th>
|
||||
<th>Родители</th>
|
||||
<th>Действия</th>
|
||||
<th>
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '8px', 'flex-direction': 'column' }}>
|
||||
<div style={{ display: 'flex', 'align-items': 'center', gap: '4px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllSelected()}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="Выбрать все"
|
||||
/>
|
||||
<span style={{ 'font-size': '12px' }}>Все</span>
|
||||
</div>
|
||||
<Show when={hasSelectedTopics()}>
|
||||
<div style={{ display: 'flex', gap: '4px', 'align-items': 'center' }}>
|
||||
<select
|
||||
value={groupAction()}
|
||||
onChange={(e) => setGroupAction(e.target.value as 'delete' | 'merge' | '')}
|
||||
style={{
|
||||
padding: '2px 4px',
|
||||
'font-size': '11px',
|
||||
border: '1px solid #ddd',
|
||||
'border-radius': '3px'
|
||||
}}
|
||||
>
|
||||
<option value="">Действие</option>
|
||||
<option value="delete">Удалить</option>
|
||||
<option value="merge">Слить</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={executeGroupAction}
|
||||
disabled={!groupAction()}
|
||||
style={{
|
||||
padding: '2px 6px',
|
||||
'font-size': '11px',
|
||||
background: groupAction() ? '#007bff' : '#ccc',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
'border-radius': '3px',
|
||||
cursor: groupAction() ? 'pointer' : 'not-allowed'
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -431,25 +592,79 @@ const TopicsRoute: Component<TopicsRouteProps> = (props) => {
|
||||
title="Подтверждение удаления"
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
Вы уверены, что хотите удалить топик "<strong>{deleteModal().topic?.title}</strong>"?
|
||||
</p>
|
||||
<p class={styles['warning-text']}>
|
||||
Это действие нельзя отменить. Все дочерние топики также будут удалены.
|
||||
</p>
|
||||
<div class={styles['modal-actions']}>
|
||||
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, topic: null })}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
<Show when={selectedTopics().length > 1}>
|
||||
<p>
|
||||
Вы уверены, что хотите удалить <strong>{selectedTopics().length}</strong> выбранных тем?
|
||||
</p>
|
||||
<p class={styles['warning-text']}>
|
||||
Это действие нельзя отменить. Все дочерние топики также будут удалены.
|
||||
</p>
|
||||
<div class={styles['modal-actions']}>
|
||||
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, topic: null })}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="danger" onClick={deleteSelectedTopics}>
|
||||
Удалить {selectedTopics().length} тем
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={selectedTopics().length <= 1}>
|
||||
<p>
|
||||
Вы уверены, что хотите удалить топик "<strong>{deleteModal().topic?.title}</strong>"?
|
||||
</p>
|
||||
<p class={styles['warning-text']}>
|
||||
Это действие нельзя отменить. Все дочерние топики также будут удалены.
|
||||
</p>
|
||||
<div class={styles['modal-actions']}>
|
||||
<Button variant="secondary" onClick={() => setDeleteModal({ show: false, topic: null })}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => deleteModal().topic && deleteTopic(deleteModal().topic!.id)}
|
||||
onClick={() => {
|
||||
if (deleteModal().topic) {
|
||||
void deleteTopic(deleteModal().topic!.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Модальное окно слияния тем */}
|
||||
<TopicMergeModal
|
||||
isOpen={mergeModal().show}
|
||||
onClose={() => {
|
||||
setMergeModal({ show: false })
|
||||
setSelectedTopics([])
|
||||
setGroupAction('')
|
||||
}}
|
||||
topics={rawTopics().filter(topic => selectedTopics().includes(topic.id))}
|
||||
onSuccess={(message) => {
|
||||
props.onSuccess(message)
|
||||
setSelectedTopics([])
|
||||
setGroupAction('')
|
||||
void loadTopics()
|
||||
}}
|
||||
onError={props.onError}
|
||||
/>
|
||||
|
||||
{/* Модальное окно назначения родителя */}
|
||||
<TopicSimpleParentModal
|
||||
isOpen={simpleParentModal().show}
|
||||
onClose={() => setSimpleParentModal({ show: false, topic: null })}
|
||||
topic={simpleParentModal().topic}
|
||||
allTopics={rawTopics()}
|
||||
onSuccess={(message) => {
|
||||
props.onSuccess(message)
|
||||
setSimpleParentModal({ show: false, topic: null })
|
||||
void loadTopics() // Перезагружаем данные
|
||||
}}
|
||||
onError={props.onError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
Reference in New Issue
Block a user