Add user stats page and refactor user navigation; replace modal with dedicated page for user statistics, enhancing routing and UI consistency. Update localization for new status keys in both English and Russian.
This commit is contained in:
@@ -31,6 +31,8 @@ export const URLs = {
|
||||
|
||||
// Users
|
||||
users: makeUrl('/users'),
|
||||
userStats: (userId: string) => makeUrl(`/users/${userId}`),
|
||||
userStatsPath: makeUrl('/users/:userId'),
|
||||
|
||||
// Submissions
|
||||
submissions: makeUrl('/submissions'),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TaskFormPage } from './pages/tasks/TaskFormPage'
|
||||
import { ChainsListPage } from './pages/chains/ChainsListPage'
|
||||
import { ChainFormPage } from './pages/chains/ChainFormPage'
|
||||
import { UsersPage } from './pages/users/UsersPage'
|
||||
import { UserStatsPage } from './pages/users/UserStatsPage'
|
||||
import { SubmissionsPage } from './pages/submissions/SubmissionsPage'
|
||||
import { SubmissionDetailsPage } from './pages/submissions/SubmissionDetailsPage'
|
||||
import { URLs } from './__data__/urls'
|
||||
@@ -111,6 +112,14 @@ export const Dashboard = () => {
|
||||
</PageWrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={URLs.userStatsPath}
|
||||
element={
|
||||
<PageWrapper>
|
||||
<UserStatsPage />
|
||||
</PageWrapper>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Submissions */}
|
||||
<Route
|
||||
|
||||
196
src/pages/users/UserStatsPage.tsx
Normal file
196
src/pages/users/UserStatsPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Box, Heading, Text, Button, Grid, VStack, HStack, Badge, Progress } from '@chakra-ui/react'
|
||||
import { useGetUserStatsQuery } from '../../__data__/api/api'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||
import { URLs } from '../../__data__/urls'
|
||||
|
||||
interface RouteParams {
|
||||
userId: string
|
||||
}
|
||||
|
||||
export const UserStatsPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { userId } = useParams<RouteParams>()
|
||||
|
||||
const { data: stats, isLoading, error, refetch } = useGetUserStatsQuery(userId!, {
|
||||
skip: !userId,
|
||||
})
|
||||
|
||||
const handleBack = () => {
|
||||
navigate(URLs.users)
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return (
|
||||
<Box>
|
||||
<Button variant="ghost" onClick={handleBack} mb={4}>
|
||||
← {t('challenge.admin.common.close')}
|
||||
</Button>
|
||||
<ErrorAlert message={t('challenge.admin.users.stats.no.data')} onRetry={handleBack} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message={t('challenge.admin.users.stats.loading')} />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box>
|
||||
<Button variant="ghost" onClick={handleBack} mb={4}>
|
||||
← {t('challenge.admin.common.close')}
|
||||
</Button>
|
||||
<ErrorAlert message={t('challenge.admin.users.load.error')} onRetry={refetch} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<Box>
|
||||
<Button variant="ghost" onClick={handleBack} mb={4}>
|
||||
← {t('challenge.admin.common.close')}
|
||||
</Button>
|
||||
<Text color="gray.600">{t('challenge.admin.users.stats.no.data')}</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack mb={6}>
|
||||
<Button variant="ghost" onClick={handleBack}>
|
||||
← {t('challenge.admin.common.close')}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
<Heading mb={6}>{t('challenge.admin.users.stats.title')}</Heading>
|
||||
|
||||
<VStack gap={6} align="stretch">
|
||||
{/* Overview */}
|
||||
<Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.users.stats.completed')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="green.600">
|
||||
{stats.completedTasks}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.users.stats.total.submissions')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
|
||||
{stats.totalSubmissions}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.users.stats.in.progress')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||
{stats.inProgressTasks}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.users.stats.needs.revision')}
|
||||
</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}>
|
||||
{t('challenge.admin.users.stats.chains.progress')}
|
||||
</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.Root value={chain.progress} colorPalette="teal" size="sm">
|
||||
<Progress.Track>
|
||||
<Progress.Range />
|
||||
</Progress.Track>
|
||||
</Progress.Root>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Task Stats */}
|
||||
{stats.taskStats.length > 0 && (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={3}>
|
||||
{t('challenge.admin.users.stats.tasks')}
|
||||
</Text>
|
||||
<VStack gap={2} align="stretch" maxH="300px" overflowY="auto">
|
||||
{stats.taskStats.map((taskStat) => {
|
||||
const getBadgeColor = () => {
|
||||
if (taskStat.status === 'completed') return 'green'
|
||||
if (taskStat.status === 'needs_revision') return 'red'
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
return (
|
||||
<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={getBadgeColor()}>
|
||||
{t(`challenge.admin.users.stats.status.${taskStat.status}`)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
{t('challenge.admin.users.stats.attempts')} {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}>
|
||||
{t('challenge.admin.users.stats.avg.check.time')}
|
||||
</Text>
|
||||
<Text fontSize="lg" fontWeight="bold" color="purple.700">
|
||||
{t('challenge.admin.dashboard.check.time.value', {
|
||||
time: (stats.averageCheckTimeMs / 1000).toFixed(2),
|
||||
})}
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,21 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
Table,
|
||||
Input,
|
||||
Text,
|
||||
Button,
|
||||
DialogRoot,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
DialogActionTrigger,
|
||||
Grid,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Progress,
|
||||
} from '@chakra-ui/react'
|
||||
import { useGetSystemStatsV2Query, useGetUserStatsQuery } from '../../__data__/api/api'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Box, Heading, Table, Input, Text, Button } from '@chakra-ui/react'
|
||||
import { useGetSystemStatsV2Query } from '../../__data__/api/api'
|
||||
import type { ActiveParticipant } from '../../types/challenge'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||
import { EmptyState } from '../../components/EmptyState'
|
||||
import { URLs } from '../../__data__/urls'
|
||||
|
||||
export const UsersPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { data: stats, isLoading, error, refetch } = useGetSystemStatsV2Query(undefined, {
|
||||
pollingInterval: 10000,
|
||||
})
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message={t('challenge.admin.users.loading')} />
|
||||
@@ -108,7 +91,7 @@ export const UsersPage: React.FC = () => {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorPalette="teal"
|
||||
onClick={() => setSelectedUserId(user.userId)}
|
||||
onClick={() => navigate(URLs.userStats(user.userId))}
|
||||
>
|
||||
{t('challenge.admin.users.button.stats')}
|
||||
</Button>
|
||||
@@ -120,167 +103,6 @@ export const UsersPage: React.FC = () => {
|
||||
</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 { t } = useTranslation()
|
||||
const { data: stats, isLoading } = useGetUserStatsQuery(userId!, {
|
||||
skip: !userId,
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('challenge.admin.users.stats.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
{isLoading ? (
|
||||
<LoadingSpinner message={t('challenge.admin.users.stats.loading')} />
|
||||
) : !stats ? (
|
||||
<Text color="gray.600">{t('challenge.admin.users.stats.no.data')}</Text>
|
||||
) : (
|
||||
<VStack gap={6} align="stretch">
|
||||
{/* Overview */}
|
||||
<Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.users.stats.completed')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="green.600">
|
||||
{stats.completedTasks}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.users.stats.total.submissions')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
|
||||
{stats.totalSubmissions}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.users.stats.in.progress')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||
{stats.inProgressTasks}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.users.stats.needs.revision')}
|
||||
</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}>
|
||||
{t('challenge.admin.users.stats.chains.progress')}
|
||||
</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.Root value={chain.progress} colorPalette="teal" size="sm">
|
||||
<Progress.Track>
|
||||
<Progress.Range />
|
||||
</Progress.Track>
|
||||
</Progress.Root>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Task Stats */}
|
||||
{stats.taskStats.length > 0 && (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={3}>
|
||||
{t('challenge.admin.users.stats.tasks')}
|
||||
</Text>
|
||||
<VStack gap={2} align="stretch" maxH="300px" overflowY="auto">
|
||||
{stats.taskStats.map((taskStat) => {
|
||||
const getBadgeColor = () => {
|
||||
if (taskStat.status === 'completed') return 'green'
|
||||
if (taskStat.status === 'needs_revision') return 'red'
|
||||
return 'gray'
|
||||
}
|
||||
|
||||
return (
|
||||
<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={getBadgeColor()}>
|
||||
{t(`challenge.admin.users.stats.status.${taskStat.status}`)}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
{t('challenge.admin.users.stats.attempts')} {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}>
|
||||
{t('challenge.admin.users.stats.avg.check.time')}
|
||||
</Text>
|
||||
<Text fontSize="lg" fontWeight="bold" color="purple.700">
|
||||
{t('challenge.admin.dashboard.check.time.value', { time: (stats.averageCheckTimeMs / 1000).toFixed(2) })}
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
)}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<DialogActionTrigger asChild>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</DialogActionTrigger>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</DialogRoot>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user