Enhance localization support by integrating i18next for translations across various components and pages. Update UI elements to utilize translated strings for improved user experience in both English and Russian. Additionally, refactor the Toaster component to support a context-based approach for toast notifications.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
@@ -29,6 +30,7 @@ export const ChainFormPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const isEdit = !!id
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { data: chain, isLoading: isLoadingChain, error: loadError } = useGetChainQuery(id!, {
|
||||
skip: !id,
|
||||
@@ -53,8 +55,8 @@ export const ChainFormPage: React.FC = () => {
|
||||
|
||||
if (!name.trim()) {
|
||||
toaster.create({
|
||||
title: 'Ошибка валидации',
|
||||
description: 'Введите название цепочки',
|
||||
title: t('challenge.admin.common.validation.error'),
|
||||
description: t('challenge.admin.chains.validation.enter.name'),
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
@@ -62,8 +64,8 @@ export const ChainFormPage: React.FC = () => {
|
||||
|
||||
if (selectedTasks.length === 0) {
|
||||
toaster.create({
|
||||
title: 'Ошибка валидации',
|
||||
description: 'Добавьте хотя бы одно задание',
|
||||
title: t('challenge.admin.common.validation.error'),
|
||||
description: t('challenge.admin.chains.validation.add.task'),
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
@@ -81,8 +83,8 @@ export const ChainFormPage: React.FC = () => {
|
||||
},
|
||||
}).unwrap()
|
||||
toaster.create({
|
||||
title: 'Успешно',
|
||||
description: 'Цепочка обновлена',
|
||||
title: t('challenge.admin.common.success'),
|
||||
description: t('challenge.admin.chains.updated'),
|
||||
type: 'success',
|
||||
})
|
||||
} else {
|
||||
@@ -91,16 +93,24 @@ export const ChainFormPage: React.FC = () => {
|
||||
tasks: taskIds,
|
||||
}).unwrap()
|
||||
toaster.create({
|
||||
title: 'Успешно',
|
||||
description: 'Цепочка создана',
|
||||
title: t('challenge.admin.common.success'),
|
||||
description: t('challenge.admin.chains.created'),
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
navigate(URLs.chains)
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
const errorMessage =
|
||||
(err && typeof err === 'object' && 'data' in err &&
|
||||
err.data && typeof err.data === 'object' && 'error' in err.data &&
|
||||
err.data.error && typeof err.data.error === 'object' && 'message' in err.data.error &&
|
||||
typeof err.data.error.message === 'string')
|
||||
? err.data.error.message
|
||||
: t('challenge.admin.chains.save.error')
|
||||
|
||||
toaster.create({
|
||||
title: 'Ошибка',
|
||||
description: err?.data?.error?.message || 'Не удалось сохранить цепочку',
|
||||
title: t('challenge.admin.common.error'),
|
||||
description: errorMessage,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
@@ -131,19 +141,19 @@ export const ChainFormPage: React.FC = () => {
|
||||
}
|
||||
|
||||
if (isEdit && isLoadingChain) {
|
||||
return <LoadingSpinner message="Загрузка цепочки..." />
|
||||
return <LoadingSpinner message={t('challenge.admin.chains.loading')} />
|
||||
}
|
||||
|
||||
if (isEdit && loadError) {
|
||||
return <ErrorAlert message="Не удалось загрузить цепочку" />
|
||||
return <ErrorAlert message={t('challenge.admin.chains.load.error')} />
|
||||
}
|
||||
|
||||
if (isLoadingTasks) {
|
||||
return <LoadingSpinner message="Загрузка заданий..." />
|
||||
return <LoadingSpinner message={t('challenge.admin.common.loading.tasks')} />
|
||||
}
|
||||
|
||||
if (!allTasks) {
|
||||
return <ErrorAlert message="Не удалось загрузить список заданий" />
|
||||
return <ErrorAlert message={t('challenge.admin.chains.tasks.load.error')} />
|
||||
}
|
||||
|
||||
const isLoading = isCreating || isUpdating
|
||||
@@ -156,7 +166,7 @@ export const ChainFormPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading mb={6}>{isEdit ? 'Редактировать цепочку' : 'Создать цепочку'}</Heading>
|
||||
<Heading mb={6}>{isEdit ? t('challenge.admin.chains.edit.title') : t('challenge.admin.chains.create.title')}</Heading>
|
||||
|
||||
<Box
|
||||
as="form"
|
||||
@@ -171,11 +181,11 @@ export const ChainFormPage: React.FC = () => {
|
||||
<VStack gap={6} align="stretch">
|
||||
{/* Name */}
|
||||
<Field.Root required>
|
||||
<Field.Label>Название цепочки</Field.Label>
|
||||
<Field.Label>{t('challenge.admin.chains.field.name')}</Field.Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Введите название цепочки"
|
||||
placeholder={t('challenge.admin.chains.field.name.placeholder')}
|
||||
maxLength={255}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
@@ -184,7 +194,7 @@ export const ChainFormPage: React.FC = () => {
|
||||
{/* Selected Tasks */}
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={3}>
|
||||
Задания в цепочке ({selectedTasks.length})
|
||||
{t('challenge.admin.chains.selected.tasks')} ({selectedTasks.length})
|
||||
</Text>
|
||||
{selectedTasks.length === 0 ? (
|
||||
<Box
|
||||
@@ -195,7 +205,7 @@ export const ChainFormPage: React.FC = () => {
|
||||
borderRadius="md"
|
||||
textAlign="center"
|
||||
>
|
||||
<Text color="gray.500">Добавьте задания из списка ниже</Text>
|
||||
<Text color="gray.500">{t('challenge.admin.chains.selected.tasks.empty')}</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<VStack gap={2} align="stretch">
|
||||
@@ -255,10 +265,10 @@ export const ChainFormPage: React.FC = () => {
|
||||
{/* Available Tasks */}
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={3}>
|
||||
Доступные задания
|
||||
{t('challenge.admin.chains.available.tasks')}
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Поиск заданий..."
|
||||
placeholder={t('challenge.admin.chains.search.placeholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
mb={3}
|
||||
@@ -273,8 +283,8 @@ export const ChainFormPage: React.FC = () => {
|
||||
>
|
||||
<Text color="gray.500">
|
||||
{allTasks.length === selectedTasks.length
|
||||
? 'Все задания уже добавлены'
|
||||
: 'Ничего не найдено'}
|
||||
? t('challenge.admin.chains.all.tasks.added')
|
||||
: t('challenge.admin.common.not.found')}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
@@ -295,7 +305,7 @@ export const ChainFormPage: React.FC = () => {
|
||||
>
|
||||
<Text>{task.title}</Text>
|
||||
<Button size="sm" colorPalette="teal" variant="ghost">
|
||||
+ Добавить
|
||||
{t('challenge.admin.chains.button.add')}
|
||||
</Button>
|
||||
</Flex>
|
||||
))}
|
||||
@@ -306,10 +316,10 @@ export const ChainFormPage: React.FC = () => {
|
||||
{/* Actions */}
|
||||
<HStack gap={3} justify="flex-end">
|
||||
<Button variant="outline" onClick={() => navigate(URLs.chains)} disabled={isLoading}>
|
||||
Отмена
|
||||
{t('challenge.admin.common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" colorPalette="teal" loading={isLoading}>
|
||||
{isEdit ? 'Сохранить изменения' : 'Создать цепочку'}
|
||||
<Button type="submit" colorPalette="teal" disabled={isLoading}>
|
||||
{isEdit ? t('challenge.admin.chains.button.save') : t('challenge.admin.chains.button.create')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
@@ -22,6 +23,7 @@ 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, { isLoading: isDeleting }] = useDeleteChainMutation()
|
||||
|
||||
@@ -34,26 +36,26 @@ export const ChainsListPage: React.FC = () => {
|
||||
try {
|
||||
await deleteChain(chainToDelete.id).unwrap()
|
||||
toaster.create({
|
||||
title: 'Успешно',
|
||||
description: 'Цепочка удалена',
|
||||
title: t('challenge.admin.common.success'),
|
||||
description: t('challenge.admin.chains.deleted'),
|
||||
type: 'success',
|
||||
})
|
||||
setChainToDelete(null)
|
||||
} catch (err) {
|
||||
toaster.create({
|
||||
title: 'Ошибка',
|
||||
description: 'Не удалось удалить цепочку',
|
||||
title: t('challenge.admin.common.error'),
|
||||
description: t('challenge.admin.chains.delete.error'),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message="Загрузка цепочек..." />
|
||||
return <LoadingSpinner message={t('challenge.admin.chains.list.loading')} />
|
||||
}
|
||||
|
||||
if (error || !chains) {
|
||||
return <ErrorAlert message="Не удалось загрузить список цепочек" onRetry={refetch} />
|
||||
return <ErrorAlert message={t('challenge.admin.chains.list.load.error')} onRetry={refetch} />
|
||||
}
|
||||
|
||||
const filteredChains = chains.filter((chain) =>
|
||||
@@ -71,16 +73,16 @@ export const ChainsListPage: React.FC = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Flex justify="space-between" align="center" mb={6}>
|
||||
<Heading>Цепочки заданий</Heading>
|
||||
<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="Поиск по названию..."
|
||||
placeholder={t('challenge.admin.chains.list.search.placeholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
maxW="400px"
|
||||
@@ -90,25 +92,25 @@ export const ChainsListPage: React.FC = () => {
|
||||
|
||||
{filteredChains.length === 0 && chains.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Нет цепочек"
|
||||
description="Создайте первую цепочку заданий"
|
||||
actionLabel="Создать цепочку"
|
||||
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="Ничего не найдено"
|
||||
description={`По запросу "${searchQuery}" ничего не найдено`}
|
||||
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>Название</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Количество заданий</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Дата создания</Table.ColumnHeader>
|
||||
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader>
|
||||
<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 textAlign="right">{t('challenge.admin.chains.list.table.actions')}</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
@@ -117,7 +119,7 @@ export const ChainsListPage: React.FC = () => {
|
||||
<Table.Cell fontWeight="medium">{chain.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge colorPalette="teal" variant="subtle">
|
||||
{chain.tasks.length} заданий
|
||||
{chain.tasks.length} {t('challenge.admin.chains.list.badge.tasks')}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
@@ -132,7 +134,7 @@ export const ChainsListPage: React.FC = () => {
|
||||
variant="ghost"
|
||||
onClick={() => navigate(URLs.chainEdit(chain.id))}
|
||||
>
|
||||
Редактировать
|
||||
{t('challenge.admin.chains.list.button.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -140,7 +142,7 @@ export const ChainsListPage: React.FC = () => {
|
||||
colorPalette="red"
|
||||
onClick={() => setChainToDelete(chain)}
|
||||
>
|
||||
Удалить
|
||||
{t('challenge.admin.chains.list.button.delete')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Table.Cell>
|
||||
@@ -155,9 +157,9 @@ export const ChainsListPage: React.FC = () => {
|
||||
isOpen={!!chainToDelete}
|
||||
onClose={() => setChainToDelete(null)}
|
||||
onConfirm={handleDeleteChain}
|
||||
title="Удалить цепочку"
|
||||
message={`Вы уверены, что хотите удалить цепочку "${chainToDelete?.name}"? Это действие нельзя отменить.`}
|
||||
confirmLabel="Удалить"
|
||||
title={t('challenge.admin.chains.delete.confirm.title')}
|
||||
message={t('challenge.admin.chains.delete.confirm.message', { name: chainToDelete?.name })}
|
||||
confirmLabel={t('challenge.admin.chains.delete.confirm.button')}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Box, Heading, Grid, Text, VStack, HStack, Badge, Progress } from '@chakra-ui/react'
|
||||
import { useGetSystemStatsQuery } from '../../__data__/api/api'
|
||||
import { StatCard } from '../../components/StatCard'
|
||||
@@ -6,16 +7,17 @@ import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { data: stats, isLoading, error, refetch } = useGetSystemStatsQuery(undefined, {
|
||||
pollingInterval: 10000, // Обновление каждые 10 секунд
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message="Загрузка статистики..." />
|
||||
return <LoadingSpinner message={t('challenge.admin.dashboard.loading')} />
|
||||
}
|
||||
|
||||
if (error || !stats) {
|
||||
return <ErrorAlert message="Не удалось загрузить статистику системы" onRetry={refetch} />
|
||||
return <ErrorAlert message={t('challenge.admin.dashboard.load.error')} onRetry={refetch} />
|
||||
}
|
||||
|
||||
const acceptanceRate = stats.submissions.total > 0
|
||||
@@ -32,25 +34,25 @@ export const DashboardPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading mb={6}>Dashboard</Heading>
|
||||
<Heading mb={6}>{t('challenge.admin.dashboard.title')}</Heading>
|
||||
|
||||
{/* Main Stats */}
|
||||
<Grid templateColumns="repeat(auto-fit, minmax(250px, 1fr))" gap={6} mb={8}>
|
||||
<StatCard label="Всего пользователей" value={stats.users} colorScheme="blue" />
|
||||
<StatCard label="Всего заданий" value={stats.tasks} colorScheme="teal" />
|
||||
<StatCard label="Всего цепочек" value={stats.chains} colorScheme="purple" />
|
||||
<StatCard label="Всего проверок" value={stats.submissions.total} colorScheme="orange" />
|
||||
<StatCard label={t('challenge.admin.dashboard.stats.users')} value={stats.users} colorScheme="blue" />
|
||||
<StatCard label={t('challenge.admin.dashboard.stats.tasks')} value={stats.tasks} colorScheme="teal" />
|
||||
<StatCard label={t('challenge.admin.dashboard.stats.chains')} value={stats.chains} colorScheme="purple" />
|
||||
<StatCard label={t('challenge.admin.dashboard.stats.submissions')} value={stats.submissions.total} colorScheme="orange" />
|
||||
</Grid>
|
||||
|
||||
{/* Submissions Stats */}
|
||||
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" mb={8}>
|
||||
<Heading size="md" mb={4}>
|
||||
Статистика проверок
|
||||
{t('challenge.admin.dashboard.submissions.title')}
|
||||
</Heading>
|
||||
<Grid templateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={6}>
|
||||
<VStack align="start" gap={2}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Принято
|
||||
{t('challenge.admin.dashboard.submissions.accepted')}
|
||||
</Text>
|
||||
<HStack>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="green.600">
|
||||
@@ -62,7 +64,7 @@ export const DashboardPage: React.FC = () => {
|
||||
|
||||
<VStack align="start" gap={2}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Отклонено
|
||||
{t('challenge.admin.dashboard.submissions.rejected')}
|
||||
</Text>
|
||||
<HStack>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="red.600">
|
||||
@@ -74,7 +76,7 @@ export const DashboardPage: React.FC = () => {
|
||||
|
||||
<VStack align="start" gap={2}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Ожидают
|
||||
{t('challenge.admin.dashboard.submissions.pending')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="yellow.600">
|
||||
{stats.submissions.pending}
|
||||
@@ -83,7 +85,7 @@ export const DashboardPage: React.FC = () => {
|
||||
|
||||
<VStack align="start" gap={2}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
В процессе
|
||||
{t('challenge.admin.dashboard.submissions.in.progress')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
|
||||
{stats.submissions.inProgress}
|
||||
@@ -95,13 +97,13 @@ export const DashboardPage: React.FC = () => {
|
||||
{/* Queue Stats */}
|
||||
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" mb={8}>
|
||||
<Heading size="md" mb={4}>
|
||||
Статус очереди
|
||||
{t('challenge.admin.dashboard.queue.title')}
|
||||
</Heading>
|
||||
|
||||
<Grid templateColumns="repeat(auto-fit, minmax(250px, 1fr))" gap={6} mb={4}>
|
||||
<VStack align="start" gap={2}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
В обработке
|
||||
{t('challenge.admin.dashboard.queue.processing')}
|
||||
</Text>
|
||||
<HStack align="baseline">
|
||||
<Text fontSize="2xl" fontWeight="bold" color="teal.600">
|
||||
@@ -115,7 +117,7 @@ export const DashboardPage: React.FC = () => {
|
||||
|
||||
<VStack align="start" gap={2}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Ожидают в очереди
|
||||
{t('challenge.admin.dashboard.queue.waiting')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||
{stats.queue.waiting}
|
||||
@@ -124,7 +126,7 @@ export const DashboardPage: React.FC = () => {
|
||||
|
||||
<VStack align="start" gap={2}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Всего в очереди
|
||||
{t('challenge.admin.dashboard.queue.total')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
|
||||
{stats.queue.queueLength}
|
||||
@@ -134,7 +136,7 @@ export const DashboardPage: React.FC = () => {
|
||||
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600" mb={2}>
|
||||
Загруженность очереди: {queueUtilization}%
|
||||
{t('challenge.admin.dashboard.queue.utilization')} {queueUtilization}%
|
||||
</Text>
|
||||
<Progress.Root value={Number(queueUtilization)} colorPalette="teal" size="sm" borderRadius="full">
|
||||
<Progress.Track>
|
||||
@@ -147,13 +149,13 @@ export const DashboardPage: React.FC = () => {
|
||||
{/* Average Check Time */}
|
||||
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
|
||||
<Heading size="md" mb={2}>
|
||||
Среднее время проверки
|
||||
{t('challenge.admin.dashboard.check.time.title')}
|
||||
</Heading>
|
||||
<Text fontSize="3xl" fontWeight="bold" color="purple.600">
|
||||
{(stats.averageCheckTimeMs / 1000).toFixed(2)} сек
|
||||
{t('challenge.admin.dashboard.check.time.value', { time: (stats.averageCheckTimeMs / 1000).toFixed(2) })}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.600" mt={2}>
|
||||
Время от отправки решения до получения результата
|
||||
{t('challenge.admin.dashboard.check.time.description')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import { lazy } from 'react'
|
||||
|
||||
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export { MainPage as default } from './main'
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
export const MainPage = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Главная страница проекта challenge-admin-pl</h1>
|
||||
<p>Это базовая страница с React Router</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
@@ -27,6 +28,7 @@ import { StatusBadge } from '../../components/StatusBadge'
|
||||
import type { ChallengeSubmission, SubmissionStatus, ChallengeTask, ChallengeUser } from '../../types/challenge'
|
||||
|
||||
export const SubmissionsPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { data: submissions, isLoading, error, refetch } = useGetAllSubmissionsQuery()
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@@ -34,11 +36,11 @@ export const SubmissionsPage: React.FC = () => {
|
||||
const [selectedSubmission, setSelectedSubmission] = useState<ChallengeSubmission | null>(null)
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message="Загрузка попыток..." />
|
||||
return <LoadingSpinner message={t('challenge.admin.submissions.loading')} />
|
||||
}
|
||||
|
||||
if (error || !submissions) {
|
||||
return <ErrorAlert message="Не удалось загрузить список попыток" onRetry={refetch} />
|
||||
return <ErrorAlert message={t('challenge.admin.submissions.load.error')} onRetry={refetch} />
|
||||
}
|
||||
|
||||
const filteredSubmissions = submissions.filter((submission) => {
|
||||
@@ -69,28 +71,28 @@ export const SubmissionsPage: React.FC = () => {
|
||||
const submitted = new Date(submission.submittedAt).getTime()
|
||||
const checked = new Date(submission.checkedAt).getTime()
|
||||
const diff = Math.round((checked - submitted) / 1000)
|
||||
return `${diff} сек`
|
||||
return t('challenge.admin.submissions.check.time', { time: diff })
|
||||
}
|
||||
|
||||
const statusOptions = createListCollection({
|
||||
items: [
|
||||
{ label: 'Все статусы', value: 'all' },
|
||||
{ label: 'Принято', value: 'accepted' },
|
||||
{ label: 'Доработка', value: 'needs_revision' },
|
||||
{ label: 'Проверяется', value: 'in_progress' },
|
||||
{ label: 'Ожидает', value: 'pending' },
|
||||
{ label: t('challenge.admin.submissions.status.all'), value: 'all' },
|
||||
{ label: t('challenge.admin.submissions.status.accepted'), value: 'accepted' },
|
||||
{ label: t('challenge.admin.submissions.status.needs.revision'), value: 'needs_revision' },
|
||||
{ label: t('challenge.admin.submissions.status.in.progress'), value: 'in_progress' },
|
||||
{ label: t('challenge.admin.submissions.status.pending'), value: 'pending' },
|
||||
],
|
||||
})
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading mb={6}>Попытки решений</Heading>
|
||||
<Heading mb={6}>{t('challenge.admin.submissions.title')}</Heading>
|
||||
|
||||
{/* Filters */}
|
||||
{submissions.length > 0 && (
|
||||
<HStack mb={4} gap={4}>
|
||||
<Input
|
||||
placeholder="Поиск по пользователю или заданию..."
|
||||
placeholder={t('challenge.admin.submissions.search.placeholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
maxW="400px"
|
||||
@@ -102,7 +104,7 @@ export const SubmissionsPage: React.FC = () => {
|
||||
maxW="200px"
|
||||
>
|
||||
<Select.Trigger>
|
||||
<Select.ValueText placeholder="Статус" />
|
||||
<Select.ValueText placeholder={t('challenge.admin.submissions.filter.status')} />
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{statusOptions.items.map((option) => (
|
||||
@@ -116,21 +118,21 @@ export const SubmissionsPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{filteredSubmissions.length === 0 && submissions.length === 0 ? (
|
||||
<EmptyState title="Нет попыток" description="Попытки появятся после отправки решений" />
|
||||
<EmptyState title={t('challenge.admin.submissions.empty.title')} description={t('challenge.admin.submissions.empty.description')} />
|
||||
) : filteredSubmissions.length === 0 ? (
|
||||
<EmptyState title="Ничего не найдено" description="Попробуйте изменить фильтры" />
|
||||
<EmptyState title={t('challenge.admin.submissions.search.empty.title')} description={t('challenge.admin.submissions.search.empty.description')} />
|
||||
) : (
|
||||
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
|
||||
<Table.Root size="sm">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader>Пользователь</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Задание</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Статус</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Попытка</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Дата отправки</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Время проверки</Table.ColumnHeader>
|
||||
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t('challenge.admin.submissions.table.user')}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t('challenge.admin.submissions.table.task')}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t('challenge.admin.submissions.table.status')}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t('challenge.admin.submissions.table.attempt')}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t('challenge.admin.submissions.table.submitted')}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t('challenge.admin.submissions.table.check.time')}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader textAlign="right">{t('challenge.admin.submissions.table.actions')}</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
@@ -167,7 +169,7 @@ export const SubmissionsPage: React.FC = () => {
|
||||
colorPalette="teal"
|
||||
onClick={() => setSelectedSubmission(submission)}
|
||||
>
|
||||
Детали
|
||||
{t('challenge.admin.submissions.button.details')}
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
@@ -199,6 +201,8 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!submission) return null
|
||||
|
||||
const user = submission.user as ChallengeUser
|
||||
@@ -215,7 +219,7 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
|
||||
})
|
||||
}
|
||||
|
||||
const getCheckTime = () => {
|
||||
const getCheckTimeValue = () => {
|
||||
if (!submission.checkedAt) return null
|
||||
const submitted = new Date(submission.submittedAt).getTime()
|
||||
const checked = new Date(submission.checkedAt).getTime()
|
||||
@@ -226,7 +230,7 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
|
||||
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Детали попытки #{submission.attemptNumber}</DialogTitle>
|
||||
<DialogTitle>{t('challenge.admin.submissions.details.title')} #{submission.attemptNumber}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<VStack gap={6} align="stretch">
|
||||
@@ -235,13 +239,13 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
|
||||
<HStack mb={4} justify="space-between">
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600" mb={1}>
|
||||
Пользователь
|
||||
{t('challenge.admin.submissions.details.user')}
|
||||
</Text>
|
||||
<Text fontWeight="bold">{user.nickname}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600" mb={1}>
|
||||
Статус
|
||||
{t('challenge.admin.submissions.details.status')}
|
||||
</Text>
|
||||
<StatusBadge status={submission.status} />
|
||||
</Box>
|
||||
@@ -249,15 +253,15 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
|
||||
|
||||
<VStack align="stretch" gap={2}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
<strong>Отправлено:</strong> {formatDate(submission.submittedAt)}
|
||||
<strong>{t('challenge.admin.submissions.details.submitted')}</strong> {formatDate(submission.submittedAt)}
|
||||
</Text>
|
||||
{submission.checkedAt && (
|
||||
<>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
<strong>Проверено:</strong> {formatDate(submission.checkedAt)}
|
||||
<strong>{t('challenge.admin.submissions.details.checked')}</strong> {formatDate(submission.checkedAt)}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
<strong>Время проверки:</strong> {getCheckTime()} сек
|
||||
<strong>{t('challenge.admin.submissions.details.check.time')}</strong> {t('challenge.admin.submissions.check.time', { time: getCheckTimeValue() })}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -267,7 +271,7 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
|
||||
{/* Task */}
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2}>
|
||||
Задание: {task.title}
|
||||
{t('challenge.admin.submissions.details.task')} {task.title}
|
||||
</Text>
|
||||
<Box
|
||||
p={4}
|
||||
@@ -285,7 +289,7 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
|
||||
{/* Solution */}
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2}>
|
||||
Решение пользователя:
|
||||
{t('challenge.admin.submissions.details.solution')}
|
||||
</Text>
|
||||
<Box
|
||||
p={4}
|
||||
@@ -311,7 +315,7 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
|
||||
{submission.feedback && (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2}>
|
||||
Обратная связь от LLM:
|
||||
{t('challenge.admin.submissions.details.feedback')}
|
||||
</Text>
|
||||
<Box
|
||||
p={4}
|
||||
@@ -329,7 +333,7 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
|
||||
<DialogFooter>
|
||||
<DialogActionTrigger asChild>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Закрыть
|
||||
{t('challenge.admin.submissions.details.close')}
|
||||
</Button>
|
||||
</DialogActionTrigger>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
@@ -9,8 +10,6 @@ import {
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Flex,
|
||||
Stack,
|
||||
Field,
|
||||
Tabs,
|
||||
} from '@chakra-ui/react'
|
||||
@@ -29,7 +28,7 @@ export const TaskFormPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const isEdit = !!id
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { data: task, isLoading: isLoadingTask, error: loadError } = useGetTaskQuery(id!, {
|
||||
skip: !id,
|
||||
})
|
||||
@@ -54,8 +53,8 @@ export const TaskFormPage: React.FC = () => {
|
||||
|
||||
if (!title.trim() || !description.trim()) {
|
||||
toaster.create({
|
||||
title: 'Ошибка валидации',
|
||||
description: 'Заполните обязательные поля',
|
||||
title: t('challenge.admin.common.validation.error'),
|
||||
description: t('challenge.admin.tasks.validation.fill.required.fields'),
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
@@ -72,8 +71,8 @@ export const TaskFormPage: React.FC = () => {
|
||||
},
|
||||
}).unwrap()
|
||||
toaster.create({
|
||||
title: 'Успешно',
|
||||
description: 'Задание обновлено',
|
||||
title: t('challenge.admin.common.success'),
|
||||
description: t('challenge.admin.tasks.updated'),
|
||||
type: 'success',
|
||||
})
|
||||
} else {
|
||||
@@ -83,34 +82,42 @@ export const TaskFormPage: React.FC = () => {
|
||||
hiddenInstructions: hiddenInstructions.trim() || undefined,
|
||||
}).unwrap()
|
||||
toaster.create({
|
||||
title: 'Успешно',
|
||||
description: 'Задание создано',
|
||||
title: t('challenge.admin.common.success'),
|
||||
description: t('challenge.admin.tasks.created'),
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
navigate(URLs.tasks)
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
const errorMessage =
|
||||
(err && typeof err === 'object' && 'data' in err &&
|
||||
err.data && typeof err.data === 'object' && 'error' in err.data &&
|
||||
err.data.error && typeof err.data.error === 'object' && 'message' in err.data.error &&
|
||||
typeof err.data.error.message === 'string')
|
||||
? err.data.error.message
|
||||
: t('challenge.admin.tasks.save.error')
|
||||
|
||||
toaster.create({
|
||||
title: 'Ошибка',
|
||||
description: err?.data?.error?.message || 'Не удалось сохранить задание',
|
||||
title: t('challenge.admin.common.error'),
|
||||
description: errorMessage,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isEdit && isLoadingTask) {
|
||||
return <LoadingSpinner message="Загрузка задания..." />
|
||||
return <LoadingSpinner message={t('challenge.admin.tasks.loading')} />
|
||||
}
|
||||
|
||||
if (isEdit && loadError) {
|
||||
return <ErrorAlert message="Не удалось загрузить задание" />
|
||||
return <ErrorAlert message={t('challenge.admin.tasks.load.error')} />
|
||||
}
|
||||
|
||||
const isLoading = isCreating || isUpdating
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading mb={6}>{isEdit ? 'Редактировать задание' : 'Создать задание'}</Heading>
|
||||
<Heading mb={6}>{isEdit ? t('challenge.admin.tasks.edit.title') : t('challenge.admin.tasks.create.title')}</Heading>
|
||||
|
||||
<Box
|
||||
as="form"
|
||||
@@ -125,33 +132,33 @@ export const TaskFormPage: React.FC = () => {
|
||||
<VStack gap={6} align="stretch">
|
||||
{/* Title */}
|
||||
<Field.Root required>
|
||||
<Field.Label>Название задания</Field.Label>
|
||||
<Field.Label>{t('challenge.admin.tasks.field.title')}</Field.Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Введите название задания"
|
||||
placeholder={t('challenge.admin.tasks.field.title.placeholder')}
|
||||
maxLength={255}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Field.HelperText>Максимум 255 символов</Field.HelperText>
|
||||
<Field.HelperText>{t('challenge.admin.tasks.field.title.helper')}</Field.HelperText>
|
||||
</Field.Root>
|
||||
|
||||
{/* Description with Markdown */}
|
||||
<Field.Root required>
|
||||
<Field.Label>Описание (Markdown)</Field.Label>
|
||||
<Field.Label>{t('challenge.admin.tasks.field.description')}</Field.Label>
|
||||
<Tabs.Root
|
||||
value={showDescPreview ? 'preview' : 'editor'}
|
||||
onValueChange={(e) => setShowDescPreview(e.value === 'preview')}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="editor">Редактор</Tabs.Trigger>
|
||||
<Tabs.Trigger value="preview">Превью</Tabs.Trigger>
|
||||
<Tabs.Trigger value="editor">{t('challenge.admin.tasks.tab.editor')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="preview">{t('challenge.admin.tasks.tab.preview')}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="editor" pt={4}>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="# Заголовок задания Описание задания в формате Markdown..."
|
||||
placeholder={t('challenge.admin.tasks.field.description.placeholder')}
|
||||
rows={15}
|
||||
fontFamily="monospace"
|
||||
disabled={isLoading}
|
||||
@@ -172,13 +179,13 @@ export const TaskFormPage: React.FC = () => {
|
||||
</Box>
|
||||
) : (
|
||||
<Text color="gray.400" fontStyle="italic">
|
||||
Предпросмотр появится здесь...
|
||||
{t('challenge.admin.tasks.preview.empty')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
<Field.HelperText>Используйте Markdown для форматирования текста</Field.HelperText>
|
||||
<Field.HelperText>{t('challenge.admin.tasks.field.description.helper')}</Field.HelperText>
|
||||
</Field.Root>
|
||||
|
||||
{/* Hidden Instructions */}
|
||||
@@ -186,22 +193,21 @@ export const TaskFormPage: React.FC = () => {
|
||||
<Box bg="purple.50" p={4} borderRadius="md" borderWidth="1px" borderColor="purple.200">
|
||||
<HStack mb={2}>
|
||||
<Text fontWeight="bold" color="purple.800">
|
||||
🔒 Скрытые инструкции для LLM
|
||||
{t('challenge.admin.tasks.field.hidden.instructions')}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="purple.700" mb={3}>
|
||||
Эти инструкции будут переданы LLM при проверке решений студентов. Студенты их не
|
||||
увидят.
|
||||
{t('challenge.admin.tasks.field.hidden.instructions.description')}
|
||||
</Text>
|
||||
<Textarea
|
||||
value={hiddenInstructions}
|
||||
onChange={(e) => setHiddenInstructions(e.target.value)}
|
||||
placeholder="Например: Проверь, что сложность алгоритма O(n log n). Код должен обрабатывать edge cases..."
|
||||
placeholder={t('challenge.admin.tasks.field.hidden.instructions.placeholder')}
|
||||
rows={6}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Field.HelperText>
|
||||
Опционально. Используйте для тонкой настройки проверки LLM.
|
||||
{t('challenge.admin.tasks.field.hidden.instructions.helper')}
|
||||
</Field.HelperText>
|
||||
</Box>
|
||||
</Field.Root>
|
||||
@@ -210,17 +216,17 @@ export const TaskFormPage: React.FC = () => {
|
||||
{isEdit && task && (
|
||||
<Box p={4} bg="gray.50" borderRadius="md">
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
<strong>Создано:</strong>{' '}
|
||||
<strong>{t('challenge.admin.tasks.meta.created')}</strong>{' '}
|
||||
{new Date(task.createdAt).toLocaleString('ru-RU')}
|
||||
</Text>
|
||||
{task.creator && (
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
<strong>Автор:</strong> {task.creator.preferred_username}
|
||||
<strong>{t('challenge.admin.tasks.meta.author')}</strong> {task.creator.preferred_username}
|
||||
</Text>
|
||||
)}
|
||||
{task.updatedAt !== task.createdAt && (
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
<strong>Обновлено:</strong>{' '}
|
||||
<strong>{t('challenge.admin.tasks.meta.updated')}</strong>{' '}
|
||||
{new Date(task.updatedAt).toLocaleString('ru-RU')}
|
||||
</Text>
|
||||
)}
|
||||
@@ -230,10 +236,10 @@ export const TaskFormPage: React.FC = () => {
|
||||
{/* Actions */}
|
||||
<HStack gap={3} justify="flex-end">
|
||||
<Button variant="outline" onClick={() => navigate(URLs.tasks)} disabled={isLoading}>
|
||||
Отмена
|
||||
{t('challenge.admin.common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" colorPalette="teal" loading={isLoading}>
|
||||
{isEdit ? 'Сохранить изменения' : 'Создать задание'}
|
||||
<Button type="submit" colorPalette="teal" disabled={isLoading}>
|
||||
{isEdit ? t('challenge.admin.tasks.button.save') : t('challenge.admin.tasks.button.create')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
@@ -9,9 +10,7 @@ import {
|
||||
Input,
|
||||
HStack,
|
||||
Text,
|
||||
IconButton,
|
||||
Badge,
|
||||
createListCollection,
|
||||
} from '@chakra-ui/react'
|
||||
import { useGetTasksQuery, useDeleteTaskMutation } from '../../__data__/api/api'
|
||||
import { URLs } from '../../__data__/urls'
|
||||
@@ -24,6 +23,7 @@ import { toaster } from '../../components/ui/toaster'
|
||||
|
||||
export const TasksListPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const { data: tasks, isLoading, error, refetch } = useGetTasksQuery()
|
||||
const [deleteTask, { isLoading: isDeleting }] = useDeleteTaskMutation()
|
||||
|
||||
@@ -36,26 +36,26 @@ export const TasksListPage: React.FC = () => {
|
||||
try {
|
||||
await deleteTask(taskToDelete.id).unwrap()
|
||||
toaster.create({
|
||||
title: 'Успешно',
|
||||
description: 'Задание удалено',
|
||||
title: t('challenge.admin.common.success'),
|
||||
description: t('challenge.admin.tasks.deleted'),
|
||||
type: 'success',
|
||||
})
|
||||
setTaskToDelete(null)
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
toaster.create({
|
||||
title: 'Ошибка',
|
||||
description: 'Не удалось удалить задание',
|
||||
title: t('challenge.admin.common.error'),
|
||||
description: t('challenge.admin.tasks.delete.error'),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message="Загрузка заданий..." />
|
||||
return <LoadingSpinner message={t('challenge.admin.tasks.list.loading')} />
|
||||
}
|
||||
|
||||
if (error || !tasks) {
|
||||
return <ErrorAlert message="Не удалось загрузить список заданий" onRetry={refetch} />
|
||||
return <ErrorAlert message={t('challenge.admin.tasks.list.load.error')} onRetry={refetch} />
|
||||
}
|
||||
|
||||
const filteredTasks = tasks.filter((task) =>
|
||||
@@ -73,16 +73,16 @@ export const TasksListPage: React.FC = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Flex justify="space-between" align="center" mb={6}>
|
||||
<Heading>Задания</Heading>
|
||||
<Heading>{t('challenge.admin.tasks.list.title')}</Heading>
|
||||
<Button colorPalette="teal" onClick={() => navigate(URLs.taskNew)}>
|
||||
+ Создать задание
|
||||
{t('challenge.admin.tasks.list.create.button')}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{tasks.length > 0 && (
|
||||
<Box mb={4}>
|
||||
<Input
|
||||
placeholder="Поиск по названию..."
|
||||
placeholder={t('challenge.admin.tasks.list.search.placeholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
maxW="400px"
|
||||
@@ -92,26 +92,26 @@ export const TasksListPage: React.FC = () => {
|
||||
|
||||
{filteredTasks.length === 0 && tasks.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Нет заданий"
|
||||
description="Создайте первое задание для начала работы"
|
||||
actionLabel="Создать задание"
|
||||
title={t('challenge.admin.tasks.list.empty.title')}
|
||||
description={t('challenge.admin.tasks.list.empty.description')}
|
||||
actionLabel={t('challenge.admin.tasks.list.empty.action')}
|
||||
onAction={() => navigate(URLs.taskNew)}
|
||||
/>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Ничего не найдено"
|
||||
description={`По запросу "${searchQuery}" ничего не найдено`}
|
||||
title={t('challenge.admin.common.not.found')}
|
||||
description={t('challenge.admin.tasks.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>Название</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Создатель</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Дата создания</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Скрытые инструкции</Table.ColumnHeader>
|
||||
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t('challenge.admin.tasks.list.table.title')}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t('challenge.admin.tasks.list.table.creator')}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t('challenge.admin.tasks.list.table.created')}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t('challenge.admin.tasks.list.table.hidden.instructions')}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader textAlign="right">{t('challenge.admin.tasks.list.table.actions')}</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
@@ -131,7 +131,7 @@ export const TasksListPage: React.FC = () => {
|
||||
<Table.Cell>
|
||||
{task.hiddenInstructions ? (
|
||||
<Badge colorPalette="purple" variant="subtle">
|
||||
🔒 Есть
|
||||
{t('challenge.admin.tasks.list.badge.has.instructions')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text fontSize="sm" color="gray.400">
|
||||
@@ -146,7 +146,7 @@ export const TasksListPage: React.FC = () => {
|
||||
variant="ghost"
|
||||
onClick={() => navigate(URLs.taskEdit(task.id))}
|
||||
>
|
||||
Редактировать
|
||||
{t('challenge.admin.tasks.list.button.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -154,7 +154,7 @@ export const TasksListPage: React.FC = () => {
|
||||
colorPalette="red"
|
||||
onClick={() => setTaskToDelete(task)}
|
||||
>
|
||||
Удалить
|
||||
{t('challenge.admin.tasks.list.button.delete')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Table.Cell>
|
||||
@@ -169,9 +169,9 @@ export const TasksListPage: React.FC = () => {
|
||||
isOpen={!!taskToDelete}
|
||||
onClose={() => setTaskToDelete(null)}
|
||||
onConfirm={handleDeleteTask}
|
||||
title="Удалить задание"
|
||||
message={`Вы уверены, что хотите удалить задание "${taskToDelete?.title}"? Это действие нельзя отменить.`}
|
||||
confirmLabel="Удалить"
|
||||
title={t('challenge.admin.tasks.delete.confirm.title')}
|
||||
message={t('challenge.admin.tasks.delete.confirm.message', { title: taskToDelete?.title })}
|
||||
confirmLabel={t('challenge.admin.tasks.delete.confirm.button')}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
@@ -23,19 +24,19 @@ import { useGetUsersQuery, useGetUserStatsQuery } from '../../__data__/api/api'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||
import { EmptyState } from '../../components/EmptyState'
|
||||
import type { ChallengeUser } from '../../types/challenge'
|
||||
|
||||
export const UsersPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { data: users, isLoading, error, refetch } = useGetUsersQuery()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message="Загрузка пользователей..." />
|
||||
return <LoadingSpinner message={t('challenge.admin.users.loading')} />
|
||||
}
|
||||
|
||||
if (error || !users) {
|
||||
return <ErrorAlert message="Не удалось загрузить список пользователей" onRetry={refetch} />
|
||||
return <ErrorAlert message={t('challenge.admin.users.load.error')} onRetry={refetch} />
|
||||
}
|
||||
|
||||
const filteredUsers = users.filter((user) =>
|
||||
@@ -52,12 +53,12 @@ export const UsersPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading mb={6}>Пользователи</Heading>
|
||||
<Heading mb={6}>{t('challenge.admin.users.title')}</Heading>
|
||||
|
||||
{users.length > 0 && (
|
||||
<Box mb={4}>
|
||||
<Input
|
||||
placeholder="Поиск по nickname..."
|
||||
placeholder={t('challenge.admin.users.search.placeholder')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
maxW="400px"
|
||||
@@ -66,21 +67,21 @@ export const UsersPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{filteredUsers.length === 0 && users.length === 0 ? (
|
||||
<EmptyState title="Нет пользователей" description="Пользователи появятся после регистрации" />
|
||||
<EmptyState title={t('challenge.admin.users.empty.title')} description={t('challenge.admin.users.empty.description')} />
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Ничего не найдено"
|
||||
description={`По запросу "${searchQuery}" ничего не найдено`}
|
||||
title={t('challenge.admin.common.not.found')}
|
||||
description={t('challenge.admin.users.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>Nickname</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>ID</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Дата регистрации</Table.ColumnHeader>
|
||||
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t('challenge.admin.users.table.nickname')}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t('challenge.admin.users.table.id')}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t('challenge.admin.users.table.registered')}</Table.ColumnHeader>
|
||||
<Table.ColumnHeader textAlign="right">{t('challenge.admin.users.table.actions')}</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
@@ -104,7 +105,7 @@ export const UsersPage: React.FC = () => {
|
||||
colorPalette="teal"
|
||||
onClick={() => setSelectedUserId(user.id)}
|
||||
>
|
||||
Статистика
|
||||
{t('challenge.admin.users.button.stats')}
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
@@ -131,6 +132,7 @@ interface UserStatsModalProps {
|
||||
}
|
||||
|
||||
const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose }) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: stats, isLoading } = useGetUserStatsQuery(userId!, {
|
||||
skip: !userId,
|
||||
})
|
||||
@@ -139,20 +141,20 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
|
||||
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Статистика пользователя</DialogTitle>
|
||||
<DialogTitle>{t('challenge.admin.users.stats.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
{isLoading ? (
|
||||
<LoadingSpinner message="Загрузка статистики..." />
|
||||
<LoadingSpinner message={t('challenge.admin.users.stats.loading')} />
|
||||
) : !stats ? (
|
||||
<Text color="gray.600">Нет данных</Text>
|
||||
<Text color="gray.600">{t('challenge.admin.users.stats.no.data')}</Text>
|
||||
) : (
|
||||
<VStack gap={6} align="stretch">
|
||||
{/* Overview */}
|
||||
<Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Выполнено
|
||||
{t('challenge.admin.users.stats.completed')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="green.600">
|
||||
{stats.completedTasks}
|
||||
@@ -160,7 +162,7 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Всего попыток
|
||||
{t('challenge.admin.users.stats.total.submissions')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
|
||||
{stats.totalSubmissions}
|
||||
@@ -168,7 +170,7 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
В процессе
|
||||
{t('challenge.admin.users.stats.in.progress')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||
{stats.inProgressTasks}
|
||||
@@ -176,7 +178,7 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Требует доработки
|
||||
{t('challenge.admin.users.stats.needs.revision')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="red.600">
|
||||
{stats.needsRevisionTasks}
|
||||
@@ -188,7 +190,7 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
|
||||
{stats.chainStats.length > 0 && (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={3}>
|
||||
Прогресс по цепочкам
|
||||
{t('challenge.admin.users.stats.chains.progress')}
|
||||
</Text>
|
||||
<VStack gap={3} align="stretch">
|
||||
{stats.chainStats.map((chain) => (
|
||||
@@ -201,7 +203,11 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
|
||||
{chain.completedTasks} / {chain.totalTasks}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Progress value={chain.progress} colorPalette="teal" size="sm" />
|
||||
<Progress.Root value={chain.progress} colorPalette="teal" size="sm">
|
||||
<Progress.Track>
|
||||
<Progress.Range />
|
||||
</Progress.Track>
|
||||
</Progress.Root>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
@@ -212,45 +218,39 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
|
||||
{stats.taskStats.length > 0 && (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={3}>
|
||||
Задания
|
||||
{t('challenge.admin.users.stats.tasks')}
|
||||
</Text>
|
||||
<VStack gap={2} align="stretch" maxH="300px" overflowY="auto">
|
||||
{stats.taskStats.map((taskStat) => (
|
||||
<Box
|
||||
key={taskStat.taskId}
|
||||
p={3}
|
||||
bg="gray.50"
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{taskStat.taskTitle}
|
||||
{stats.taskStats.map((taskStat) => {
|
||||
const getBadgeColor = () => {
|
||||
if (taskStat.status === 'completed') return 'green'
|
||||
if (taskStat.status === 'needs_revision') return 'red'
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={taskStat.taskId}
|
||||
p={3}
|
||||
bg="gray.50"
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{taskStat.taskTitle}
|
||||
</Text>
|
||||
<Badge colorPalette={getBadgeColor()}>
|
||||
{t(`challenge.admin.users.stats.status.${taskStat.status}`)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
{t('challenge.admin.users.stats.attempts')} {taskStat.totalAttempts}
|
||||
</Text>
|
||||
<Badge
|
||||
colorPalette={
|
||||
taskStat.status === 'completed'
|
||||
? 'green'
|
||||
: taskStat.status === 'needs_revision'
|
||||
? 'red'
|
||||
: 'gray'
|
||||
}
|
||||
>
|
||||
{taskStat.status === 'completed'
|
||||
? 'Завершено'
|
||||
: taskStat.status === 'needs_revision'
|
||||
? 'Доработка'
|
||||
: taskStat.status === 'in_progress'
|
||||
? 'В процессе'
|
||||
: 'Не начато'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
Попыток: {taskStat.totalAttempts}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
@@ -258,10 +258,10 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
|
||||
{/* Average Check Time */}
|
||||
<Box p={3} bg="purple.50" borderRadius="md">
|
||||
<Text fontSize="sm" color="gray.700" mb={1}>
|
||||
Среднее время проверки
|
||||
{t('challenge.admin.users.stats.avg.check.time')}
|
||||
</Text>
|
||||
<Text fontSize="lg" fontWeight="bold" color="purple.700">
|
||||
{(stats.averageCheckTimeMs / 1000).toFixed(2)} сек
|
||||
{t('challenge.admin.dashboard.check.time.value', { time: (stats.averageCheckTimeMs / 1000).toFixed(2) })}
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
Reference in New Issue
Block a user