Add user filtering and progress overview to submissions page; enhance localization for user selection and progress display
This commit is contained in:
@@ -147,6 +147,7 @@
|
|||||||
"challenge.admin.submissions.loading": "Loading attempts...",
|
"challenge.admin.submissions.loading": "Loading attempts...",
|
||||||
"challenge.admin.submissions.load.error": "Failed to load attempts list",
|
"challenge.admin.submissions.load.error": "Failed to load attempts list",
|
||||||
"challenge.admin.submissions.search.placeholder": "Search by user or task...",
|
"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.filter.status": "Status",
|
||||||
"challenge.admin.submissions.status.all": "All statuses",
|
"challenge.admin.submissions.status.all": "All statuses",
|
||||||
"challenge.admin.submissions.status.accepted": "Accepted",
|
"challenge.admin.submissions.status.accepted": "Accepted",
|
||||||
|
|||||||
@@ -146,6 +146,8 @@
|
|||||||
"challenge.admin.submissions.loading": "Загрузка попыток...",
|
"challenge.admin.submissions.loading": "Загрузка попыток...",
|
||||||
"challenge.admin.submissions.load.error": "Не удалось загрузить список попыток",
|
"challenge.admin.submissions.load.error": "Не удалось загрузить список попыток",
|
||||||
"challenge.admin.submissions.search.placeholder": "Поиск по пользователю или заданию...",
|
"challenge.admin.submissions.search.placeholder": "Поиск по пользователю или заданию...",
|
||||||
|
"challenge.admin.submissions.filter.user": "Выберите пользователя",
|
||||||
|
"challenge.admin.submissions.filter.user.clear": "Показать всех",
|
||||||
"challenge.admin.submissions.filter.status": "Статус",
|
"challenge.admin.submissions.filter.status": "Статус",
|
||||||
"challenge.admin.submissions.status.all": "Все статусы",
|
"challenge.admin.submissions.status.all": "Все статусы",
|
||||||
"challenge.admin.submissions.status.accepted": "Принято",
|
"challenge.admin.submissions.status.accepted": "Принято",
|
||||||
@@ -175,6 +177,11 @@
|
|||||||
"challenge.admin.submissions.details.solution": "Решение пользователя:",
|
"challenge.admin.submissions.details.solution": "Решение пользователя:",
|
||||||
"challenge.admin.submissions.details.feedback": "Обратная связь от LLM:",
|
"challenge.admin.submissions.details.feedback": "Обратная связь от LLM:",
|
||||||
"challenge.admin.submissions.details.close": "Закрыть",
|
"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.title": "Challenge Admin",
|
||||||
"challenge.admin.layout.nav.dashboard": "Dashboard",
|
"challenge.admin.layout.nav.dashboard": "Dashboard",
|
||||||
"challenge.admin.layout.nav.detailed.stats": "Детальная статистика",
|
"challenge.admin.layout.nav.detailed.stats": "Детальная статистика",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
DialogBody,
|
DialogBody,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogActionTrigger,
|
DialogActionTrigger,
|
||||||
|
Progress,
|
||||||
createListCollection,
|
createListCollection,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
@@ -31,6 +32,7 @@ import type {
|
|||||||
SubmissionStatus,
|
SubmissionStatus,
|
||||||
ChallengeTask,
|
ChallengeTask,
|
||||||
ChallengeUser,
|
ChallengeUser,
|
||||||
|
ParticipantProgress,
|
||||||
} from '../../types/challenge'
|
} from '../../types/challenge'
|
||||||
|
|
||||||
export const SubmissionsPage: React.FC = () => {
|
export const SubmissionsPage: React.FC = () => {
|
||||||
@@ -125,6 +127,28 @@ export const SubmissionsPage: React.FC = () => {
|
|||||||
const hasParticipants = participants.length > 0
|
const hasParticipants = participants.length > 0
|
||||||
const hasSelectedUser = !!selectedUserId
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Heading mb={6}>{t('challenge.admin.submissions.title')}</Heading>
|
<Heading mb={6}>{t('challenge.admin.submissions.title')}</Heading>
|
||||||
@@ -132,7 +156,7 @@ export const SubmissionsPage: React.FC = () => {
|
|||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
{hasParticipants && (
|
{hasParticipants && (
|
||||||
<VStack mb={4} gap={3} align="stretch">
|
<VStack mb={4} gap={3} align="stretch">
|
||||||
<HStack gap={4}>
|
<HStack gap={4} align="center">
|
||||||
<Select.Root
|
<Select.Root
|
||||||
collection={userOptions}
|
collection={userOptions}
|
||||||
value={selectedUserId ? [selectedUserId] : []}
|
value={selectedUserId ? [selectedUserId] : []}
|
||||||
@@ -151,6 +175,20 @@ export const SubmissionsPage: React.FC = () => {
|
|||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</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 && (
|
{submissionsList.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
@@ -188,10 +226,96 @@ export const SubmissionsPage: React.FC = () => {
|
|||||||
description={t('challenge.admin.submissions.empty.description')}
|
description={t('challenge.admin.submissions.empty.description')}
|
||||||
/>
|
/>
|
||||||
) : !hasSelectedUser ? (
|
) : !hasSelectedUser ? (
|
||||||
<EmptyState
|
<Box>
|
||||||
title={t('challenge.admin.submissions.empty.title')}
|
<Heading size="md" mb={4}>
|
||||||
description={t('challenge.admin.submissions.filter.user')}
|
{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 ? (
|
) : filteredSubmissions.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={t('challenge.admin.submissions.search.empty.title')}
|
title={t('challenge.admin.submissions.search.empty.title')}
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ export const UsersPage: React.FC = () => {
|
|||||||
|
|
||||||
const users: ActiveParticipant[] = stats.activeParticipants || []
|
const users: ActiveParticipant[] = stats.activeParticipants || []
|
||||||
|
|
||||||
|
const normalizedQuery = (searchQuery ?? '').toLowerCase()
|
||||||
|
|
||||||
const filteredUsers = users.filter((user) =>
|
const filteredUsers = users.filter((user) =>
|
||||||
user.nickname.toLowerCase().includes(searchQuery.toLowerCase())
|
(user.nickname ?? '').toLowerCase().includes(normalizedQuery)
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -200,6 +200,28 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"activeParticipants": [
|
"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",
|
"userId": "user_1",
|
||||||
"nickname": "alex_dev",
|
"nickname": "alex_dev",
|
||||||
|
|||||||
@@ -61,6 +61,32 @@ const getStatsV2 = () => {
|
|||||||
return statsV2Cache;
|
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());
|
router.use(timer());
|
||||||
|
|
||||||
// ============= TASKS =============
|
// ============= TASKS =============
|
||||||
@@ -282,7 +308,7 @@ router.get('/challenge/stats', (req, res) => {
|
|||||||
|
|
||||||
// GET /api/challenge/stats/v2
|
// GET /api/challenge/stats/v2
|
||||||
router.get('/challenge/stats/v2', (req, res) => {
|
router.get('/challenge/stats/v2', (req, res) => {
|
||||||
const statsV2 = getStatsV2();
|
const statsV2 = getStatsV2WithUsers();
|
||||||
const chainId = req.query.chainId;
|
const chainId = req.query.chainId;
|
||||||
|
|
||||||
// Если chainId не передан, возвращаем все данные
|
// Если chainId не передан, возвращаем все данные
|
||||||
|
|||||||
Reference in New Issue
Block a user