21 Commits

Author SHA1 Message Date
e93de750fc 1.2.0 2025-12-14 14:47:50 +03:00
5f41c4a943 Enhance dialog components by adding smooth scroll to top functionality upon opening; update ConfirmDialog, ClearSubmissionsDialog, and DuplicateChainDialog for improved user experience. Remove unused ConfirmDialog from ChainsListPage and TasksListPage, streamlining code. 2025-12-14 14:46:28 +03:00
1d364a2351 Refactor ClearSubmissionsDialog and DuplicateChainDialog components by removing unnecessary whitespace; improve code cleanliness and maintainability. 2025-12-14 13:00:57 +03:00
88b95a7651 Add duplicate and clear submissions functionality for challenge chains; implement corresponding dialogs and API endpoints, enhancing user experience and task management. Update localization for new features in English and Russian. 2025-12-13 21:32:22 +03:00
04836ea6ce Implement chain submissions API and update frontend to utilize new endpoint; enhance submissions page with feature flag for API selection, participant progress display, and improved filtering logic. 2025-12-13 20:32:23 +03:00
18e2ccb6bc 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. 2025-12-13 20:16:40 +03:00
9104280325 Update challengePlayer URL key in URLs data structure to use 'link.challenge.main' for improved navigation consistency. 2025-12-13 19:59:35 +03:00
d1bddcf972 Add functionality to restore and save test answers in localStorage for task editing; enhance user experience by preserving input across sessions. 2025-12-10 15:36:13 +03:00
86dffc802b Refactor API response handling in test submission feature to align with server response structure; update ChainsListPage to use 'disabled' prop for button state instead of 'isDisabled', enhancing code clarity and consistency. 2025-12-10 15:13:05 +03:00
7b9cb044fa Enhance test submission feature by adding optional hiddenInstructions field for temporary instructions during LLM checks; update API, UI components, and types to support this functionality, improving task evaluation for teachers and challenge authors. 2025-12-10 14:50:17 +03:00
fb25422df1 1.1.0 2025-12-10 12:41:53 +03:00
ec79dd58aa Add test submission feature for LLM checks without creating submissions; update API and UI components to support new functionality, enhancing task evaluation for teachers and challenge authors. Update localization for test check messages in English and Russian. 2025-12-10 12:41:03 +03:00
173954f685 Add isActive field to challenge chains and update localization; implement functionality to toggle chain status in the UI, enhancing task management and user experience. 2025-12-10 12:02:11 +03:00
4e1b290f99 Add edit task button to ChainFormPage for improved task management; enhances user interaction by allowing direct navigation to task editing. 2025-12-10 11:23:24 +03:00
7323e80dcb Add user stats page and refactor user navigation; replace modal with dedicated page for user statistics, enhancing routing and UI consistency. Update localization for new status keys in both English and Russian. 2025-12-10 11:11:17 +03:00
06bcb6ee51 Update navigation in SubmissionDetailsPage to include userId in URL if available; initialize selectedUserId in SubmissionsPage from search parameters for improved user experience. 2025-12-10 00:36:05 +03:00
71b6180ab9 Add Submission Details Page and Update Localization for Submissions Overview 2025-12-10 00:25:25 +03:00
8710718a12 Enhance SubmissionDetailsModal by adding max height and overflow properties to DialogContent for improved usability and responsiveness. 2025-12-10 00:07:32 +03:00
e4a1fe4b23 Refactor submissions page to improve participant progress display; replace table with grid layout for better responsiveness and user interaction. Update data processing for participant overview and overall progress calculation. 2025-12-10 00:01:24 +03:00
b3febaeea1 Refactor submission filtering and details display to handle various user and task data types; improve search functionality and localization for better user experience. 2025-12-09 23:53:42 +03:00
cbf2168e52 Add user filtering and progress overview to submissions page; enhance localization for user selection and progress display 2025-12-09 14:37:04 +03:00
26 changed files with 2472 additions and 486 deletions

116
CLAUDE.MD Normal file
View File

@@ -0,0 +1,116 @@
## Overview
This document summarizes the recent changes around submissions/users pages and records guardrails to avoid similar issues in the future.
We:
- Reworked submissions and user stats UIs to use real routes/pages instead of modals.
- Added compact progress overview for participants.
- Introduced deep-linked details pages for submissions and users.
- Fixed Chakra UI dialog misuse and type/translation issues.
## Routing & Page Structure
### Do
- **Define all routes centrally** in `src/__data__/urls.ts` and `src/dashboard.ts`:
- Add both the **URL builder** (e.g. `submissionDetails(userId, submissionId)`) and the **`:param` path**.
- Wrap pages in `PageWrapper` in `dashboard.tsx`.
- **Use real pages for complex views** (details, stats) instead of large modals:
- Submissions details: `SubmissionDetailsPage` with URL `/submissions/:userId/:submissionId`.
- User stats: `UserStatsPage` with URL `/users/:userId`.
- **Pass IDs via URL**, not only component state:
- Use route params for `userId`, `submissionId`, etc.
- For “return and keep selection”, encode it as a query param (e.g. `?userId=...`) and read it on the list page.
### Dont
- **Dont hardcode paths in components** (e.g. `'/submissions/...'`); always use `URLs.*` helpers.
- **Dont rely solely on local React state for deep links**:
- If a view must be shareable/bookmarkable or restorable on reload, it must be addressable by URL.
## Chakra UI Dialogs & Layout
### Do
- **Use dialog subcomponents only inside a dialog root**:
- If you use `DialogBody`, `DialogContent`, etc., they must be wrapped in `<DialogRoot>`.
- For **standalone pages**, use plain layout components:
- `Box`, `Heading`, `VStack`, `Grid`, `Progress`, etc.
- No `Dialog*` components on normal routed pages.
### Dont
- **Dont import or use `DialogBody`, `DialogContent`, `DialogHeader`, etc. on regular pages**:
- This causes `useDialogStyles returned 'undefined'` runtime errors.
- **Dont mix modal patterns and page patterns**:
- Either a true modal (`DialogRoot` + `DialogContent`) over an existing page,
- Or a full page route with normal layout — not both at the same time.
## Data Safety & Types
### Do
- Assume backend fields can be **either object or ID string**, per `ChallengeSubmission` types:
- Example safe access in submissions:
- Guard before reading `user.nickname` or `task.title`.
- Derive strings like:
- `const nickname = typeof rawUser === 'object' && 'nickname' in rawUser ? rawUser.nickname ?? '' : typeof rawUser === 'string' ? rawUser : ''`.
- Normalize strings before calling `.toLowerCase()`:
- `const normalized = (value ?? '').toLowerCase()`.
- When filtering/searching, **never call string methods on possibly `undefined` or non-object values**.
### Dont
- **Dont cast blindly** (`as ChallengeUser`) and then access `.nickname` or `.title` without guards.
- **Dont call `.toLowerCase()` directly on untrusted values** from API or union-typed fields.
## “Back” Navigation & State Restoration
### Do
- For **details pages that should restore list state**:
- Encode the necessary selection into the URL when navigating _to_ details.
- Example: `SubmissionDetailsPage` returns to `URLs.submissions` with `?userId=...`, and `SubmissionsPage` reads `userId` from `useSearchParams` to preselect the user.
- Prefer **semantic back actions** over bare `navigate(-1)` when the previous page/state is known:
- Use `navigate(URLs.submissions + '?userId=...')` or `navigate(URLs.users)` when appropriate.
### Dont
- **Dont rely on `navigate(-1)`** when:
- The previous page might not be the canonical list page,
- You need a specific state (e.g. selected user) restored.
## i18n / Locales
### Do
- **Keep `ru.json` and `en.json` in sync** for any new keys:
- When adding a key under `challenge.admin.*` in one file, add the corresponding entry in the other.
- For **status enums**, ensure all possible values have translations:
- `challenge.admin.users.stats.status.*` must cover all values of `taskStat.status`.
- `challenge.admin.submissions.status.*` must cover all submission statuses.
- Use **consistent key naming patterns**:
- Example: `challenge.admin.users.stats.status.accepted`, `...status.needs_revision`, etc.
### Dont
- **Dont introduce new `t('...')` keys in code without adding them to both locale files**.
- **Dont reuse unrelated keys** just to avoid adding translations — create clear, specific keys.
## UI Patterns for High-Density Overviews
### Do
- For high-density screens (e.g. 100 participants at once):
- Use **compact cards or rows** with:
- Truncated names (`truncate`),
- Thin `Progress` bars,
- Minimal text (percentage + small counters).
- Sort by progress to surface lagging participants.
### Dont
- **Dont use wide tables** when many rows must fit on one screen; prefer grids or narrow rows with fixed-width text columns and flexible progress area.
## When Adding New Features
Before merging:
- **Check routing**:
- New URL added to `URLs`.
- Route wired in `dashboard.tsx`.
- **Check data safety**:
- No unchecked property access on union/nullable types.
- **Check i18n**:
- New keys exist in both `ru.json` and `en.json`.
- **Check Chakra usage**:
- No `Dialog*` components outside a proper `<DialogRoot>` _or_ on standalone pages.

View File

