11 Commits

Author SHA1 Message Date
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
20 changed files with 1327 additions and 382 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.

241
docs/updateAPI.md Normal file
View File

@@ -0,0 +1,241 @@
## Обновление API Challenge Service
Документ для frontend-разработчика. Описывает НОВЫЕ возможности и требования к клиенту.
Содержит два блока изменений:
- **Управление видимостью цепочек заданий** (поле `isActive` и новый админский эндпоинт).
- **Тестовая проверка решения задания админом** (флаг `isTest` в `/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 // НОВОЕ: опциональный флаг
}
```
### 2.2. Обычный режим (без `isTest`)
- Если `isTest` **не передан** или `false` — поведение **НЕ изменилось**:
- проверяется существование пользователя по `userId`;
- считается количество попыток;
- создаётся `ChallengeSubmission`;
- попытка ставится в очередь на проверку через LLM;
- в ответе фронтенд получает `queueId` и `submissionId`.
### 2.3. Тестовый режим (`isTest: true`)
- Доступен только для ролей `teacher` / `challenge-author` (проверка через `isTeacher(req, true)`).
- **Не создаётся** запись `ChallengeSubmission`.
- **Не используется** очередь проверки.
- Проверяется только существование задания (`taskId`), пользователь по `userId` в этом режиме **не ищется и не нужен**.
- Сразу вызывается LLM и возвращается результат проверки.
**Пример запроса (тестовый режим):**
```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
}
```
> `userId` формально обязателен по схеме, но в тестовом режиме не используется на бэке. Можно передавать любой корректный ObjectId.
**Пример ответа (тестовый режим):**
```json
{
"error": null,
"data": {
"isTest": true,
"status": "accepted", // или "needs_revision"
"feedback": "Развёрнутый комментарий от LLM"
}
}
```
При отсутствии прав (нет роли `teacher` / `challenge-author`) вернётся 403.
### 2.4. Требования к фронтенду
- **Где использовать тестовый режим**:
- только в админских/преподавательских интерфейсах (например, экран настройки задания или предпросмотр проверки);
- использовать флаг `isTest: true`, когда нужно получить мгновенный ответ от LLM без записи в историю.
- **Где НЕ использовать**:
- в пользовательском флоу сдачи заданий студентами — там должен использоваться обычный режим **без** `isTest`.
- **UI-ожидания**:
- показывать администратору статус (`accepted` / `needs_revision`) и `feedback`;
- явно обозначить в интерфейсе, что это «тестовая проверка» и она **не попадает в статистику / попытки**.
---
## 3. Краткое резюме
- Для цепочек:
- пользовательский список: `GET /api/challenge/chains` → только активные (`isActive: true`);
- админский список: `GET /api/challenge/chains/admin` → все цепочки + управление `isActive` через `POST/PUT /chain`.
- Для отправки решений:
- обычный режим без `isTest` — всё как раньше (очередь, попытки, статистика);
- тестовый режим с `isTest: true` — только для `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",
@@ -137,9 +154,12 @@
"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",
@@ -147,6 +167,7 @@
"challenge.admin.submissions.loading": "Loading attempts...",
"challenge.admin.submissions.load.error": "Failed to load attempts list",
"challenge.admin.submissions.search.placeholder": "Search by user or task...",
"challenge.admin.submissions.filter.user": "Select user",
"challenge.admin.submissions.filter.status": "Status",
"challenge.admin.submissions.status.all": "All statuses",
"challenge.admin.submissions.status.accepted": "Accepted",
@@ -176,6 +197,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": "Цепочка удалена",
@@ -136,9 +153,12 @@
"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": "Закрыть",
@@ -146,6 +166,8 @@
"challenge.admin.submissions.loading": "Загрузка попыток...",
"challenge.admin.submissions.load.error": "Не удалось загрузить список попыток",
"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 +197,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.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "challenge-admin-pl",
"version": "1.0.0",
"version": "1.1.0",
"license": "ISC",
"dependencies": {
"@brojs/cli": "^1.9.4",

View File

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

View File

@@ -13,6 +13,9 @@ import type {
UpdateTaskRequest,
CreateChainRequest,
UpdateChainRequest,
SubmitRequest,
TestSubmissionResult,
APIResponse,
} from '../../types/challenge'
export const api = createApi({
@@ -77,7 +80,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'],
}),
@@ -141,6 +144,21 @@ export const api = createApi({
transformResponse: (response: { body: ChallengeSubmission[] }) => response.body,
providesTags: ['Submission'],
}),
// Test submission (LLM check without creating a real submission)
testSubmission: builder.mutation<TestSubmissionResult, SubmitRequest>({
query: ({ userId, taskId, result, isTest = true }) => ({
url: '/challenge/submit',
method: 'POST',
body: {
userId,
taskId,
result,
isTest,
},
}),
transformResponse: (response: APIResponse<TestSubmissionResult>) => response.data,
}),
}),
})
@@ -159,5 +177,6 @@ export const {
useGetSystemStatsV2Query,
useGetUserStatsQuery,
useGetUserSubmissionsQuery,
useTestSubmissionMutation,
} = api

View File

@@ -31,9 +31,13 @@ export const URLs = {
// Users
users: makeUrl('/users'),
userStats: (userId: string) => makeUrl(`/users/${userId}`),
userStatsPath: makeUrl('/users/:userId'),
// Submissions
submissions: makeUrl('/submissions'),
submissionDetails: (userId: string, submissionId: string) => makeUrl(`/submissions/${userId}/${submissionId}`),
submissionDetailsPath: makeUrl('/submissions/:userId/:submissionId'),
// External links
challengePlayer: navs['link.challenge'] || '/challenge',

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,14 @@ export const Dashboard = () => {
</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,7 +12,7 @@ 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'
@@ -29,6 +29,8 @@ export const ChainsListPage: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('')
const [chainToDelete, setChainToDelete] = useState<ChallengeChain | null>(null)
const [updatingChainId, setUpdatingChainId] = useState<string | null>(null)
const [updateChain] = useUpdateChainMutation()
const handleDeleteChain = async () => {
if (!chainToDelete) return
@@ -50,6 +52,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 +136,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 +154,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)}
isDisabled={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

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 { userId, submissionId } = useParams<{ 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 (userId) {
navigate(`${URLs.submissions}?userId=${encodeURIComponent(userId)}`)
} 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 { useTranslation } from 'react-i18next'
import { useNavigate, useSearchParams } from 'react-router-dom'
import {
Box,
Heading,
@@ -10,21 +11,16 @@ import {
HStack,
VStack,
Select,
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
Progress,
Grid,
createListCollection,
} from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown'
import { 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,
@@ -35,13 +31,15 @@ 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 [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 [selectedUserId, setSelectedUserId] = useState<string | null>(initialUserId)
const {
data: submissions,
@@ -74,13 +72,25 @@ export const SubmissionsPage: React.FC = () => {
const participants: ActiveParticipant[] = stats.activeParticipants || []
const submissionsList: ChallengeSubmission[] = submissions || []
const normalizedSearchQuery = (searchQuery ?? '').toLowerCase()
const filteredSubmissions = submissionsList.filter((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 ?? '')
: ''
const title =
rawTask && typeof rawTask === 'object' && 'title' in rawTask
? (rawTask.title ?? '')
: ''
const matchesSearch =
user.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.title.toLowerCase().includes(searchQuery.toLowerCase())
nickname.toLowerCase().includes(normalizedSearchQuery) ||
title.toLowerCase().includes(normalizedSearchQuery)
const matchesStatus = statusFilter === 'all' || submission.status === statusFilter
@@ -125,6 +135,30 @@ export const SubmissionsPage: React.FC = () => {
const hasParticipants = participants.length > 0
const hasSelectedUser = !!selectedUserId
const participantOverviewRows = participants
.map((participant) => {
const chains = participant.chainProgress || []
const totalTasks = chains.reduce((sum, chain) => sum + (chain.totalTasks ?? 0), 0)
const completedTasks = chains.reduce(
(sum, chain) => sum + (chain.completedTasks ?? 0),
0
)
const overallPercent =
totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
return {
userId: participant.userId,
nickname: participant.nickname,
totalSubmissions: participant.totalSubmissions,
completedTasks,
totalTasks,
overallPercent,
}
})
.sort((a, b) => a.overallPercent - b.overallPercent)
return (
<Box>
<Heading mb={6}>{t('challenge.admin.submissions.title')}</Heading>
@@ -132,7 +166,7 @@ export const SubmissionsPage: React.FC = () => {
{/* Filters */}
{hasParticipants && (
<VStack mb={4} gap={3} align="stretch">
<HStack gap={4}>
<HStack gap={4} align="center">
<Select.Root
collection={userOptions}
value={selectedUserId ? [selectedUserId] : []}
@@ -151,6 +185,20 @@ export const SubmissionsPage: React.FC = () => {
</Select.Content>
</Select.Root>
{hasSelectedUser && (
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedUserId(null)
setSearchQuery('')
setStatusFilter('all')
}}
>
{t('challenge.admin.submissions.filter.user.clear')}
</Button>
)}
{submissionsList.length > 0 && (
<>
<Input
@@ -188,10 +236,70 @@ export const SubmissionsPage: React.FC = () => {
description={t('challenge.admin.submissions.empty.description')}
/>
) : !hasSelectedUser ? (
<EmptyState
title={t('challenge.admin.submissions.empty.title')}
description={t('challenge.admin.submissions.filter.user')}
/>
<Box>
<Heading size="md" mb={4}>
{t('challenge.admin.submissions.overview.title')}
</Heading>
<Text mb={4} color="gray.600">
{t('challenge.admin.submissions.overview.description')}
</Text>
{participantOverviewRows.length === 0 ? (
<EmptyState
title={t('challenge.admin.detailed.stats.participants.empty')}
description={t('challenge.admin.detailed.stats.chains.empty')}
/>
) : (
<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={2}
>
{participantOverviewRows.map((row) => {
const colorPalette =
row.overallPercent >= 70
? 'green'
: row.overallPercent >= 40
? 'orange'
: 'red'
return (
<Box
key={row.userId}
p={2}
borderWidth="1px"
borderRadius="md"
borderColor="gray.200"
_hover={{ bg: 'gray.50' }}
cursor="pointer"
onClick={() => setSelectedUserId(row.userId)}
>
<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}%
</Text>
</HStack>
<Progress.Root value={row.overallPercent} size="xs" colorPalette={colorPalette}>
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
<Text fontSize="xs" color="gray.500" mt={1}>
{row.completedTasks} / {row.totalTasks}
</Text>
</Box>
)
})}
</Grid>
)}
</Box>
) : filteredSubmissions.length === 0 ? (
<EmptyState
title={t('challenge.admin.submissions.search.empty.title')}
@@ -213,13 +321,27 @@ export const SubmissionsPage: React.FC = () => {
</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 +365,7 @@ export const SubmissionsPage: React.FC = () => {
size="sm"
variant="ghost"
colorPalette="teal"
onClick={() => setSelectedSubmission(submission)}
onClick={() => navigate(URLs.submissionDetails(selectedUserId!, submission.id))}
>
{t('challenge.admin.submissions.button.details')}
</Button>
@@ -255,166 +377,7 @@ 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) {
@@ -106,6 +111,58 @@ 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,
}).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 +366,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

@@ -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,13 @@ 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
}
// ========== Stats v2 Types ==========
@@ -223,3 +226,19 @@ export interface SystemStatsV2 {
chainsDetailed: ChainDetailed[]
}
// ========== Submissions / Checking ==========
export interface SubmitRequest {
userId: string
taskId: string
result: string
// Флаг тестового режима: проверка без создания Submission и очереди
isTest?: boolean
}
export interface TestSubmissionResult {
isTest: true
status: Exclude<SubmissionStatus, 'pending' | 'in_progress'>
feedback?: string
}

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,
@@ -249,6 +285,10 @@ router.put('/challenge/chain/:id', (req, res) => {
chain.tasks = populatedTasks;
}
if (isActive !== undefined) {
chain.isActive = !!isActive;
}
chain.updatedAt = new Date().toISOString();
respond(res, chain);
@@ -282,7 +322,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 не передан, возвращаем все данные