Add Submission Details Page and Update Localization for Submissions Overview

This commit is contained in:
2025-12-10 00:25:25 +03:00
parent 8710718a12
commit 71b6180ab9
6 changed files with 257 additions and 190 deletions

View File

@@ -177,6 +177,12 @@
"challenge.admin.submissions.details.solution": "User solution:",
"challenge.admin.submissions.details.feedback": "LLM feedback:",
"challenge.admin.submissions.details.close": "Close",
"challenge.admin.submissions.details.not.found": "Attempt not found",
"challenge.admin.submissions.overview.title": "Overall participant progress",
"challenge.admin.submissions.overview.description": "Below is an overview of all participants and chains. Select a user above to see their individual attempts.",
"challenge.admin.submissions.overview.table.user": "Participant",
"challenge.admin.submissions.overview.table.chain": "Chain",
"challenge.admin.submissions.overview.table.progress": "Progress",
"challenge.admin.layout.title": "Challenge Admin",
"challenge.admin.layout.nav.dashboard": "Dashboard",
"challenge.admin.layout.nav.detailed.stats": "Detailed Statistics",

View File

@@ -177,6 +177,7 @@
"challenge.admin.submissions.details.solution": "Решение пользователя:",
"challenge.admin.submissions.details.feedback": "Обратная связь от LLM:",
"challenge.admin.submissions.details.close": "Закрыть",
"challenge.admin.submissions.details.not.found": "Попытка не найдена",
"challenge.admin.submissions.overview.title": "Общий прогресс по участникам",
"challenge.admin.submissions.overview.description": "Ниже — сводка по прогрессу всех участников и цепочек. Выберите пользователя выше, чтобы просмотреть его отдельные попытки.",
"challenge.admin.submissions.overview.table.user": "Участник",

View File

@@ -34,6 +34,8 @@ export const URLs = {
// Submissions
submissions: makeUrl('/submissions'),
submissionDetails: (userId: string, submissionId: string) => makeUrl(`/submissions/${userId}/${submissionId}`),
submissionDetailsPath: makeUrl('/submissions/:userId/:submissionId'),
// External links
challengePlayer: navs['link.challenge'] || '/challenge',

View File

@@ -10,6 +10,7 @@ import { ChainsListPage } from './pages/chains/ChainsListPage'
import { ChainFormPage } from './pages/chains/ChainFormPage'
import { UsersPage } from './pages/users/UsersPage'
import { SubmissionsPage } from './pages/submissions/SubmissionsPage'
import { SubmissionDetailsPage } from './pages/submissions/SubmissionDetailsPage'
import { URLs } from './__data__/urls'
const PageWrapper = ({ children }: React.PropsWithChildren) => (
@@ -120,6 +121,14 @@ export const Dashboard = () => {
</PageWrapper>
}
/>
<Route
path={URLs.submissionDetailsPath}
element={
<PageWrapper>
<SubmissionDetailsPage />
</PageWrapper>
}
/>
</Routes>
)
}

View File

