421 lines
15 KiB
TypeScript
421 lines
15 KiB
TypeScript
import React, { useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
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 { 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 type {
|
|
ActiveParticipant,
|
|
ChallengeSubmission,
|
|
SubmissionStatus,
|
|
ChallengeTask,
|
|
ChallengeUser,
|
|
} from '../../types/challenge'
|
|
|
|
export const SubmissionsPage: React.FC = () => {
|
|
const { t } = useTranslation()
|
|
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 {
|
|
data: submissions,
|
|
isLoading: isSubmissionsLoading,
|
|
error: submissionsError,
|
|
refetch: refetchSubmissions,
|
|
} = useGetUserSubmissionsQuery(
|
|
{ userId: selectedUserId!, taskId: undefined },
|
|
{ skip: !selectedUserId }
|
|
)
|
|
|
|
const isLoading = isStatsLoading || (selectedUserId && isSubmissionsLoading)
|
|
const error = statsError || submissionsError
|
|
|
|
const handleRetry = () => {
|
|
refetchStats()
|
|
if (selectedUserId) {
|
|
refetchSubmissions()
|
|
}
|
|
}
|
|
|
|
if (isLoading) {
|
|
return <LoadingSpinner message={t('challenge.admin.submissions.loading')} />
|
|
}
|
|
|
|
if (error || !stats) {
|
|
return <ErrorAlert message={t('challenge.admin.submissions.load.error')} onRetry={handleRetry} />
|
|
}
|
|
|
|
const participants: ActiveParticipant[] = stats.activeParticipants || []
|
|
const submissionsList: ChallengeSubmission[] = submissions || []
|
|
|
|
const filteredSubmissions = submissionsList.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 t('challenge.admin.submissions.check.time', { time: diff })
|
|
}
|
|
|
|
const statusOptions = createListCollection({
|
|
items: [
|
|
{ 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' },
|
|
],
|
|
})
|
|
|
|
const userOptions = createListCollection({
|
|
items: participants.map((participant) => ({
|
|
label: `${participant.nickname} (${participant.userId})`,
|
|
value: participant.userId,
|
|
})),
|
|
})
|
|
|
|
const hasParticipants = participants.length > 0
|
|
const hasSelectedUser = !!selectedUserId
|
|
|
|
return (
|
|
<Box>
|
|
<Heading mb={6}>{t('challenge.admin.submissions.title')}</Heading>
|
|
|
|
{/* Filters */}
|
|
{hasParticipants && (
|
|
<VStack mb={4} gap={3} align="stretch">
|
|
<HStack gap={4}>
|
|
<Select.Root
|
|
collection={userOptions}
|
|
value={selectedUserId ? [selectedUserId] : []}
|
|
onValueChange={(e) => setSelectedUserId(e.value[0] ?? null)}
|
|
maxW="300px"
|
|
>
|
|
<Select.Trigger>
|
|
<Select.ValueText placeholder={t('challenge.admin.submissions.filter.user')} />
|
|
</Select.Trigger>
|
|
<Select.Content>
|
|
{userOptions.items.map((option) => (
|
|
<Select.Item key={option.value} item={option}>
|
|
{option.label}
|
|
</Select.Item>
|
|
))}
|
|
</Select.Content>
|
|
</Select.Root>
|
|
|
|
{submissionsList.length > 0 && (
|
|
<>
|
|
<Input
|
|
placeholder={t('challenge.admin.submissions.search.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={t('challenge.admin.submissions.filter.status')} />
|
|
</Select.Trigger>
|
|
<Select.Content>
|
|
{statusOptions.items.map((option) => (
|
|
<Select.Item key={option.value} item={option}>
|
|
{option.label}
|
|
</Select.Item>
|
|
))}
|
|
</Select.Content>
|
|
</Select.Root>
|
|
</>
|
|
)}
|
|
</HStack>
|
|
</VStack>
|
|
)}
|
|
|
|
{!hasParticipants ? (
|
|
<EmptyState
|
|
title={t('challenge.admin.submissions.empty.title')}
|
|
description={t('challenge.admin.submissions.empty.description')}
|
|
/>
|
|
) : !hasSelectedUser ? (
|
|
<EmptyState
|
|
title={t('challenge.admin.submissions.empty.title')}
|
|
description={t('challenge.admin.submissions.filter.user')}
|
|
/>
|
|
) : filteredSubmissions.length === 0 ? (
|
|
<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>{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>
|
|
{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)}
|
|
>
|
|
{t('challenge.admin.submissions.button.details')}
|
|
</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,
|
|
}) => {
|
|
const { t } = useTranslation()
|
|
|
|
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 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>
|
|
<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">{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>
|
|
</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')} {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}>
|
|
{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>
|
|
)
|
|
}
|
|
|