14 Commits

Author SHA1 Message Date
6b7c773977 1.3.1 2025-12-14 16:33:15 +03:00
a748e608cf Comment out navigation to tasks after successful form submission in TaskFormPage to prevent unintended redirects during testing. 2025-12-14 16:25:35 +03:00
d0e26b02c7 1.3.0 2025-12-14 15:43:08 +03:00
4aae3c154e Add optional learningMaterial field to ChallengeTask model for additional educational content; update API endpoints, TypeScript interfaces, and frontend forms to support this feature. Enhance localization for English and Russian to include new field descriptions and placeholders. 2025-12-14 15:02:43 +03:00
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
22 changed files with 1575 additions and 197 deletions

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 часов (включая тесты)

View File

@@ -0,0 +1,202 @@
# Добавление поля learningMaterial в задачу челленджа
## Описание изменений
В модель задачи челленджа (`ChallengeTask`) добавлено новое необязательное текстовое поле `learningMaterial` для хранения дополнительной обучающей информации в формате Markdown.
## Структура данных
### Модель ChallengeTask
```typescript
{
title: string, // Заголовок задания (обязательное)
description: string, // Основное описание в Markdown (обязательное, видно студентам)
learningMaterial: string, // Дополнительный учебный материал в Markdown (необязательное, видно студентам)
hiddenInstructions: string, // Скрытые инструкции для LLM (необязательное, только для преподавателей)
createdAt: Date, // Дата создания
updatedAt: Date, // Дата последнего обновления
creator: Object // Данные создателя из Keycloak
}
```
## Изменения в API
### 1. Создание задания (POST /challenge/task)
**Добавлено поле в тело запроса:**
```json
{
"title": "Название задания",
"description": "Основное описание в Markdown",
"learningMaterial": "Дополнительный учебный материал в Markdown",
"hiddenInstructions": "Скрытые инструкции для преподавателей"
}
```
**Пример запроса:**
```bash
POST /challenge/task
Content-Type: application/json
{
"title": "Реализация алгоритма сортировки",
"description": "Напишите функцию сортировки массива методом пузырька",
"learningMaterial": "## Теория\n\nМетод пузырьковой сортировки работает путем...\n\n## Полезные ссылки\n- [Википедия](https://ru.wikipedia.org/wiki/Сортировка_пузырьком)\n- [Видео объяснение](https://example.com/video)",
"hiddenInstructions": "Оценить эффективность алгоритма и стиль кода"
}
```
### 2. Обновление задания (PUT /challenge/task/:taskId)
**Добавлено поле в тело запроса:**
```json
{
"title": "Новое название",
"description": "Обновленное описание",
"learningMaterial": "Обновленный учебный материал",
"hiddenInstructions": "Обновленные инструкции"
}
```
## Получение данных
### Получение задания (GET /challenge/task/:taskId)
**Ответ содержит новое поле:**
```json
{
"id": "task_id",
"title": "Название задания",
"description": "Основное описание в Markdown",
"learningMaterial": "Дополнительный учебный материал в Markdown",
"createdAt": "2025-01-15T10:00:00.000Z",
"updatedAt": "2025-01-15T10:30:00.000Z"
}
```
**Важно:** Поле `learningMaterial` видно всем пользователям (студентам и преподавателям), в отличие от `hiddenInstructions`, которое скрывается от студентов.
### Получение всех заданий (GET /challenge/tasks)
Возвращает массив заданий с новым полем `learningMaterial`.
### Получение цепочек (GET /challenge/chains, GET /challenge/chain/:chainId)
При получении цепочек с populate заданий, поле `learningMaterial` будет доступно в каждом задании цепочки.
## Frontend изменения
### Интерфейсы TypeScript
```typescript
interface ChallengeTask {
id: string;
title: string;
description: string; // Markdown
learningMaterial?: string; // Новое поле - дополнительный материал в Markdown
createdAt: string;
updatedAt: string;
}
```
### Формы создания/редактирования заданий
В формах создания и редактирования заданий необходимо добавить поле для ввода `learningMaterial`:
```typescript
// Пример компонента формы
const TaskForm = () => {
const [formData, setFormData] = useState({
title: '',
description: '',
learningMaterial: '', // Новое поле
hiddenInstructions: ''
});
// Визуальный редактор или textarea для learningMaterial
return (
<form>
<input name="title" value={formData.title} />
<textarea name="description" value={formData.description} />
{/* Новое поле для дополнительного материала */}
<label>Дополнительный учебный материал (Markdown)</label>
<textarea
name="learningMaterial"
value={formData.learningMaterial}
placeholder="Дополнительные объяснения, ссылки, примеры..."
/>
{/* Только для преподавателей */}
<textarea name="hiddenInstructions" value={formData.hiddenInstructions} />
</form>
);
};
```
### Отображение заданий
При отображении задания студентам показывать `learningMaterial` как дополнительную информацию:
```typescript
const TaskView = ({ task }: { task: ChallengeTask }) => {
return (
<div>
<h1>{task.title}</h1>
{/* Основное описание */}
<div dangerouslySetInnerHTML={{ __html: marked(task.description) }} />
{/* Дополнительный учебный материал */}
{task.learningMaterial && (
<div className="learning-material">
<h2>Дополнительные материалы</h2>
<div dangerouslySetInnerHTML={{ __html: marked(task.learningMaterial) }} />
</div>
)}
</div>
);
};
```
## Миграция данных
Поле `learningMaterial` добавлено как необязательное с значением по умолчанию `''`, поэтому:
- Существующие задания будут работать без изменений
- Новое поле будет пустым для старых заданий
- Можно постепенно добавлять учебный материал к существующим заданиям
## Тестирование
### Создание задания с учебным материалом
```bash
# Создать задание с дополнительным материалом
POST /challenge/task
{
"title": "Тестовое задание",
"description": "Основное задание",
"learningMaterial": "# Полезная информация\n\nЭто дополнительный материал для студентов"
}
```
### Получение задания
```bash
GET /challenge/task/{taskId}
# Проверить, что learningMaterial присутствует в ответе
```
### Обновление учебного материала
```bash
PUT /challenge/task/{taskId}
{
"learningMaterial": "# Обновленная информация\n\nНовые полезные материалы..."
}
```
## Влияние на существующий код
- Все существующие эндпоинты получения данных автоматически возвращают новое поле
- Создание заданий без указания `learningMaterial` работает как прежде
- Фильтрация и валидация не затрагиваются
- Поле индексируется MongoDB автоматически