@@ -0,0 +1,235 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useParams, useNavigate } from 'react-router-dom'
import { Box, Heading, Text, Button, HStack, VStack } from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown'
import { useGetUserSubmissionsQuery } from '../../__data__/api/api'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { StatusBadge } from '../../components/StatusBadge'
import type { ChallengeTask, ChallengeUser } from '../../types/challenge'
import { URLs } from '../../__data__/urls'
export const SubmissionDetailsPage: React.FC = () => {
const { t } = useTranslation()
const { userId, submissionId } = useParams<{ userId: string; submissionId: string }>()
const navigate = useNavigate()
// Получаем submissions для конкретного пользователя
const { data: submissions, isLoading, error } = useGetUserSubmissionsQuery(
{ userId: userId!, taskId: undefined },
{ skip: !userId }
)
const submission = submissions?.find((s) => s.id === submissionId)
const handleBack = () => {
navigate(URLs.submissions)
}
if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.submissions.loading')} />
}
if (error) {
return <ErrorAlert message={t('challenge.admin.submissions.load.error')} onRetry={handleBack} />
}
if (!submission) {
return (
<Box>
<Button variant="ghost" onClick={handleBack} mb={4}>
{t('challenge.admin.common.close')}
</Button>
<ErrorAlert
message={t('challenge.admin.submissions.details.not.found')}
onRetry={handleBack}
/>
</Box>
)
}
const rawUser = submission.user as ChallengeUser | string | undefined
const rawTask = submission.task as ChallengeTask | string | undefined
const userNickname =
rawUser && typeof rawUser === 'object' && 'nickname' in rawUser
? rawUser.nickname ?? ''
: typeof rawUser === 'string'
? rawUser
: ''
const taskTitle =
rawTask && typeof rawTask === 'object' && 'title' in rawTask
? rawTask.title ?? ''
: typeof rawTask === 'string'
? rawTask
: ''
const taskDescription =
rawTask && typeof rawTask === 'object' && 'description' in rawTask
? rawTask.description ?? ''
: ''
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 getCheckTimeValue = () => {
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 (
<Box>
{/* Header with back button */}
<HStack mb={6}>
<Button variant="ghost" onClick={handleBack}>
{t('challenge.admin.common.close')}
</Button>
</HStack>
<Heading mb={6}>
{t('challenge.admin.submissions.details.title')} #{submission.attemptNumber}
</Heading>
<VStack gap={6} align="stretch">
{/* Meta */}
<Box
p={6}
bg="white"
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<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">{userNickname}</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600" mb={1}>
{t('challenge.admin.submissions.details.status')}
</Text>
<StatusBadge status={submission.status} />
</Box>
</HStack>
<VStack align="stretch" gap={2}>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.submitted')}</strong>{' '}
{formatDate(submission.submittedAt)}
</Text>
{submission.checkedAt && (
<>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.checked')}</strong>{' '}
{formatDate(submission.checkedAt)}
</Text>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.check.time')}</strong>{' '}
{t('challenge.admin.submissions.check.time', { time: getCheckTimeValue() })}
</Text>
</>
)}
</VStack>
</Box>
{/* Task */}
<Box
p={6}
bg="white"
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<Text fontWeight="bold" mb={4}>
{t('challenge.admin.submissions.details.task')} {taskTitle}
</Text>
<Box
p={4}
bg="gray.50"
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
maxH="400px"
overflowY="auto"
>
<ReactMarkdown>{taskDescription}</ReactMarkdown>
</Box>
</Box>
{/* Solution */}
<Box
p={6}
bg="white"
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<Text fontWeight="bold" mb={4}>
{t('challenge.admin.submissions.details.solution')}
</Text>
<Box
p={4}
bg="blue.50"
borderRadius="md"
borderWidth="1px"
borderColor="blue.200"
maxH="500px"
overflowY="auto"
>
<Text
fontFamily="monospace"
fontSize="sm"
whiteSpace="pre-wrap"
wordBreak="break-word"
>
{submission.result}
</Text>
</Box>
</Box>
{/* Feedback */}
{submission.feedback && (
<Box
p={6}
bg="white"
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<Text fontWeight="bold" mb={4}>
{t('challenge.admin.submissions.details.feedback')}
</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>
</Box>
)
}

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import {
Box,
Heading,
@@ -10,23 +11,16 @@ import {
HStack,
VStack,
Select,
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
Progress,
Grid,
createListCollection,
} from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown'
import { useGetSystemStatsV2Query, useGetUserSubmissionsQuery } 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 { URLs } from '../../__data__/urls'
import type {
ActiveParticipant,
ChallengeSubmission,
@@ -37,12 +31,12 @@ import type {
export const SubmissionsPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { data: stats, isLoading: isStatsLoading, error: statsError, refetch: refetchStats } =
useGetSystemStatsV2Query(undefined)
const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<SubmissionStatus | 'all'>('all')
const [selectedSubmission, setSelectedSubmission] = useState<ChallengeSubmission | null>(null)
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
const {
@@ -369,7 +363,7 @@ export const SubmissionsPage: React.FC = () => {
size="sm"
variant="ghost"
colorPalette="teal"
onClick={() => setSelectedSubmission(submission)}
onClick={() => navigate(URLs.submissionDetails(selectedUserId!, submission.id))}
>
{t('challenge.admin.submissions.button.details')}
</Button>
@@ -381,187 +375,7 @@ export const SubmissionsPage: React.FC = () => {
</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,
}) => {
const { t } = useTranslation()
if (!submission) return null
const rawUser = submission.user as ChallengeUser | string | undefined
const rawTask = submission.task as ChallengeTask | string | undefined
const userNickname =
rawUser && typeof rawUser === 'object' && 'nickname' in rawUser
? (rawUser.nickname ?? '')
: typeof rawUser === 'string'
? rawUser
: ''
const taskTitle =
rawTask && typeof rawTask === 'object' && 'title' in rawTask
? (rawTask.title ?? '')
: typeof rawTask === 'string'
? rawTask
: ''
const taskDescription =
rawTask && typeof rawTask === 'object' && 'description' in rawTask
? (rawTask.description ?? '')
: ''
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 getCheckTimeValue = () => {
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 maxH="90vh" overflowY="auto">
<DialogHeader>
<DialogTitle>
{t('challenge.admin.submissions.details.title')} #{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}>
{t('challenge.admin.submissions.details.user')}
</Text>
<Text fontWeight="bold">{userNickname}</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600" mb={1}>
{t('challenge.admin.submissions.details.status')}
</Text>
<StatusBadge status={submission.status} />
</Box>
</HStack>
<VStack align="stretch" gap={2}>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.submitted')}</strong> {formatDate(submission.submittedAt)}
</Text>
{submission.checkedAt && (
<>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.checked')}</strong> {formatDate(submission.checkedAt)}
</Text>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.check.time')}</strong> {t('challenge.admin.submissions.check.time', { time: getCheckTimeValue() })}
</Text>
</>
)}
</VStack>
</Box>
{/* Task */}
<Box>
<Text fontWeight="bold" mb={2}>
{t('challenge.admin.submissions.details.task')} {taskTitle}
</Text>
<Box
p={4}
bg="gray.50"
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
maxH="200px"
overflowY="auto"
>
<ReactMarkdown>{taskDescription}</ReactMarkdown>
</Box>
</Box>
{/* Solution */}
<Box>
<Text fontWeight="bold" mb={2}>
{t('challenge.admin.submissions.details.solution')}
</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}>
{t('challenge.admin.submissions.details.feedback')}
</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}>
{t('challenge.admin.submissions.details.close')}
</Button>
</DialogActionTrigger>
</DialogFooter>
</DialogContent>
</DialogRoot>
)
}