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:
2025-12-10 11:11:17 +03:00
parent 06bcb6ee51
commit 7323e80dcb
7 changed files with 335 additions and 184 deletions

View File

@@ -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'),

View File

@@ -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

View 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>
)
}

View File

@@ -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>
)
}