View File

@@ -4,7 +4,7 @@
Содержит два блока изменений:
- **Управление видимостью цепочек заданий** (поле `isActive` и новый админский эндпоинт).
- **Тестовая проверка решения задания админом** (флаг `isTest` в `/submit`).
- **Тестовая проверка решения задания админом** (флаг `isTest` и опциональные `hiddenInstructions` в `/submit`).
---
@@ -158,14 +158,15 @@
#### `POST /api/challenge/submit`
К существующему API добавлен новый опциональный флаг в теле запроса:
К существующему API добавлены новые опциональные поля в теле запроса:
```json
{
"userId": "...",
"taskId": "...",
"result": "...",
"isTest": true // НОВОЕ: опциональный флаг
"isTest": true, // НОВОЕ: флаг тестового режима
"hiddenInstructions": "..." // НОВОЕ: опциональные инструкции для проверки
}
```
@@ -183,8 +184,9 @@
- Доступен только для ролей `teacher` / `challenge-author` (проверка через `isTeacher(req, true)`).
- **Не создаётся** запись `ChallengeSubmission`.
- **Не используется** очередь проверки.
- Проверяется только существование задания (`taskId`), пользователь по `userId` в этом режиме **не ищется и не нужен**.
- Сразу вызывается LLM и возвращается результат проверки.
- Проверяется только существование задания (`taskId`), пользователь по `userId` в этом режиме **не ищется и не нужен** (но поле всё ещё формально обязательно по схеме).
- Если переданы `hiddenInstructions`, они используются **вместо** `task.hiddenInstructions` при формировании промпта для LLM.
- Никакие изменения инструкций, переданные через `hiddenInstructions`, **не сохраняются** в базу — это чисто временная инструкция для одной тестовой проверки.
**Пример запроса (тестовый режим):**
@@ -197,12 +199,11 @@ Authorization: Bearer <keycloak_token_teacher_or_author>
"userId": "any-or-dummy-id",
"taskId": "507f1f77bcf86cd799439012",
"result": "function solve() { ... }",
"isTest": true
"isTest": true,
"hiddenInstructions": "ВРЕМЕННЫЕ инструкции для проверки, не сохраняются"
}
```
> `userId` формально обязателен по схеме, но в тестовом режиме не используется на бэке. Можно передавать любой корректный ObjectId.
**Пример ответа (тестовый режим):**
```json
@@ -222,12 +223,13 @@ Authorization: Bearer <keycloak_token_teacher_or_author>
- **Где использовать тестовый режим**:
- только в админских/преподавательских интерфейсах (например, экран настройки задания или предпросмотр проверки);
- использовать флаг `isTest: true`, когда нужно получить мгновенный ответ от LLM без записи в историю.
- использовать флаг `isTest: true`, когда нужно получить мгновенный ответ от LLM без записи в историю;
- при наличии UI-редактора скрытых инструкций использовать `hiddenInstructions` для передачи временного варианта, не сохраняя его.
- **Где НЕ использовать**:
- в пользовательском флоу сдачи заданий студентами — там должен использоваться обычный режим **без** `isTest`.
- **UI-ожидания**:
- показывать администратору статус (`accepted` / `needs_revision`) и `feedback`;
- явно обозначить в интерфейсе, что это «тестовая проверка» и она **не попадает в статистику / попытки**.
- явно обозначить в интерфейсе, что это «тестовая проверка» и она **не попадает в статистику / попытки**, а переданные `hiddenInstructions` не сохраняются.
---
@@ -238,4 +240,4 @@ Authorization: Bearer <keycloak_token_teacher_or_author>
- админский список: `GET /api/challenge/chains/admin` → все цепочки + управление `isActive` через `POST/PUT /chain`.
- Для отправки решений:
- обычный режим без `isTest` — всё как раньше (очередь, попытки, статистика);
- тестовый режим с `isTest: true` — только для `teacher/challenge-author`, без записи прогресса, сразу возвращает результат проверки.
- тестовый режим с `isTest: true` + опциональные `hiddenInstructions` — только для `teacher/challenge-author`, без записи прогресса, сразу возвращает результат проверки с учётом временных инструкций.

