init + api use

This commit is contained in:
Primakov Alexandr Alexandrovich
2025-11-03 17:59:08 +03:00
commit e777b57991
52 changed files with 20725 additions and 0 deletions

View File

@@ -0,0 +1,320 @@
import React, { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Box,
Heading,
Button,
Input,
VStack,
HStack,
Text,
Field,
Badge,
IconButton,
Flex,
} from '@chakra-ui/react'
import {
useGetChainQuery,
useGetTasksQuery,
useCreateChainMutation,
useUpdateChainMutation,
} from '../../__data__/api/api'
import { URLs } from '../../__data__/urls'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { toaster } from '../../components/ui/toaster'
import type { ChallengeTask } from '../../types/challenge'
export const ChainFormPage: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const isEdit = !!id
const { data: chain, isLoading: isLoadingChain, error: loadError } = useGetChainQuery(id!, {
skip: !id,
})
const { data: allTasks, isLoading: isLoadingTasks } = useGetTasksQuery()
const [createChain, { isLoading: isCreating }] = useCreateChainMutation()
const [updateChain, { isLoading: isUpdating }] = useUpdateChainMutation()
const [name, setName] = useState('')
const [selectedTasks, setSelectedTasks] = useState<ChallengeTask[]>([])
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
if (chain) {
setName(chain.name)
setSelectedTasks(chain.tasks)
}
}, [chain])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
toaster.create({
title: 'Ошибка валидации',
description: 'Введите название цепочки',
type: 'error',
})
return
}
if (selectedTasks.length === 0) {
toaster.create({
title: 'Ошибка валидации',
description: 'Добавьте хотя бы одно задание',
type: 'error',
})
return
}
try {
const taskIds = selectedTasks.map((task) => task.id)
if (isEdit && id) {
await updateChain({
id,
data: {
name: name.trim(),
tasks: taskIds,
},
}).unwrap()
toaster.create({
title: 'Успешно',
description: 'Цепочка обновлена',
type: 'success',
})
} else {
await createChain({
name: name.trim(),
tasks: taskIds,
}).unwrap()
toaster.create({
title: 'Успешно',
description: 'Цепочка создана',
type: 'success',
})
}
navigate(URLs.chains)
} catch (err: any) {
toaster.create({
title: 'Ошибка',
description: err?.data?.error?.message || 'Не удалось сохранить цепочку',
type: 'error',
})
}
}
const handleAddTask = (task: ChallengeTask) => {
if (!selectedTasks.find((t) => t.id === task.id)) {
setSelectedTasks([...selectedTasks, task])
}
}
const handleRemoveTask = (taskId: string) => {
setSelectedTasks(selectedTasks.filter((t) => t.id !== taskId))
}
const handleMoveUp = (index: number) => {
if (index === 0) return
const newTasks = [...selectedTasks]
;[newTasks[index - 1], newTasks[index]] = [newTasks[index], newTasks[index - 1]]
setSelectedTasks(newTasks)
}
const handleMoveDown = (index: number) => {
if (index === selectedTasks.length - 1) return
const newTasks = [...selectedTasks]
;[newTasks[index], newTasks[index + 1]] = [newTasks[index + 1], newTasks[index]]
setSelectedTasks(newTasks)
}
if (isEdit && isLoadingChain) {
return <LoadingSpinner message="Загрузка цепочки..." />
}
if (isEdit && loadError) {
return <ErrorAlert message="Не удалось загрузить цепочку" />
}
if (isLoadingTasks) {
return <LoadingSpinner message="Загрузка заданий..." />
}
if (!allTasks) {
return <ErrorAlert message="Не удалось загрузить список заданий" />
}
const isLoading = isCreating || isUpdating
const availableTasks = allTasks.filter(
(task) =>
!selectedTasks.find((t) => t.id === task.id) &&
task.title.toLowerCase().includes(searchQuery.toLowerCase())
)
return (
<Box>
<Heading mb={6}>{isEdit ? 'Редактировать цепочку' : 'Создать цепочку'}</Heading>
<Box
as="form"
onSubmit={handleSubmit}
bg="white"
p={6}
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<VStack gap={6} align="stretch">
{/* Name */}
<Field.Root required>
<Field.Label>Название цепочки</Field.Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Введите название цепочки"
maxLength={255}
disabled={isLoading}
/>
</Field.Root>
{/* Selected Tasks */}
<Box>
<Text fontWeight="bold" mb={3}>
Задания в цепочке ({selectedTasks.length})
</Text>
{selectedTasks.length === 0 ? (
<Box
p={6}
borderWidth="2px"
borderStyle="dashed"
borderColor="gray.200"
borderRadius="md"
textAlign="center"
>
<Text color="gray.500">Добавьте задания из списка ниже</Text>
</Box>
) : (
<VStack gap={2} align="stretch">
{selectedTasks.map((task, index) => (
<Flex
key={task.id}
p={3}
bg="teal.50"
borderWidth="1px"
borderColor="teal.200"
borderRadius="md"
align="center"
justify="space-between"
>
<HStack gap={3} flex={1}>
<Badge colorPalette="teal" variant="solid">
#{index + 1}
</Badge>
<Text fontWeight="medium">{task.title}</Text>
</HStack>
<HStack gap={1}>
<IconButton
size="sm"
variant="ghost"
onClick={() => handleMoveUp(index)}
disabled={index === 0 || isLoading}
aria-label="Move up"
>
</IconButton>
<IconButton
size="sm"
variant="ghost"
onClick={() => handleMoveDown(index)}
disabled={index === selectedTasks.length - 1 || isLoading}
aria-label="Move down"
>
</IconButton>
<IconButton
size="sm"
variant="ghost"
colorPalette="red"
onClick={() => handleRemoveTask(task.id)}
disabled={isLoading}
aria-label="Remove"
>
</IconButton>
</HStack>
</Flex>
))}
</VStack>
)}
</Box>
{/* Available Tasks */}
<Box>
<Text fontWeight="bold" mb={3}>
Доступные задания
</Text>
<Input
placeholder="Поиск заданий..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
mb={3}
/>
{availableTasks.length === 0 ? (
<Box
p={6}
borderWidth="1px"
borderColor="gray.200"
borderRadius="md"
textAlign="center"
>
<Text color="gray.500">
{allTasks.length === selectedTasks.length
? 'Все задания уже добавлены'
: 'Ничего не найдено'}
</Text>
</Box>
) : (
<VStack gap={2} align="stretch" maxH="400px" overflowY="auto">
{availableTasks.map((task) => (
<Flex
key={task.id}
p={3}
bg="gray.50"
borderWidth="1px"
borderColor="gray.200"
borderRadius="md"
align="center"
justify="space-between"
cursor="pointer"
_hover={{ bg: 'gray.100' }}
onClick={() => handleAddTask(task)}
>
<Text>{task.title}</Text>
<Button size="sm" colorPalette="teal" variant="ghost">
+ Добавить
</Button>
</Flex>
))}
</VStack>
)}
</Box>
{/* Actions */}
<HStack gap={3} justify="flex-end">
<Button variant="outline" onClick={() => navigate(URLs.chains)} disabled={isLoading}>
Отмена
</Button>
<Button type="submit" colorPalette="teal" loading={isLoading}>
{isEdit ? 'Сохранить изменения' : 'Создать цепочку'}
</Button>
</HStack>
</VStack>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,166 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Box,
Heading,
Button,
Table,
Flex,
Input,
HStack,
Text,
Badge,
} from '@chakra-ui/react'
import { useGetChainsQuery, useDeleteChainMutation } 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 { ConfirmDialog } from '../../components/ConfirmDialog'
import type { ChallengeChain } from '../../types/challenge'
import { toaster } from '../../components/ui/toaster'
export const ChainsListPage: React.FC = () => {
const navigate = useNavigate()
const { data: chains, isLoading, error, refetch } = useGetChainsQuery()
const [deleteChain, { isLoading: isDeleting }] = useDeleteChainMutation()
const [searchQuery, setSearchQuery] = useState('')
const [chainToDelete, setChainToDelete] = useState<ChallengeChain | null>(null)
const handleDeleteChain = async () => {
if (!chainToDelete) return
try {
await deleteChain(chainToDelete.id).unwrap()
toaster.create({
title: 'Успешно',
description: 'Цепочка удалена',
type: 'success',
})
setChainToDelete(null)
} catch (err) {
toaster.create({
title: 'Ошибка',
description: 'Не удалось удалить цепочку',
type: 'error',
})
}
}
if (isLoading) {
return <LoadingSpinner message="Загрузка цепочек..." />
}
if (error || !chains) {
return <ErrorAlert message="Не удалось загрузить список цепочек" 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>Цепочки заданий</Heading>
<Button colorPalette="teal" onClick={() => navigate(URLs.chainNew)}>
+ Создать цепочку
</Button>
</Flex>
{chains.length > 0 && (
<Box mb={4}>
<Input
placeholder="Поиск по названию..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
maxW="400px"
/>
</Box>
)}
{filteredChains.length === 0 && chains.length === 0 ? (
<EmptyState
title="Нет цепочек"
description="Создайте первую цепочку заданий"
actionLabel="Создать цепочку"
onAction={() => navigate(URLs.chainNew)}
/>
) : filteredChains.length === 0 ? (
<EmptyState
title="Ничего не найдено"
description={`По запросу "${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.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} заданий
</Badge>
</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{formatDate(chain.createdAt)}
</Text>
</Table.Cell>
<Table.Cell textAlign="right">
<HStack gap={2} justify="flex-end">
<Button
size="sm"
variant="ghost"
onClick={() => navigate(URLs.chainEdit(chain.id))}
>
Редактировать
</Button>
<Button
size="sm"
variant="ghost"
colorPalette="red"
onClick={() => setChainToDelete(chain)}
>
Удалить
</Button>
</HStack>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Box>
)}
<ConfirmDialog
isOpen={!!chainToDelete}
onClose={() => setChainToDelete(null)}
onConfirm={handleDeleteChain}
title="Удалить цепочку"
message={`Вы уверены, что хотите удалить цепочку "${chainToDelete?.name}"? Это действие нельзя отменить.`}
confirmLabel="Удалить"
isLoading={isDeleting}
/>
</Box>
)
}

View File

@@ -0,0 +1,162 @@
import React from 'react'
import { Box, Heading, Grid, Text, VStack, HStack, Badge, Progress } from '@chakra-ui/react'
import { useGetSystemStatsQuery } from '../../__data__/api/api'
import { StatCard } from '../../components/StatCard'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
export const DashboardPage: React.FC = () => {
const { data: stats, isLoading, error, refetch } = useGetSystemStatsQuery(undefined, {
pollingInterval: 10000, // Обновление каждые 10 секунд
})
if (isLoading) {
return <LoadingSpinner message="Загрузка статистики..." />
}
if (error || !stats) {
return <ErrorAlert message="Не удалось загрузить статистику системы" onRetry={refetch} />
}
const acceptanceRate = stats.submissions.total > 0
? ((stats.submissions.accepted / stats.submissions.total) * 100).toFixed(1)
: '0'
const rejectionRate = stats.submissions.total > 0
? ((stats.submissions.rejected / stats.submissions.total) * 100).toFixed(1)
: '0'
const queueUtilization = stats.queue.maxConcurrency > 0
? ((stats.queue.currentlyProcessing / stats.queue.maxConcurrency) * 100).toFixed(0)
: '0'
return (
<Box>
<Heading mb={6}>Dashboard</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" />
</Grid>
{/* Submissions Stats */}
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" mb={8}>
<Heading size="md" mb={4}>
Статистика проверок
</Heading>
<Grid templateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={6}>
<VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600">
Принято
</Text>
<HStack>
<Text fontSize="2xl" fontWeight="bold" color="green.600">
{stats.submissions.accepted}
</Text>
<Badge colorPalette="green">{acceptanceRate}%</Badge>
</HStack>
</VStack>
<VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600">
Отклонено
</Text>
<HStack>
<Text fontSize="2xl" fontWeight="bold" color="red.600">
{stats.submissions.rejected}
</Text>
<Badge colorPalette="red">{rejectionRate}%</Badge>
</HStack>
</VStack>
<VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600">
Ожидают
</Text>
<Text fontSize="2xl" fontWeight="bold" color="yellow.600">
{stats.submissions.pending}
</Text>
</VStack>
<VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600">
В процессе
</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.submissions.inProgress}
</Text>
</VStack>
</Grid>
</Box>
{/* Queue Stats */}
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" mb={8}>
<Heading size="md" mb={4}>
Статус очереди
</Heading>
<Grid templateColumns="repeat(auto-fit, minmax(250px, 1fr))" gap={6} mb={4}>
<VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600">
В обработке
</Text>
<HStack align="baseline">
<Text fontSize="2xl" fontWeight="bold" color="teal.600">
{stats.queue.currentlyProcessing}
</Text>
<Text fontSize="sm" color="gray.500">
/ {stats.queue.maxConcurrency}
</Text>
</HStack>
</VStack>
<VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600">
Ожидают в очереди
</Text>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{stats.queue.waiting}
</Text>
</VStack>
<VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600">
Всего в очереди
</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.queue.queueLength}
</Text>
</VStack>
</Grid>
<Box>
<Text fontSize="sm" color="gray.600" mb={2}>
Загруженность очереди: {queueUtilization}%
</Text>
<Progress.Root value={Number(queueUtilization)} colorPalette="teal" size="sm" borderRadius="full">
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
</Box>
</Box>
{/* Average Check Time */}
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
<Heading size="md" mb={2}>
Среднее время проверки
</Heading>
<Text fontSize="3xl" fontWeight="bold" color="purple.600">
{(stats.averageCheckTimeMs / 1000).toFixed(2)} сек
</Text>
<Text fontSize="sm" color="gray.600" mt={2}>
Время от отправки решения до получения результата
</Text>
</Box>
</Box>
)
}

4
src/pages/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import { lazy } from 'react'
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))

2
src/pages/main/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { MainPage as default } from './main'

11
src/pages/main/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react'
export const MainPage = () => {
return (
<div>
<h1>Главная страница проекта challenge-admin-pl</h1>
<p>Это базовая страница с React Router</p>
</div>
)
}

View File

@@ -0,0 +1,340 @@
import React, { useState } from 'react'
import {
Box,
Heading,
Table,
Input,
Text,
Button,
HStack,
VStack,
Select,
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
createListCollection,
} from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown'
import { useGetAllSubmissionsQuery } from '../../__data__/api/api'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { EmptyState } from '../../components/EmptyState'
import { StatusBadge } from '../../components/StatusBadge'
import type { ChallengeSubmission, SubmissionStatus, ChallengeTask, ChallengeUser } from '../../types/challenge'
export const SubmissionsPage: React.FC = () => {
const { data: submissions, isLoading, error, refetch } = useGetAllSubmissionsQuery()
const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<SubmissionStatus | 'all'>('all')
const [selectedSubmission, setSelectedSubmission] = useState<ChallengeSubmission | null>(null)
if (isLoading) {
return <LoadingSpinner message="Загрузка попыток..." />
}
if (error || !submissions) {
return <ErrorAlert message="Не удалось загрузить список попыток" onRetry={refetch} />
}
const filteredSubmissions = submissions.filter((submission) => {
const user = submission.user as ChallengeUser
const task = submission.task as ChallengeTask
const matchesSearch =
user.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.title.toLowerCase().includes(searchQuery.toLowerCase())
const matchesStatus = statusFilter === 'all' || submission.status === statusFilter
return matchesSearch && matchesStatus
})
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
const getCheckTime = (submission: ChallengeSubmission) => {
if (!submission.checkedAt) return '—'
const submitted = new Date(submission.submittedAt).getTime()
const checked = new Date(submission.checkedAt).getTime()
const diff = Math.round((checked - submitted) / 1000)
return `${diff} сек`
}
const statusOptions = createListCollection({
items: [
{ label: 'Все статусы', value: 'all' },
{ label: 'Принято', value: 'accepted' },
{ label: 'Доработка', value: 'needs_revision' },
{ label: 'Проверяется', value: 'in_progress' },
{ label: 'Ожидает', value: 'pending' },
],
})
return (
<Box>
<Heading mb={6}>Попытки решений</Heading>
{/* Filters */}
{submissions.length > 0 && (
<HStack mb={4} gap={4}>
<Input
placeholder="Поиск по пользователю или заданию..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
maxW="400px"
/>
<Select.Root
collection={statusOptions}
value={[statusFilter]}
onValueChange={(e) => setStatusFilter(e.value[0] as SubmissionStatus | 'all')}
maxW="200px"
>
<Select.Trigger>
<Select.ValueText placeholder="Статус" />
</Select.Trigger>
<Select.Content>
{statusOptions.items.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
</Select.Item>
))}
</Select.Content>
</Select.Root>
</HStack>
)}
{filteredSubmissions.length === 0 && submissions.length === 0 ? (
<EmptyState title="Нет попыток" description="Попытки появятся после отправки решений" />
) : filteredSubmissions.length === 0 ? (
<EmptyState title="Ничего не найдено" 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.Row>
</Table.Header>
<Table.Body>
{filteredSubmissions.map((submission) => {
const user = submission.user as ChallengeUser
const task = submission.task as ChallengeTask
return (
<Table.Row key={submission.id}>
<Table.Cell fontWeight="medium">{user.nickname}</Table.Cell>
<Table.Cell>{task.title}</Table.Cell>
<Table.Cell>
<StatusBadge status={submission.status} />
</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
#{submission.attemptNumber}
</Text>
</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{formatDate(submission.submittedAt)}
</Text>
</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{getCheckTime(submission)}
</Text>
</Table.Cell>
<Table.Cell textAlign="right">
<Button
size="sm"
variant="ghost"
colorPalette="teal"
onClick={() => setSelectedSubmission(submission)}
>
Детали
</Button>
</Table.Cell>
</Table.Row>
)
})}
</Table.Body>
</Table.Root>
</Box>
)}
{/* Submission Details Modal */}
<SubmissionDetailsModal
submission={selectedSubmission}
isOpen={!!selectedSubmission}
onClose={() => setSelectedSubmission(null)}
/>
</Box>
)
}
interface SubmissionDetailsModalProps {
submission: ChallengeSubmission | null
isOpen: boolean
onClose: () => void
}
const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
submission,
isOpen,
onClose,
}) => {
if (!submission) return null
const user = submission.user as ChallengeUser
const task = submission.task as ChallengeTask
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
const getCheckTime = () => {
if (!submission.checkedAt) return null
const submitted = new Date(submission.submittedAt).getTime()
const checked = new Date(submission.checkedAt).getTime()
return ((checked - submitted) / 1000).toFixed(2)
}
return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
<DialogContent>
<DialogHeader>
<DialogTitle>Детали попытки #{submission.attemptNumber}</DialogTitle>
</DialogHeader>
<DialogBody>
<VStack gap={6} align="stretch">
{/* Meta */}
<Box>
<HStack mb={4} justify="space-between">
<Box>
<Text fontSize="sm" color="gray.600" mb={1}>
Пользователь
</Text>
<Text fontWeight="bold">{user.nickname}</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600" mb={1}>
Статус
</Text>
<StatusBadge status={submission.status} />
</Box>
</HStack>
<VStack align="stretch" gap={2}>
<Text fontSize="sm" color="gray.600">
<strong>Отправлено:</strong> {formatDate(submission.submittedAt)}
</Text>
{submission.checkedAt && (
<>
<Text fontSize="sm" color="gray.600">
<strong>Проверено:</strong> {formatDate(submission.checkedAt)}
</Text>
<Text fontSize="sm" color="gray.600">
<strong>Время проверки:</strong> {getCheckTime()} сек
</Text>
</>
)}
</VStack>
</Box>
{/* Task */}
<Box>
<Text fontWeight="bold" mb={2}>
Задание: {task.title}
</Text>
<Box
p={4}
bg="gray.50"
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
maxH="200px"
overflowY="auto"
>
<ReactMarkdown>{task.description}</ReactMarkdown>
</Box>
</Box>
{/* Solution */}
<Box>
<Text fontWeight="bold" mb={2}>
Решение пользователя:
</Text>
<Box
p={4}
bg="blue.50"
borderRadius="md"
borderWidth="1px"
borderColor="blue.200"
maxH="300px"
overflowY="auto"
>
<Text
fontFamily="monospace"
fontSize="sm"
whiteSpace="pre-wrap"
wordBreak="break-word"
>
{submission.result}
</Text>
</Box>
</Box>
{/* Feedback */}
{submission.feedback && (
<Box>
<Text fontWeight="bold" mb={2}>
Обратная связь от LLM:
</Text>
<Box
p={4}
bg={submission.status === 'accepted' ? 'green.50' : 'red.50'}
borderRadius="md"
borderWidth="1px"
borderColor={submission.status === 'accepted' ? 'green.200' : 'red.200'}
>
<Text>{submission.feedback}</Text>
</Box>
</Box>
)}
</VStack>
</DialogBody>
<DialogFooter>
<DialogActionTrigger asChild>
<Button variant="outline" onClick={onClose}>
Закрыть
</Button>
</DialogActionTrigger>
</DialogFooter>
</DialogContent>
</DialogRoot>
)
}

View File

@@ -0,0 +1,244 @@
import React, { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
Box,
Heading,
Button,
Input,
Textarea,
VStack,
HStack,
Text,
Flex,
Stack,
Field,
Tabs,
} from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown'
import {
useGetTaskQuery,
useCreateTaskMutation,
useUpdateTaskMutation,
} from '../../__data__/api/api'
import { URLs } from '../../__data__/urls'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { toaster } from '../../components/ui/toaster'
export const TaskFormPage: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const isEdit = !!id
const { data: task, isLoading: isLoadingTask, error: loadError } = useGetTaskQuery(id!, {
skip: !id,
})
const [createTask, { isLoading: isCreating }] = useCreateTaskMutation()
const [updateTask, { isLoading: isUpdating }] = useUpdateTaskMutation()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [hiddenInstructions, setHiddenInstructions] = useState('')
const [showDescPreview, setShowDescPreview] = useState(false)
useEffect(() => {
if (task) {
setTitle(task.title)
setDescription(task.description)
setHiddenInstructions(task.hiddenInstructions || '')
}
}, [task])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim() || !description.trim()) {
toaster.create({
title: 'Ошибка валидации',
description: 'Заполните обязательные поля',
type: 'error',
})
return
}
try {
if (isEdit && id) {
await updateTask({
id,
data: {
title: title.trim(),
description: description.trim(),
hiddenInstructions: hiddenInstructions.trim() || undefined,
},
}).unwrap()
toaster.create({
title: 'Успешно',
description: 'Задание обновлено',
type: 'success',
})
} else {
await createTask({
title: title.trim(),
description: description.trim(),
hiddenInstructions: hiddenInstructions.trim() || undefined,
}).unwrap()
toaster.create({
title: 'Успешно',
description: 'Задание создано',
type: 'success',
})
}
navigate(URLs.tasks)
} catch (err: any) {
toaster.create({
title: 'Ошибка',
description: err?.data?.error?.message || 'Не удалось сохранить задание',
type: 'error',
})
}
}
if (isEdit && isLoadingTask) {
return <LoadingSpinner message="Загрузка задания..." />
}
if (isEdit && loadError) {
return <ErrorAlert message="Не удалось загрузить задание" />
}
const isLoading = isCreating || isUpdating
return (
<Box>
<Heading mb={6}>{isEdit ? 'Редактировать задание' : 'Создать задание'}</Heading>
<Box
as="form"
onSubmit={handleSubmit}
bg="white"
p={6}
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<VStack gap={6} align="stretch">
{/* Title */}
<Field.Root required>
<Field.Label>Название задания</Field.Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Введите название задания"
maxLength={255}
disabled={isLoading}
/>
<Field.HelperText>Максимум 255 символов</Field.HelperText>
</Field.Root>
{/* Description with Markdown */}
<Field.Root required>
<Field.Label>Описание (Markdown)</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.List>
<Tabs.Content value="editor" pt={4}>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="# Заголовок задания&#10;&#10;Описание задания в формате Markdown..."
rows={15}
fontFamily="monospace"
disabled={isLoading}
/>
</Tabs.Content>
<Tabs.Content value="preview" pt={4}>
<Box
p={4}
borderWidth="1px"
borderColor="gray.200"
borderRadius="md"
minH="300px"
bg="gray.50"
>
{description ? (
<Box className="markdown-preview">
<ReactMarkdown>{description}</ReactMarkdown>
</Box>
) : (
<Text color="gray.400" fontStyle="italic">
Предпросмотр появится здесь...
</Text>
)}
</Box>
</Tabs.Content>
</Tabs.Root>
<Field.HelperText>Используйте Markdown для форматирования текста</Field.HelperText>
</Field.Root>
{/* Hidden Instructions */}
<Field.Root>
<Box bg="purple.50" p={4} borderRadius="md" borderWidth="1px" borderColor="purple.200">
<HStack mb={2}>
<Text fontWeight="bold" color="purple.800">
🔒 Скрытые инструкции для LLM
</Text>
</HStack>
<Text fontSize="sm" color="purple.700" mb={3}>
Эти инструкции будут переданы LLM при проверке решений студентов. Студенты их не
увидят.
</Text>
<Textarea
value={hiddenInstructions}
onChange={(e) => setHiddenInstructions(e.target.value)}
placeholder="Например: Проверь, что сложность алгоритма O(n log n). Код должен обрабатывать edge cases..."
rows={6}
disabled={isLoading}
/>
<Field.HelperText>
Опционально. Используйте для тонкой настройки проверки LLM.
</Field.HelperText>
</Box>
</Field.Root>
{/* Meta info for edit mode */}
{isEdit && task && (
<Box p={4} bg="gray.50" borderRadius="md">
<Text fontSize="sm" color="gray.600">
<strong>Создано:</strong>{' '}
{new Date(task.createdAt).toLocaleString('ru-RU')}
</Text>
{task.creator && (
<Text fontSize="sm" color="gray.600">
<strong>Автор:</strong> {task.creator.preferred_username}
</Text>
)}
{task.updatedAt !== task.createdAt && (
<Text fontSize="sm" color="gray.600">
<strong>Обновлено:</strong>{' '}
{new Date(task.updatedAt).toLocaleString('ru-RU')}
</Text>
)}
</Box>
)}
{/* Actions */}
<HStack gap={3} justify="flex-end">
<Button variant="outline" onClick={() => navigate(URLs.tasks)} disabled={isLoading}>
Отмена
</Button>
<Button type="submit" colorPalette="teal" loading={isLoading}>
{isEdit ? 'Сохранить изменения' : 'Создать задание'}
</Button>
</HStack>
</VStack>
</Box>
</Box>
)
}

View File

@@ -0,0 +1,180 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Box,
Heading,
Button,
Table,
Flex,
Input,
HStack,
Text,
IconButton,
Badge,
createListCollection,
} from '@chakra-ui/react'
import { useGetTasksQuery, useDeleteTaskMutation } 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 { ConfirmDialog } from '../../components/ConfirmDialog'
import type { ChallengeTask } from '../../types/challenge'
import { toaster } from '../../components/ui/toaster'
export const TasksListPage: React.FC = () => {
const navigate = useNavigate()
const { data: tasks, isLoading, error, refetch } = useGetTasksQuery()
const [deleteTask, { isLoading: isDeleting }] = useDeleteTaskMutation()
const [searchQuery, setSearchQuery] = useState('')
const [taskToDelete, setTaskToDelete] = useState<ChallengeTask | null>(null)
const handleDeleteTask = async () => {
if (!taskToDelete) return
try {
await deleteTask(taskToDelete.id).unwrap()
toaster.create({
title: 'Успешно',
description: 'Задание удалено',
type: 'success',
})
setTaskToDelete(null)
} catch (err) {
toaster.create({
title: 'Ошибка',
description: 'Не удалось удалить задание',
type: 'error',
})
}
}
if (isLoading) {
return <LoadingSpinner message="Загрузка заданий..." />
}
if (error || !tasks) {
return <ErrorAlert message="Не удалось загрузить список заданий" onRetry={refetch} />
}
const filteredTasks = tasks.filter((task) =>
task.title.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>Задания</Heading>
<Button colorPalette="teal" onClick={() => navigate(URLs.taskNew)}>
+ Создать задание
</Button>
</Flex>
{tasks.length > 0 && (
<Box mb={4}>
<Input
placeholder="Поиск по названию..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
maxW="400px"
/>
</Box>
)}
{filteredTasks.length === 0 && tasks.length === 0 ? (
<EmptyState
title="Нет заданий"
description="Создайте первое задание для начала работы"
actionLabel="Создать задание"
onAction={() => navigate(URLs.taskNew)}
/>
) : filteredTasks.length === 0 ? (
<EmptyState
title="Ничего не найдено"
description={`По запросу "${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.Row>
</Table.Header>
<Table.Body>
{filteredTasks.map((task) => (
<Table.Row key={task.id}>
<Table.Cell fontWeight="medium">{task.title}</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{task.creator?.preferred_username || 'N/A'}
</Text>
</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{formatDate(task.createdAt)}
</Text>
</Table.Cell>
<Table.Cell>
{task.hiddenInstructions ? (
<Badge colorPalette="purple" variant="subtle">
🔒 Есть
</Badge>
) : (
<Text fontSize="sm" color="gray.400">
</Text>
)}
</Table.Cell>
<Table.Cell textAlign="right">
<HStack gap={2} justify="flex-end">
<Button
size="sm"
variant="ghost"
onClick={() => navigate(URLs.taskEdit(task.id))}
>
Редактировать
</Button>
<Button
size="sm"
variant="ghost"
colorPalette="red"
onClick={() => setTaskToDelete(task)}
>
Удалить
</Button>
</HStack>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Box>
)}
<ConfirmDialog
isOpen={!!taskToDelete}
onClose={() => setTaskToDelete(null)}
onConfirm={handleDeleteTask}
title="Удалить задание"
message={`Вы уверены, что хотите удалить задание "${taskToDelete?.title}"? Это действие нельзя отменить.`}
confirmLabel="Удалить"
isLoading={isDeleting}
/>
</Box>
)
}

View File

@@ -0,0 +1,281 @@
import React, { useState } from 'react'
import {
Box,
Heading,
Table,
Input,
Text,
Button,
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
Grid,
VStack,
HStack,
Badge,
Progress,
} from '@chakra-ui/react'
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 { data: users, isLoading, error, refetch } = useGetUsersQuery()
const [searchQuery, setSearchQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
if (isLoading) {
return <LoadingSpinner message="Загрузка пользователей..." />
}
if (error || !users) {
return <ErrorAlert message="Не удалось загрузить список пользователей" onRetry={refetch} />
}
const filteredUsers = users.filter((user) =>
user.nickname.toLowerCase().includes(searchQuery.toLowerCase())
)
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
return (
<Box>
<Heading mb={6}>Пользователи</Heading>
{users.length > 0 && (
<Box mb={4}>
<Input
placeholder="Поиск по nickname..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
maxW="400px"
/>
</Box>
)}
{filteredUsers.length === 0 && users.length === 0 ? (
<EmptyState title="Нет пользователей" description="Пользователи появятся после регистрации" />
) : filteredUsers.length === 0 ? (
<EmptyState
title="Ничего не найдено"
description={`По запросу "${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.Row>
</Table.Header>
<Table.Body>
{filteredUsers.map((user) => (
<Table.Row key={user.id}>
<Table.Cell fontWeight="medium">{user.nickname}</Table.Cell>
<Table.Cell>
<Text fontSize="xs" fontFamily="monospace" color="gray.600">
{user.id}
</Text>
</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{formatDate(user.createdAt)}
</Text>
</Table.Cell>
<Table.Cell textAlign="right">
<Button
size="sm"
variant="ghost"
colorPalette="teal"
onClick={() => setSelectedUserId(user.id)}
>
Статистика
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Box>
)}
{/* User Stats Modal */}
<UserStatsModal
userId={selectedUserId}
isOpen={!!selectedUserId}
onClose={() => setSelectedUserId(null)}
/>
</Box>
)
}
interface UserStatsModalProps {
userId: string | null
isOpen: boolean
onClose: () => void
}
const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose }) => {
const { data: stats, isLoading } = useGetUserStatsQuery(userId!, {
skip: !userId,
})
return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
<DialogContent>
<DialogHeader>
<DialogTitle>Статистика пользователя</DialogTitle>
</DialogHeader>
<DialogBody>
{isLoading ? (
<LoadingSpinner message="Загрузка статистики..." />
) : !stats ? (
<Text color="gray.600">Нет данных</Text>
) : (
<VStack gap={6} align="stretch">
{/* Overview */}
<Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}>
<Box>
<Text fontSize="sm" color="gray.600">
Выполнено
</Text>
<Text fontSize="2xl" fontWeight="bold" color="green.600">
{stats.completedTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
Всего попыток
</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.totalSubmissions}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
В процессе
</Text>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{stats.inProgressTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
Требует доработки
</Text>
<Text fontSize="2xl" fontWeight="bold" color="red.600">
{stats.needsRevisionTasks}
</Text>
</Box>
</Grid>
{/* Chains Progress */}
{stats.chainStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
Прогресс по цепочкам
</Text>
<VStack gap={3} align="stretch">
{stats.chainStats.map((chain) => (
<Box key={chain.chainId}>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">
{chain.chainName}
</Text>
<Text fontSize="sm" color="gray.600">
{chain.completedTasks} / {chain.totalTasks}
</Text>
</HStack>
<Progress value={chain.progress} colorPalette="teal" size="sm" />
</Box>
))}
</VStack>
</Box>
)}
{/* Task Stats */}
{stats.taskStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
Задания
</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}
</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>
))}
</VStack>
</Box>
)}
{/* Average Check Time */}
<Box p={3} bg="purple.50" borderRadius="md">
<Text fontSize="sm" color="gray.700" mb={1}>
Среднее время проверки
</Text>
<Text fontSize="lg" fontWeight="bold" color="purple.700">
{(stats.averageCheckTimeMs / 1000).toFixed(2)} сек
</Text>
</Box>
</VStack>
)}
</DialogBody>
<DialogFooter>
<DialogActionTrigger asChild>
<Button variant="outline" onClick={onClose}>
Закрыть
</Button>
</DialogActionTrigger>
</DialogFooter>
</DialogContent>
</DialogRoot>
)
}