diff --git a/bro.config.js b/bro.config.js index 0025a67..2690651 100644 --- a/bro.config.js +++ b/bro.config.js @@ -22,7 +22,7 @@ module.exports = { }, features: { 'challenge-admin': { - // add your features here in the format [featureName]: { value: string } + 'use-chain-submissions-api': { value: 'true' }, }, }, config: { diff --git a/src/__data__/api/api.ts b/src/__data__/api/api.ts index f41ea8e..b03e42b 100644 --- a/src/__data__/api/api.ts +++ b/src/__data__/api/api.ts @@ -15,6 +15,8 @@ import type { UpdateChainRequest, SubmitRequest, TestSubmissionResult, + ChainSubmissionsResponse, + SubmissionStatus, } from '../../types/challenge' export const api = createApi({ @@ -143,6 +145,17 @@ export const api = createApi({ transformResponse: (response: { body: ChallengeSubmission[] }) => response.body, providesTags: ['Submission'], }), + getChainSubmissions: builder.query< + ChainSubmissionsResponse, + { chainId: string; userId?: string; status?: SubmissionStatus } + >({ + query: ({ chainId, userId, status }) => ({ + url: `/challenge/chain/${chainId}/submissions`, + params: userId || status ? { userId, status } : undefined, + }), + transformResponse: (response: { body: ChainSubmissionsResponse }) => response.body, + providesTags: ['Submission'], + }), // Test submission (LLM check without creating a real submission) testSubmission: builder.mutation({ @@ -178,6 +191,7 @@ export const { useGetSystemStatsV2Query, useGetUserStatsQuery, useGetUserSubmissionsQuery, + useGetChainSubmissionsQuery, useTestSubmissionMutation, } = api diff --git a/src/pages/submissions/SubmissionsPage.tsx b/src/pages/submissions/SubmissionsPage.tsx index 82b5b96..c45d561 100644 --- a/src/pages/submissions/SubmissionsPage.tsx +++ b/src/pages/submissions/SubmissionsPage.tsx @@ -17,7 +17,13 @@ import { Select, createListCollection, } from '@chakra-ui/react' -import { useGetChainsQuery, useGetSystemStatsV2Query, useGetUserSubmissionsQuery } from '../../__data__/api/api' +import { getFeatureValue } from '@brojs/cli' +import { + useGetChainsQuery, + useGetChainSubmissionsQuery, + useGetSystemStatsV2Query, + useGetUserSubmissionsQuery, +} from '../../__data__/api/api' import { LoadingSpinner } from '../../components/LoadingSpinner' import { ErrorAlert } from '../../components/ErrorAlert' import { EmptyState } from '../../components/EmptyState' @@ -28,7 +34,6 @@ import type { SubmissionStatus, ChallengeTask, ChallengeUser, - ActiveParticipant, } from '../../types/challenge' export const SubmissionsPage: React.FC = () => { @@ -36,30 +41,48 @@ export const SubmissionsPage: React.FC = () => { const navigate = useNavigate() const { chainId } = useParams<{ chainId?: string }>() + // Проверяем feature flag + const featureValue = getFeatureValue('challenge-admin', 'use-chain-submissions-api') + const useNewApi = featureValue?.value === 'true' + // Состояние для выбранного пользователя и фильтров const [selectedUserId, setSelectedUserId] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [statusFilter, setStatusFilter] = useState('all') // Получаем список цепочек - const { - data: chains, - isLoading: isChainsLoading, + const { + data: chains, + isLoading: isChainsLoading, error: chainsError, refetch: refetchChains, } = useGetChainsQuery() - // Получаем общую статистику (без фильтра по chainId - получаем всех участников) + // Новый API: получаем данные по цепочке через новый эндпоинт + const { + data: chainData, + isLoading: isChainDataLoading, + error: chainDataError, + refetch: refetchChainData, + } = useGetChainSubmissionsQuery( + { + chainId: chainId!, + userId: selectedUserId || undefined, + status: statusFilter !== 'all' ? statusFilter : undefined, + }, + { skip: !chainId || !useNewApi } + ) + + // Старый API: получаем общую статистику и submissions отдельно const { data: stats, isLoading: isStatsLoading, error: statsError, refetch: refetchStats, } = useGetSystemStatsV2Query(undefined, { - skip: !chainId, // Загружаем только когда выбрана цепочка + skip: !chainId || useNewApi, }) - // Получаем submissions для выбранного пользователя const { data: submissions, isLoading: isSubmissionsLoading, @@ -67,43 +90,50 @@ export const SubmissionsPage: React.FC = () => { refetch: refetchSubmissions, } = useGetUserSubmissionsQuery( { userId: selectedUserId!, taskId: undefined }, - { skip: !selectedUserId } + { skip: !selectedUserId || useNewApi } ) - const isLoading = isChainsLoading || (chainId && isStatsLoading) || (selectedUserId && isSubmissionsLoading) - const error = chainsError || statsError || submissionsError + const isLoading = + isChainsLoading || + (chainId && useNewApi && isChainDataLoading) || + (chainId && !useNewApi && isStatsLoading) || + (selectedUserId && !useNewApi && isSubmissionsLoading) + + const error = chainsError || (useNewApi ? chainDataError : statsError || submissionsError) const handleRetry = () => { refetchChains() - refetchStats() - if (selectedUserId) { - refetchSubmissions() + if (chainId) { + if (useNewApi) { + refetchChainData() + } else { + refetchStats() + if (selectedUserId) { + refetchSubmissions() + } + } } } - // Получаем данные выбранной цепочки из списка chains + // Получаем данные выбранной цепочки из списка chains (для старого API) const selectedChain = useMemo(() => { if (!chainId || !chains) return null return chains.find((c) => c.id === chainId) || null }, [chainId, chains]) - // Получаем taskIds из текущей цепочки + // Получаем taskIds из текущей цепочки (для старого API) const chainTaskIds = useMemo(() => { if (!selectedChain) return new Set() return new Set(selectedChain.tasks.map((t) => t.id)) }, [selectedChain]) - // Фильтруем участников - только те, кто имеет прогресс в этой цепочке - const chainParticipants = useMemo(() => { - if (!stats?.activeParticipants || !chainId) return [] - + // Старый API: фильтруем участников - только те, кто имеет прогресс в этой цепочке + const chainParticipantsOld = useMemo(() => { + if (!stats?.activeParticipants || !chainId || useNewApi) return [] + return stats.activeParticipants .map((participant) => { - // Ищем прогресс участника по выбранной цепочке const chainProgress = participant.chainProgress?.find((cp) => cp.chainId === chainId) - - // Если нет прогресса по этой цепочке, пробуем рассчитать на основе submissions - // Для простоты показываем всех участников с базовым прогрессом 0% return { ...participant, progressPercent: chainProgress?.progressPercent ?? 0, @@ -112,43 +142,90 @@ export const SubmissionsPage: React.FC = () => { } }) .sort((a, b) => a.progressPercent - b.progressPercent) - }, [stats?.activeParticipants, chainId, selectedChain]) + }, [stats?.activeParticipants, chainId, selectedChain, useNewApi]) - // Фильтруем submissions только по заданиям из текущей цепочки - const filteredSubmissions = useMemo(() => { - if (!submissions || chainTaskIds.size === 0) return [] + // Старый API: фильтруем submissions только по заданиям из текущей цепочки + const filteredSubmissionsOld = useMemo(() => { + if (!submissions || chainTaskIds.size === 0 || useNewApi) return [] const normalizedSearchQuery = (searchQuery ?? '').toLowerCase() 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 : '' - + const taskId = + rawTask && typeof rawTask === 'object' && 'id' in rawTask + ? rawTask.id + : typeof rawTask === 'string' + ? rawTask + : '' + if (!chainTaskIds.has(taskId)) return false - // Фильтр по поиску 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 matchesStatus = statusFilter === 'all' || submission.status === statusFilter return matchesSearch && matchesStatus }) - }, [submissions, chainTaskIds, searchQuery, statusFilter]) + }, [submissions, chainTaskIds, searchQuery, statusFilter, useNewApi]) + + // Новый API: фильтруем submissions по поисковому запросу (статус уже отфильтрован на сервере) + const filteredSubmissionsNew = useMemo(() => { + if (!chainData?.submissions || !useNewApi) return [] + + const normalizedSearchQuery = (searchQuery ?? '').toLowerCase() + if (!normalizedSearchQuery) return chainData.submissions + + return chainData.submissions.filter((submission) => { + const rawUser = submission.user as ChallengeUser | string | undefined + const rawTask = submission.task as ChallengeTask | string | undefined + + const nickname = + rawUser && typeof rawUser === 'object' && 'nickname' in rawUser + ? (rawUser.nickname ?? '') + : typeof rawUser === 'string' + ? rawUser + : '' + + const title = + rawTask && typeof rawTask === 'object' && 'title' in rawTask + ? (rawTask.title ?? '') + : typeof rawTask === 'string' + ? rawTask + : '' + + return ( + nickname.toLowerCase().includes(normalizedSearchQuery) || + title.toLowerCase().includes(normalizedSearchQuery) + ) + }) + }, [chainData?.submissions, searchQuery, useNewApi]) + + // Выбираем данные в зависимости от фичи + const filteredSubmissions = useNewApi ? filteredSubmissionsNew : filteredSubmissionsOld + + // Сортируем участников по прогрессу + const sortedParticipants = useMemo(() => { + if (useNewApi) { + if (!chainData?.participants) return [] + return [...chainData.participants].sort((a, b) => a.progressPercent - b.progressPercent) + } else { + return chainParticipantsOld + } + }, [chainData?.participants, chainParticipantsOld, useNewApi]) const formatDate = (dateStr: string) => { return new Date(dateStr).toLocaleString('ru-RU', { @@ -250,7 +327,7 @@ export const SubmissionsPage: React.FC = () => { } // Если цепочка выбрана но данных нет - if (!selectedChain) { + if (useNewApi && !chainData) { return ( @@ -263,7 +340,21 @@ export const SubmissionsPage: React.FC = () => { ) } - const participants: ActiveParticipant[] = stats?.activeParticipants || [] + if (!useNewApi && !selectedChain) { + return ( + + + + ← {t('challenge.admin.submissions.back.to.chains')} + + + + + ) + } + + const chainName = useNewApi ? chainData?.chain.name : selectedChain?.name + const chainTasksCount = useNewApi ? chainData?.chain.tasks.length : selectedChain?.tasks.length return ( @@ -276,14 +367,14 @@ export const SubmissionsPage: React.FC = () => { - {selectedChain.name} + {chainName} - {t('challenge.admin.submissions.chain.description', { count: selectedChain.tasks.length })} + {t('challenge.admin.submissions.chain.description', { count: chainTasksCount ?? 0 })} {/* Выбор участника и фильтры */} - {participants.length > 0 && ( + {sortedParticipants.length > 0 && ( {selectedUserId && ( @@ -342,7 +433,7 @@ export const SubmissionsPage: React.FC = () => { {t('challenge.admin.submissions.participants.description')} - {chainParticipants.length === 0 ? ( + {sortedParticipants.length === 0 ? ( { }} gap={3} > - {chainParticipants.map((participant) => { + {sortedParticipants.map((participant) => { const colorPalette = participant.progressPercent >= 70 ? 'green' diff --git a/src/types/challenge.ts b/src/types/challenge.ts index 12d8f9b..427f503 100644 --- a/src/types/challenge.ts +++ b/src/types/challenge.ts @@ -244,3 +244,28 @@ export interface TestSubmissionResult { feedback?: string } +// ========== Chain Submissions API ========== + +export interface ChainSubmissionsParticipant { + userId: string + nickname: string + completedTasks: number + totalTasks: number + progressPercent: number +} + +export interface ChainSubmissionsResponse { + chain: { + id: string + name: string + tasks: Array<{ id: string; title: string }> + } + participants: ChainSubmissionsParticipant[] + submissions: ChallengeSubmission[] + pagination: { + total: number + limit: number + offset: number + } +} + diff --git a/stubs/api/index.js b/stubs/api/index.js index a1cd87c..feaf148 100644 --- a/stubs/api/index.js +++ b/stubs/api/index.js @@ -461,4 +461,116 @@ router.get('/challenge/user/:userId/submissions', (req, res) => { respond(res, filtered); }); +// GET /api/challenge/chain/:chainId/submissions +router.get('/challenge/chain/:chainId/submissions', (req, res) => { + const chains = getChains(); + const submissions = getSubmissions(); + const users = getUsers(); + + const chainId = req.params.chainId; + const userId = req.query.userId; + const status = req.query.status; + const limit = parseInt(req.query.limit) || 100; + const offset = parseInt(req.query.offset) || 0; + + // Найти цепочку + const chain = chains.find(c => c.id === chainId); + if (!chain) { + return respondError(res, 'Chain not found', 404); + } + + // Получить taskIds из цепочки + const taskIds = new Set(chain.tasks.map(t => t.id)); + + // Фильтровать submissions по taskIds цепочки + let filteredSubmissions = submissions.filter(s => { + const taskId = typeof s.task === 'object' ? s.task.id : s.task; + return taskIds.has(taskId); + }); + + // Применить фильтр по userId если указан + if (userId) { + filteredSubmissions = filteredSubmissions.filter(s => { + const subUserId = typeof s.user === 'object' ? s.user.id : s.user; + return subUserId === userId; + }); + } + + // Применить фильтр по status если указан + if (status) { + filteredSubmissions = filteredSubmissions.filter(s => s.status === status); + } + + // Получить уникальных участников + const participantMap = new Map(); + + filteredSubmissions.forEach(sub => { + const subUserId = typeof sub.user === 'object' ? sub.user.id : sub.user; + const subUserNickname = typeof sub.user === 'object' ? sub.user.nickname : ''; + + // Найти nickname если не заполнен + let nickname = subUserNickname; + if (!nickname) { + const user = users.find(u => u.id === subUserId); + nickname = user ? user.nickname : subUserId; + } + + if (!participantMap.has(subUserId)) { + participantMap.set(subUserId, { + userId: subUserId, + nickname: nickname, + completedTasks: new Set(), + totalTasks: chain.tasks.length, + }); + } + + // Если статус accepted, добавляем taskId в completedTasks + if (sub.status === 'accepted') { + const taskId = typeof sub.task === 'object' ? sub.task.id : sub.task; + participantMap.get(subUserId).completedTasks.add(taskId); + } + }); + + // Преобразовать в массив и рассчитать прогресс + const participants = Array.from(participantMap.values()).map(p => ({ + userId: p.userId, + nickname: p.nickname, + completedTasks: p.completedTasks.size, + totalTasks: p.totalTasks, + progressPercent: p.totalTasks > 0 + ? Math.round((p.completedTasks.size / p.totalTasks) * 100) + : 0, + })); + + // Сортировать submissions по дате (новые сначала) + filteredSubmissions.sort((a, b) => + new Date(b.submittedAt) - new Date(a.submittedAt) + ); + + // Применить пагинацию + const total = filteredSubmissions.length; + const paginatedSubmissions = filteredSubmissions.slice(offset, offset + limit); + + // Формируем ответ + const response = { + chain: { + id: chain.id, + name: chain.name, + tasks: chain.tasks.map(t => ({ + id: t.id, + title: t.title, + })), + }, + participants: participants, + submissions: paginatedSubmissions, + pagination: { + total: total, + limit: limit, + offset: offset, + }, + }; + + respond(res, response); +}); + module.exports = router;