View File

@@ -20,6 +20,9 @@
"challenge.admin.tasks.field.description": "Description (Markdown)",
"challenge.admin.tasks.field.description.placeholder": "# Task title\n\nTask description in Markdown format...",
"challenge.admin.tasks.field.description.helper": "Use Markdown to format text",
"challenge.admin.tasks.field.learning.material": "Additional Learning Material (Markdown)",
"challenge.admin.tasks.field.learning.material.placeholder": "# Additional Materials\n\nTheory, links, solution examples...",
"challenge.admin.tasks.field.learning.material.helper": "Materials for in-depth study. Displayed with scrolling like a book.",
"challenge.admin.tasks.tab.editor": "Editor",
"challenge.admin.tasks.tab.preview": "Preview",
"challenge.admin.tasks.preview.empty": "Preview will appear here...",
@@ -112,6 +115,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",
@@ -166,8 +184,21 @@
"challenge.admin.submissions.title": "Solution attempts",
"challenge.admin.submissions.loading": "Loading attempts...",
"challenge.admin.submissions.load.error": "Failed to load attempts list",
"challenge.admin.submissions.select.chain": "Select a chain to view participant attempts",
"challenge.admin.submissions.chain.tasks": "tasks",
"challenge.admin.submissions.chain.click": "Click to view attempts",
"challenge.admin.submissions.no.chains.title": "No chains",
"challenge.admin.submissions.no.chains.description": "Create a task chain to get started",
"challenge.admin.submissions.back.to.chains": "Back to chain selection",
"challenge.admin.submissions.chain.description": "Total tasks in chain: {{count}}",
"challenge.admin.submissions.participants.title": "Chain participants",
"challenge.admin.submissions.participants.description": "Select a participant to view their attempts in this chain",
"challenge.admin.submissions.participants.empty.title": "No participants",
"challenge.admin.submissions.participants.empty.description": "No one has submitted solutions in this chain yet",
"challenge.admin.submissions.participants.click.to.view": "→ view",
"challenge.admin.submissions.search.placeholder": "Search by user or task...",
"challenge.admin.submissions.filter.user": "Select user",
"challenge.admin.submissions.filter.user.clear": "← All participants",
"challenge.admin.submissions.filter.status": "Status",
"challenge.admin.submissions.status.all": "All statuses",
"challenge.admin.submissions.status.accepted": "Accepted",

