Add user filtering and progress overview to submissions page; enhance localization for user selection and progress display

This commit is contained in:
2025-12-09 14:37:04 +03:00
parent d8d39ea443
commit cbf2168e52
6 changed files with 189 additions and 7 deletions

View File

@@ -147,6 +147,7 @@
"challenge.admin.submissions.loading": "Loading attempts...",
"challenge.admin.submissions.load.error": "Failed to load attempts list",
"challenge.admin.submissions.search.placeholder": "Search by user or task...",
"challenge.admin.submissions.filter.user": "Select user",
"challenge.admin.submissions.filter.status": "Status",
"challenge.admin.submissions.status.all": "All statuses",
"challenge.admin.submissions.status.accepted": "Accepted",

View File

@@ -146,6 +146,8 @@
"challenge.admin.submissions.loading": "Загрузка попыток...",
"challenge.admin.submissions.load.error": "Не удалось загрузить список попыток",
"challenge.admin.submissions.search.placeholder": "Поиск по пользователю или заданию...",
"challenge.admin.submissions.filter.user": "Выберите пользователя",
"challenge.admin.submissions.filter.user.clear": "Показать всех",
"challenge.admin.submissions.filter.status": "Статус",
"challenge.admin.submissions.status.all": "Все статусы",
"challenge.admin.submissions.status.accepted": "Принято",
@@ -175,6 +177,11 @@
"challenge.admin.submissions.details.solution": "Решение пользователя:",
"challenge.admin.submissions.details.feedback": "Обратная связь от LLM:",
"challenge.admin.submissions.details.close": "Закрыть",
"challenge.admin.submissions.overview.title": "Общий прогресс по участникам",
"challenge.admin.submissions.overview.description": "Ниже — сводка по прогрессу всех участников и цепочек. Выберите пользователя выше, чтобы просмотреть его отдельные попытки.",
"challenge.admin.submissions.overview.table.user": "Участник",
"challenge.admin.submissions.overview.table.chain": "Цепочка",
"challenge.admin.submissions.overview.table.progress": "Прогресс",
"challenge.admin.layout.title": "Challenge Admin",
"challenge.admin.layout.nav.dashboard": "Dashboard",
"challenge.admin.layout.nav.detailed.stats": "Детальная статистика",

View File