@@ -18,11 +18,11 @@ module.exports = {
/* use https://admin.bro-js.ru/ to create config, navigations and features */
navigations: {
'challenge-admin.main': '/challenge-admin',
'link.challenge': '/challenge',
'link.challenge.main': '/challenge',
},
features: {
'challenge-admin': {
// add your features here in the format [featureName]: { value: string }
'use-chain-submissions-api': { value: 'true' },
},
},
config: {

View File

@@ -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<ChainSubmissionsResponse, {
chainId: string;
userId?: string;
status?: SubmissionStatus;
limit?: number;
offset?: number;
}>({
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 часов (включая тесты)

243
docs/updateAPI.md Normal file
View File

@@ -0,0 +1,243 @@
## Обновление API Challenge Service
Документ для frontend-разработчика. Описывает НОВЫЕ возможности и требования к клиенту.
Содержит два блока изменений:
- **Управление видимостью цепочек заданий** (поле `isActive` и новый админский эндпоинт).
- **Тестовая проверка решения задания админом** (флаг `isTest` и опциональные `hiddenInstructions` в `/submit`).
---
## 1. Управление видимостью цепочек заданий
### 1.1. Новое поле в модели цепочки
**Поле `isActive`**
- **Тип**: `boolean`
- **По умолчанию**: `true`
- **Смысл**: определяет, видна ли цепочка обычным пользователям в пользовательском списке.
> В базе: поле уже есть в модели `ChallengeChain`, на фронте его нужно учитывать в админских интерфейсах.
---
### 1.2. Пользовательский список цепочек
#### `GET /api/challenge/chains`
- **Назначение**: список цепочек для студентов/обычных пользователей.
- **Фильтрация на бэке**: возвращаются **только цепочки с `isActive: true`**.
- **Доступ**: без специальных ролей.
**Гарантии для фронтенда:**
- Выключенные / черновые цепочки **никогда** не попадут в этот список.
- Можно строить каталог цепочек, не фильтруя по `isActive` на клиенте.
Упрощённая структура элемента:
```json
{
"id": "...",
"name": "Основы программирования",
"tasks": [
{
"id": "...",
"title": "...",
"description": "..."
// Для не-преподавателей поля hiddenInstructions и creator отсутствуют
}
],
"isActive": true
}
```
**Требования к фронтенду:**
- Для пользовательских экранов достаточно этого эндпоинта, **дополнительную фильтрацию по активности делать не нужно**.
---
### 1.3. Админский список цепочек
#### `GET /api/challenge/chains/admin`
- **Назначение**: полный список цепочек (и включённых, и выключенных) для админских/преподавательских экранов.
- **Фильтрации по активности нет** — возвращаются **все** цепочки.
- **Доступ**: только роли `teacher` или `challenge-author`.
- Включает все данные по задачам, в т.ч. `hiddenInstructions`, `creator`.
Пример ответа (фрагмент):
```json
{
"error": null,
"data": [
{
"id": "...",
"name": "Основы программирования",
"tasks": [
{
"id": "...",
"title": "...",
"description": "...",
"hiddenInstructions": "...",
"creator": { "sub": "...", "preferred_username": "teacher1" }
}
],
"isActive": true,
"createdAt": "2023-10-29T12:00:00.000Z",
"updatedAt": "2023-10-29T12:00:00.000Z"
}
]
}
```
**Требования к фронтенду (админский UI):**
- Использовать этот эндпоинт для экранов управления цепочками.
- Показывать состояние активности (`isActive`) каждой цепочки (badge, тумблер и т.п.).
- При ошибке 403 (нет роли `teacher` / `challenge-author`) отображать сообщение об отсутствии доступа и, при необходимости, перенаправлять на пользовательский список.
---
### 1.4. Создание и обновление цепочек с учётом активности
#### `POST /api/challenge/chain`
**Роли**: `teacher` или `challenge-author`.
**Тело запроса:**
```json
{
"name": "Основы программирования",
"taskIds": ["...", "..."],
"isActive": true // опционально, по умолчанию true
}
```
- Если `isActive` не передан, цепочка создаётся **активной**.
**Требования к фронтенду:**
- На форме создания цепочки можно:
- либо не показывать тумблер активности (все новые будут активными),
- либо добавить переключатель «Активна» и передавать `isActive: false` для черновиков.
#### `PUT /api/challenge/chain/:chainId`
**Роли**: `teacher` или `challenge-author`.
**Тело запроса (все поля опциональны):**
```json
{
"name": "Новое имя",
"taskIds": ["..."],
"isActive": false
}
```
- Если `isActive` передан, его значение меняет активность цепочки.
- Если `isActive` не передан, активность не меняется.
**Сценарии:**
- Включить цепочку: `PUT /api/challenge/chain/:id` с `{ "isActive": true }`.
- Выключить цепочку (спрятать из пользовательского списка): `{ "isActive": false }`.
- Переименовать / поменять задачи без изменения активности: отправлять только `name` / `taskIds` без поля `isActive`.
**Требования к UI:**
- На экране «управление цепочками» (данные из `/chains/admin`):
- показывать `isActive`;
- давать возможность включать/выключать цепочку (тумблер → вызов `PUT /chain/:id` с нужным `isActive`).
---
## 2. Тестовая проверка решения задания (без записи прогресса)
Добавлен режим тестовой проверки решения, который позволяет **преподавателю/автору** проверить ответ через LLM **без создания попытки и без постановки в очередь**.
### 2.1. Расширение эндпоинта отправки решения
#### `POST /api/challenge/submit`
К существующему API добавлены новые опциональные поля в теле запроса:
```json
{
"userId": "...",
"taskId": "...",
"result": "...",
"isTest": true, // НОВОЕ: флаг тестового режима
"hiddenInstructions": "..." // НОВОЕ: опциональные инструкции для проверки
}
```
### 2.2. Обычный режим (без `isTest`)
- Если `isTest` **не передан** или `false` — поведение **НЕ изменилось**:
- проверяется существование пользователя по `userId`;
- считается количество попыток;
- создаётся `ChallengeSubmission`;
- попытка ставится в очередь на проверку через LLM;
- в ответе фронтенд получает `queueId` и `submissionId`.
### 2.3. Тестовый режим (`isTest: true`)
- Доступен только для ролей `teacher` / `challenge-author` (проверка через `isTeacher(req, true)`).
- **Не создаётся** запись `ChallengeSubmission`.
- **Не используется** очередь проверки.
- Проверяется только существование задания (`taskId`), пользователь по `userId` в этом режиме **не ищется и не нужен** (но поле всё ещё формально обязательно по схеме).
- Если переданы `hiddenInstructions`, они используются **вместо** `task.hiddenInstructions` при формировании промпта для LLM.
- Никакие изменения инструкций, переданные через `hiddenInstructions`, **не сохраняются** в базу — это чисто временная инструкция для одной тестовой проверки.
**Пример запроса (тестовый режим):**
```http
POST /api/challenge/submit
Content-Type: application/json
Authorization: Bearer <keycloak_token_teacher_or_author>
{
"userId": "any-or-dummy-id",
"taskId": "507f1f77bcf86cd799439012",
"result": "function solve() { ... }",
"isTest": true,
"hiddenInstructions": "ВРЕМЕННЫЕ инструкции для проверки, не сохраняются"
}
```
**Пример ответа (тестовый режим):**
```json
{
"error": null,
"data": {
"isTest": true,
"status": "accepted", // или "needs_revision"
"feedback": "Развёрнутый комментарий от LLM"
}
}
```
При отсутствии прав (нет роли `teacher` / `challenge-author`) вернётся 403.
### 2.4. Требования к фронтенду
- **Где использовать тестовый режим**:
- только в админских/преподавательских интерфейсах (например, экран настройки задания или предпросмотр проверки);
- использовать флаг `isTest: true`, когда нужно получить мгновенный ответ от LLM без записи в историю;
- при наличии UI-редактора скрытых инструкций использовать `hiddenInstructions` для передачи временного варианта, не сохраняя его.
- **Где НЕ использовать**:
- в пользовательском флоу сдачи заданий студентами — там должен использоваться обычный режим **без** `isTest`.
- **UI-ожидания**:
- показывать администратору статус (`accepted` / `needs_revision`) и `feedback`;
- явно обозначить в интерфейсе, что это «тестовая проверка» и она **не попадает в статистику / попытки**, а переданные `hiddenInstructions` не сохраняются.
---
## 3. Краткое резюме
- Для цепочек:
- пользовательский список: `GET /api/challenge/chains` → только активные (`isActive: true`);
- админский список: `GET /api/challenge/chains/admin` → все цепочки + управление `isActive` через `POST/PUT /chain`.
- Для отправки решений:
- обычный режим без `isTest` — всё как раньше (очередь, попытки, статистика);
- тестовый режим с `isTest: true` + опциональные `hiddenInstructions` — только для `teacher/challenge-author`, без записи прогресса, сразу возвращает результат проверки с учётом временных инструкций.

View File

@@ -54,6 +54,18 @@
"challenge.admin.tasks.delete.confirm.title": "Delete task",
"challenge.admin.tasks.delete.confirm.message": "Are you sure you want to delete task \"{title}\"? This action cannot be undone.",
"challenge.admin.tasks.delete.confirm.button": "Delete",
"challenge.admin.tasks.test.title": "Test check of answer",
"challenge.admin.tasks.test.description": "Send a sample answer to see how the LLM will evaluate this task with hidden instructions applied. This check does not affect statistics or attempt history.",
"challenge.admin.tasks.test.field.answer": "Answer for test check",
"challenge.admin.tasks.test.field.answer.placeholder": "Enter a sample solution as a student would write it...",
"challenge.admin.tasks.test.field.answer.helper": "The answer is sent in test mode (isTest: true) — no submission is created and no queue job is scheduled.",
"challenge.admin.tasks.test.button.run": "Run test check",
"challenge.admin.tasks.test.success": "Test check completed",
"challenge.admin.tasks.test.error": "Failed to run test check",
"challenge.admin.tasks.test.forbidden": "You don't have permissions for test checking. Teacher or challenge-author role is required.",
"challenge.admin.tasks.test.validation.fill.answer": "Enter an answer text for test check",
"challenge.admin.tasks.test.status.accepted": "✅ Answer accepted",
"challenge.admin.tasks.test.status.needs_revision": "⚠️ Answer needs revision",
"challenge.admin.chains.updated": "Chain updated",
"challenge.admin.chains.created": "Chain created",
"challenge.admin.chains.validation.enter.name": "Enter chain name",
@@ -74,6 +86,8 @@
"challenge.admin.chains.button.add": "+ Add",
"challenge.admin.chains.button.save": "Save changes",
"challenge.admin.chains.button.create": "Create chain",
"challenge.admin.chains.field.isActive": "Active for students",
"challenge.admin.chains.field.isActive.helper": "If disabled, the chain will not appear in the user-facing list.",
"challenge.admin.chains.list.title": "Task Chains",
"challenge.admin.chains.list.create.button": "+ Create Chain",
"challenge.admin.chains.list.search.placeholder": "Search by name...",
@@ -84,8 +98,11 @@
"challenge.admin.chains.list.table.name": "Name",
"challenge.admin.chains.list.table.tasks.count": "Number of tasks",
"challenge.admin.chains.list.table.created": "Created date",
"challenge.admin.chains.list.table.status": "Status",
"challenge.admin.chains.list.table.actions": "Actions",
"challenge.admin.chains.list.badge.tasks": "tasks",
"challenge.admin.chains.list.status.active": "Active",
"challenge.admin.chains.list.status.inactive": "Inactive",
"challenge.admin.chains.list.button.edit": "Edit",
"challenge.admin.chains.list.button.delete": "Delete",
"challenge.admin.chains.deleted": "Chain deleted",
@@ -95,6 +112,21 @@
"challenge.admin.chains.delete.confirm.title": "Delete chain",
"challenge.admin.chains.delete.confirm.message": "Are you sure you want to delete chain \"{name}\"? This action cannot be undone.",
"challenge.admin.chains.delete.confirm.button": "Delete",
"challenge.admin.chains.duplicate.button": "Duplicate",
"challenge.admin.chains.duplicate.dialog.title": "Duplicate chain",
"challenge.admin.chains.duplicate.dialog.description": "Create a copy of chain \"{name}\" with the same tasks. The new chain will be created as inactive.",
"challenge.admin.chains.duplicate.dialog.field.name": "New chain name",
"challenge.admin.chains.duplicate.dialog.field.name.placeholder": "Copy - {name}",
"challenge.admin.chains.duplicate.dialog.field.name.helper": "Leave empty for auto-generated name",
"challenge.admin.chains.duplicate.dialog.button.confirm": "Create copy",
"challenge.admin.chains.duplicate.success": "Chain successfully duplicated",
"challenge.admin.chains.duplicate.error": "Failed to duplicate chain",
"challenge.admin.chains.clear.submissions.button": "Clear submissions",
"challenge.admin.chains.clear.submissions.dialog.title": "Clear chain submissions",
"challenge.admin.chains.clear.submissions.dialog.message": "Are you sure you want to delete all submissions for chain \"{name}\"? This action is irreversible. All deleted submissions cannot be restored.",
"challenge.admin.chains.clear.submissions.dialog.button.confirm": "Delete all submissions",
"challenge.admin.chains.clear.submissions.success": "Submissions successfully deleted",
"challenge.admin.chains.clear.submissions.error": "Failed to delete submissions",
"challenge.admin.dashboard.title": "Dashboard",
"challenge.admin.dashboard.loading": "Loading statistics...",
"challenge.admin.dashboard.load.error": "Failed to load system statistics",
@@ -137,16 +169,33 @@
"challenge.admin.users.stats.chains.progress": "Chain progress",
"challenge.admin.users.stats.tasks": "Tasks",
"challenge.admin.users.stats.status.completed": "Completed",
"challenge.admin.users.stats.status.accepted": "Accepted",
"challenge.admin.users.stats.status.needs_revision": "Revision",
"challenge.admin.users.stats.status.in_progress": "In progress",
"challenge.admin.users.stats.status.pending": "Pending",
"challenge.admin.users.stats.status.not_started": "Not started",
"challenge.admin.users.stats.status.not_attempted": "Not attempted",
"challenge.admin.users.stats.attempts": "Attempts:",
"challenge.admin.users.stats.avg.check.time": "Average check time",
"challenge.admin.users.stats.close": "Close",
"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",
@@ -176,6 +225,12 @@
"challenge.admin.submissions.details.solution": "User solution:",
"challenge.admin.submissions.details.feedback": "LLM feedback:",
"challenge.admin.submissions.details.close": "Close",
"challenge.admin.submissions.details.not.found": "Attempt not found",
"challenge.admin.submissions.overview.title": "Overall participant progress",
"challenge.admin.submissions.overview.description": "Below is an overview of all participants and chains. Select a user above to see their individual attempts.",
"challenge.admin.submissions.overview.table.user": "Participant",
"challenge.admin.submissions.overview.table.chain": "Chain",
"challenge.admin.submissions.overview.table.progress": "Progress",
"challenge.admin.layout.title": "Challenge Admin",
"challenge.admin.layout.nav.dashboard": "Dashboard",
"challenge.admin.layout.nav.detailed.stats": "Detailed Statistics",

View File

@@ -53,6 +53,18 @@
"challenge.admin.tasks.delete.confirm.title": "Удалить задание",
"challenge.admin.tasks.delete.confirm.message": "Вы уверены, что хотите удалить задание \"{title}\"? Это действие нельзя отменить.",
"challenge.admin.tasks.delete.confirm.button": "Удалить",
"challenge.admin.tasks.test.title": "Тестовая проверка ответа",
"challenge.admin.tasks.test.description": "Отправьте пример ответа, чтобы проверить, как LLM будет оценивать это задание с учётом скрытых инструкций. Эта проверка не попадает в статистику и историю попыток.",
"challenge.admin.tasks.test.field.answer": "Ответ для тестовой проверки",
"challenge.admin.tasks.test.field.answer.placeholder": "Введите пример решения так, как его написал бы студент...",
"challenge.admin.tasks.test.field.answer.helper": "Ответ отправляется в режиме тестовой проверки (isTest: true) — без создания попытки и постановки в очередь.",
"challenge.admin.tasks.test.button.run": "Проверить ответ",
"challenge.admin.tasks.test.success": "Тестовая проверка выполнена",
"challenge.admin.tasks.test.error": "Не удалось выполнить тестовую проверку",
"challenge.admin.tasks.test.forbidden": "Недостаточно прав для тестовой проверки. Нужна роль преподавателя или автора челленджа.",
"challenge.admin.tasks.test.validation.fill.answer": "Введите текст ответа для тестовой проверки",
"challenge.admin.tasks.test.status.accepted": "✅ Ответ принят (accepted)",
"challenge.admin.tasks.test.status.needs_revision": "⚠️ Ответ требует доработки (needs_revision)",
"challenge.admin.chains.updated": "Цепочка обновлена",
"challenge.admin.chains.created": "Цепочка создана",
"challenge.admin.chains.validation.enter.name": "Введите название цепочки",
@@ -73,6 +85,8 @@
"challenge.admin.chains.button.add": "+ Добавить",
"challenge.admin.chains.button.save": "Сохранить изменения",
"challenge.admin.chains.button.create": "Создать цепочку",
"challenge.admin.chains.field.isActive": "Активна для студентов",
"challenge.admin.chains.field.isActive.helper": "Если выключить, цепочка не будет отображаться в пользовательском списке.",
"challenge.admin.chains.list.title": "Цепочки заданий",
"challenge.admin.chains.list.create.button": "+ Создать цепочку",
"challenge.admin.chains.list.search.placeholder": "Поиск по названию...",
@@ -83,8 +97,11 @@
"challenge.admin.chains.list.table.name": "Название",
"challenge.admin.chains.list.table.tasks.count": "Количество заданий",
"challenge.admin.chains.list.table.created": "Дата создания",
"challenge.admin.chains.list.table.status": "Статус",
"challenge.admin.chains.list.table.actions": "Действия",
"challenge.admin.chains.list.badge.tasks": "заданий",
"challenge.admin.chains.list.status.active": "Включена",
"challenge.admin.chains.list.status.inactive": "Выключена",
"challenge.admin.chains.list.button.edit": "Редактировать",
"challenge.admin.chains.list.button.delete": "Удалить",
"challenge.admin.chains.deleted": "Цепочка удалена",
@@ -94,6 +111,21 @@
"challenge.admin.chains.delete.confirm.title": "Удалить цепочку",
"challenge.admin.chains.delete.confirm.message": "Вы уверены, что хотите удалить цепочку \"{name}\"? Это действие нельзя отменить.",
"challenge.admin.chains.delete.confirm.button": "Удалить",
"challenge.admin.chains.duplicate.button": "Дублировать",
"challenge.admin.chains.duplicate.dialog.title": "Дублировать цепочку",
"challenge.admin.chains.duplicate.dialog.description": "Создать копию цепочки \"{name}\" с теми же заданиями. Новая цепочка будет создана неактивной.",
"challenge.admin.chains.duplicate.dialog.field.name": "Название новой цепочки",
"challenge.admin.chains.duplicate.dialog.field.name.placeholder": "Копия - {name}",
"challenge.admin.chains.duplicate.dialog.field.name.helper": "Оставьте пустым для автоматического названия",
"challenge.admin.chains.duplicate.dialog.button.confirm": "Создать копию",
"challenge.admin.chains.duplicate.success": "Цепочка успешно скопирована",
"challenge.admin.chains.duplicate.error": "Не удалось скопировать цепочку",
"challenge.admin.chains.clear.submissions.button": "Очистить попытки",
"challenge.admin.chains.clear.submissions.dialog.title": "Очистить попытки по цепочке",
"challenge.admin.chains.clear.submissions.dialog.message": "Вы уверены, что хотите удалить все попытки по цепочке \"{name}\"? Это действие необратимо. Все удаленные попытки невозможно восстановить.",
"challenge.admin.chains.clear.submissions.dialog.button.confirm": "Удалить все попытки",
"challenge.admin.chains.clear.submissions.success": "Попытки успешно удалены",
"challenge.admin.chains.clear.submissions.error": "Не удалось удалить попытки",
"challenge.admin.dashboard.title": "Dashboard",
"challenge.admin.dashboard.loading": "Загрузка статистики...",
"challenge.admin.dashboard.load.error": "Не удалось загрузить статистику системы",
@@ -136,16 +168,33 @@
"challenge.admin.users.stats.chains.progress": "Прогресс по цепочкам",
"challenge.admin.users.stats.tasks": "Задания",
"challenge.admin.users.stats.status.completed": "Завершено",
"challenge.admin.users.stats.status.accepted": "Принято",
"challenge.admin.users.stats.status.needs_revision": "Доработка",
"challenge.admin.users.stats.status.in_progress": "В процессе",
"challenge.admin.users.stats.status.pending": "Ожидает",
"challenge.admin.users.stats.status.not_started": "Не начато",
"challenge.admin.users.stats.status.not_attempted": "Не пытался",
"challenge.admin.users.stats.attempts": "Попыток:",
"challenge.admin.users.stats.avg.check.time": "Среднее время проверки",
"challenge.admin.users.stats.close": "Закрыть",
"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.status": "Статус",
"challenge.admin.submissions.status.all": "Все статусы",
"challenge.admin.submissions.status.accepted": "Принято",
@@ -175,6 +224,12 @@
"challenge.admin.submissions.details.solution": "Решение пользователя:",
"challenge.admin.submissions.details.feedback": "Обратная связь от LLM:",
"challenge.admin.submissions.details.close": "Закрыть",
"challenge.admin.submissions.details.not.found": "Попытка не найдена",
"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": "Детальная статистика",

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "challenge-admin-pl",
"version": "1.0.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "challenge-admin-pl",
"version": "1.0.0",
"version": "1.2.0",
"license": "ISC",
"dependencies": {
"@brojs/cli": "^1.9.4",

View File

@@ -1,6 +1,6 @@
{
"name": "challenge-admin",
"version": "1.0.0",
"version": "1.2.0",
"description": "",
"main": "./src/index.tsx",
"scripts": {

View File

@@ -13,6 +13,12 @@ import type {
UpdateTaskRequest,
CreateChainRequest,
UpdateChainRequest,
DuplicateChainRequest,
ClearSubmissionsResponse,
SubmitRequest,
TestSubmissionResult,
ChainSubmissionsResponse,
SubmissionStatus,
} from '../../types/challenge'
export const api = createApi({
@@ -77,7 +83,7 @@ export const api = createApi({
// Chains
getChains: builder.query<ChallengeChain[], void>({
query: () => '/challenge/chains',
query: () => '/challenge/chains/admin',
transformResponse: (response: { body: ChallengeChain[] }) => response.body,
providesTags: ['Chain'],
}),
@@ -111,6 +117,23 @@ export const api = createApi({
}),
invalidatesTags: ['Chain'],
}),
duplicateChain: builder.mutation<ChallengeChain, { chainId: string; name?: string }>({
query: ({ chainId, name }) => ({
url: `/challenge/chain/${chainId}/duplicate`,
method: 'POST',
body: name ? { name } : {},
}),
transformResponse: (response: { body: ChallengeChain }) => response.body,
invalidatesTags: ['Chain'],
}),
clearChainSubmissions: builder.mutation<ClearSubmissionsResponse, string>({
query: (chainId) => ({
url: `/challenge/chain/${chainId}/submissions`,
method: 'DELETE',
}),
transformResponse: (response: { body: ClearSubmissionsResponse }) => response.body,
invalidatesTags: ['Chain', 'Submission'],
}),
// Statistics
getSystemStats: builder.query<SystemStats, void>({
@@ -141,6 +164,34 @@ 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<TestSubmissionResult, SubmitRequest>({
query: ({ userId, taskId, result, isTest = true, hiddenInstructions }) => ({
url: '/challenge/submit',
method: 'POST',
body: {
userId,
taskId,
result,
isTest,
hiddenInstructions,
},
}),
// Сервер возвращает { success: boolean; body: TestSubmissionResult }
transformResponse: (response: { success: boolean; body: TestSubmissionResult }) => response.body,
}),
}),
})
@@ -155,9 +206,13 @@ export const {
useCreateChainMutation,
useUpdateChainMutation,
useDeleteChainMutation,
useDuplicateChainMutation,
useClearChainSubmissionsMutation,
useGetSystemStatsQuery,
useGetSystemStatsV2Query,
useGetUserStatsQuery,
useGetUserSubmissionsQuery,
useGetChainSubmissionsQuery,
useTestSubmissionMutation,
} = api

View File

@@ -31,11 +31,17 @@ export const URLs = {
// Users
users: makeUrl('/users'),
userStats: (userId: string) => makeUrl(`/users/${userId}`),
userStatsPath: makeUrl('/users/:userId'),
// Submissions
submissions: makeUrl('/submissions'),
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'] || '/challenge',
challengePlayer: navs['link.challenge.main'] || '/challenge',
}

View File

@@ -0,0 +1,87 @@
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import {
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
Button,
Text,
} from '@chakra-ui/react'
import { useClearChainSubmissionsMutation } from '../__data__/api/api'
import { toaster } from './ui/toaster'
import type { ChallengeChain } from '../types/challenge'
interface ClearSubmissionsDialogProps {
isOpen: boolean
onClose: () => void
chain: ChallengeChain | null
}
export const ClearSubmissionsDialog: React.FC<ClearSubmissionsDialogProps> = ({
isOpen,
onClose,
chain,
}) => {
const { t } = useTranslation()
const [clearSubmissions, { isLoading }] = useClearChainSubmissionsMutation()
// Прокручиваем страницу к началу при открытии диалога
useEffect(() => {
if (isOpen) {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}, [isOpen])
const handleConfirm = async () => {
if (!chain) return
try {
await clearSubmissions(chain.id).unwrap()
toaster.create({
title: t('challenge.admin.common.success'),
description: t('challenge.admin.chains.clear.submissions.success'),
type: 'success',
})
onClose()
} catch (err) {
toaster.create({
title: t('challenge.admin.common.error'),
description: t('challenge.admin.chains.clear.submissions.error'),
type: 'error',
})
}
}
if (!chain) return null
return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} scrollBehavior="inside">
<DialogContent>
<DialogHeader>
<DialogTitle>{t('challenge.admin.chains.clear.submissions.dialog.title')}</DialogTitle>
</DialogHeader>
<DialogBody>
<Text>{t('challenge.admin.chains.clear.submissions.dialog.message', { name: chain.name })}</Text>
</DialogBody>
<DialogFooter>
<DialogActionTrigger asChild>
<Button variant="outline" onClick={onClose} disabled={isLoading}>
{t('challenge.admin.common.cancel')}
</Button>
</DialogActionTrigger>
<Button colorPalette="red" onClick={handleConfirm} disabled={isLoading}>
{t('challenge.admin.chains.clear.submissions.dialog.button.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
)
}

View File

@@ -1,4 +1,4 @@
import React from 'react'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import {
DialogRoot,
@@ -36,8 +36,16 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
const confirm = confirmLabel || t('challenge.admin.common.confirm')
const cancel = cancelLabel || t('challenge.admin.common.cancel')
// Прокручиваем страницу к началу при открытии диалога
useEffect(() => {
if (isOpen) {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}, [isOpen])
return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()}>
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} scrollBehavior="inside">
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>

View File

@@ -0,0 +1,116 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import {
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
Button,
Field,
Input,
Text,
VStack,
} from '@chakra-ui/react'
import { useDuplicateChainMutation } from '../__data__/api/api'
import { toaster } from './ui/toaster'
import type { ChallengeChain } from '../types/challenge'
interface DuplicateChainDialogProps {
isOpen: boolean
onClose: () => void
chain: ChallengeChain | null
}
export const DuplicateChainDialog: React.FC<DuplicateChainDialogProps> = ({
isOpen,
onClose,
chain,
}) => {
const { t } = useTranslation()
const [name, setName] = useState('')
const [duplicateChain, { isLoading }] = useDuplicateChainMutation()
// Прокручиваем страницу к началу при открытии диалога
useEffect(() => {
if (isOpen) {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}, [isOpen])
const handleClose = () => {
setName('')
onClose()
}
const handleConfirm = async () => {
if (!chain) return
try {
await duplicateChain({
chainId: chain.id,
name: name.trim() || undefined,
}).unwrap()
toaster.create({
title: t('challenge.admin.common.success'),
description: t('challenge.admin.chains.duplicate.success'),
type: 'success',
})
handleClose()
} catch (err) {
toaster.create({
title: t('challenge.admin.common.error'),
description: t('challenge.admin.chains.duplicate.error'),
type: 'error',
})
}
}
if (!chain) return null
const defaultPlaceholder = t('challenge.admin.chains.duplicate.dialog.field.name.placeholder', {
name: chain.name,
})
return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && handleClose()} scrollBehavior="inside">
<DialogContent>
<DialogHeader>
<DialogTitle>{t('challenge.admin.chains.duplicate.dialog.title')}</DialogTitle>
</DialogHeader>
<DialogBody>
<VStack gap={4} align="stretch">
<Text>{t('challenge.admin.chains.duplicate.dialog.description', { name: chain.name })}</Text>
<Field.Root>
<Field.Label>{t('challenge.admin.chains.duplicate.dialog.field.name')}</Field.Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={defaultPlaceholder}
/>
<Field.HelperText>
{t('challenge.admin.chains.duplicate.dialog.field.name.helper')}
</Field.HelperText>
</Field.Root>
</VStack>
</DialogBody>
<DialogFooter>
<DialogActionTrigger asChild>
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
{t('challenge.admin.common.cancel')}
</Button>
</DialogActionTrigger>
<Button colorPalette="teal" onClick={handleConfirm} disabled={isLoading}>
{t('challenge.admin.chains.duplicate.dialog.button.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
)
}

View File

@@ -9,7 +9,9 @@ import { TaskFormPage } from './pages/tasks/TaskFormPage'
import { ChainsListPage } from './pages/chains/ChainsListPage'
import { ChainFormPage } from './pages/chains/ChainFormPage'
import { UsersPage } from './pages/users/UsersPage'
import { UserStatsPage } from './pages/users/UserStatsPage'
import { SubmissionsPage } from './pages/submissions/SubmissionsPage'
import { SubmissionDetailsPage } from './pages/submissions/SubmissionDetailsPage'
import { URLs } from './__data__/urls'
const PageWrapper = ({ children }: React.PropsWithChildren) => (
@@ -110,6 +112,14 @@ export const Dashboard = () => {
</PageWrapper>
}
/>
<Route
path={URLs.userStatsPath}
element={
<PageWrapper>
<UserStatsPage />
</PageWrapper>
}
/>
{/* Submissions */}
<Route
@@ -120,6 +130,22 @@ export const Dashboard = () => {
</PageWrapper>
}
/>
<Route
path={URLs.submissionsChainPath}
element={
<PageWrapper>
<SubmissionsPage />
</PageWrapper>
}
/>
<Route
path={URLs.submissionDetailsPath}
element={
<PageWrapper>
<SubmissionDetailsPage />
</PageWrapper>
}
/>
</Routes>
)
}

View File

@@ -42,11 +42,13 @@ export const ChainFormPage: React.FC = () => {
const [name, setName] = useState('')
const [selectedTasks, setSelectedTasks] = useState<ChallengeTask[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [isActive, setIsActive] = useState(true)
useEffect(() => {
if (chain) {
setName(chain.name)
setSelectedTasks(chain.tasks)
setIsActive(chain.isActive !== false)
}
}, [chain])
@@ -80,6 +82,7 @@ export const ChainFormPage: React.FC = () => {
data: {
name: name.trim(),
taskIds: taskIds,
isActive,
},
}).unwrap()
toaster.create({
@@ -91,6 +94,7 @@ export const ChainFormPage: React.FC = () => {
await createChain({
name: name.trim(),
taskIds: taskIds,
isActive,
}).unwrap()
toaster.create({
title: t('challenge.admin.common.success'),
@@ -191,6 +195,25 @@ export const ChainFormPage: React.FC = () => {
/>
</Field.Root>
{/* Active flag */}
<Field.Root>
<HStack justify="space-between" align="flex-start">
<HStack gap={2}>
<input
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
disabled={isLoading}
style={{ cursor: isLoading ? 'not-allowed' : 'pointer' }}
/>
<Text>{t('challenge.admin.chains.field.isActive')}</Text>
</HStack>
<Text fontSize="sm" color="gray.500" maxW="md">
{t('challenge.admin.chains.field.isActive.helper')}
</Text>
</HStack>
</Field.Root>
{/* Selected Tasks */}
<Box>
<Text fontWeight="bold" mb={3}>
@@ -227,6 +250,15 @@ export const ChainFormPage: React.FC = () => {
<Text fontWeight="medium">{task.title}</Text>
</HStack>
<HStack gap={1}>
<IconButton
size="sm"
variant="ghost"
onClick={() => navigate(URLs.taskEdit(task.id))}
disabled={isLoading}
aria-label="Edit task"
>
</IconButton>
<IconButton
size="sm"
variant="ghost"

View File

@@ -12,12 +12,13 @@ import {
Text,
Badge,
} from '@chakra-ui/react'
import { useGetChainsQuery, useDeleteChainMutation } from '../../__data__/api/api'
import { useGetChainsQuery, useDeleteChainMutation, useUpdateChainMutation } from '../../__data__/api/api'
import { URLs } from '../../__data__/urls'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { EmptyState } from '../../components/EmptyState'
import { ConfirmDialog } from '../../components/ConfirmDialog'
import { DuplicateChainDialog } from '../../components/DuplicateChainDialog'
import { ClearSubmissionsDialog } from '../../components/ClearSubmissionsDialog'
import type { ChallengeChain } from '../../types/challenge'
import { toaster } from '../../components/ui/toaster'
@@ -25,22 +26,28 @@ export const ChainsListPage: React.FC = () => {
const navigate = useNavigate()
const { t } = useTranslation()
const { data: chains, isLoading, error, refetch } = useGetChainsQuery()
const [deleteChain, { isLoading: isDeleting }] = useDeleteChainMutation()
const [deleteChain] = useDeleteChainMutation()
const [searchQuery, setSearchQuery] = useState('')
const [chainToDelete, setChainToDelete] = useState<ChallengeChain | null>(null)
const [chainToDuplicate, setChainToDuplicate] = useState<ChallengeChain | null>(null)
const [chainToClearSubmissions, setChainToClearSubmissions] = useState<ChallengeChain | null>(null)
const [updatingChainId, setUpdatingChainId] = useState<string | null>(null)
const [updateChain] = useUpdateChainMutation()
const handleDeleteChain = async () => {
if (!chainToDelete) return
const handleDeleteChain = async (chain: ChallengeChain) => {
const confirmed = window.confirm(
t('challenge.admin.chains.delete.confirm.message', { name: chain.name })
)
if (!confirmed) return
try {
await deleteChain(chainToDelete.id).unwrap()
await deleteChain(chain.id).unwrap()
toaster.create({
title: t('challenge.admin.common.success'),
description: t('challenge.admin.chains.deleted'),
type: 'success',
})
setChainToDelete(null)
} catch (err) {
toaster.create({
title: t('challenge.admin.common.error'),
@@ -50,6 +57,30 @@ export const ChainsListPage: React.FC = () => {
}
}
const handleToggleActive = async (chain: ChallengeChain, nextValue: boolean) => {
setUpdatingChainId(chain.id)
try {
await updateChain({
id: chain.id,
data: { isActive: nextValue },
}).unwrap()
toaster.create({
title: t('challenge.admin.common.success'),
description: t('challenge.admin.chains.updated'),
type: 'success',
})
} catch {
toaster.create({
title: t('challenge.admin.common.error'),
description: t('challenge.admin.chains.save.error'),
type: 'error',
})
} finally {
setUpdatingChainId(null)
}
}
if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.chains.list.loading')} />
}
@@ -110,6 +141,7 @@ export const ChainsListPage: React.FC = () => {
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.name')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.tasks.count')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.created')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.status')}</Table.ColumnHeader>
<Table.ColumnHeader textAlign="right">{t('challenge.admin.chains.list.table.actions')}</Table.ColumnHeader>
</Table.Row>
</Table.Header>
@@ -127,6 +159,25 @@ export const ChainsListPage: React.FC = () => {
{formatDate(chain.createdAt)}
</Text>
</Table.Cell>
<Table.Cell>
<HStack gap={3} justify="flex-start">
<Badge colorPalette={chain.isActive ? 'green' : 'gray'} variant="subtle">
{chain.isActive
? t('challenge.admin.chains.list.status.active')
: t('challenge.admin.chains.list.status.inactive')}
</Badge>
<Button
size="xs"
variant="outline"
onClick={() => handleToggleActive(chain, !chain.isActive)}
disabled={updatingChainId === chain.id}
>
{chain.isActive
? t('challenge.admin.chains.list.status.inactive')
: t('challenge.admin.chains.list.status.active')}
</Button>
</HStack>
</Table.Cell>
<Table.Cell textAlign="right">
<HStack gap={2} justify="flex-end">
<Button
@@ -136,11 +187,26 @@ export const ChainsListPage: React.FC = () => {
>
{t('challenge.admin.chains.list.button.edit')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setChainToDuplicate(chain)}
>
{t('challenge.admin.chains.duplicate.button')}
</Button>
<Button
size="sm"
variant="ghost"
colorPalette="red"
onClick={() => setChainToDelete(chain)}
onClick={() => setChainToClearSubmissions(chain)}
>
{t('challenge.admin.chains.clear.submissions.button')}
</Button>
<Button
size="sm"
variant="ghost"
colorPalette="red"
onClick={() => handleDeleteChain(chain)}
>
{t('challenge.admin.chains.list.button.delete')}
</Button>
@@ -153,14 +219,16 @@ export const ChainsListPage: React.FC = () => {
</Box>
)}
<ConfirmDialog
isOpen={!!chainToDelete}
onClose={() => setChainToDelete(null)}
onConfirm={handleDeleteChain}
title={t('challenge.admin.chains.delete.confirm.title')}
message={t('challenge.admin.chains.delete.confirm.message', { name: chainToDelete?.name })}
confirmLabel={t('challenge.admin.chains.delete.confirm.button')}
isLoading={isDeleting}
<DuplicateChainDialog
isOpen={!!chainToDuplicate}
onClose={() => setChainToDuplicate(null)}
chain={chainToDuplicate}
/>
<ClearSubmissionsDialog
isOpen={!!chainToClearSubmissions}
onClose={() => setChainToClearSubmissions(null)}
chain={chainToClearSubmissions}
/>
</Box>
)

View File

@@ -0,0 +1,239 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useParams, useNavigate } from 'react-router-dom'
import { Box, Heading, Text, Button, HStack, VStack } from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown'
import { useGetUserSubmissionsQuery } from '../../__data__/api/api'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { StatusBadge } from '../../components/StatusBadge'
import type { ChallengeTask, ChallengeUser } from '../../types/challenge'
import { URLs } from '../../__data__/urls'
export const SubmissionDetailsPage: React.FC = () => {
const { t } = useTranslation()
const { chainId, userId, submissionId } = useParams<{ chainId: string; userId: string; submissionId: string }>()
const navigate = useNavigate()
// Получаем submissions для конкретного пользователя
const { data: submissions, isLoading, error } = useGetUserSubmissionsQuery(
{ userId: userId!, taskId: undefined },
{ skip: !userId }
)
const submission = submissions?.find((s) => s.id === submissionId)
const handleBack = () => {
if (chainId) {
navigate(URLs.submissionsChain(chainId))
} else {
navigate(URLs.submissions)
}
}
if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.submissions.loading')} />
}
if (error) {
return <ErrorAlert message={t('challenge.admin.submissions.load.error')} onRetry={handleBack} />
}
if (!submission) {
return (
<Box>
<Button variant="ghost" onClick={handleBack} mb={4}>
{t('challenge.admin.common.close')}
</Button>
<ErrorAlert
message={t('challenge.admin.submissions.details.not.found')}
onRetry={handleBack}
/>
</Box>
)
}
const rawUser = submission.user as ChallengeUser | string | undefined
const rawTask = submission.task as ChallengeTask | string | undefined
const userNickname =
rawUser && typeof rawUser === 'object' && 'nickname' in rawUser
? rawUser.nickname ?? ''
: typeof rawUser === 'string'
? rawUser
: ''
const taskTitle =
rawTask && typeof rawTask === 'object' && 'title' in rawTask
? rawTask.title ?? ''
: typeof rawTask === 'string'
? rawTask
: ''
const taskDescription =
rawTask && typeof rawTask === 'object' && 'description' in rawTask
? rawTask.description ?? ''
: ''
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
const getCheckTimeValue = () => {
if (!submission.checkedAt) return null
const submitted = new Date(submission.submittedAt).getTime()
const checked = new Date(submission.checkedAt).getTime()
return ((checked - submitted) / 1000).toFixed(2)
}
return (
<Box>
{/* Header with back button */}
<HStack mb={6}>
<Button variant="ghost" onClick={handleBack}>
{t('challenge.admin.common.close')}
</Button>
</HStack>
<Heading mb={6}>
{t('challenge.admin.submissions.details.title')} #{submission.attemptNumber}
</Heading>
<VStack gap={6} align="stretch">
{/* Meta */}
<Box
p={6}
bg="white"
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<HStack mb={4} justify="space-between">
<Box>
<Text fontSize="sm" color="gray.600" mb={1}>
{t('challenge.admin.submissions.details.user')}
</Text>
<Text fontWeight="bold">{userNickname}</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600" mb={1}>
{t('challenge.admin.submissions.details.status')}
</Text>
<StatusBadge status={submission.status} />
</Box>
</HStack>
<VStack align="stretch" gap={2}>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.submitted')}</strong>{' '}
{formatDate(submission.submittedAt)}
</Text>
{submission.checkedAt && (
<>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.checked')}</strong>{' '}
{formatDate(submission.checkedAt)}
</Text>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.check.time')}</strong>{' '}
{t('challenge.admin.submissions.check.time', { time: getCheckTimeValue() })}
</Text>
</>
)}
</VStack>
</Box>
{/* Task */}
<Box
p={6}
bg="white"
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<Text fontWeight="bold" mb={4}>
{t('challenge.admin.submissions.details.task')} {taskTitle}
</Text>
<Box
p={4}
bg="gray.50"
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
maxH="400px"
overflowY="auto"
>
<ReactMarkdown>{taskDescription}</ReactMarkdown>
</Box>
</Box>
{/* Solution */}
<Box
p={6}
bg="white"
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<Text fontWeight="bold" mb={4}>
{t('challenge.admin.submissions.details.solution')}
</Text>
<Box
p={4}
bg="blue.50"
borderRadius="md"
borderWidth="1px"
borderColor="blue.200"
maxH="500px"
overflowY="auto"
>
<Text
fontFamily="monospace"
fontSize="sm"
whiteSpace="pre-wrap"
wordBreak="break-word"
>
{submission.result}
</Text>
</Box>
</Box>
{/* Feedback */}
{submission.feedback && (
<Box
p={6}
bg="white"
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<Text fontWeight="bold" mb={4}>
{t('challenge.admin.submissions.details.feedback')}
</Text>
<Box
p={4}
bg={submission.status === 'accepted' ? 'green.50' : 'red.50'}
borderRadius="md"
borderWidth="1px"
borderColor={submission.status === 'accepted' ? 'green.200' : 'red.200'}
>
<Text>{submission.feedback}</Text>
</Box>
</Box>
)}
</VStack>
</Box>
)
}

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react'
import React, { useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useParams, Link } from 'react-router-dom'
import {
Box,
Heading,
@@ -9,24 +10,26 @@ import {
Button,
HStack,
VStack,
Badge,
Progress,
Grid,
SimpleGrid,
Select,
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
createListCollection,
} from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown'
import { 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'
import { StatusBadge } from '../../components/StatusBadge'
import { URLs } from '../../__data__/urls'
import type {
ActiveParticipant,
ChallengeSubmission,
SubmissionStatus,
ChallengeTask,
@@ -35,13 +38,50 @@ import type {
export const SubmissionsPage: React.FC = () => {
const { t } = useTranslation()
const { data: stats, isLoading: isStatsLoading, error: statsError, refetch: refetchStats } =
useGetSystemStatsV2Query(undefined)
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<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<SubmissionStatus | 'all'>('all')
const [selectedSubmission, setSelectedSubmission] = useState<ChallengeSubmission | null>(null)
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
// Получаем список цепочек
const {
data: chains,
isLoading: isChainsLoading,
error: chainsError,
refetch: refetchChains,
} = useGetChainsQuery()
// Новый 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 || useNewApi,
})
const {
data: submissions,
@@ -50,42 +90,142 @@ export const SubmissionsPage: React.FC = () => {
refetch: refetchSubmissions,
} = useGetUserSubmissionsQuery(
{ userId: selectedUserId!, taskId: undefined },
{ skip: !selectedUserId }
{ skip: !selectedUserId || useNewApi }
)
const isLoading = isStatsLoading || (selectedUserId && isSubmissionsLoading)
const error = statsError || submissionsError
const isLoading =
isChainsLoading ||
(chainId && useNewApi && isChainDataLoading) ||
(chainId && !useNewApi && isStatsLoading) ||
(selectedUserId && !useNewApi && isSubmissionsLoading)
const error = chainsError || (useNewApi ? chainDataError : statsError || submissionsError)
const handleRetry = () => {
refetchStats()
if (selectedUserId) {
refetchSubmissions()
refetchChains()
if (chainId) {
if (useNewApi) {
refetchChainData()
} else {
refetchStats()
if (selectedUserId) {
refetchSubmissions()
}
}
}
}
if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.submissions.loading')} />
}
// Получаем данные выбранной цепочки из списка chains (для старого API)
const selectedChain = useMemo(() => {
if (!chainId || !chains) return null
return chains.find((c) => c.id === chainId) || null
}, [chainId, chains])
if (error || !stats) {
return <ErrorAlert message={t('challenge.admin.submissions.load.error')} onRetry={handleRetry} />
}
// Получаем taskIds из текущей цепочки (для старого API)
const chainTaskIds = useMemo(() => {
if (!selectedChain) return new Set<string>()
return new Set(selectedChain.tasks.map((t) => t.id))
}, [selectedChain])
const participants: ActiveParticipant[] = stats.activeParticipants || []
const submissionsList: ChallengeSubmission[] = submissions || []
// Старый API: фильтруем участников - только те, кто имеет прогресс в этой цепочке
const chainParticipantsOld = useMemo(() => {
if (!stats?.activeParticipants || !chainId || useNewApi) return []
const filteredSubmissions = submissionsList.filter((submission) => {
const user = submission.user as ChallengeUser
const task = submission.task as ChallengeTask
return stats.activeParticipants
.map((participant) => {
const chainProgress = participant.chainProgress?.find((cp) => cp.chainId === chainId)
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, useNewApi])
const matchesSearch =
user.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.title.toLowerCase().includes(searchQuery.toLowerCase())
// Старый API: фильтруем submissions только по заданиям из текущей цепочки
const filteredSubmissionsOld = useMemo(() => {
if (!submissions || chainTaskIds.size === 0 || useNewApi) return []
const matchesStatus = statusFilter === 'all' || submission.status === statusFilter
const normalizedSearchQuery = (searchQuery ?? '').toLowerCase()
return matchesSearch && matchesStatus
})
return submissions.filter((submission) => {
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 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, 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', {
@@ -115,49 +255,150 @@ export const SubmissionsPage: React.FC = () => {
],
})
const userOptions = createListCollection({
items: participants.map((participant) => ({
label: `${participant.nickname} (${participant.userId})`,
value: participant.userId,
})),
})
if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.submissions.loading')} />
}
const hasParticipants = participants.length > 0
const hasSelectedUser = !!selectedUserId
if (error) {
return <ErrorAlert message={t('challenge.admin.submissions.load.error')} onRetry={handleRetry} />
}
// Если chainId не указан - показываем выбор цепочки
if (!chainId) {
return (
<Box>
<Box mb={6}>
<Heading mb={2}>{t('challenge.admin.submissions.title')}</Heading>
<Text color="gray.600" fontSize="sm">
{t('challenge.admin.submissions.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.submissionsChain(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.submissions.chain.tasks')}
</Badge>
{!chain.isActive && (
<Badge colorPalette="gray" size="lg">
{t('challenge.admin.chains.list.status.inactive')}
</Badge>
)}
</HStack>
<Text fontSize="sm" color="gray.600" mt={2}>
{t('challenge.admin.submissions.chain.click')}
</Text>
</VStack>
</Box>
</Link>
))}
</SimpleGrid>
) : (
<EmptyState
title={t('challenge.admin.submissions.no.chains.title')}
description={t('challenge.admin.submissions.no.chains.description')}
/>
)}
</Box>
)
}
// Если цепочка выбрана но данных нет
if (useNewApi && !chainData) {
return (
<Box>
<Link to={URLs.submissions} style={{ textDecoration: 'none', color: '#319795' }}>
<Text fontSize="sm" _hover={{ textDecoration: 'underline' }} mb={4}>
{t('challenge.admin.submissions.back.to.chains')}
</Text>
</Link>
<ErrorAlert message={t('challenge.admin.common.not.found')} onRetry={handleRetry} />
</Box>
)
}
if (!useNewApi && !selectedChain) {
return (
<Box>
<Link to={URLs.submissions} style={{ textDecoration: 'none', color: '#319795' }}>
<Text fontSize="sm" _hover={{ textDecoration: 'underline' }} mb={4}>
{t('challenge.admin.submissions.back.to.chains')}
</Text>
</Link>
<ErrorAlert message={t('challenge.admin.common.not.found')} onRetry={handleRetry} />
</Box>
)
}
const chainName = useNewApi ? chainData?.chain.name : selectedChain?.name
const chainTasksCount = useNewApi ? chainData?.chain.tasks.length : selectedChain?.tasks.length
return (
<Box>
<Heading mb={6}>{t('challenge.admin.submissions.title')}</Heading>
{/* Header с навигацией */}
<Box mb={6}>
<HStack gap={2} mb={2}>
<Link to={URLs.submissions} style={{ textDecoration: 'none', color: '#319795' }}>
<Text fontSize="sm" _hover={{ textDecoration: 'underline' }}>
{t('challenge.admin.submissions.back.to.chains')}
</Text>
</Link>
</HStack>
<Heading mb={2}>{chainName}</Heading>
<Text color="gray.600" fontSize="sm">
{t('challenge.admin.submissions.chain.description', { count: chainTasksCount ?? 0 })}
</Text>
</Box>
{/* Filters */}
{hasParticipants && (
{/* Выбор участника и фильтры */}
{sortedParticipants.length > 0 && (
<VStack mb={4} gap={3} align="stretch">
<HStack gap={4}>
<Select.Root
collection={userOptions}
value={selectedUserId ? [selectedUserId] : []}
onValueChange={(e) => setSelectedUserId(e.value[0] ?? null)}
maxW="300px"
>
<Select.Trigger>
<Select.ValueText placeholder={t('challenge.admin.submissions.filter.user')} />
</Select.Trigger>
<Select.Content>
{userOptions.items.map((option) => (
<Select.Item key={option.value} item={option}>
{option.label}
</Select.Item>
))}
</Select.Content>
</Select.Root>
<HStack gap={4} align="center" wrap="wrap">
{selectedUserId && (
<Button
size="sm"
variant="outline"
colorPalette="teal"
onClick={() => {
setSelectedUserId(null)
setSearchQuery('')
setStatusFilter('all')
}}
>
{t('challenge.admin.submissions.filter.user.clear')}
</Button>
)}
{submissionsList.length > 0 && (
{selectedUserId && filteredSubmissions.length > 0 && (
<>
<Input
placeholder={t('challenge.admin.submissions.search.placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
maxW="400px"
maxW="300px"
/>
<Select.Root
collection={statusOptions}
@@ -182,22 +423,86 @@ export const SubmissionsPage: React.FC = () => {
</VStack>
)}
{!hasParticipants ? (
<EmptyState
title={t('challenge.admin.submissions.empty.title')}
description={t('challenge.admin.submissions.empty.description')}
/>
) : !hasSelectedUser ? (
<EmptyState
title={t('challenge.admin.submissions.empty.title')}
description={t('challenge.admin.submissions.filter.user')}
/>
{/* Если не выбран пользователь - показываем обзор участников */}
{!selectedUserId ? (
<Box>
<Heading size="md" mb={4}>
{t('challenge.admin.submissions.participants.title')}
</Heading>
<Text mb={4} color="gray.600">
{t('challenge.admin.submissions.participants.description')}
</Text>
{sortedParticipants.length === 0 ? (
<EmptyState
title={t('challenge.admin.submissions.participants.empty.title')}
description={t('challenge.admin.submissions.participants.empty.description')}
/>
) : (
<Grid
templateColumns={{
base: 'repeat(1, minmax(0, 1fr))',
md: 'repeat(2, minmax(0, 1fr))',
lg: 'repeat(3, minmax(0, 1fr))',
xl: 'repeat(4, minmax(0, 1fr))',
}}
gap={3}
>
{sortedParticipants.map((participant) => {
const colorPalette =
participant.progressPercent >= 70
? 'green'
: participant.progressPercent >= 40
? 'orange'
: 'red'
return (
<Box
key={participant.userId}
p={3}
borderWidth="1px"
borderRadius="md"
borderColor="gray.200"
bg="white"
_hover={{ bg: 'gray.50', borderColor: 'teal.300' }}
cursor="pointer"
onClick={() => setSelectedUserId(participant.userId)}
transition="all 0.2s"
>
<HStack justify="space-between" mb={2} gap={2}>
<Text fontSize="sm" fontWeight="medium" truncate maxW="180px">
{participant.nickname}
</Text>
<Badge colorPalette={colorPalette} size="sm">
{participant.progressPercent}%
</Badge>
</HStack>
<Progress.Root value={participant.progressPercent} size="sm" colorPalette={colorPalette}>
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
<HStack justify="space-between" mt={2}>
<Text fontSize="xs" color="gray.500">
{participant.completedTasks} / {participant.totalTasks}
</Text>
<Text fontSize="xs" color="gray.400">
{t('challenge.admin.submissions.participants.click.to.view')}
</Text>
</HStack>
</Box>
)
})}
</Grid>
)}
</Box>
) : filteredSubmissions.length === 0 ? (
<EmptyState
title={t('challenge.admin.submissions.search.empty.title')}
description={t('challenge.admin.submissions.search.empty.description')}
/>
) : (
/* Таблица попыток выбранного пользователя */
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
<Table.Root size="sm">
<Table.Header>
@@ -208,18 +513,34 @@ export const SubmissionsPage: React.FC = () => {
<Table.ColumnHeader>{t('challenge.admin.submissions.table.attempt')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.submitted')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.check.time')}</Table.ColumnHeader>
<Table.ColumnHeader textAlign="right">{t('challenge.admin.submissions.table.actions')}</Table.ColumnHeader>
<Table.ColumnHeader textAlign="right">
{t('challenge.admin.submissions.table.actions')}
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{filteredSubmissions.map((submission) => {
const user = submission.user as ChallengeUser
const task = submission.task as ChallengeTask
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 (
<Table.Row key={submission.id}>
<Table.Cell fontWeight="medium">{user.nickname}</Table.Cell>
<Table.Cell>{task.title}</Table.Cell>
<Table.Cell fontWeight="medium">{nickname}</Table.Cell>
<Table.Cell>{title}</Table.Cell>
<Table.Cell>
<StatusBadge status={submission.status} />
</Table.Cell>
@@ -243,7 +564,7 @@ export const SubmissionsPage: React.FC = () => {
size="sm"
variant="ghost"
colorPalette="teal"
onClick={() => setSelectedSubmission(submission)}
onClick={() => navigate(URLs.submissionDetails(chainId!, selectedUserId, submission.id))}
>
{t('challenge.admin.submissions.button.details')}
</Button>
@@ -255,166 +576,6 @@ export const SubmissionsPage: React.FC = () => {
</Table.Root>
</Box>
)}
{/* Submission Details Modal */}
<SubmissionDetailsModal
submission={selectedSubmission}
isOpen={!!selectedSubmission}
onClose={() => setSelectedSubmission(null)}
/>
</Box>
)
}
interface SubmissionDetailsModalProps {
submission: ChallengeSubmission | null
isOpen: boolean
onClose: () => void
}
const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
submission,
isOpen,
onClose,
}) => {
const { t } = useTranslation()
if (!submission) return null
const user = submission.user as ChallengeUser
const task = submission.task as ChallengeTask
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
const getCheckTimeValue = () => {
if (!submission.checkedAt) return null
const submitted = new Date(submission.submittedAt).getTime()
const checked = new Date(submission.checkedAt).getTime()
return ((checked - submitted) / 1000).toFixed(2)
}
return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
<DialogContent>
<DialogHeader>
<DialogTitle>{t('challenge.admin.submissions.details.title')} #{submission.attemptNumber}</DialogTitle>
</DialogHeader>
<DialogBody>
<VStack gap={6} align="stretch">
{/* Meta */}
<Box>
<HStack mb={4} justify="space-between">
<Box>
<Text fontSize="sm" color="gray.600" mb={1}>
{t('challenge.admin.submissions.details.user')}
</Text>
<Text fontWeight="bold">{user.nickname}</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600" mb={1}>
{t('challenge.admin.submissions.details.status')}
</Text>
<StatusBadge status={submission.status} />
</Box>
</HStack>
<VStack align="stretch" gap={2}>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.submitted')}</strong> {formatDate(submission.submittedAt)}
</Text>
{submission.checkedAt && (
<>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.checked')}</strong> {formatDate(submission.checkedAt)}
</Text>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.check.time')}</strong> {t('challenge.admin.submissions.check.time', { time: getCheckTimeValue() })}
</Text>
</>
)}
</VStack>
</Box>
{/* Task */}
<Box>
<Text fontWeight="bold" mb={2}>
{t('challenge.admin.submissions.details.task')} {task.title}
</Text>
<Box
p={4}
bg="gray.50"
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
maxH="200px"
overflowY="auto"
>
<ReactMarkdown>{task.description}</ReactMarkdown>
</Box>
</Box>
{/* Solution */}
<Box>
<Text fontWeight="bold" mb={2}>
{t('challenge.admin.submissions.details.solution')}
</Text>
<Box
p={4}
bg="blue.50"
borderRadius="md"
borderWidth="1px"
borderColor="blue.200"
maxH="300px"
overflowY="auto"
>
<Text
fontFamily="monospace"
fontSize="sm"
whiteSpace="pre-wrap"
wordBreak="break-word"
>
{submission.result}
</Text>
</Box>
</Box>
{/* Feedback */}
{submission.feedback && (
<Box>
<Text fontWeight="bold" mb={2}>
{t('challenge.admin.submissions.details.feedback')}
</Text>
<Box
p={4}
bg={submission.status === 'accepted' ? 'green.50' : 'red.50'}
borderRadius="md"
borderWidth="1px"
borderColor={submission.status === 'accepted' ? 'green.200' : 'red.200'}
>
<Text>{submission.feedback}</Text>
</Box>
</Box>
)}
</VStack>
</DialogBody>
<DialogFooter>
<DialogActionTrigger asChild>
<Button variant="outline" onClick={onClose}>
{t('challenge.admin.submissions.details.close')}
</Button>
</DialogActionTrigger>
</DialogFooter>
</DialogContent>
</DialogRoot>
)
}

View File

@@ -19,6 +19,7 @@ import {
useGetTaskQuery,
useCreateTaskMutation,
useUpdateTaskMutation,
useTestSubmissionMutation,
} from '../../__data__/api/api'
import { URLs } from '../../__data__/urls'
import { LoadingSpinner } from '../../components/LoadingSpinner'
@@ -35,11 +36,15 @@ export const TaskFormPage: React.FC = () => {
})
const [createTask, { isLoading: isCreating }] = useCreateTaskMutation()
const [updateTask, { isLoading: isUpdating }] = useUpdateTaskMutation()
const [testSubmission, { isLoading: isTesting }] = useTestSubmissionMutation()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [hiddenInstructions, setHiddenInstructions] = useState('')
const [showDescPreview, setShowDescPreview] = useState(false)
const [testAnswer, setTestAnswer] = useState('')
const [testStatus, setTestStatus] = useState<string | null>(null)
const [testFeedback, setTestFeedback] = useState<string | null>(null)
useEffect(() => {
if (task) {
@@ -49,6 +54,39 @@ export const TaskFormPage: React.FC = () => {
}
}, [task])
// Восстановление сохранённого тестового ответа для конкретной задачи
useEffect(() => {
if (!isEdit || !id) return
if (typeof window === 'undefined') return
const key = `challenge-admin.task-test-answer.${id}`
try {
const saved = window.localStorage.getItem(key)
if (saved) {
setTestAnswer(saved)
}
} catch {
// ignore localStorage errors
}
}, [isEdit, id])
// Сохранение тестового ответа в localStorage
useEffect(() => {
if (!isEdit || !id) return
if (typeof window === 'undefined') return
const key = `challenge-admin.task-test-answer.${id}`
try {
if (testAnswer.trim()) {
window.localStorage.setItem(key, testAnswer)
} else {
window.localStorage.removeItem(key)
}
} catch {
// ignore localStorage errors
}
}, [isEdit, id, testAnswer])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -106,6 +144,59 @@ export const TaskFormPage: React.FC = () => {
}
}
const handleTestSubmit = async () => {
if (!task || !id) {
return
}
if (!testAnswer.trim()) {
toaster.create({
title: t('challenge.admin.common.validation.error'),
description: t('challenge.admin.tasks.test.validation.fill.answer'),
type: 'error',
})
return
}
setTestStatus(null)
setTestFeedback(null)
try {
const dummyUserId = task.creator?.sub || task.id
const result = await testSubmission({
userId: dummyUserId,
taskId: task.id,
result: testAnswer.trim(),
isTest: true,
hiddenInstructions: hiddenInstructions.trim() || undefined,
}).unwrap()
setTestStatus(result.status)
setTestFeedback(result.feedback ?? null)
toaster.create({
title: t('challenge.admin.common.success'),
description: t('challenge.admin.tasks.test.success'),
type: 'success',
})
} catch (err: unknown) {
const isForbidden =
err &&
typeof err === 'object' &&
'status' in err &&
(err as { status?: number }).status === 403
toaster.create({
title: t('challenge.admin.common.error'),
description: isForbidden
? t('challenge.admin.tasks.test.forbidden')
: t('challenge.admin.tasks.test.error'),
type: 'error',
})
}
}
if (isEdit && isLoadingTask) {
return <LoadingSpinner message={t('challenge.admin.tasks.loading')} />
}
@@ -309,6 +400,57 @@ export const TaskFormPage: React.FC = () => {
</Box>
)}
{/* Test submission (LLM check) */}
{isEdit && task && (
<Box p={4} bg="teal.50" borderRadius="md" borderWidth="1px" borderColor="teal.200">
<Text fontWeight="bold" mb={2} color="teal.900">
{t('challenge.admin.tasks.test.title')}
</Text>
<Text fontSize="sm" mb={3} color="teal.800">
{t('challenge.admin.tasks.test.description')}
</Text>
<Field.Root>
<Field.Label>{t('challenge.admin.tasks.test.field.answer')}</Field.Label>
<Textarea
value={testAnswer}
onChange={(e) => setTestAnswer(e.target.value)}
placeholder={t('challenge.admin.tasks.test.field.answer.placeholder')}
rows={6}
fontFamily="monospace"
disabled={isTesting}
/>
<Field.HelperText>
{t('challenge.admin.tasks.test.field.answer.helper')}
</Field.HelperText>
</Field.Root>
<HStack mt={3} align="flex-start" justify="space-between" gap={4}>
<Button
onClick={handleTestSubmit}
colorPalette="teal"
disabled={isTesting || !testAnswer.trim()}
>
{t('challenge.admin.tasks.test.button.run')}
</Button>
{(testStatus || testFeedback) && (
<Box flex="1" p={3} bg="white" borderRadius="md" borderWidth="1px" borderColor="teal.100">
{testStatus && (
<Text fontSize="sm" fontWeight="medium" mb={1}>
{t(`challenge.admin.tasks.test.status.${testStatus}`)}
</Text>
)}
{testFeedback && (
<Text fontSize="sm" whiteSpace="pre-wrap">
{testFeedback}
</Text>
)}
</Box>
)}
</HStack>
</Box>
)}
{/* Actions */}
<HStack gap={3} justify="flex-end">
<Button variant="outline" onClick={() => navigate(URLs.tasks)} disabled={isLoading}>

View File

@@ -17,7 +17,6 @@ import { URLs } from '../../__data__/urls'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { EmptyState } from '../../components/EmptyState'
import { ConfirmDialog } from '../../components/ConfirmDialog'
import type { ChallengeTask } from '../../types/challenge'
import { toaster } from '../../components/ui/toaster'
@@ -25,22 +24,24 @@ export const TasksListPage: React.FC = () => {
const navigate = useNavigate()
const { t } = useTranslation()
const { data: tasks, isLoading, error, refetch } = useGetTasksQuery()
const [deleteTask, { isLoading: isDeleting }] = useDeleteTaskMutation()
const [deleteTask] = useDeleteTaskMutation()
const [searchQuery, setSearchQuery] = useState('')
const [taskToDelete, setTaskToDelete] = useState<ChallengeTask | null>(null)
const handleDeleteTask = async () => {
if (!taskToDelete) return
const handleDeleteTask = async (task: ChallengeTask) => {
const confirmed = window.confirm(
t('challenge.admin.tasks.delete.confirm.message', { title: task.title })
)
if (!confirmed) return
try {
await deleteTask(taskToDelete.id).unwrap()
await deleteTask(task.id).unwrap()
toaster.create({
title: t('challenge.admin.common.success'),
description: t('challenge.admin.tasks.deleted'),
type: 'success',
})
setTaskToDelete(null)
} catch (_err) {
toaster.create({
title: t('challenge.admin.common.error'),
@@ -152,7 +153,7 @@ export const TasksListPage: React.FC = () => {
size="sm"
variant="ghost"
colorPalette="red"
onClick={() => setTaskToDelete(task)}
onClick={() => handleDeleteTask(task)}
>
{t('challenge.admin.tasks.list.button.delete')}
</Button>
@@ -164,16 +165,6 @@ export const TasksListPage: React.FC = () => {
</Table.Root>
</Box>
)}
<ConfirmDialog
isOpen={!!taskToDelete}
onClose={() => setTaskToDelete(null)}
onConfirm={handleDeleteTask}
title={t('challenge.admin.tasks.delete.confirm.title')}
message={t('challenge.admin.tasks.delete.confirm.message', { title: taskToDelete?.title })}
confirmLabel={t('challenge.admin.tasks.delete.confirm.button')}
isLoading={isDeleting}
/>
</Box>
)
}

View File

@@ -0,0 +1,196 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useParams } from 'react-router-dom'
import { Box, Heading, Text, Button, Grid, VStack, HStack, Badge, Progress } from '@chakra-ui/react'
import { useGetUserStatsQuery } from '../../__data__/api/api'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { URLs } from '../../__data__/urls'
interface RouteParams {
userId: string
}
export const UserStatsPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { userId } = useParams<RouteParams>()
const { data: stats, isLoading, error, refetch } = useGetUserStatsQuery(userId!, {
skip: !userId,
})
const handleBack = () => {
navigate(URLs.users)
}
if (!userId) {
return (
<Box>
<Button variant="ghost" onClick={handleBack} mb={4}>
{t('challenge.admin.common.close')}
</Button>
<ErrorAlert message={t('challenge.admin.users.stats.no.data')} onRetry={handleBack} />
</Box>
)
}
if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.users.stats.loading')} />
}
if (error) {
return (
<Box>
<Button variant="ghost" onClick={handleBack} mb={4}>
{t('challenge.admin.common.close')}
</Button>
<ErrorAlert message={t('challenge.admin.users.load.error')} onRetry={refetch} />
</Box>
)
}
if (!stats) {
return (
<Box>
<Button variant="ghost" onClick={handleBack} mb={4}>
{t('challenge.admin.common.close')}
</Button>
<Text color="gray.600">{t('challenge.admin.users.stats.no.data')}</Text>
</Box>
)
}
return (
<Box>
<HStack mb={6}>
<Button variant="ghost" onClick={handleBack}>
{t('challenge.admin.common.close')}
</Button>
</HStack>
<Heading mb={6}>{t('challenge.admin.users.stats.title')}</Heading>
<VStack gap={6} align="stretch">
{/* Overview */}
<Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.completed')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="green.600">
{stats.completedTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.total.submissions')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.totalSubmissions}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.in.progress')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{stats.inProgressTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.needs.revision')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="red.600">
{stats.needsRevisionTasks}
</Text>
</Box>
</Grid>
{/* Chains Progress */}
{stats.chainStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
{t('challenge.admin.users.stats.chains.progress')}
</Text>
<VStack gap={3} align="stretch">
{stats.chainStats.map((chain) => (
<Box key={chain.chainId}>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">
{chain.chainName}
</Text>
<Text fontSize="sm" color="gray.600">
{chain.completedTasks} / {chain.totalTasks}
</Text>
</HStack>
<Progress.Root value={chain.progress} colorPalette="teal" size="sm">
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
</Box>
))}
</VStack>
</Box>
)}
{/* Task Stats */}
{stats.taskStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
{t('challenge.admin.users.stats.tasks')}
</Text>
<VStack gap={2} align="stretch" maxH="300px" overflowY="auto">
{stats.taskStats.map((taskStat) => {
const getBadgeColor = () => {
if (taskStat.status === 'completed') return 'green'
if (taskStat.status === 'needs_revision') return 'red'
return 'gray'
}
return (
<Box
key={taskStat.taskId}
p={3}
bg="gray.50"
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">
{taskStat.taskTitle}
</Text>
<Badge colorPalette={getBadgeColor()}>
{t(`challenge.admin.users.stats.status.${taskStat.status}`)}
</Badge>
</HStack>
<Text fontSize="xs" color="gray.600">
{t('challenge.admin.users.stats.attempts')} {taskStat.totalAttempts}
</Text>
</Box>
)
})}
</VStack>
</Box>
)}
{/* Average Check Time */}
<Box p={3} bg="purple.50" borderRadius="md">
<Text fontSize="sm" color="gray.700" mb={1}>
{t('challenge.admin.users.stats.avg.check.time')}
</Text>
<Text fontSize="lg" fontWeight="bold" color="purple.700">
{t('challenge.admin.dashboard.check.time.value', {
time: (stats.averageCheckTimeMs / 1000).toFixed(2),
})}
</Text>
</Box>
</VStack>
</Box>
)
}

View File

@@ -1,38 +1,21 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Box,
Heading,
Table,
Input,
Text,
Button,
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
Grid,
VStack,
HStack,
Badge,
Progress,
} from '@chakra-ui/react'
import { useGetSystemStatsV2Query, useGetUserStatsQuery } from '../../__data__/api/api'
import { useNavigate } from 'react-router-dom'
import { Box, Heading, Table, Input, Text, Button } from '@chakra-ui/react'
import { useGetSystemStatsV2Query } from '../../__data__/api/api'
import type { ActiveParticipant } from '../../types/challenge'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { EmptyState } from '../../components/EmptyState'
import { URLs } from '../../__data__/urls'
export const UsersPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { data: stats, isLoading, error, refetch } = useGetSystemStatsV2Query(undefined, {
pollingInterval: 10000,
})
const [searchQuery, setSearchQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.users.loading')} />
@@ -44,8 +27,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 (
@@ -106,7 +91,7 @@ export const UsersPage: React.FC = () => {
size="sm"
variant="ghost"
colorPalette="teal"
onClick={() => setSelectedUserId(user.userId)}
onClick={() => navigate(URLs.userStats(user.userId))}
>
{t('challenge.admin.users.button.stats')}
</Button>
@@ -118,167 +103,6 @@ export const UsersPage: React.FC = () => {
</Box>
)}
{/* User Stats Modal */}
<UserStatsModal
userId={selectedUserId}
isOpen={!!selectedUserId}
onClose={() => setSelectedUserId(null)}
/>
</Box>
)
}
interface UserStatsModalProps {
userId: string | null
isOpen: boolean
onClose: () => void
}
const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose }) => {
const { t } = useTranslation()
const { data: stats, isLoading } = useGetUserStatsQuery(userId!, {
skip: !userId,
})
return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
<DialogContent>
<DialogHeader>
<DialogTitle>{t('challenge.admin.users.stats.title')}</DialogTitle>
</DialogHeader>
<DialogBody>
{isLoading ? (
<LoadingSpinner message={t('challenge.admin.users.stats.loading')} />
) : !stats ? (
<Text color="gray.600">{t('challenge.admin.users.stats.no.data')}</Text>
) : (
<VStack gap={6} align="stretch">
{/* Overview */}
<Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.completed')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="green.600">
{stats.completedTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.total.submissions')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.totalSubmissions}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.in.progress')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{stats.inProgressTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.needs.revision')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="red.600">
{stats.needsRevisionTasks}
</Text>
</Box>
</Grid>
{/* Chains Progress */}
{stats.chainStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
{t('challenge.admin.users.stats.chains.progress')}
</Text>
<VStack gap={3} align="stretch">
{stats.chainStats.map((chain) => (
<Box key={chain.chainId}>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">
{chain.chainName}
</Text>
<Text fontSize="sm" color="gray.600">
{chain.completedTasks} / {chain.totalTasks}
</Text>
</HStack>
<Progress.Root value={chain.progress} colorPalette="teal" size="sm">
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
</Box>
))}
</VStack>
</Box>
)}
{/* Task Stats */}
{stats.taskStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
{t('challenge.admin.users.stats.tasks')}
</Text>
<VStack gap={2} align="stretch" maxH="300px" overflowY="auto">
{stats.taskStats.map((taskStat) => {
const getBadgeColor = () => {
if (taskStat.status === 'completed') return 'green'
if (taskStat.status === 'needs_revision') return 'red'
return 'gray'
}
return (
<Box
key={taskStat.taskId}
p={3}
bg="gray.50"
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">
{taskStat.taskTitle}
</Text>
<Badge colorPalette={getBadgeColor()}>
{t(`challenge.admin.users.stats.status.${taskStat.status}`)}
</Badge>
</HStack>
<Text fontSize="xs" color="gray.600">
{t('challenge.admin.users.stats.attempts')} {taskStat.totalAttempts}
</Text>
</Box>
)
})}
</VStack>
</Box>
)}
{/* Average Check Time */}
<Box p={3} bg="purple.50" borderRadius="md">
<Text fontSize="sm" color="gray.700" mb={1}>
{t('challenge.admin.users.stats.avg.check.time')}
</Text>
<Text fontSize="lg" fontWeight="bold" color="purple.700">
{t('challenge.admin.dashboard.check.time.value', { time: (stats.averageCheckTimeMs / 1000).toFixed(2) })}
</Text>
</Box>
</VStack>
)}
</DialogBody>
<DialogFooter>
<DialogActionTrigger asChild>
<Button variant="outline" onClick={onClose}>
Закрыть
</Button>
</DialogActionTrigger>
</DialogFooter>
</DialogContent>
</DialogRoot>
)
}

View File

@@ -27,6 +27,7 @@ export interface ChallengeChain {
id: string
name: string
tasks: ChallengeTask[] // Populated
isActive: boolean
createdAt: string
updatedAt: string
}
@@ -132,11 +133,23 @@ export interface UpdateTaskRequest {
export interface CreateChainRequest {
name: string
taskIds: string[] // Array of task IDs
isActive?: boolean
}
export interface UpdateChainRequest {
name?: string
taskIds?: string[]
isActive?: boolean
}
export interface DuplicateChainRequest {
name?: string
}
export interface ClearSubmissionsResponse {
deletedCount: number
chainId: string
userId?: string
}
// ========== Stats v2 Types ==========
@@ -223,3 +236,46 @@ export interface SystemStatsV2 {
chainsDetailed: ChainDetailed[]
}
// ========== Submissions / Checking ==========
export interface SubmitRequest {
userId: string
taskId: string
result: string
// Флаг тестового режима: проверка без создания Submission и очереди
isTest?: boolean
// Временные скрытые инструкции для тестовой проверки (не сохраняются в задачу)
hiddenInstructions?: string
}
export interface TestSubmissionResult {
isTest: true
status: Exclude<SubmissionStatus, 'pending' | 'in_progress'>
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
}
}

View File

@@ -21,6 +21,7 @@
"updatedAt": "2024-11-05T11:00:00.000Z"
}
],
"isActive": true,
"createdAt": "2024-11-01T09:00:00.000Z",
"updatedAt": "2024-11-05T12:00:00.000Z"
},
@@ -38,6 +39,7 @@
"updatedAt": "2024-11-03T09:15:00.000Z"
}
],
"isActive": false,
"createdAt": "2024-11-03T08:00:00.000Z",
"updatedAt": "2024-11-03T09:30:00.000Z"
},
@@ -63,6 +65,7 @@
"updatedAt": "2024-11-04T14:20:00.000Z"
}
],
"isActive": true,
"createdAt": "2024-11-02T11:00:00.000Z",
"updatedAt": "2024-11-04T15:00:00.000Z"
}

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 =============
@@ -158,8 +184,15 @@ router.delete('/challenge/task/:id', (req, res) => {
// ============= CHAINS =============
// GET /api/challenge/chains
// GET /api/challenge/chains (user-facing list: only active chains)
router.get('/challenge/chains', (req, res) => {
const chains = getChains();
const activeChains = chains.filter(c => c.isActive !== false);
respond(res, activeChains);
});
// GET /api/challenge/chains/admin (admin list: all chains)
router.get('/challenge/chains/admin', (req, res) => {
const chains = getChains();
respond(res, chains);
});
@@ -178,17 +211,17 @@ router.get('/challenge/chain/:id', (req, res) => {
// POST /api/challenge/chain
router.post('/challenge/chain', (req, res) => {
const { name, tasks } = req.body;
const { name, taskIds, isActive } = req.body;
if (!name || !tasks || !Array.isArray(tasks)) {
return respondError(res, 'Name and tasks array are required');
if (!name || !taskIds || !Array.isArray(taskIds)) {
return respondError(res, 'Name and taskIds array are required');
}
const chains = getChains();
const allTasks = getTasks();
// Populate tasks
const populatedTasks = tasks.map(taskId => {
const populatedTasks = taskIds.map(taskId => {
const task = allTasks.find(t => t.id === taskId);
return task ? {
_id: task._id,
@@ -205,6 +238,7 @@ router.post('/challenge/chain', (req, res) => {
id: `chain_${Date.now()}`,
name,
tasks: populatedTasks,
isActive: isActive !== undefined ? !!isActive : true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
@@ -227,14 +261,16 @@ router.put('/challenge/chain/:id', (req, res) => {
return respondError(res, 'Chain not found', 404);
}
const { name, tasks } = req.body;
const { name, taskIds, tasks, isActive } = req.body;
const chain = chains[chainIndex];
if (name) chain.name = name;
if (tasks && Array.isArray(tasks)) {
const effectiveTaskIds = Array.isArray(taskIds) ? taskIds : (Array.isArray(tasks) ? tasks : null);
if (effectiveTaskIds) {
const allTasks = getTasks();
const populatedTasks = tasks.map(taskId => {
const populatedTasks = effectiveTaskIds.map(taskId => {
const task = allTasks.find(t => t.id === taskId);
return task ? {
_id: task._id,
@@ -248,6 +284,10 @@ router.put('/challenge/chain/:id', (req, res) => {
chain.tasks = populatedTasks;
}
if (isActive !== undefined) {
chain.isActive = !!isActive;
}
chain.updatedAt = new Date().toISOString();
@@ -272,6 +312,84 @@ router.delete('/challenge/chain/:id', (req, res) => {
respond(res, { success: true });
});
// POST /api/challenge/chain/:chainId/duplicate
router.post('/challenge/chain/:chainId/duplicate', (req, res) => {
const chains = getChains();
const chainIndex = chains.findIndex(c => c.id === req.params.chainId);
if (chainIndex === -1) {
return respondError(res, 'Chain not found', 404);
}
const originalChain = chains[chainIndex];
const { name } = req.body;
// Generate new name if not provided
const newName = name || `Копия - ${originalChain.name}`;
// Create duplicate with same tasks but inactive
const duplicatedChain = {
_id: `chain_${Date.now()}`,
id: `chain_${Date.now()}`,
name: newName,
tasks: originalChain.tasks.map(task => ({
_id: task._id,
id: task.id,
title: task.title,
description: task.description,
createdAt: task.createdAt,
updatedAt: task.updatedAt
})),
isActive: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
chains.push(duplicatedChain);
// Update stats
const stats = getStats();
stats.chains = chains.length;
respond(res, duplicatedChain);
});
// DELETE /api/challenge/chain/:chainId/submissions
router.delete('/challenge/chain/:chainId/submissions', (req, res) => {
const chains = getChains();
const submissions = getSubmissions();
const chain = chains.find(c => c.id === req.params.chainId);
if (!chain) {
return respondError(res, 'Chain not found', 404);
}
// Get task IDs from chain
const taskIds = new Set(chain.tasks.map(t => t.id));
// Count and remove submissions for tasks in this chain
let deletedCount = 0;
for (let i = submissions.length - 1; i >= 0; i--) {
const sub = submissions[i];
const taskId = typeof sub.task === 'object' ? sub.task.id : sub.task;
if (taskIds.has(taskId)) {
submissions.splice(i, 1);
deletedCount++;
}
}
// Update stats
const stats = getStats();
stats.submissions.total = Math.max(0, stats.submissions.total - deletedCount);
respond(res, {
deletedCount: deletedCount,
chainId: chain.id
});
});
// ============= STATS =============
// GET /api/challenge/stats
@@ -282,7 +400,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 не передан, возвращаем все данные
@@ -291,13 +409,32 @@ router.get('/challenge/stats/v2', (req, res) => {
return;
}
// Фильтруем данные по выбранной цепочке
const filteredChain = statsV2.chainsDetailed.find(c => c.chainId === chainId);
// Сначала проверяем наличие цепочки в chains.json
const chains = getChains();
const chain = chains.find(c => c.id === chainId);
if (!filteredChain) {
if (!chain) {
return respondError(res, 'Chain not found', 404);
}
// Ищем данные цепочки в stats-v2.json
let filteredChain = statsV2.chainsDetailed.find(c => c.chainId === chainId);
// Если цепочка не найдена в stats-v2.json, создаем пустую структуру на основе chains.json
if (!filteredChain) {
filteredChain = {
chainId: chain.id,
name: chain.name,
totalTasks: chain.tasks.length,
tasks: chain.tasks.map(t => ({
taskId: t.id,
title: t.title,
description: t.description || ''
})),
participantProgress: []
};
}
// Фильтруем tasksTable - только задания из этой цепочки
const chainTaskIds = new Set(filteredChain.tasks.map(t => t.taskId));
const filteredTasksTable = statsV2.tasksTable.filter(t => chainTaskIds.has(t.taskId));
@@ -421,4 +558,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;