View File

@@ -19,6 +19,9 @@
"challenge.admin.tasks.field.description": "Описание (Markdown)",
"challenge.admin.tasks.field.description.placeholder": "# Заголовок задания\n\nОписание задания в формате Markdown...",
"challenge.admin.tasks.field.description.helper": "Используйте Markdown для форматирования текста",
"challenge.admin.tasks.field.learning.material": "Дополнительный учебный материал (Markdown)",
"challenge.admin.tasks.field.learning.material.placeholder": "# Дополнительные материалы\n\nТеория, ссылки, примеры решений...",
"challenge.admin.tasks.field.learning.material.helper": "Материалы для углубленного изучения. Отображаются с прокруткой как книга.",
"challenge.admin.tasks.tab.editor": "Редактор",
"challenge.admin.tasks.tab.preview": "Превью",
"challenge.admin.tasks.preview.empty": "Предпросмотр появится здесь...",
@@ -111,6 +114,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": "Не удалось загрузить статистику системы",
@@ -165,9 +183,21 @@
"challenge.admin.submissions.title": "Попытки решений",
"challenge.admin.submissions.loading": "Загрузка попыток...",
"challenge.admin.submissions.load.error": "Не удалось загрузить список попыток",
"challenge.admin.submissions.select.chain": "Выберите цепочку для просмотра попыток участников",
"challenge.admin.submissions.chain.tasks": "заданий",
"challenge.admin.submissions.chain.click": "Нажмите для просмотра попыток",
"challenge.admin.submissions.no.chains.title": "Нет цепочек",
"challenge.admin.submissions.no.chains.description": "Создайте цепочку заданий для начала работы",
"challenge.admin.submissions.back.to.chains": "Назад к выбору цепочки",
"challenge.admin.submissions.chain.description": "Всего заданий в цепочке: {{count}}",
"challenge.admin.submissions.participants.title": "Участники цепочки",
"challenge.admin.submissions.participants.description": "Выберите участника для просмотра его попыток в этой цепочке",
"challenge.admin.submissions.participants.empty.title": "Нет участников",
"challenge.admin.submissions.participants.empty.description": "Пока никто не отправил решения в этой цепочке",
"challenge.admin.submissions.participants.click.to.view": "→ посмотреть",
"challenge.admin.submissions.search.placeholder": "Поиск по пользователю или заданию...",
"challenge.admin.submissions.filter.user": "Выберите пользователя",
"challenge.admin.submissions.filter.user.clear": "Показать всех",
"challenge.admin.submissions.filter.user.clear": "Все участники",
"challenge.admin.submissions.filter.status": "Статус",
"challenge.admin.submissions.status.all": "Все статусы",
"challenge.admin.submissions.status.accepted": "Принято",

4
package-lock.json generated
View File

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

View File

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

View File

@@ -13,9 +13,12 @@ import type {
UpdateTaskRequest,
CreateChainRequest,
UpdateChainRequest,
DuplicateChainRequest,
ClearSubmissionsResponse,
SubmitRequest,
TestSubmissionResult,
APIResponse,
ChainSubmissionsResponse,
SubmissionStatus,
} from '../../types/challenge'
export const api = createApi({
@@ -114,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>({
@@ -144,10 +164,21 @@ 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 }) => ({
query: ({ userId, taskId, result, isTest = true, hiddenInstructions }) => ({
url: '/challenge/submit',
method: 'POST',
body: {
@@ -155,9 +186,11 @@ export const api = createApi({
taskId,
result,
isTest,
hiddenInstructions,
},
}),
transformResponse: (response: APIResponse<TestSubmissionResult>) => response.data,
// Сервер возвращает { success: boolean; body: TestSubmissionResult }
transformResponse: (response: { success: boolean; body: TestSubmissionResult }) => response.body,
}),
}),
})
@@ -173,10 +206,13 @@ export const {
useCreateChainMutation,
useUpdateChainMutation,
useDeleteChainMutation,
useDuplicateChainMutation,
useClearChainSubmissionsMutation,
useGetSystemStatsQuery,
useGetSystemStatsV2Query,
useGetUserStatsQuery,
useGetUserSubmissionsQuery,
useGetChainSubmissionsQuery,
useTestSubmissionMutation,
} = api

