@@ -1,6 +1,6 @@
import React , { useState } from 'react'
import React , { useState , useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate , useSearch Params } from 'react-router-dom'
import { useNavigate , useParams, Link } from 'react-router-dom'
import {
Box ,
Heading ,
@@ -10,37 +10,56 @@ import {
Button ,
HStack ,
VStack ,
Select ,
Badge ,
Progress ,
Grid ,
SimpleGrid ,
Select ,
createListCollection ,
} from '@chakra-ui/react'
import { useGetSystemStatsV2Query , useGetUserSubmissionsQuery } from '../../__data__/api/api'
import { useGetChainsQuery , 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 ,
SubmissionStatus ,
ChallengeTask ,
ChallengeUser ,
ActiveParticipant ,
} from '../../types/challenge'
export const SubmissionsPage : React.FC = ( ) = > {
const { t } = useTranslation ( )
const navigate = useNavigate ( )
const [ searchParams ] = useSearchParams ( )
const initialUserId = searchParams . get ( 'userId' )
const { data : stats , isLoading : isStatsLoading , error : statsError , refetch : refetchStats } =
useGetSystemStatsV2Query ( undefined )
const { chainId } = useParams < { chainId? : string } > ( )
// Состояние для выбранного пользователя и фильтров
const [ selectedUserId , setSelectedUserId ] = useState < string | null > ( null )
const [ searchQuery , setSearchQuery ] = useState ( '' )
const [ statusFilter , setStatusFilter ] = useState < SubmissionStatus | 'all' > ( 'all' )
const [ selectedUserId , setSelectedUserId ] = useState < string | null > ( initialUserId )
// Получаем список цепочек
const {
data : chains ,
isLoading : isChainsLoading ,
error : chainsError ,
refetch : refetchChains ,
} = useGetChainsQuery ( )
// Получаем общую статистику (без фильтра по chainId - получаем всех участников)
const {
data : stats ,
isLoading : isStatsLoading ,
error : statsError ,
refetch : refetchStats ,
} = useGetSystemStatsV2Query ( undefined , {
skip : ! chainId , // Загружаем только когда выбрана цепочка
} )
// Получаем submissions для выбранного пользователя
const {
data : submissions ,
isLoading : isSubmissionsLoading ,
@@ -51,51 +70,85 @@ export const SubmissionsPage: React.FC = () => {
{ skip : ! selectedUserId }
)
const isLoading = isStatsLoading || ( selectedUserId && isSubmissionsLoading )
const error = statsError || submissionsError
const isLoading = isChainsLoading || ( chainId && isStatsLoading) || ( selectedUserId && isSubmissionsLoading )
const error = chainsError || statsError || submissionsError
const handleRetry = ( ) = > {
refetchChains ( )
refetchStats ( )
if ( selectedUserId ) {
refetchSubmissions ( )
}
}
if ( isLoading ) {
return < LoadingSpinner message = { t ( 'challenge.admin.submissions.loading' ) } / >
}
// Получаем данные выбранной цепочки из списка chains
const selectedChain = useMemo ( ( ) = > {
if ( ! chainId || ! chains ) return null
return chains . find ( ( c ) = > c . id === chainId ) || null
} , [ chainId , chains ] )
if ( error || ! stats ) {
return < ErrorAlert message = { t ( 'challenge.admin.submissions.load.error' ) } onRetry = { handleRetry } / >
}
// Получаем taskIds из текущей цепочки
const chainTaskIds = useMemo ( ( ) = > {
if ( ! selectedChain ) return new Set < string > ( )
return new Set ( selectedChain . tasks . map ( ( t ) = > t . id ) )
} , [ selectedChain ] )
const participants : ActiveParticipant [ ] = stats . activeParticipants || [ ]
const submissionsList : ChallengeSubmission [ ] = submissions || [ ]
// Фильтруем участников - только те, кто имеет прогресс в этой цепочке
const chainParticipants = useMemo ( ( ) = > {
if ( ! stats ? . activeParticipants || ! chainId ) return [ ]
return stats . activeParticipants
. map ( ( participant ) = > {
// Ищем прогресс участника по выбранной цепочке
const chainProgress = participant . chainProgress ? . find ( ( cp ) = > cp . chainId === chainId )
// Если нет прогресса по этой цепочке, пробуем рассчитать на основе submissions
// Для простоты показываем всех участников с базовым прогрессом 0%
return {
. . . participant ,
progressPercent : chainProgress?.progressPercent ? ? 0 ,
completedTasks : chainProgress?.completedTasks ? ? 0 ,
totalTasks : selectedChain?.tasks.length ? ? 0 ,
}
} )
. sort ( ( a , b ) = > a . progressPercent - b . progressPercent )
} , [ stats ? . activeParticipants , chainId , selectedChain ] )
const normalizedSearchQuery = ( searchQuery ? ? '' ) . toLowerCase ( )
// Фильтруем submissions только по заданиям из текущей цепочки
const filteredSubmissions = useMemo ( ( ) = > {
if ( ! submissions || chainTaskIds . size === 0 ) return [ ]
const filteredSubmissions = submissionsList . filter ( ( submission ) = > {
const rawUser = submission . user as ChallengeUser | string | undefined
const rawTask = submission . task as ChallengeTask | string | undefined
const normalizedSearchQuery = ( searchQuery ? ? '' ) . toLowerCase ( )
const nickname =
rawUser && typeof rawUser === 'object' && 'nickname' in rawUser
? ( rawUser . nickname ? ? '' )
: ''
return submissions . filter ( ( submission ) = > {
// Фильтр по цепочке (по taskId)
const rawTask = submission . task as ChallengeTask | string | undefined
const taskId = rawTask && typeof rawTask === 'object' && 'id' in rawTask
? rawTask.id
: typeof rawTask === 'string' ? rawTask : ''
if ( ! chainTaskIds . has ( taskId ) ) return false
const title =
rawTask && typeof rawTask === 'object' && 'title' in rawTask
? ( rawTask . title ? ? '' )
: ''
// Фильтр по поиску
const rawUser = submission . user as ChallengeUser | 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 matchesSearch =
nickname . toLowerCase ( ) . includes ( normalizedSearchQuery ) ||
title . toLowerCase ( ) . includes ( normalizedSearchQuery )
// Фильтр по статусу
const matchesStatus = statusFilter === 'all' || submission . status === statusFilter
const matchesStatus = statusFilter === 'all' || submission . status === statusFilter
return matchesSearch && matchesStatus
} )
return matchesSearch && matchesStatus
} )
} , [ submissions , chainTaskIds , searchQuery , statusFilter ] )
const formatDate = ( dateStr : string ) = > {
return new Date ( dateStr ) . toLocaleString ( 'ru-RU' , {
@@ -125,70 +178,119 @@ export const SubmissionsPage: React.FC = () => {
] ,
} )
const userOptions = createListCollection ( {
items : participants.map ( ( participant ) = > ( {
label : ` ${ participant . nickname } ( ${ participant . userId } ) ` ,
value : participant.userId ,
} ) ) ,
} )
if ( isLoading ) {
return < LoadingSpinner message = { t ( 'challenge.admin.submissions.loading' ) } / >
}
const hasParticipants = participants . length > 0
const hasSelectedUser = ! ! selectedUserId
if ( error ) {
return < ErrorAlert message = { t ( 'challenge.admin.submissions.load.error' ) } onRetry = { handleRetry } / >
}
const participantOverviewRows = participants
. map ( ( participant ) = > {
const chains = participant . chainProgress || [ ]
// Если chainId не указан - показываем выбор цепочки
if ( ! chainId ) {
return (
< Box >
< Box mb = { 6 } >
< Heading mb = { 2 } > { t ( 'challenge.admin.submissions.title' ) } < / Heading >
< Text color = "gray.600" fontSize = "sm" >
{ t ( 'challenge.admin.submissions.select.chain' ) }
< / Text >
< / Box >
const totalTasks = chains . reduce ( ( sum , chain ) = > sum + ( chain . totalTasks ? ? 0 ) , 0 )
const completedTasks = chains . reduce (
( sum , chain ) = > sum + ( chain . completedTasks ? ? 0 ) ,
0
)
{ chains && chains . length > 0 ? (
< SimpleGrid columns = { { base : 1 , md : 2 , lg : 3 } } gap = { 6 } >
{ chains . map ( ( chain ) = > (
< Link key = { chain . id } to = { URLs . submissionsChain ( chain . id ) } style = { { textDecoration : 'none' } } >
< Box
p = { 6 }
bg = "white"
borderRadius = "lg"
boxShadow = "sm"
borderWidth = "1px"
borderColor = "gray.200"
_hover = { {
boxShadow : 'md' ,
borderColor : 'teal.400' ,
transform : 'translateY(-2px)' ,
} }
transition = "all 0.2s"
cursor = "pointer"
height = "100%"
>
< VStack align = "start" gap = { 3 } >
< Heading size = "md" color = "teal.600" >
{ chain . name }
< / Heading >
< HStack >
< Badge colorPalette = "teal" size = "lg" >
{ chain . tasks . length } { t ( 'challenge.admin.submissions.chain.tasks' ) }
< / Badge >
{ ! chain . isActive && (
< Badge colorPalette = "gray" size = "lg" >
{ t ( 'challenge.admin.chains.list.status.inactive' ) }
< / Badge >
) }
< / HStack >
< Text fontSize = "sm" color = "gray.600" mt = { 2 } >
{ t ( 'challenge.admin.submissions.chain.click' ) }
< / Text >
< / VStack >
< / Box >
< / Link >
) ) }
< / SimpleGrid >
) : (
< EmptyState
title = { t ( 'challenge.admin.submissions.no.chains.title' ) }
description = { t ( 'challenge.admin.submissions.no.chains.description' ) }
/ >
) }
< / Box >
)
}
const overallPercent =
totalTasks > 0 ? Math . round ( ( comp letedTasks / totalTasks ) * 100 ) : 0
// Если цепочка выбрана но данных нет
if ( ! se lec tedChain ) {
return (
< Box >
< Link to = { URLs . submissions } style = { { textDecoration : 'none' , color : '#319795' } } >
< Text fontSize = "sm" _hover = { { textDecoration : 'underline' } } mb = { 4 } >
← { t ( 'challenge.admin.submissions.back.to.chains' ) }
< / Text >
< / Link >
< ErrorAlert message = { t ( 'challenge.admin.common.not.found' ) } onRetry = { handleRetry } / >
< / Box >
)
}
return {
userId : participant.userId ,
nickname : participant.nickname ,
totalSubmissions : participant.totalSubmissions ,
completedTasks ,
totalTasks ,
overallPercent ,
}
} )
. sort ( ( a , b ) = > a . overallPercent - b . overallPercent )
const participants : ActiveParticipant [ ] = stats ? . activeParticipants || [ ]
return (
< Box >
< Heading mb = { 6 } > { t ( 'challenge.admin.submissions.title' ) } < / Heading >
{ /* Header с навигацией */ }
< Box mb = { 6 } >
< HStack gap = { 2 } mb = { 2 } >
< Link to = { URLs . submissions } style = { { textDecoration : 'none' , color : '#319795' } } >
< Text fontSize = "sm" _hover = { { textDecoration : 'underline' } } >
← { t ( 'challenge.admin.submissions.back.to.chains' ) }
< / Text >
< / Link >
< / HStack >
< Heading mb = { 2 } > { selectedChain . name } < / Heading >
< Text color = "gray.600" fontSize = "sm" >
{ t ( 'challenge.admin.submissions.chain.description' , { count : selectedChain.tasks.length } ) }
< / Text >
< / Box >
{ /* Filters */ }
{ hasP articipants && (
{ /* Выбор участника и фильтры */ }
{ p articipants. length > 0 && (
< VStack mb = { 4 } gap = { 3 } align = "stretch" >
< HStack gap = { 4 } align = "center" >
< S elect.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 && (
< HStack gap = { 4 } align = "center" wrap = "wrap" >
{ s electedUserId && (
< Button
size = "sm"
variant = "ghost "
variant = "outline "
colorPalette = "teal"
onClick = { ( ) = > {
setSelectedUserId ( null )
setSearchQuery ( '' )
@@ -199,13 +301,13 @@ export const SubmissionsPage: React.FC = () => {
< / Button >
) }
{ submissionsList . length > 0 && (
{ selectedUserId && filteredS ubmissions . length > 0 && (
< >
< Input
placeholder = { t ( 'challenge.admin.submissions.search.placeholder' ) }
value = { searchQuery }
onChange = { ( e ) = > setSearchQuery ( e . target . value ) }
maxW = "4 00px"
maxW = "3 00px"
/ >
< Select.Root
collection = { statusOptions }
@@ -230,24 +332,20 @@ export const SubmissionsPage: React.FC = () => {
< / VStack >
) }
{ ! hasParticipants ? (
< EmptyState
title = { t ( 'challenge.admin.submissions.empty.title' ) }
description = { t ( 'challenge.admin.submissions.empty.description' ) }
/ >
) : ! hasSelectedUser ? (
{ /* Если не выбран пользователь - показываем обзор участников */ }
{ ! selectedUserId ? (
< Box >
< Heading size = "md" mb = { 4 } >
{ t ( 'challenge.admin.submissions.overview .title' ) }
{ t ( 'challenge.admin.submissions.participants .title' ) }
< / Heading >
< Text mb = { 4 } color = "gray.600" >
{ t ( 'challenge.admin.submissions.overview .description' ) }
{ t ( 'challenge.admin.submissions.participants .description' ) }
< / Text >
{ p articipantOverviewRow s. length === 0 ? (
{ chainP articipants. length === 0 ? (
< EmptyState
title = { t ( 'challenge.admin.detailed.stat s.participants.empty' ) }
description = { t ( 'challenge.admin.detailed.stats.chains.empty ' ) }
title = { t ( 'challenge.admin.submission s.participants.empty.title ' ) }
description = { t ( 'challenge.admin.submissions.participants.empty.description ' ) }
/ >
) : (
< Grid
@@ -257,43 +355,50 @@ export const SubmissionsPage: React.FC = () => {
lg : 'repeat(3, minmax(0, 1fr))' ,
xl : 'repeat(4, minmax(0, 1fr))' ,
} }
gap = { 2 }
gap = { 3 }
>
{ p articipantOverviewRow s. map ( ( row ) = > {
{ chainP articipants. map ( ( participant ) = > {
const colorPalette =
row . overall Percent >= 70
participant . progress Percent >= 70
? 'green'
: row . overall Percent >= 40
: participant . progress Percent >= 40
? 'orange'
: 'red'
return (
< Box
key = { row . userId }
p = { 2 }
key = { participant . userId }
p = { 3 }
borderWidth = "1px"
borderRadius = "md"
borderColor = "gray.200"
_hover = { { bg : 'gray.50' } }
bg = "white"
_hover = { { bg : 'gray.50' , borderColor : 'teal.300' } }
cursor = "pointer"
onClick = { ( ) = > setSelectedUserId ( row . userId ) }
onClick = { ( ) = > setSelectedUserId ( participant . userId ) }
transition = "all 0.2s"
>
< HStack justify = "space-between" mb = { 1 } gap = { 2 } >
< Text fontSize = "x s" fontWeight = "medium" truncate maxW = "15 0px" >
{ row . nickname }
< / Text >
< Text fontSize = "xs" color = "gray.500" >
{ row . overallPercent } %
< HStack justify = "space-between" mb = { 2 } gap = { 2 } >
< Text fontSize = "sm " fontWeight = "medium" truncate maxW = "18 0px" >
{ participant . nickname }
< / Text >
< Badge colorPalette = { colorPalette } size = "sm" >
{ participant . progressPercent } %
< / Badge >
< / HStack >
< Progress.Root value = { row . overall Percent} size = "x s" colorPalette = { colorPalette } >
< Progress.Root value = { participant . progress Percent} size = "sm " colorPalette = { colorPalette } >
< Progress.Track >
< Progress.Range / >
< / Progress.Track >
< / Progress.Root >
< Text fontSize = "xs" color = "gray.500 " mt = { 1 } >
{ row . completedTasks } / { row . totalTasks }
< / Text >
< HStack justify = "space-between " mt = { 2 } >
< Text fontSize = "xs" color = "gray.500" >
{ participant . completedTasks } / { participant . totalTasks }
< / Text >
< Text fontSize = "xs" color = "gray.400" >
{ t ( 'challenge.admin.submissions.participants.click.to.view' ) }
< / Text >
< / HStack >
< / Box >
)
} ) }
@@ -306,6 +411,7 @@ export const SubmissionsPage: React.FC = () => {
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 >
@@ -316,7 +422,9 @@ export const SubmissionsPage: React.FC = () => {
< 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.ColumnHeader textAlign = "right" >
{ t ( 'challenge.admin.submissions.table.actions' ) }
< / Table.ColumnHeader >
< / Table.Row >
< / Table.Header >
< Table.Body >
@@ -365,7 +473,7 @@ export const SubmissionsPage: React.FC = () => {
size = "sm"
variant = "ghost"
colorPalette = "teal"
onClick = { ( ) = > navigate ( URLs . submissionDetails ( selectedUserId ! , submission . id ) ) }
onClick = { ( ) = > navigate ( URLs . submissionDetails ( chainId ! , selectedUserId, submission . id ) ) }
>
{ t ( 'challenge.admin.submissions.button.details' ) }
< / Button >
@@ -380,4 +488,3 @@ export const SubmissionsPage: React.FC = () => {
< / Box >
)
}