Files
challenge-admin-pl/src/pages/submissions/SubmissionsPage.tsx

566 lines
19 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,
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 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 normalizedSearchQuery = (searchQuery ?? '').toLowerCase()
const filteredSubmissions = submissionsList.filter((submission) => {
const rawUser = submission.user as ChallengeUser | string | undefined
const rawTask = submission.task as ChallengeTask | string | undefined
const nickname =
rawUser && typeof rawUser === 'object' && 'nickname' in rawUser
? (rawUser.nickname ?? '')
: ''
const title =
rawTask && typeof rawTask === 'object' && 'title' in rawTask
? (rawTask.title ?? '')
: ''
const matchesSearch =
nickname.toLowerCase().includes(normalizedSearchQuery) ||
title.toLowerCase().includes(normalizedSearchQuery)
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
const participantOverviewRows = participants
.map((participant) => {
const chains = participant.chainProgress || []
const totalTasks = chains.reduce((sum, chain) => sum + (chain.totalTasks ?? 0), 0)
const completedTasks = chains.reduce(
(sum, chain) => sum + (chain.completedTasks ?? 0),
0
)
const overallPercent =
totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
return {
userId: participant.userId,
nickname: participant.nickname,
totalSubmissions: participant.totalSubmissions,
completedTasks,
totalTasks,
overallPercent,
}
})
.sort((a, b) => a.overallPercent - b.overallPercent)
return (
<Box>
<Heading mb={6}>{t('challenge.admin.submissions.title')}</Heading>
{/* Filters */}
{hasParticipants && (
<VStack mb={4} gap={3} align="stretch">
<HStack gap={4} align="center">
<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>
{hasSelectedUser && (
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedUserId(null)
setSearchQuery('')
setStatusFilter('all')
}}
>
{t('challenge.admin.submissions.filter.user.clear')}
</Button>
)}
{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 ? (
<Box>
<Heading size="md" mb={4}>
{t('challenge.admin.submissions.overview.title')}
</Heading>
<Text mb={4} color="gray.600">
{t('challenge.admin.submissions.overview.description')}
</Text>
{participantOverviewRows.length === 0 ? (
<EmptyState
title={t('challenge.admin.detailed.stats.participants.empty')}
description={t('challenge.admin.detailed.stats.chains.empty')}
/>
) : (
<Grid
templateColumns={{
base: 'repeat(1, minmax(0, 1fr))',
md: 'repeat(2, minmax(0, 1fr))',
lg: 'repeat(3, minmax(0, 1fr))',
xl: 'repeat(4, minmax(0, 1fr))',
}}
gap={2}
>
{participantOverviewRows.map((row) => {
const colorPalette =
row.overallPercent >= 70
? 'green'
: row.overallPercent >= 40
? 'orange'
: 'red'
return (
<Box
key={row.userId}
p={2}
borderWidth="1px"
borderRadius="md"
borderColor="gray.200"
_hover={{ bg: 'gray.50' }}
cursor="pointer"
onClick={() => setSelectedUserId(row.userId)}
>
<HStack justify="space-between" mb={1} gap={2}>
<Text fontSize="xs" fontWeight="medium" truncate maxW="150px">
{row.nickname}
</Text>
<Text fontSize="xs" color="gray.500">
{row.overallPercent}%
</Text>
</HStack>
<Progress.Root value={row.overallPercent} size="xs" colorPalette={colorPalette}>
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
<Text fontSize="xs" color="gray.500" mt={1}>
{row.completedTasks} / {row.totalTasks}
</Text>
</Box>
)
})}
</Grid>
)}
</Box>
) : 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 rawUser = submission.user as ChallengeUser | string | undefined
const rawTask = submission.task as ChallengeTask | string | undefined
const nickname =
rawUser && typeof rawUser === 'object' && 'nickname' in rawUser
? (rawUser.nickname ?? '')
: typeof rawUser === 'string'
? rawUser
: ''
const title =
rawTask && typeof rawTask === 'object' && 'title' in rawTask
? (rawTask.title ?? '')
: typeof rawTask === 'string'
? rawTask
: ''
return (
<Table.Row key={submission.id}>
<Table.Cell fontWeight="medium">{nickname}</Table.Cell>
<Table.Cell>{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 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>
<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>
)
}