View File

@@ -3,6 +3,6 @@ import Keycloak from 'keycloak-js'
export const keycloak = new Keycloak({
url: KC_URL,
realm: KC_REALM,
clientId: KC_CLIENT_ID,
});
clientId: KC_CLIENT_ID
})

View File

@@ -36,10 +36,12 @@ export const URLs = {
// Submissions
submissions: makeUrl('/submissions'),
submissionDetails: (userId: string, submissionId: string) => makeUrl(`/submissions/${userId}/${submissionId}`),
submissionDetailsPath: makeUrl('/submissions/:userId/:submissionId'),
submissionsChain: (chainId: string) => makeUrl(`/submissions/${chainId}`),
submissionsChainPath: makeUrl('/submissions/:chainId'),
submissionDetails: (chainId: string, userId: string, submissionId: string) => makeUrl(`/submissions/${chainId}/${userId}/${submissionId}`),
submissionDetailsPath: makeUrl('/submissions/:chainId/:userId/:submissionId'),
// External links
challengePlayer: navs['link.challenge'] || '/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

@@ -130,6 +130,14 @@ export const Dashboard = () => {
</PageWrapper>
}
/>
<Route
path={URLs.submissionsChainPath}
element={
<PageWrapper>
<SubmissionsPage />
</PageWrapper>
}
/>
<Route
path={URLs.submissionDetailsPath}
element={

View File

@@ -17,7 +17,8 @@ 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,24 +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'),
@@ -165,7 +170,7 @@ export const ChainsListPage: React.FC = () => {
size="xs"
variant="outline"
onClick={() => handleToggleActive(chain, !chain.isActive)}
isDisabled={updatingChainId === chain.id}
disabled={updatingChainId === chain.id}
>
{chain.isActive
? t('challenge.admin.chains.list.status.inactive')
@@ -182,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>
@@ -199,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

@@ -12,7 +12,7 @@ import { URLs } from '../../__data__/urls'
export const SubmissionDetailsPage: React.FC = () => {
const { t } = useTranslation()
const { userId, submissionId } = useParams<{ userId: string; submissionId: string }>()
const { chainId, userId, submissionId } = useParams<{ chainId: string; userId: string; submissionId: string }>()
const navigate = useNavigate()
// Получаем submissions для конкретного пользователя
@@ -24,8 +24,8 @@ export const SubmissionDetailsPage: React.FC = () => {
const submission = submissions?.find((s) => s.id === submissionId)
const handleBack = () => {
if (userId) {
navigate(`${URLs.submissions}?userId=${encodeURIComponent(userId)}`)
if (chainId) {
navigate(URLs.submissionsChain(chainId))
} else {
navigate(URLs.submissions)
}

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react'
import React, { useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useNavigate, useParams, Link } from 'react-router-dom'
import {
Box,
Heading,
@@ -10,19 +10,26 @@ import {
Button,
HStack,
VStack,
Select,
Badge,
Progress,
Grid,
SimpleGrid,
Select,
createListCollection,
} from '@chakra-ui/react'
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,
@@ -32,14 +39,49 @@ import type {
export const SubmissionsPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const initialUserId = searchParams.get('userId')
const { data: stats, isLoading: isStatsLoading, error: statsError, refetch: refetchStats } =
useGetSystemStatsV2Query(undefined)
const { chainId } = useParams<{ chainId?: string }>()
// Проверяем 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 [selectedUserId, setSelectedUserId] = useState<string | null>(initialUserId)
// Получаем список цепочек
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,
@@ -48,54 +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 normalizedSearchQuery = (searchQuery ?? '').toLowerCase()
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 filteredSubmissions = submissionsList.filter((submission) => {
const rawUser = submission.user as ChallengeUser | string | undefined
const rawTask = submission.task as ChallengeTask | string | undefined
// Старый API: фильтруем submissions только по заданиям из текущей цепочки
const filteredSubmissionsOld = useMemo(() => {
if (!submissions || chainTaskIds.size === 0 || useNewApi) return []
const nickname =
rawUser && typeof rawUser === 'object' && 'nickname' in rawUser
? (rawUser.nickname ?? '')
: ''
const normalizedSearchQuery = (searchQuery ?? '').toLowerCase()
const title =
rawTask && typeof rawTask === 'object' && 'title' in rawTask
? (rawTask.title ?? '')
: ''
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
: ''
const matchesSearch =
nickname.toLowerCase().includes(normalizedSearchQuery) ||
title.toLowerCase().includes(normalizedSearchQuery)
if (!chainTaskIds.has(taskId)) return false
const matchesStatus = statusFilter === 'all' || submission.status === statusFilter
const rawUser = submission.user as ChallengeUser | string | undefined
const nickname =
rawUser && typeof rawUser === 'object' && 'nickname' in rawUser
? (rawUser.nickname ?? '')
: ''
return matchesSearch && matchesStatus
})
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', {
@@ -125,70 +255,133 @@ 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} />
}
const participantOverviewRows = participants
.map((participant) => {
const chains = participant.chainProgress || []
// Если 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>
const totalTasks = chains.reduce((sum, chain) => sum + (chain.totalTasks ?? 0), 0)
const completedTasks = chains.reduce(
(sum, chain) => sum + (chain.completedTasks ?? 0),
0
)
{chains && chains.length > 0 ? (
<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>
)
}
const overallPercent =
totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
// Если цепочка выбрана но данных нет
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>
)
}
return {
userId: participant.userId,
nickname: participant.nickname,
totalSubmissions: participant.totalSubmissions,
completedTasks,
totalTasks,
overallPercent,
}
})
.sort((a, b) => a.overallPercent - b.overallPercent)
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} align="center">
<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>
{hasSelectedUser && (
<HStack gap={4} align="center" wrap="wrap">
{selectedUserId && (
<Button
size="sm"
variant="ghost"
variant="outline"
colorPalette="teal"
onClick={() => {
setSelectedUserId(null)
setSearchQuery('')
@@ -199,13 +392,13 @@ export const SubmissionsPage: React.FC = () => {
</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}
@@ -230,24 +423,20 @@ export const SubmissionsPage: React.FC = () => {
</VStack>
)}
{!hasParticipants ? (
<EmptyState
title={t('challenge.admin.submissions.empty.title')}
description={t('challenge.admin.submissions.empty.description')}
/>
) : !hasSelectedUser ? (
{/* Если не выбран пользователь - показываем обзор участников */}
{!selectedUserId ? (
<Box>
<Heading size="md" mb={4}>
{t('challenge.admin.submissions.overview.title')}
{t('challenge.admin.submissions.participants.title')}
</Heading>
<Text mb={4} color="gray.600">
{t('challenge.admin.submissions.overview.description')}
{t('challenge.admin.submissions.participants.description')}
</Text>
{participantOverviewRows.length === 0 ? (
{sortedParticipants.length === 0 ? (
<EmptyState
title={t('challenge.admin.detailed.stats.participants.empty')}
description={t('challenge.admin.detailed.stats.chains.empty')}
title={t('challenge.admin.submissions.participants.empty.title')}
description={t('challenge.admin.submissions.participants.empty.description')}
/>
) : (
<Grid
@@ -257,43 +446,50 @@ export const SubmissionsPage: React.FC = () => {
lg: 'repeat(3, minmax(0, 1fr))',
xl: 'repeat(4, minmax(0, 1fr))',
}}
gap={2}
gap={3}
>
{participantOverviewRows.map((row) => {
{sortedParticipants.map((participant) => {
const colorPalette =
row.overallPercent >= 70
participant.progressPercent >= 70
? 'green'
: row.overallPercent >= 40
: participant.progressPercent >= 40
? 'orange'
: 'red'
return (
<Box
key={row.userId}
p={2}
key={participant.userId}
p={3}
borderWidth="1px"
borderRadius="md"
borderColor="gray.200"
_hover={{ bg: 'gray.50' }}
bg="white"
_hover={{ bg: 'gray.50', borderColor: 'teal.300' }}
cursor="pointer"
onClick={() => setSelectedUserId(row.userId)}
onClick={() => setSelectedUserId(participant.userId)}
transition="all 0.2s"
>
<HStack justify="space-between" mb={1} gap={2}>
<Text fontSize="xs" fontWeight="medium" truncate maxW="150px">
{row.nickname}
</Text>
<Text fontSize="xs" color="gray.500">
{row.overallPercent}%
<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={row.overallPercent} size="xs" colorPalette={colorPalette}>
<Progress.Root value={participant.progressPercent} size="sm" colorPalette={colorPalette}>
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
<Text fontSize="xs" color="gray.500" mt={1}>
{row.completedTasks} / {row.totalTasks}
</Text>
<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>
)
})}
@@ -306,6 +502,7 @@ export const SubmissionsPage: React.FC = () => {
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>
@@ -316,7 +513,9 @@ 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>
@@ -365,7 +564,7 @@ export const SubmissionsPage: React.FC = () => {
size="sm"
variant="ghost"
colorPalette="teal"
onClick={() => navigate(URLs.submissionDetails(selectedUserId!, submission.id))}
onClick={() => navigate(URLs.submissionDetails(chainId!, selectedUserId, submission.id))}
>
{t('challenge.admin.submissions.button.details')}
</Button>
@@ -380,4 +579,3 @@ export const SubmissionsPage: React.FC = () => {
</Box>
)
}

View File

@@ -40,6 +40,7 @@ export const TaskFormPage: React.FC = () => {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [learningMaterial, setLearningMaterial] = useState('')
const [hiddenInstructions, setHiddenInstructions] = useState('')
const [showDescPreview, setShowDescPreview] = useState(false)
const [testAnswer, setTestAnswer] = useState('')
@@ -50,10 +51,44 @@ export const TaskFormPage: React.FC = () => {
if (task) {
setTitle(task.title)
setDescription(task.description)
setLearningMaterial(task.learningMaterial || '')
setHiddenInstructions(task.hiddenInstructions || '')
}
}, [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()
@@ -73,6 +108,7 @@ export const TaskFormPage: React.FC = () => {
data: {
title: title.trim(),
description: description.trim(),
learningMaterial: learningMaterial.trim() || undefined,
hiddenInstructions: hiddenInstructions.trim() || undefined,
},
}).unwrap()
@@ -85,6 +121,7 @@ export const TaskFormPage: React.FC = () => {
await createTask({
title: title.trim(),
description: description.trim(),
learningMaterial: learningMaterial.trim() || undefined,
hiddenInstructions: hiddenInstructions.trim() || undefined,
}).unwrap()
toaster.create({
@@ -93,7 +130,7 @@ export const TaskFormPage: React.FC = () => {
type: 'success',
})
}
navigate(URLs.tasks)
// navigate(URLs.tasks)
} catch (err: unknown) {
const errorMessage =
(err && typeof err === 'object' && 'data' in err &&
@@ -136,6 +173,7 @@ export const TaskFormPage: React.FC = () => {
taskId: task.id,
result: testAnswer.trim(),
isTest: true,
hiddenInstructions: hiddenInstructions.trim() || undefined,
}).unwrap()
setTestStatus(result.status)
@@ -321,6 +359,128 @@ export const TaskFormPage: React.FC = () => {
<Field.HelperText>{t('challenge.admin.tasks.field.description.helper')}</Field.HelperText>
</Field.Root>
{/* Learning Material */}
<Field.Root>
<Field.Label>{t('challenge.admin.tasks.field.learning.material')}</Field.Label>
<Box display={{ base: 'block', lg: 'none' }}>
{/* Табы для мобильных */}
<Tabs.Root
value={showDescPreview ? 'preview' : 'editor'}
onValueChange={(e) => setShowDescPreview(e.value === 'preview')}
>
<Tabs.List>
<Tabs.Trigger value="editor">{t('challenge.admin.tasks.tab.editor')}</Tabs.Trigger>
<Tabs.Trigger value="preview">{t('challenge.admin.tasks.tab.preview')}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="editor" pt={4}>
<Textarea
value={learningMaterial}
onChange={(e) => setLearningMaterial(e.target.value)}
placeholder={t('challenge.admin.tasks.field.learning.material.placeholder')}
rows={12}
fontFamily="monospace"
disabled={isLoading}
/>
</Tabs.Content>
<Tabs.Content value="preview" pt={4}>
<Box
p={4}
borderWidth="1px"
borderColor="gray.200"
borderRadius="md"
minH="250px"
maxH="400px"
bg="gray.50"
overflowY="auto"
>
{learningMaterial ? (
<Box
className="markdown-preview"
css={{
'& a': {
color: '#0f766e',
textDecoration: 'underline',
cursor: 'pointer',
'&:hover': {
color: '#115e59',
}
}
}}
>
<ReactMarkdown>{learningMaterial}</ReactMarkdown>
</Box>
) : (
<Text color="gray.400" fontStyle="italic">
{t('challenge.admin.tasks.preview.empty')}
</Text>
)}
</Box>
</Tabs.Content>
</Tabs.Root>
</Box>
{/* Две колонки для десктопа */}
<Grid
display={{ base: 'none', lg: 'grid' }}
templateColumns="1fr 1fr"
gap={4}
mt={2}
>
<Box>
<Text fontSize="sm" fontWeight="medium" mb={2} color="gray.700">
{t('challenge.admin.tasks.tab.editor')}
</Text>
<Textarea
value={learningMaterial}
onChange={(e) => setLearningMaterial(e.target.value)}
placeholder={t('challenge.admin.tasks.field.learning.material.placeholder')}
rows={15}
fontFamily="monospace"
disabled={isLoading}
/>
</Box>
<Box>
<Text fontSize="sm" fontWeight="medium" mb={2} color="gray.700">
{t('challenge.admin.tasks.tab.preview')}
</Text>
<Box
p={4}
borderWidth="1px"
borderColor="gray.200"
borderRadius="md"
minH="250px"
maxH="400px"
bg="gray.50"
overflowY="auto"
height="100%"
>
{learningMaterial ? (
<Box
className="markdown-preview"
css={{
'& a': {
color: '#0f766e',
textDecoration: 'underline',
cursor: 'pointer',
'&:hover': {
color: '#115e59',
}
}
}}
>
<ReactMarkdown>{learningMaterial}</ReactMarkdown>
</Box>
) : (
<Text color="gray.400" fontStyle="italic">
{t('challenge.admin.tasks.preview.empty')}
</Text>
)}
</Box>
</Box>
</Grid>
<Field.HelperText>{t('challenge.admin.tasks.field.learning.material.helper')}</Field.HelperText>
</Field.Root>
{/* Hidden Instructions */}
<Field.Root>
<Box bg="purple.50" p={4} borderRadius="md" borderWidth="1px" borderColor="purple.200">

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

@@ -12,6 +12,7 @@ export interface ChallengeTask {
id: string
title: string
description: string // Markdown
learningMaterial?: string // Дополнительный учебный материал в Markdown
hiddenInstructions?: string // Только для преподавателей
creator?: {
sub: string
@@ -121,12 +122,14 @@ export interface APIResponse<T> {
export interface CreateTaskRequest {
title: string
description: string
learningMaterial?: string
hiddenInstructions?: string
}
export interface UpdateTaskRequest {
title?: string
description?: string
learningMaterial?: string
hiddenInstructions?: string
}
@@ -142,6 +145,16 @@ export interface UpdateChainRequest {
isActive?: boolean
}
export interface DuplicateChainRequest {
name?: string
}
export interface ClearSubmissionsResponse {
deletedCount: number
chainId: string
userId?: string
}
// ========== Stats v2 Types ==========
export type TaskProgressStatus = 'not_started' | 'pending' | 'in_progress' | 'needs_revision' | 'completed'
@@ -234,6 +247,8 @@ export interface SubmitRequest {
result: string
// Флаг тестового режима: проверка без создания Submission и очереди
isTest?: boolean
// Временные скрытые инструкции для тестовой проверки (не сохраняются в задачу)
hiddenInstructions?: string
}
export interface TestSubmissionResult {
@@ -242,3 +257,28 @@ export interface TestSubmissionResult {
feedback?: string
}
// ========== Chain Submissions API ==========
export interface ChainSubmissionsParticipant {
userId: string
nickname: string
completedTasks: number
totalTasks: number
progressPercent: number
}
export interface ChainSubmissionsResponse {
chain: {
id: string
name: string
tasks: Array<{ id: string; title: string }>
}
participants: ChainSubmissionsParticipant[]
submissions: ChallengeSubmission[]
pagination: {
total: number
limit: number
offset: number
}
}

View File

@@ -312,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
@@ -331,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));
@@ -461,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;