From 18e2ccb6bc34fe69d600276643afd2c6b01dcd76 Mon Sep 17 00:00:00 2001 From: Primakov Alexandr Date: Sat, 13 Dec 2025 20:16:40 +0300 Subject: [PATCH] Add new API endpoint for retrieving submissions by challenge chain; update frontend to support chain selection and display participant progress. Enhance localization for submissions page in English and Russian. --- docs/API_CHAIN_SUBMISSIONS.md | 236 ++++++++++++ locales/en.json | 13 + locales/ru.json | 14 +- src/__data__/urls.ts | 6 +- src/dashboard.tsx | 8 + .../submissions/SubmissionDetailsPage.tsx | 6 +- src/pages/submissions/SubmissionsPage.tsx | 357 ++++++++++++------ 7 files changed, 509 insertions(+), 131 deletions(-) create mode 100644 docs/API_CHAIN_SUBMISSIONS.md diff --git a/docs/API_CHAIN_SUBMISSIONS.md b/docs/API_CHAIN_SUBMISSIONS.md new file mode 100644 index 0000000..e74db83 --- /dev/null +++ b/docs/API_CHAIN_SUBMISSIONS.md @@ -0,0 +1,236 @@ +# Техническое задание: Эндпоинт получения попыток по цепочке + +## Цель + +Создать новый API эндпоинт для получения списка попыток (submissions) участников в рамках конкретной цепочки заданий. Это упростит работу админ-панели и уменьшит объём передаваемых данных. + +## Текущая проблема + +Сейчас для отображения попыток по цепочке фронтенд должен: +1. Загрузить список цепочек (`GET /challenge/chains/admin`) +2. Загрузить общую статистику (`GET /challenge/stats/v2`) +3. Для каждого участника отдельно загрузить его submissions (`GET /challenge/user/:userId/submissions`) +4. На клиенте фильтровать submissions по taskIds из выбранной цепочки + +Это создаёт избыточные запросы и усложняет логику на фронтенде. + +--- + +## Новый эндпоинт + +### `GET /challenge/chain/:chainId/submissions` + +Возвращает все попытки всех участников для заданий из указанной цепочки. + +### Параметры URL + +| Параметр | Тип | Обязательный | Описание | +|----------|-----|--------------|----------| +| `chainId` | string | Да | ID цепочки заданий | + +### Query параметры (опциональные) + +| Параметр | Тип | По умолчанию | Описание | +|----------|-----|--------------|----------| +| `userId` | string | - | Фильтр по конкретному пользователю | +| `status` | string | - | Фильтр по статусу: `pending`, `in_progress`, `accepted`, `needs_revision` | +| `limit` | number | 100 | Лимит записей | +| `offset` | number | 0 | Смещение для пагинации | + +### Формат ответа + +```typescript +interface ChainSubmissionsResponse { + success: boolean; + body: { + chain: { + id: string; + name: string; + tasks: Array<{ + id: string; + title: string; + }>; + }; + participants: Array<{ + userId: string; + nickname: string; + completedTasks: number; + totalTasks: number; + progressPercent: number; + }>; + submissions: Array<{ + id: string; + user: { + id: string; + nickname: string; + }; + task: { + id: string; + title: string; + }; + status: 'pending' | 'in_progress' | 'accepted' | 'needs_revision'; + attemptNumber: number; + submittedAt: string; // ISO date + checkedAt?: string; // ISO date + feedback?: string; + }>; + pagination: { + total: number; + limit: number; + offset: number; + }; + }; +} +``` + +### Пример запроса + +```bash +GET /api/challenge/chain/607f1f77bcf86cd799439021/submissions?status=needs_revision&limit=50 +``` + +### Пример ответа + +```json +{ + "success": true, + "body": { + "chain": { + "id": "607f1f77bcf86cd799439021", + "name": "Основы JavaScript", + "tasks": [ + { "id": "507f1f77bcf86cd799439011", "title": "Реализовать сортировку массива" }, + { "id": "507f1f77bcf86cd799439015", "title": "Валидация формы" } + ] + }, + "participants": [ + { + "userId": "user_123", + "nickname": "alex_dev", + "completedTasks": 1, + "totalTasks": 2, + "progressPercent": 50 + }, + { + "userId": "user_456", + "nickname": "maria_coder", + "completedTasks": 2, + "totalTasks": 2, + "progressPercent": 100 + } + ], + "submissions": [ + { + "id": "sub_001", + "user": { + "id": "user_123", + "nickname": "alex_dev" + }, + "task": { + "id": "507f1f77bcf86cd799439011", + "title": "Реализовать сортировку массива" + }, + "status": "needs_revision", + "attemptNumber": 2, + "submittedAt": "2024-12-10T14:30:00.000Z", + "checkedAt": "2024-12-10T14:30:45.000Z", + "feedback": "Алгоритм работает неверно для отрицательных чисел" + } + ], + "pagination": { + "total": 15, + "limit": 50, + "offset": 0 + } + } +} +``` + +--- + +## Логика на бэкенде + +### Алгоритм + +1. Получить цепочку по `chainId` +2. Если цепочка не найдена — вернуть 404 +3. Получить список `taskIds` из цепочки +4. Найти все submissions где `task._id` входит в `taskIds` +5. Применить фильтры (`userId`, `status`) если указаны +6. Вычислить прогресс по каждому участнику: + - Найти уникальных пользователей из submissions + - Для каждого посчитать `completedTasks` (количество уникальных tasks со статусом `accepted`) + - Рассчитать `progressPercent = (completedTasks / totalTasks) * 100` +7. Применить пагинацию к submissions +8. Вернуть результат + +### Индексы MongoDB (рекомендуется) + +```javascript +// Для быстрой выборки submissions по task +db.submissions.createIndex({ "task": 1, "submittedAt": -1 }) + +// Составной индекс для фильтрации +db.submissions.createIndex({ "task": 1, "status": 1, "submittedAt": -1 }) +``` + +--- + +## Права доступа + +Эндпоинт должен быть доступен только пользователям с ролями: +- `challenge-admin` +- `challenge-teacher` + +--- + +## Коды ошибок + +| Код | Описание | +|-----|----------| +| 200 | Успешный ответ | +| 400 | Некорректные параметры запроса | +| 401 | Не авторизован | +| 403 | Недостаточно прав | +| 404 | Цепочка не найдена | +| 500 | Внутренняя ошибка сервера | + +--- + +## Изменения на фронтенде после реализации + +После добавления эндпоинта в `src/__data__/api/api.ts` нужно добавить: + +```typescript +// В endpoints builder +getChainSubmissions: builder.query({ + query: ({ chainId, userId, status, limit, offset }) => ({ + url: `/challenge/chain/${chainId}/submissions`, + params: { userId, status, limit, offset }, + }), + transformResponse: (response: { body: ChainSubmissionsResponse }) => response.body, + providesTags: ['Submission'], +}), +``` + +Это позволит упростить `SubmissionsPage.tsx`: +- Один запрос вместо нескольких +- Убрать клиентскую фильтрацию по taskIds +- Получать готовый прогресс участников + +--- + +## Приоритет + +**Средний** — текущая реализация работает, но создаёт избыточную нагрузку при большом количестве участников. + +## Оценка трудозатрат + +~4-6 часов (включая тесты) + diff --git a/locales/en.json b/locales/en.json index bafd2ca..81421b8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -166,8 +166,21 @@ "challenge.admin.submissions.title": "Solution attempts", "challenge.admin.submissions.loading": "Loading attempts...", "challenge.admin.submissions.load.error": "Failed to load attempts list", + "challenge.admin.submissions.select.chain": "Select a chain to view participant attempts", + "challenge.admin.submissions.chain.tasks": "tasks", + "challenge.admin.submissions.chain.click": "Click to view attempts", + "challenge.admin.submissions.no.chains.title": "No chains", + "challenge.admin.submissions.no.chains.description": "Create a task chain to get started", + "challenge.admin.submissions.back.to.chains": "Back to chain selection", + "challenge.admin.submissions.chain.description": "Total tasks in chain: {{count}}", + "challenge.admin.submissions.participants.title": "Chain participants", + "challenge.admin.submissions.participants.description": "Select a participant to view their attempts in this chain", + "challenge.admin.submissions.participants.empty.title": "No participants", + "challenge.admin.submissions.participants.empty.description": "No one has submitted solutions in this chain yet", + "challenge.admin.submissions.participants.click.to.view": "→ view", "challenge.admin.submissions.search.placeholder": "Search by user or task...", "challenge.admin.submissions.filter.user": "Select user", + "challenge.admin.submissions.filter.user.clear": "← All participants", "challenge.admin.submissions.filter.status": "Status", "challenge.admin.submissions.status.all": "All statuses", "challenge.admin.submissions.status.accepted": "Accepted", diff --git a/locales/ru.json b/locales/ru.json index 8207298..50959d0 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -165,9 +165,21 @@ "challenge.admin.submissions.title": "Попытки решений", "challenge.admin.submissions.loading": "Загрузка попыток...", "challenge.admin.submissions.load.error": "Не удалось загрузить список попыток", + "challenge.admin.submissions.select.chain": "Выберите цепочку для просмотра попыток участников", + "challenge.admin.submissions.chain.tasks": "заданий", + "challenge.admin.submissions.chain.click": "Нажмите для просмотра попыток", + "challenge.admin.submissions.no.chains.title": "Нет цепочек", + "challenge.admin.submissions.no.chains.description": "Создайте цепочку заданий для начала работы", + "challenge.admin.submissions.back.to.chains": "Назад к выбору цепочки", + "challenge.admin.submissions.chain.description": "Всего заданий в цепочке: {{count}}", + "challenge.admin.submissions.participants.title": "Участники цепочки", + "challenge.admin.submissions.participants.description": "Выберите участника для просмотра его попыток в этой цепочке", + "challenge.admin.submissions.participants.empty.title": "Нет участников", + "challenge.admin.submissions.participants.empty.description": "Пока никто не отправил решения в этой цепочке", + "challenge.admin.submissions.participants.click.to.view": "→ посмотреть", "challenge.admin.submissions.search.placeholder": "Поиск по пользователю или заданию...", "challenge.admin.submissions.filter.user": "Выберите пользователя", - "challenge.admin.submissions.filter.user.clear": "Показать всех", + "challenge.admin.submissions.filter.user.clear": "← Все участники", "challenge.admin.submissions.filter.status": "Статус", "challenge.admin.submissions.status.all": "Все статусы", "challenge.admin.submissions.status.accepted": "Принято", diff --git a/src/__data__/urls.ts b/src/__data__/urls.ts index 26fda3f..32c0090 100644 --- a/src/__data__/urls.ts +++ b/src/__data__/urls.ts @@ -36,8 +36,10 @@ export const URLs = { // Submissions submissions: makeUrl('/submissions'), - submissionDetails: (userId: string, submissionId: string) => makeUrl(`/submissions/${userId}/${submissionId}`), - submissionDetailsPath: makeUrl('/submissions/:userId/:submissionId'), + submissionsChain: (chainId: string) => makeUrl(`/submissions/${chainId}`), + submissionsChainPath: makeUrl('/submissions/:chainId'), + submissionDetails: (chainId: string, userId: string, submissionId: string) => makeUrl(`/submissions/${chainId}/${userId}/${submissionId}`), + submissionDetailsPath: makeUrl('/submissions/:chainId/:userId/:submissionId'), // External links challengePlayer: navs['link.challenge.main'] || '/challenge', diff --git a/src/dashboard.tsx b/src/dashboard.tsx index d6d2ff6..e998f4f 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -130,6 +130,14 @@ export const Dashboard = () => { } /> + + + + } + /> { const { t } = useTranslation() - const { userId, submissionId } = useParams<{ userId: string; submissionId: string }>() + const { chainId, userId, submissionId } = useParams<{ chainId: string; userId: string; submissionId: string }>() const navigate = useNavigate() // Получаем submissions для конкретного пользователя @@ -24,8 +24,8 @@ export const SubmissionDetailsPage: React.FC = () => { const submission = submissions?.find((s) => s.id === submissionId) const handleBack = () => { - if (userId) { - navigate(`${URLs.submissions}?userId=${encodeURIComponent(userId)}`) + if (chainId) { + navigate(URLs.submissionsChain(chainId)) } else { navigate(URLs.submissions) } diff --git a/src/pages/submissions/SubmissionsPage.tsx b/src/pages/submissions/SubmissionsPage.tsx index 8c499e5..82b5b96 100644 --- a/src/pages/submissions/SubmissionsPage.tsx +++ b/src/pages/submissions/SubmissionsPage.tsx @@ -1,6 +1,6 @@ -import React, { useState } from 'react' +import React, { useState, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate, useSearchParams } from 'react-router-dom' +import { useNavigate, useParams, Link } from 'react-router-dom' import { Box, Heading, @@ -10,37 +10,56 @@ import { Button, HStack, VStack, - Select, + Badge, Progress, Grid, + SimpleGrid, + Select, createListCollection, } from '@chakra-ui/react' -import { useGetSystemStatsV2Query, useGetUserSubmissionsQuery } from '../../__data__/api/api' +import { useGetChainsQuery, useGetSystemStatsV2Query, useGetUserSubmissionsQuery } from '../../__data__/api/api' import { LoadingSpinner } from '../../components/LoadingSpinner' import { ErrorAlert } from '../../components/ErrorAlert' import { EmptyState } from '../../components/EmptyState' import { StatusBadge } from '../../components/StatusBadge' import { URLs } from '../../__data__/urls' import type { - ActiveParticipant, ChallengeSubmission, SubmissionStatus, ChallengeTask, ChallengeUser, + ActiveParticipant, } from '../../types/challenge' export const SubmissionsPage: React.FC = () => { const { t } = useTranslation() const navigate = useNavigate() - const [searchParams] = useSearchParams() - const initialUserId = searchParams.get('userId') - const { data: stats, isLoading: isStatsLoading, error: statsError, refetch: refetchStats } = - useGetSystemStatsV2Query(undefined) + const { chainId } = useParams<{ chainId?: string }>() + // Состояние для выбранного пользователя и фильтров + const [selectedUserId, setSelectedUserId] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [statusFilter, setStatusFilter] = useState('all') - const [selectedUserId, setSelectedUserId] = useState(initialUserId) + // Получаем список цепочек + const { + data: chains, + isLoading: isChainsLoading, + error: chainsError, + refetch: refetchChains, + } = useGetChainsQuery() + + // Получаем общую статистику (без фильтра по chainId - получаем всех участников) + const { + data: stats, + isLoading: isStatsLoading, + error: statsError, + refetch: refetchStats, + } = useGetSystemStatsV2Query(undefined, { + skip: !chainId, // Загружаем только когда выбрана цепочка + }) + + // Получаем submissions для выбранного пользователя const { data: submissions, isLoading: isSubmissionsLoading, @@ -51,51 +70,85 @@ export const SubmissionsPage: React.FC = () => { { skip: !selectedUserId } ) - const isLoading = isStatsLoading || (selectedUserId && isSubmissionsLoading) - const error = statsError || submissionsError + const isLoading = isChainsLoading || (chainId && isStatsLoading) || (selectedUserId && isSubmissionsLoading) + const error = chainsError || statsError || submissionsError const handleRetry = () => { + refetchChains() refetchStats() if (selectedUserId) { refetchSubmissions() } } - if (isLoading) { - return - } + // Получаем данные выбранной цепочки из списка chains + const selectedChain = useMemo(() => { + if (!chainId || !chains) return null + return chains.find((c) => c.id === chainId) || null + }, [chainId, chains]) - if (error || !stats) { - return - } + // Получаем taskIds из текущей цепочки + const chainTaskIds = useMemo(() => { + if (!selectedChain) return new Set() + return new Set(selectedChain.tasks.map((t) => t.id)) + }, [selectedChain]) - const participants: ActiveParticipant[] = stats.activeParticipants || [] - const submissionsList: ChallengeSubmission[] = submissions || [] + // Фильтруем участников - только те, кто имеет прогресс в этой цепочке + const chainParticipants = useMemo(() => { + if (!stats?.activeParticipants || !chainId) return [] + + return stats.activeParticipants + .map((participant) => { + // Ищем прогресс участника по выбранной цепочке + const chainProgress = participant.chainProgress?.find((cp) => cp.chainId === chainId) + + // Если нет прогресса по этой цепочке, пробуем рассчитать на основе submissions + // Для простоты показываем всех участников с базовым прогрессом 0% + return { + ...participant, + progressPercent: chainProgress?.progressPercent ?? 0, + completedTasks: chainProgress?.completedTasks ?? 0, + totalTasks: selectedChain?.tasks.length ?? 0, + } + }) + .sort((a, b) => a.progressPercent - b.progressPercent) + }, [stats?.activeParticipants, chainId, selectedChain]) - const normalizedSearchQuery = (searchQuery ?? '').toLowerCase() + // Фильтруем submissions только по заданиям из текущей цепочки + const filteredSubmissions = useMemo(() => { + if (!submissions || chainTaskIds.size === 0) return [] - const filteredSubmissions = submissionsList.filter((submission) => { - const rawUser = submission.user as ChallengeUser | string | undefined - const rawTask = submission.task as ChallengeTask | string | undefined + const normalizedSearchQuery = (searchQuery ?? '').toLowerCase() - const nickname = - rawUser && typeof rawUser === 'object' && 'nickname' in rawUser - ? (rawUser.nickname ?? '') - : '' + return submissions.filter((submission) => { + // Фильтр по цепочке (по taskId) + const rawTask = submission.task as ChallengeTask | string | undefined + const taskId = rawTask && typeof rawTask === 'object' && 'id' in rawTask + ? rawTask.id + : typeof rawTask === 'string' ? rawTask : '' + + if (!chainTaskIds.has(taskId)) return false - const title = - rawTask && typeof rawTask === 'object' && 'title' in rawTask - ? (rawTask.title ?? '') - : '' + // Фильтр по поиску + const rawUser = submission.user as ChallengeUser | string | undefined + const nickname = + rawUser && typeof rawUser === 'object' && 'nickname' in rawUser + ? (rawUser.nickname ?? '') + : '' + const title = + rawTask && typeof rawTask === 'object' && 'title' in rawTask + ? (rawTask.title ?? '') + : '' + const matchesSearch = + nickname.toLowerCase().includes(normalizedSearchQuery) || + title.toLowerCase().includes(normalizedSearchQuery) - const matchesSearch = - nickname.toLowerCase().includes(normalizedSearchQuery) || - title.toLowerCase().includes(normalizedSearchQuery) + // Фильтр по статусу + const matchesStatus = statusFilter === 'all' || submission.status === statusFilter - const matchesStatus = statusFilter === 'all' || submission.status === statusFilter - - return matchesSearch && matchesStatus - }) + return matchesSearch && matchesStatus + }) + }, [submissions, chainTaskIds, searchQuery, statusFilter]) const formatDate = (dateStr: string) => { return new Date(dateStr).toLocaleString('ru-RU', { @@ -125,70 +178,119 @@ export const SubmissionsPage: React.FC = () => { ], }) - const userOptions = createListCollection({ - items: participants.map((participant) => ({ - label: `${participant.nickname} (${participant.userId})`, - value: participant.userId, - })), - }) + if (isLoading) { + return + } - const hasParticipants = participants.length > 0 - const hasSelectedUser = !!selectedUserId + if (error) { + return + } - const participantOverviewRows = participants - .map((participant) => { - const chains = participant.chainProgress || [] + // Если chainId не указан - показываем выбор цепочки + if (!chainId) { + return ( + + + {t('challenge.admin.submissions.title')} + + {t('challenge.admin.submissions.select.chain')} + + - const totalTasks = chains.reduce((sum, chain) => sum + (chain.totalTasks ?? 0), 0) - const completedTasks = chains.reduce( - (sum, chain) => sum + (chain.completedTasks ?? 0), - 0 - ) + {chains && chains.length > 0 ? ( + + {chains.map((chain) => ( + + + + + {chain.name} + + + + {chain.tasks.length} {t('challenge.admin.submissions.chain.tasks')} + + {!chain.isActive && ( + + {t('challenge.admin.chains.list.status.inactive')} + + )} + + + {t('challenge.admin.submissions.chain.click')} + + + + + ))} + + ) : ( + + )} + + ) + } - const overallPercent = - totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0 + // Если цепочка выбрана но данных нет + if (!selectedChain) { + return ( + + + + ← {t('challenge.admin.submissions.back.to.chains')} + + + + + ) + } - return { - userId: participant.userId, - nickname: participant.nickname, - totalSubmissions: participant.totalSubmissions, - completedTasks, - totalTasks, - overallPercent, - } - }) - .sort((a, b) => a.overallPercent - b.overallPercent) + const participants: ActiveParticipant[] = stats?.activeParticipants || [] return ( - {t('challenge.admin.submissions.title')} + {/* Header с навигацией */} + + + + + ← {t('challenge.admin.submissions.back.to.chains')} + + + + {selectedChain.name} + + {t('challenge.admin.submissions.chain.description', { count: selectedChain.tasks.length })} + + - {/* Filters */} - {hasParticipants && ( + {/* Выбор участника и фильтры */} + {participants.length > 0 && ( - - setSelectedUserId(e.value[0] ?? null)} - maxW="300px" - > - - - - - {userOptions.items.map((option) => ( - - {option.label} - - ))} - - - - {hasSelectedUser && ( + + {selectedUserId && ( )} - {submissionsList.length > 0 && ( + {selectedUserId && filteredSubmissions.length > 0 && ( <> setSearchQuery(e.target.value)} - maxW="400px" + maxW="300px" /> { )} - {!hasParticipants ? ( - - ) : !hasSelectedUser ? ( + {/* Если не выбран пользователь - показываем обзор участников */} + {!selectedUserId ? ( - {t('challenge.admin.submissions.overview.title')} + {t('challenge.admin.submissions.participants.title')} - {t('challenge.admin.submissions.overview.description')} + {t('challenge.admin.submissions.participants.description')} - {participantOverviewRows.length === 0 ? ( + {chainParticipants.length === 0 ? ( ) : ( { lg: 'repeat(3, minmax(0, 1fr))', xl: 'repeat(4, minmax(0, 1fr))', }} - gap={2} + gap={3} > - {participantOverviewRows.map((row) => { + {chainParticipants.map((participant) => { const colorPalette = - row.overallPercent >= 70 + participant.progressPercent >= 70 ? 'green' - : row.overallPercent >= 40 + : participant.progressPercent >= 40 ? 'orange' : 'red' return ( setSelectedUserId(row.userId)} + onClick={() => setSelectedUserId(participant.userId)} + transition="all 0.2s" > - - - {row.nickname} - - - {row.overallPercent}% + + + {participant.nickname} + + {participant.progressPercent}% + - + - - {row.completedTasks} / {row.totalTasks} - + + + {participant.completedTasks} / {participant.totalTasks} + + + {t('challenge.admin.submissions.participants.click.to.view')} + + ) })} @@ -306,6 +411,7 @@ export const SubmissionsPage: React.FC = () => { description={t('challenge.admin.submissions.search.empty.description')} /> ) : ( + /* Таблица попыток выбранного пользователя */ @@ -316,7 +422,9 @@ export const SubmissionsPage: React.FC = () => { {t('challenge.admin.submissions.table.attempt')} {t('challenge.admin.submissions.table.submitted')} {t('challenge.admin.submissions.table.check.time')} - {t('challenge.admin.submissions.table.actions')} + + {t('challenge.admin.submissions.table.actions')} + @@ -365,7 +473,7 @@ export const SubmissionsPage: React.FC = () => { size="sm" variant="ghost" colorPalette="teal" - onClick={() => navigate(URLs.submissionDetails(selectedUserId!, submission.id))} + onClick={() => navigate(URLs.submissionDetails(chainId!, selectedUserId, submission.id))} > {t('challenge.admin.submissions.button.details')} @@ -380,4 +488,3 @@ export const SubmissionsPage: React.FC = () => { ) } -