Add detailed statistics API v2 documentation and implement frontend components for displaying statistics
This commit is contained in:
160
src/pages/detailed-stats/ChainDetailedView.tsx
Normal file
160
src/pages/detailed-stats/ChainDetailedView.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Box, Heading, Text, Table, Badge } from '@chakra-ui/react'
|
||||
import type { ChainDetailed, TaskProgressStatus } from '../../types/challenge'
|
||||
|
||||
interface ChainDetailedViewProps {
|
||||
chains: ChainDetailed[]
|
||||
}
|
||||
|
||||
interface StatusConfig {
|
||||
label: string
|
||||
color: 'gray' | 'yellow' | 'blue' | 'orange' | 'green'
|
||||
}
|
||||
|
||||
const getStatusConfig = (status: TaskProgressStatus, t: (key: string) => string): StatusConfig => {
|
||||
const configs: Record<TaskProgressStatus, StatusConfig> = {
|
||||
not_started: { label: t('challenge.admin.detailed.stats.status.not.started'), color: 'gray' },
|
||||
pending: { label: t('challenge.admin.detailed.stats.status.pending'), color: 'yellow' },
|
||||
in_progress: { label: t('challenge.admin.detailed.stats.status.in.progress'), color: 'blue' },
|
||||
needs_revision: { label: t('challenge.admin.detailed.stats.status.needs.revision'), color: 'orange' },
|
||||
completed: { label: t('challenge.admin.detailed.stats.status.completed'), color: 'green' },
|
||||
}
|
||||
return configs[status]
|
||||
}
|
||||
|
||||
export const ChainDetailedView: React.FC<ChainDetailedViewProps> = ({ chains }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (chains.length === 0) {
|
||||
return (
|
||||
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
|
||||
<Heading size="md" mb={4}>
|
||||
{t('challenge.admin.detailed.stats.chains.title')}
|
||||
</Heading>
|
||||
<Box color="gray.500" textAlign="center" py={8}>
|
||||
{t('challenge.admin.detailed.stats.chains.empty')}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const chain = chains[0] // Теперь всегда одна цепочка из API
|
||||
|
||||
return (
|
||||
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
|
||||
<Heading size="md" mb={4}>
|
||||
{t('challenge.admin.detailed.stats.chains.title')}
|
||||
</Heading>
|
||||
|
||||
<Box mb={3}>
|
||||
<Heading size="sm" color="teal.600" mb={1}>
|
||||
{chain.name}
|
||||
</Heading>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.detailed.stats.chains.total.tasks')} {chain.totalTasks}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{chain.participantProgress.length > 0 ? (
|
||||
<Box overflowX="auto">
|
||||
<Table.Root size="sm" variant="outline">
|
||||
<Table.Header>
|
||||
<Table.Row bg="gray.50">
|
||||
<Table.ColumnHeader
|
||||
position="sticky"
|
||||
left={0}
|
||||
bg="gray.50"
|
||||
zIndex={1}
|
||||
minW="150px"
|
||||
borderRight="1px"
|
||||
borderColor="gray.200"
|
||||
>
|
||||
{t('challenge.admin.detailed.stats.chains.participant')}
|
||||
</Table.ColumnHeader>
|
||||
{chain.tasks.map((task) => (
|
||||
<Table.ColumnHeader
|
||||
key={task.taskId}
|
||||
textAlign="center"
|
||||
minW="120px"
|
||||
maxW="200px"
|
||||
>
|
||||
<Text fontSize="xs" lineClamp={2} title={task.title}>
|
||||
{task.title}
|
||||
</Text>
|
||||
</Table.ColumnHeader>
|
||||
))}
|
||||
<Table.ColumnHeader
|
||||
textAlign="center"
|
||||
minW="100px"
|
||||
borderLeft="1px"
|
||||
borderColor="gray.200"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{t('challenge.admin.detailed.stats.chains.progress')}
|
||||
</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{chain.participantProgress.map((participant) => (
|
||||
<Table.Row key={participant.userId}>
|
||||
<Table.Cell
|
||||
position="sticky"
|
||||
left={0}
|
||||
bg="white"
|
||||
zIndex={1}
|
||||
fontWeight="medium"
|
||||
borderRight="1px"
|
||||
borderColor="gray.200"
|
||||
>
|
||||
{participant.nickname}
|
||||
</Table.Cell>
|
||||
{participant.taskProgress.map((taskProg) => {
|
||||
const config = getStatusConfig(taskProg.status, t)
|
||||
return (
|
||||
<Table.Cell key={taskProg.taskId} textAlign="center">
|
||||
<Badge
|
||||
colorPalette={config.color}
|
||||
size="sm"
|
||||
title={taskProg.taskTitle}
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
)
|
||||
})}
|
||||
<Table.Cell
|
||||
textAlign="center"
|
||||
borderLeft="1px"
|
||||
borderColor="gray.200"
|
||||
fontWeight="bold"
|
||||
>
|
||||
<Badge
|
||||
colorPalette={
|
||||
participant.progressPercent >= 80
|
||||
? 'green'
|
||||
: participant.progressPercent >= 50
|
||||
? 'yellow'
|
||||
: 'red'
|
||||
}
|
||||
>
|
||||
{participant.progressPercent}%
|
||||
</Badge>
|
||||
<Text fontSize="xs" color="gray.600" mt={1}>
|
||||
{participant.completedCount}/{chain.totalTasks}
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Box>
|
||||
) : (
|
||||
<Box color="gray.500" textAlign="center" py={4} bg="gray.50" borderRadius="md">
|
||||
{t('challenge.admin.detailed.stats.chains.no.participants')}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
240
src/pages/detailed-stats/DetailedStatsPage.tsx
Normal file
240
src/pages/detailed-stats/DetailedStatsPage.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { Box, Heading, VStack, Text, HStack, Badge, SimpleGrid } from '@chakra-ui/react'
|
||||
import { useGetChainsQuery, useGetSystemStatsV2Query } from '../../__data__/api/api'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||
import { URLs } from '../../__data__/urls'
|
||||
import { TasksStatisticsTable } from './TasksStatisticsTable'
|
||||
import { ParticipantsProgress } from './ParticipantsProgress'
|
||||
import { ChainDetailedView } from './ChainDetailedView'
|
||||
|
||||
export const DetailedStatsPage: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { chainId } = useParams<{ chainId?: string }>()
|
||||
|
||||
// Получаем список цепочек
|
||||
const { data: chains, isLoading: isChainsLoading, error: chainsError } = useGetChainsQuery()
|
||||
|
||||
// Получаем детальную статистику по выбранной цепочке (только если chainId есть)
|
||||
const { data: stats, isLoading: isStatsLoading, error: statsError, refetch } = useGetSystemStatsV2Query(
|
||||
chainId,
|
||||
{
|
||||
pollingInterval: 5000, // Обновление каждые 5 секунд для реального времени
|
||||
skip: !chainId, // Не делаем запрос пока не выбрана цепочка
|
||||
}
|
||||
)
|
||||
|
||||
const isLoading = isChainsLoading || (chainId && isStatsLoading)
|
||||
const error = chainsError || statsError
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message={t('challenge.admin.detailed.stats.loading')} />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorAlert
|
||||
message={t('challenge.admin.detailed.stats.load.error')}
|
||||
onRetry={refetch}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Если chainId не указан - показываем карточки для выбора
|
||||
if (!chainId) {
|
||||
return (
|
||||
<Box>
|
||||
<Box mb={6}>
|
||||
<Heading mb={2}>{t('challenge.admin.detailed.stats.title')}</Heading>
|
||||
<Text color="gray.600" fontSize="sm">
|
||||
{t('challenge.admin.detailed.stats.select.chain')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{chains && chains.length > 0 ? (
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} gap={6}>
|
||||
{chains.map((chain) => (
|
||||
<Link key={chain.id} to={URLs.detailedStatsChain(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.detailed.stats.chain.card.tasks')}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="gray.600" mt={2}>
|
||||
{t('challenge.admin.detailed.stats.chain.card.click')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Link>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Box color="gray.500" textAlign="center" py={8}>
|
||||
{t('challenge.admin.detailed.stats.no.chains')}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Если chainId указан но данных еще нет - ждем загрузки
|
||||
if (!stats) {
|
||||
return null
|
||||
}
|
||||
|
||||
const acceptanceRate = stats.submissions.total > 0
|
||||
? ((stats.submissions.accepted / stats.submissions.total) * 100).toFixed(1)
|
||||
: '0'
|
||||
|
||||
const selectedChain = chains?.find(c => c.id === chainId)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box mb={6}>
|
||||
<HStack justify="space-between" align="start" mb={2}>
|
||||
<Box>
|
||||
<HStack gap={2} mb={2}>
|
||||
<Link to={URLs.detailedStats} style={{ textDecoration: 'none', color: '#319795' }}>
|
||||
<Text fontSize="sm" _hover={{ textDecoration: 'underline' }}>
|
||||
← {t('challenge.admin.detailed.stats.back.to.chains')}
|
||||
</Text>
|
||||
</Link>
|
||||
</HStack>
|
||||
<Heading mb={2}>
|
||||
{selectedChain?.name || t('challenge.admin.detailed.stats.title')}
|
||||
</Heading>
|
||||
<Text color="gray.600" fontSize="sm">
|
||||
{t('challenge.admin.detailed.stats.auto.refresh')}
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* Quick Stats Overview */}
|
||||
<Box
|
||||
bg="white"
|
||||
p={6}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
mb={6}
|
||||
>
|
||||
<Heading size="sm" mb={4}>
|
||||
{t('challenge.admin.detailed.stats.overview.title')}
|
||||
</Heading>
|
||||
<HStack wrap="wrap" gap={6}>
|
||||
<VStack align="start" gap={1}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.detailed.stats.overview.users')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
|
||||
{stats.users}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack align="start" gap={1}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.detailed.stats.overview.tasks')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="teal.600">
|
||||
{stats.tasks}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack align="start" gap={1}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.detailed.stats.overview.chains')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="purple.600">
|
||||
{stats.chains}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack align="start" gap={1}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.detailed.stats.overview.total.attempts')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||
{stats.submissions.total}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack align="start" gap={1}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.detailed.stats.overview.successful')}
|
||||
</Text>
|
||||
<HStack>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="green.600">
|
||||
{stats.submissions.accepted}
|
||||
</Text>
|
||||
<Badge colorPalette="green" size="lg">
|
||||
{acceptanceRate}%
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<VStack align="start" gap={1}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.detailed.stats.overview.in.progress.pending')}
|
||||
</Text>
|
||||
<HStack>
|
||||
<Badge colorPalette="blue" size="lg">
|
||||
{stats.submissions.inProgress}
|
||||
</Badge>
|
||||
<Badge colorPalette="yellow" size="lg">
|
||||
{stats.submissions.pending}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<VStack align="start" gap={1}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.detailed.stats.overview.avg.check.time')}
|
||||
</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="purple.600">
|
||||
{(stats.averageCheckTimeMs / 1000).toFixed(1)}с
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* Main Content - Three Sections */}
|
||||
{stats && (
|
||||
<VStack align="stretch" gap={6}>
|
||||
{/* 1. Tasks Statistics Table */}
|
||||
<TasksStatisticsTable tasks={stats.tasksTable} />
|
||||
|
||||
{/* 2. Active Participants Progress */}
|
||||
<ParticipantsProgress participants={stats.activeParticipants} />
|
||||
|
||||
{/* 3. Chain Detailed View */}
|
||||
<ChainDetailedView chains={stats.chainsDetailed} />
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
110
src/pages/detailed-stats/ParticipantsProgress.tsx
Normal file
110
src/pages/detailed-stats/ParticipantsProgress.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Box, Heading, Grid, Text, VStack, HStack, Progress } from '@chakra-ui/react'
|
||||
import type { ActiveParticipant } from '../../types/challenge'
|
||||
|
||||
interface ParticipantsProgressProps {
|
||||
participants: ActiveParticipant[]
|
||||
}
|
||||
|
||||
export const ParticipantsProgress: React.FC<ParticipantsProgressProps> = ({ participants }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getProgressColor = (percent: number) => {
|
||||
if (percent >= 80) return 'green'
|
||||
if (percent >= 50) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
if (participants.length === 0) {
|
||||
return (
|
||||
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
|
||||
<Heading size="md" mb={4}>
|
||||
{t('challenge.admin.detailed.stats.participants.title')}
|
||||
</Heading>
|
||||
<Box color="gray.500" textAlign="center" py={8}>
|
||||
{t('challenge.admin.detailed.stats.participants.empty')}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
|
||||
<Heading size="md" mb={4}>
|
||||
{t('challenge.admin.detailed.stats.participants.title')}
|
||||
</Heading>
|
||||
<Grid templateColumns="repeat(auto-fill, minmax(350px, 1fr))" gap={4}>
|
||||
{participants.map((participant) => (
|
||||
<Box
|
||||
key={participant.userId}
|
||||
p={4}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
bg="gray.50"
|
||||
_hover={{ borderColor: 'teal.300', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<VStack align="stretch" gap={3}>
|
||||
{/* Participant Header */}
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color="teal.700">
|
||||
{participant.nickname}
|
||||
</Text>
|
||||
<HStack gap={4} mt={1}>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.detailed.stats.participants.completed')} <Text as="span" fontWeight="bold" color="green.600">
|
||||
{participant.completedTasks}
|
||||
</Text>
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{t('challenge.admin.detailed.stats.participants.attempts')} <Text as="span" fontWeight="bold" color="blue.600">
|
||||
{participant.totalSubmissions}
|
||||
</Text>
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* Chain Progress */}
|
||||
{participant.chainProgress.length > 0 ? (
|
||||
<VStack align="stretch" gap={3} mt={2}>
|
||||
{participant.chainProgress.map((chain) => (
|
||||
<Box key={chain.chainId}>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">
|
||||
{chain.chainName}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{chain.completedTasks}/{chain.totalTasks}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Progress.Root
|
||||
value={chain.progressPercent}
|
||||
colorPalette={getProgressColor(chain.progressPercent)}
|
||||
size="sm"
|
||||
borderRadius="full"
|
||||
>
|
||||
<Progress.Track>
|
||||
<Progress.Range />
|
||||
</Progress.Track>
|
||||
</Progress.Root>
|
||||
<Text fontSize="xs" color="gray.500" mt={1} textAlign="right">
|
||||
{chain.progressPercent}%
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Text fontSize="sm" color="gray.500" textAlign="center" py={2}>
|
||||
{t('challenge.admin.detailed.stats.participants.no.progress')}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
148
src/pages/detailed-stats/TasksStatisticsTable.tsx
Normal file
148
src/pages/detailed-stats/TasksStatisticsTable.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Box, Heading, Table, Badge } from '@chakra-ui/react'
|
||||
import type { TaskTableItem } from '../../types/challenge'
|
||||
|
||||
interface TasksStatisticsTableProps {
|
||||
tasks: TaskTableItem[]
|
||||
}
|
||||
|
||||
type SortKey = keyof TaskTableItem | null
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export const TasksStatisticsTable: React.FC<TasksStatisticsTableProps> = ({ tasks }) => {
|
||||
const { t } = useTranslation()
|
||||
const [sortKey, setSortKey] = useState<SortKey>('successRate')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
|
||||
|
||||
const handleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortKey(key)
|
||||
setSortDirection('desc')
|
||||
}
|
||||
}
|
||||
|
||||
const sortedTasks = useMemo(() => {
|
||||
if (!sortKey) return tasks
|
||||
|
||||
return [...tasks].sort((a, b) => {
|
||||
const aVal = a[sortKey]
|
||||
const bVal = b[sortKey]
|
||||
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
return sortDirection === 'asc'
|
||||
? aVal.localeCompare(bVal)
|
||||
: bVal.localeCompare(aVal)
|
||||
}
|
||||
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return sortDirection === 'asc' ? aVal - bVal : bVal - aVal
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
}, [tasks, sortKey, sortDirection])
|
||||
|
||||
const getSuccessRateColor = (rate: number) => {
|
||||
if (rate >= 80) return 'green'
|
||||
if (rate >= 50) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
|
||||
<Heading size="md" mb={4}>
|
||||
{t('challenge.admin.detailed.stats.tasks.table.title')}
|
||||
</Heading>
|
||||
<Box color="gray.500" textAlign="center" py={8}>
|
||||
{t('challenge.admin.detailed.stats.tasks.table.empty')}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
|
||||
<Heading size="md" mb={4}>
|
||||
{t('challenge.admin.detailed.stats.tasks.table.title')}
|
||||
</Heading>
|
||||
<Box overflowX="auto">
|
||||
<Table.Root size="sm" variant="line">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader
|
||||
cursor="pointer"
|
||||
onClick={() => handleSort('title')}
|
||||
_hover={{ bg: 'gray.50' }}
|
||||
>
|
||||
{t('challenge.admin.detailed.stats.tasks.table.task.name')} {sortKey === 'title' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader
|
||||
cursor="pointer"
|
||||
onClick={() => handleSort('totalAttempts')}
|
||||
_hover={{ bg: 'gray.50' }}
|
||||
textAlign="right"
|
||||
>
|
||||
{t('challenge.admin.detailed.stats.tasks.table.attempts')} {sortKey === 'totalAttempts' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader
|
||||
cursor="pointer"
|
||||
onClick={() => handleSort('uniqueUsers')}
|
||||
_hover={{ bg: 'gray.50' }}
|
||||
textAlign="right"
|
||||
>
|
||||
{t('challenge.admin.detailed.stats.tasks.table.users')} {sortKey === 'uniqueUsers' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader
|
||||
cursor="pointer"
|
||||
onClick={() => handleSort('acceptedCount')}
|
||||
_hover={{ bg: 'gray.50' }}
|
||||
textAlign="right"
|
||||
>
|
||||
{t('challenge.admin.detailed.stats.tasks.table.completed')} {sortKey === 'acceptedCount' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader
|
||||
cursor="pointer"
|
||||
onClick={() => handleSort('successRate')}
|
||||
_hover={{ bg: 'gray.50' }}
|
||||
textAlign="right"
|
||||
>
|
||||
{t('challenge.admin.detailed.stats.tasks.table.success.rate')} {sortKey === 'successRate' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader
|
||||
cursor="pointer"
|
||||
onClick={() => handleSort('averageAttemptsToSuccess')}
|
||||
_hover={{ bg: 'gray.50' }}
|
||||
textAlign="right"
|
||||
>
|
||||
{t('challenge.admin.detailed.stats.tasks.table.avg.attempts')} {sortKey === 'averageAttemptsToSuccess' && (sortDirection === 'asc' ? '↑' : '↓')}
|
||||
</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{sortedTasks.map((task) => (
|
||||
<Table.Row key={task.taskId}>
|
||||
<Table.Cell fontWeight="medium">{task.title}</Table.Cell>
|
||||
<Table.Cell textAlign="right">{task.totalAttempts}</Table.Cell>
|
||||
<Table.Cell textAlign="right">{task.uniqueUsers}</Table.Cell>
|
||||
<Table.Cell textAlign="right">{task.acceptedCount}</Table.Cell>
|
||||
<Table.Cell textAlign="right">
|
||||
<Badge colorPalette={getSuccessRateColor(task.successRate)}>
|
||||
{task.successRate}%
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell textAlign="right">
|
||||
{task.averageAttemptsToSuccess.toFixed(1)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user