237 lines
8.6 KiB
TypeScript
237 lines
8.6 KiB
TypeScript
import React, { useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { useTranslation } from 'react-i18next'
|
|
import {
|
|
Box,
|
|
Heading,
|
|
Button,
|
|
Table,
|
|
Flex,
|
|
Input,
|
|
HStack,
|
|
Text,
|
|
Badge,
|
|
} from '@chakra-ui/react'
|
|
import { useGetChainsQuery, useDeleteChainMutation, useUpdateChainMutation } from '../../__data__/api/api'
|
|
import { URLs } from '../../__data__/urls'
|
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
|
import { ErrorAlert } from '../../components/ErrorAlert'
|
|
import { EmptyState } from '../../components/EmptyState'
|
|
import { DuplicateChainDialog } from '../../components/DuplicateChainDialog'
|
|
import { ClearSubmissionsDialog } from '../../components/ClearSubmissionsDialog'
|
|
import type { ChallengeChain } from '../../types/challenge'
|
|
import { toaster } from '../../components/ui/toaster'
|
|
|
|
export const ChainsListPage: React.FC = () => {
|
|
const navigate = useNavigate()
|
|
const { t } = useTranslation()
|
|
const { data: chains, isLoading, error, refetch } = useGetChainsQuery()
|
|
const [deleteChain] = useDeleteChainMutation()
|
|
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [chainToDuplicate, setChainToDuplicate] = useState<ChallengeChain | null>(null)
|
|
const [chainToClearSubmissions, setChainToClearSubmissions] = useState<ChallengeChain | null>(null)
|
|
const [updatingChainId, setUpdatingChainId] = useState<string | null>(null)
|
|
const [updateChain] = useUpdateChainMutation()
|
|
|
|
const handleDeleteChain = async (chain: ChallengeChain) => {
|
|
const confirmed = window.confirm(
|
|
t('challenge.admin.chains.delete.confirm.message', { name: chain.name })
|
|
)
|
|
|
|
if (!confirmed) return
|
|
|
|
try {
|
|
await deleteChain(chain.id).unwrap()
|
|
toaster.create({
|
|
title: t('challenge.admin.common.success'),
|
|
description: t('challenge.admin.chains.deleted'),
|
|
type: 'success',
|
|
})
|
|
} catch (err) {
|
|
toaster.create({
|
|
title: t('challenge.admin.common.error'),
|
|
description: t('challenge.admin.chains.delete.error'),
|
|
type: 'error',
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleToggleActive = async (chain: ChallengeChain, nextValue: boolean) => {
|
|
setUpdatingChainId(chain.id)
|
|
|
|
try {
|
|
await updateChain({
|
|
id: chain.id,
|
|
data: { isActive: nextValue },
|
|
}).unwrap()
|
|
toaster.create({
|
|
title: t('challenge.admin.common.success'),
|
|
description: t('challenge.admin.chains.updated'),
|
|
type: 'success',
|
|
})
|
|
} catch {
|
|
toaster.create({
|
|
title: t('challenge.admin.common.error'),
|
|
description: t('challenge.admin.chains.save.error'),
|
|
type: 'error',
|
|
})
|
|
} finally {
|
|
setUpdatingChainId(null)
|
|
}
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <LoadingSpinner message={t('challenge.admin.chains.list.loading')} />
|
|
}
|
|
|
|
if (error || !chains) {
|
|
return <ErrorAlert message={t('challenge.admin.chains.list.load.error')} onRetry={refetch} />
|
|
}
|
|
|
|
const filteredChains = chains.filter((chain) =>
|
|
chain.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
)
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
return new Date(dateStr).toLocaleDateString('ru-RU', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})
|
|
}
|
|
|
|
return (
|
|
<Box>
|
|
<Flex justify="space-between" align="center" mb={6}>
|
|
<Heading>{t('challenge.admin.chains.list.title')}</Heading>
|
|
<Button colorPalette="teal" onClick={() => navigate(URLs.chainNew)}>
|
|
{t('challenge.admin.chains.list.create.button')}
|
|
</Button>
|
|
</Flex>
|
|
|
|
{chains.length > 0 && (
|
|
<Box mb={4}>
|
|
<Input
|
|
placeholder={t('challenge.admin.chains.list.search.placeholder')}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
maxW="400px"
|
|
/>
|
|
</Box>
|
|
)}
|
|
|
|
{filteredChains.length === 0 && chains.length === 0 ? (
|
|
<EmptyState
|
|
title={t('challenge.admin.chains.list.empty.title')}
|
|
description={t('challenge.admin.chains.list.empty.description')}
|
|
actionLabel={t('challenge.admin.chains.list.empty.action')}
|
|
onAction={() => navigate(URLs.chainNew)}
|
|
/>
|
|
) : filteredChains.length === 0 ? (
|
|
<EmptyState
|
|
title={t('challenge.admin.common.not.found')}
|
|
description={t('challenge.admin.chains.list.search.empty', { query: searchQuery })}
|
|
/>
|
|
) : (
|
|
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
|
|
<Table.Root size="sm">
|
|
<Table.Header>
|
|
<Table.Row>
|
|
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.name')}</Table.ColumnHeader>
|
|
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.tasks.count')}</Table.ColumnHeader>
|
|
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.created')}</Table.ColumnHeader>
|
|
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.status')}</Table.ColumnHeader>
|
|
<Table.ColumnHeader textAlign="right">{t('challenge.admin.chains.list.table.actions')}</Table.ColumnHeader>
|
|
</Table.Row>
|
|
</Table.Header>
|
|
<Table.Body>
|
|
{filteredChains.map((chain) => (
|
|
<Table.Row key={chain.id}>
|
|
<Table.Cell fontWeight="medium">{chain.name}</Table.Cell>
|
|
<Table.Cell>
|
|
<Badge colorPalette="teal" variant="subtle">
|
|
{chain.tasks.length} {t('challenge.admin.chains.list.badge.tasks')}
|
|
</Badge>
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
<Text fontSize="sm" color="gray.600">
|
|
{formatDate(chain.createdAt)}
|
|
</Text>
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
<HStack gap={3} justify="flex-start">
|
|
<Badge colorPalette={chain.isActive ? 'green' : 'gray'} variant="subtle">
|
|
{chain.isActive
|
|
? t('challenge.admin.chains.list.status.active')
|
|
: t('challenge.admin.chains.list.status.inactive')}
|
|
</Badge>
|
|
<Button
|
|
size="xs"
|
|
variant="outline"
|
|
onClick={() => handleToggleActive(chain, !chain.isActive)}
|
|
disabled={updatingChainId === chain.id}
|
|
>
|
|
{chain.isActive
|
|
? t('challenge.admin.chains.list.status.inactive')
|
|
: t('challenge.admin.chains.list.status.active')}
|
|
</Button>
|
|
</HStack>
|
|
</Table.Cell>
|
|
<Table.Cell textAlign="right">
|
|
<HStack gap={2} justify="flex-end">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => navigate(URLs.chainEdit(chain.id))}
|
|
>
|
|
{t('challenge.admin.chains.list.button.edit')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setChainToDuplicate(chain)}
|
|
>
|
|
{t('challenge.admin.chains.duplicate.button')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
colorPalette="red"
|
|
onClick={() => setChainToClearSubmissions(chain)}
|
|
>
|
|
{t('challenge.admin.chains.clear.submissions.button')}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
colorPalette="red"
|
|
onClick={() => handleDeleteChain(chain)}
|
|
>
|
|
{t('challenge.admin.chains.list.button.delete')}
|
|
</Button>
|
|
</HStack>
|
|
</Table.Cell>
|
|
</Table.Row>
|
|
))}
|
|
</Table.Body>
|
|
</Table.Root>
|
|
</Box>
|
|
)}
|
|
|
|
<DuplicateChainDialog
|
|
isOpen={!!chainToDuplicate}
|
|
onClose={() => setChainToDuplicate(null)}
|
|
chain={chainToDuplicate}
|
|
/>
|
|
|
|
<ClearSubmissionsDialog
|
|
isOpen={!!chainToClearSubmissions}
|
|
onClose={() => setChainToClearSubmissions(null)}
|
|
chain={chainToClearSubmissions}
|
|
/>
|
|
</Box>
|
|
)
|
|
}
|
|
|