@@ -17,6 +17,7 @@ import {
DialogBody,
DialogFooter,
DialogActionTrigger,
Progress,
createListCollection,
} from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown'
@@ -31,6 +32,7 @@ import type {
SubmissionStatus,
ChallengeTask,
ChallengeUser,
ParticipantProgress,
} from '../../types/challenge'
export const SubmissionsPage: React.FC = () => {
@@ -125,6 +127,28 @@ export const SubmissionsPage: React.FC = () => {
const hasParticipants = participants.length > 0
const hasSelectedUser = !!selectedUserId
const participantProgressRows = stats.chainsDetailed
.reduce(
(acc, chain) => {
const rows = chain.participantProgress.map((participant) => ({
...participant,
chainId: chain.chainId,
chainName: chain.name,
totalTasks: chain.totalTasks,
}))
return acc.concat(rows)
},
[] as Array<
ParticipantProgress & {
chainId: string
chainName: string
totalTasks: number
}
>
)
.sort((a, b) => a.progressPercent - b.progressPercent)
return (
<Box>
<Heading mb={6}>{t('challenge.admin.submissions.title')}</Heading>
@@ -132,7 +156,7 @@ export const SubmissionsPage: React.FC = () => {
{/* Filters */}
{hasParticipants && (
<VStack mb={4} gap={3} align="stretch">
<HStack gap={4}>
<HStack gap={4} align="center">
<Select.Root
collection={userOptions}
value={selectedUserId ? [selectedUserId] : []}
@@ -151,6 +175,20 @@ export const SubmissionsPage: React.FC = () => {
</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
@@ -188,10 +226,96 @@ export const SubmissionsPage: React.FC = () => {
description={t('challenge.admin.submissions.empty.description')}
/>
) : !hasSelectedUser ? (
<EmptyState
title={t('challenge.admin.submissions.empty.title')}
description={t('challenge.admin.submissions.filter.user')}
/>
<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>
{participantProgressRows.length === 0 ? (
<EmptyState
title={t('challenge.admin.detailed.stats.participants.empty')}
description={t('challenge.admin.detailed.stats.chains.empty')}
/>
) : (
<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.overview.table.user')}
</Table.ColumnHeader>
<Table.ColumnHeader>
{t('challenge.admin.submissions.overview.table.chain')}
</Table.ColumnHeader>
<Table.ColumnHeader>
{t('challenge.admin.submissions.overview.table.progress')}
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{participantProgressRows.map((row) => {
const colorPalette =
row.progressPercent >= 70
? 'green'
: row.progressPercent >= 40
? 'orange'
: 'red'
return (
<Table.Row
key={`${row.userId}-${row.chainId}`}
cursor="pointer"
_hover={{ bg: 'gray.50' }}
onClick={() => setSelectedUserId(row.userId)}
>
<Table.Cell fontWeight="medium" w="220px" maxW="220px">
<Text truncate>{row.nickname}</Text>
</Table.Cell>
<Table.Cell w="260px" maxW="260px">
<Text fontSize="sm" color="gray.700" truncate>
{row.chainName}
</Text>
<Text fontSize="xs" color="gray.500" truncate>
{t('challenge.admin.detailed.stats.chains.total.tasks')}{' '}
{row.completedCount} / {row.totalTasks}
</Text>
</Table.Cell>
<Table.Cell w="100%" minW="200px">
<VStack align="stretch" gap={1}>
<HStack justify="space-between">
<Text fontSize="sm" color="gray.700">
{row.progressPercent}%
</Text>
</HStack>
<Progress.Root
value={row.progressPercent}
size="sm"
colorPalette={colorPalette}
>
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
</VStack>
</Table.Cell>
</Table.Row>
)
})}
</Table.Body>
</Table.Root>
</Box>
)}
</Box>
) : filteredSubmissions.length === 0 ? (
<EmptyState
title={t('challenge.admin.submissions.search.empty.title')}

View File

@@ -44,8 +44,10 @@ export const UsersPage: React.FC = () => {
const users: ActiveParticipant[] = stats.activeParticipants || []
const normalizedQuery = (searchQuery ?? '').toLowerCase()
const filteredUsers = users.filter((user) =>
user.nickname.toLowerCase().includes(searchQuery.toLowerCase())
(user.nickname ?? '').toLowerCase().includes(normalizedQuery)
)
return (

View File

@@ -200,6 +200,28 @@
}
],
"activeParticipants": [
{
"userId": "6909b51512c75d75a36a52bf",
"nickname": "Примаков А.А.",
"totalSubmissions": 14,
"completedTasks": 1,
"chainProgress": [
{
"chainId": "6909ad8612c75d75a36a4c58",
"chainName": "Это тестовая цепочка заданий",
"totalTasks": 2,
"completedTasks": 1,
"progressPercent": 50
},
{
"chainId": "690a30b1e723507972c44098",
"chainName": "Навыки работы с нейросетями для начинающих (пошагово)",
"totalTasks": 20,
"completedTasks": 1,
"progressPercent": 5
}
]
},
{
"userId": "user_1",
"nickname": "alex_dev",

View File

@@ -61,6 +61,32 @@ const getStatsV2 = () => {
return statsV2Cache;
};
// Enrich SystemStatsV2 with real user ids/nicknames from users.json
const getStatsV2WithUsers = () => {
const statsV2 = getStatsV2();
const users = getUsers();
const mapParticipant = (participant, index) => {
const user = users[index];
if (!user) return participant;
return {
...participant,
userId: user.id,
nickname: user.nickname,
};
};
return {
...statsV2,
activeParticipants: statsV2.activeParticipants.map(mapParticipant),
chainsDetailed: statsV2.chainsDetailed.map((chain) => ({
...chain,
participantProgress: chain.participantProgress.map(mapParticipant),
})),
};
};
router.use(timer());
// ============= TASKS =============
@@ -282,7 +308,7 @@ router.get('/challenge/stats', (req, res) => {
// GET /api/challenge/stats/v2
router.get('/challenge/stats/v2', (req, res) => {
const statsV2 = getStatsV2();
const statsV2 = getStatsV2WithUsers();
const chainId = req.query.chainId;
// Если chainId не передан, возвращаем все данные