22 Commits

Author SHA1 Message Date
primakov b2f947e699 1.5.2 2025-12-15 21:54:39 +03:00
primakov 43f73a129f Refactor TaskFormPage to split learning material into blocks of 30 lines for improved readability. Each block is displayed with a label indicating its order. This enhances the user experience by organizing content more effectively. 2025-12-15 21:53:45 +03:00
primakov 9bc5225c27 1.5.1 2025-12-15 21:25:02 +03:00
primakov b69d00052f Add workplaceNumber field to user authentication and statistics API. Update frontend components and localization to support new field. Enhance user experience by displaying workplace information in relevant areas. 2025-12-15 21:22:06 +03:00
primakov 833d1cc14f 1.5.0 2025-12-14 21:16:58 +03:00
primakov d624d63a37 Implement submissions polling interval feature in SubmissionsPage, allowing dynamic adjustment of API request frequency based on configuration. 2025-12-14 20:51:50 +03:00
primakov 7dab439f3a 1.4.0 2025-12-14 20:33:46 +03:00
primakov c784626b33 Update SubmissionsPage to include polling interval for API requests, enhancing data retrieval efficiency. 2025-12-14 20:33:36 +03:00
primakov 6b7c773977 1.3.1 2025-12-14 16:33:15 +03:00
primakov 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
primakov d0e26b02c7 1.3.0 2025-12-14 15:43:08 +03:00
primakov 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
primakov e93de750fc 1.2.0 2025-12-14 14:47:50 +03:00
primakov 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
primakov 1d364a2351 Refactor ClearSubmissionsDialog and DuplicateChainDialog components by removing unnecessary whitespace; improve code cleanliness and maintainability. 2025-12-14 13:00:57 +03:00
primakov 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
primakov 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
primakov 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
primakov 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
primakov 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
primakov 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
primakov 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
28 changed files with 2312 additions and 198 deletions
+582
View File
@@ -0,0 +1,582 @@
# API изменения: Поле workplaceNumber
## Обзор
Добавлено новое поле `workplaceNumber` для отслеживания рабочего места (компьютера), за которым работает ученик. Это поле сохраняется при авторизации и возвращается во всех эндпоинтах статистики.
---
## 1. Авторизация пользователя
### `POST /challenge/auth`
Регистрация или авторизация пользователя с указанием рабочего места.
#### Изменения
- ✨ Добавлен опциональный параметр `workplaceNumber`
- При создании нового пользователя сохраняется `workplaceNumber`
- При повторной авторизации существующего пользователя с другим `workplaceNumber` - значение обновляется
- Поиск пользователя по-прежнему выполняется только по `nickname`
#### Request
```http
POST /challenge/auth
Content-Type: application/json
{
"nickname": "student_ivan",
"workplaceNumber": "PC-15" // Опционально
}
```
#### Request Body Parameters
| Параметр | Тип | Обязательный | Описание |
|----------|-----|--------------|----------|
| `nickname` | `string` | ✅ Да | Никнейм пользователя (3-50 символов) |
| `workplaceNumber` | `string` | ❌ Нет | Номер рабочего места/компьютера (макс. 50 символов) |
#### Response
```json
{
"error": null,
"result": {
"ok": true,
"userId": "507f1f77bcf86cd799439011"
}
}
```
#### Примеры использования
**Первая авторизация с рабочим местом:**
```javascript
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname: 'student_ivan',
workplaceNumber: 'PC-15'
})
});
```
**Повторная авторизация с другого места:**
```javascript
// Если пользователь пересел за другой компьютер
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname: 'student_ivan',
workplaceNumber: 'PC-20' // Обновится в базе
})
});
```
**Авторизация без указания места:**
```javascript
// Работает как раньше, workplaceNumber необязателен
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname: 'student_ivan'
})
});
```
---
## 2. Статистика по цепочке заданий
### `GET /challenge/chain/:chainId/submissions`
Получение всех попыток по цепочке с данными о рабочих местах участников.
**Требует права:** `teacher` или `challenge-author`
#### Изменения
-В объекте `user` внутри `submissions` добавлено поле `workplaceNumber`
-В массиве `participants` добавлено поле `workplaceNumber`
#### Request
```http
GET /challenge/chain/507f1f77bcf86cd799439011/submissions?limit=50&offset=0
```
#### Query Parameters
| Параметр | Тип | Описание |
|----------|-----|----------|
| `userId` | `string` | Фильтр по конкретному пользователю |
| `status` | `string` | Фильтр по статусу: `pending`, `in_progress`, `accepted`, `needs_revision` |
| `limit` | `number` | Количество записей (по умолчанию: 100) |
| `offset` | `number` | Смещение для пагинации (по умолчанию: 0) |
#### Response
```json
{
"error": null,
"result": {
"chain": {
"id": "507f1f77bcf86cd799439011",
"name": "Основы Python",
"tasks": [
{
"id": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных"
}
]
},
"participants": [
{
"userId": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15", // ✨ Новое поле
"completedTasks": 5,
"totalTasks": 10,
"progressPercent": 50
},
{
"userId": "507f1f77bcf86cd799439014",
"nickname": "student_maria",
"workplaceNumber": "PC-20", // ✨ Новое поле
"completedTasks": 8,
"totalTasks": 10,
"progressPercent": 80
}
],
"submissions": [
{
"id": "507f1f77bcf86cd799439015",
"user": {
"id": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15" // ✨ Новое поле
},
"task": {
"id": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных"
},
"status": "accepted",
"attemptNumber": 2,
"submittedAt": "2024-01-15T10:30:00.000Z",
"checkedAt": "2024-01-15T10:31:23.000Z",
"feedback": "Отличная работа!"
}
],
"pagination": {
"total": 150,
"limit": 50,
"offset": 0
}
}
}
```
#### Пример использования
```javascript
const chainId = '507f1f77bcf86cd799439011';
const response = await fetch(`/challenge/chain/${chainId}/submissions`, {
headers: {
'Authorization': 'Bearer YOUR_TOKEN' // Требуется токен преподавателя
}
});
const data = await response.json();
// Отобразить список участников с их местами
data.result.participants.forEach(participant => {
console.log(`${participant.nickname} (${participant.workplaceNumber}): ${participant.progressPercent}%`);
// Вывод: "student_ivan (PC-15): 50%"
});
```
---
## 3. Расширенная статистика системы
### `GET /challenge/stats/v2`
Получение детальной статистики с данными о рабочих местах участников.
#### Изменения
-В массиве `activeParticipants` добавлено поле `workplaceNumber`
-В `chainsDetailed[].participantProgress[]` добавлено поле `workplaceNumber`
#### Request
```http
GET /challenge/stats/v2
```
или с фильтром по конкретной цепочке:
```http
GET /challenge/stats/v2?chainId=507f1f77bcf86cd799439011
```
#### Query Parameters
| Параметр | Тип | Описание |
|----------|-----|----------|
| `chainId` | `string` | Опционально: фильтр по конкретной цепочке |
#### Response (фрагмент)
```json
{
"error": null,
"result": {
"users": 25,
"tasks": 50,
"chains": 5,
"submissions": {
"total": 342,
"accepted": 150,
"rejected": 80,
"pending": 12,
"inProgress": 100
},
"averageCheckTimeMs": 2500,
"queue": {
"pending": 5,
"processing": 2,
"completed": 335
},
"tasksTable": [
{
"taskId": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных",
"totalAttempts": 45,
"uniqueUsers": 20,
"acceptedCount": 18,
"successRate": 90,
"averageAttemptsToSuccess": 2.1
}
],
"activeParticipants": [
{
"userId": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15", // ✨ Новое поле
"totalSubmissions": 25,
"completedTasks": 12,
"chainProgress": [
{
"chainId": "507f1f77bcf86cd799439011",
"chainName": "Основы Python",
"totalTasks": 10,
"completedTasks": 8,
"progressPercent": 80
}
]
}
],
"chainsDetailed": [
{
"chainId": "507f1f77bcf86cd799439011",
"name": "Основы Python",
"totalTasks": 10,
"tasks": [
{
"taskId": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных",
"description": "Изучите основные типы данных..."
}
],
"participantProgress": [
{
"userId": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15", // ✨ Новое поле
"taskProgress": [
{
"taskId": "507f1f77bcf86cd799439012",
"taskTitle": "Переменные и типы данных",
"status": "completed"
}
],
"completedCount": 8,
"progressPercent": 80
}
]
}
]
}
}
```
#### Пример использования
```javascript
const response = await fetch('/challenge/stats/v2');
const data = await response.json();
// Создать карту класса с прогрессом
const classMap = data.result.activeParticipants.map(participant => ({
workplace: participant.workplaceNumber || 'Не указано',
student: participant.nickname,
progress: participant.completedTasks,
chains: participant.chainProgress
}));
// Отсортировать по номеру места
classMap.sort((a, b) => {
const numA = parseInt(a.workplace.replace(/\D/g, '')) || 0;
const numB = parseInt(b.workplace.replace(/\D/g, '')) || 0;
return numA - numB;
});
// Визуализация карты класса
classMap.forEach(item => {
console.log(`[${item.workplace}] ${item.student}: ${item.progress} заданий`);
});
// Вывод:
// [PC-15] student_ivan: 12 заданий
// [PC-20] student_maria: 15 заданий
```
---
## Примеры интеграции на фронтенде
### Компонент авторизации (React)
```jsx
import { useState } from 'react';
function LoginForm() {
const [nickname, setNickname] = useState('');
const [workplaceNumber, setWorkplaceNumber] = useState('');
const handleLogin = async (e) => {
e.preventDefault();
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname,
workplaceNumber: workplaceNumber || undefined
})
});
const data = await response.json();
if (data.result.ok) {
localStorage.setItem('userId', data.result.userId);
localStorage.setItem('nickname', nickname);
localStorage.setItem('workplaceNumber', workplaceNumber);
// Перенаправление на главную страницу
}
};
return (
<form onSubmit={handleLogin}>
<input
type="text"
placeholder="Никнейм"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
required
/>
<input
type="text"
placeholder="Номер компьютера (опционально)"
value={workplaceNumber}
onChange={(e) => setWorkplaceNumber(e.target.value)}
/>
<button type="submit">Войти</button>
</form>
);
}
```
### Отображение карты класса (React)
```jsx
function ClassroomMap({ chainId }) {
const [participants, setParticipants] = useState([]);
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`/challenge/chain/${chainId}/submissions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
const data = await response.json();
setParticipants(data.result.participants);
};
fetchData();
}, [chainId]);
return (
<div className="classroom-map">
<h2>Карта класса</h2>
<div className="grid">
{participants.map(participant => (
<div
key={participant.userId}
className="student-card"
>
<div className="workplace-badge">
{participant.workplaceNumber || 'N/A'}
</div>
<div className="student-info">
<strong>{participant.nickname}</strong>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${participant.progressPercent}%` }}
/>
</div>
<small>
{participant.completedTasks} / {participant.totalTasks} заданий
</small>
</div>
</div>
))}
</div>
</div>
);
}
```
### TypeScript интерфейсы
```typescript
// Типы для работы с новым API
interface AuthRequest {
nickname: string;
workplaceNumber?: string;
}
interface AuthResponse {
ok: boolean;
userId: string;
}
interface UserInfo {
id: string;
nickname: string;
workplaceNumber?: string; // ✨ Новое поле
}
interface Participant {
userId: string;
nickname: string;
workplaceNumber?: string; // ✨ Новое поле
completedTasks: number;
totalTasks: number;
progressPercent: number;
}
interface Submission {
id: string;
user: UserInfo; // Содержит workplaceNumber
task: {
id: string;
title: string;
};
status: 'pending' | 'in_progress' | 'accepted' | 'needs_revision';
attemptNumber: number;
submittedAt: string;
checkedAt?: string;
feedback?: string;
}
interface ChainSubmissionsResponse {
chain: {
id: string;
name: string;
tasks: Array<{ id: string; title: string }>;
};
participants: Participant[];
submissions: Submission[];
pagination: {
total: number;
limit: number;
offset: number;
};
}
```
---
## Миграция существующего кода
### До (без workplaceNumber)
```javascript
// Старый код авторизации
await fetch('/challenge/auth', {
method: 'POST',
body: JSON.stringify({ nickname: 'student_ivan' })
});
// Старое отображение участников
participants.forEach(p => {
console.log(`${p.nickname}: ${p.progressPercent}%`);
});
```
### После (с workplaceNumber)
```javascript
// Новый код авторизации с местом
await fetch('/challenge/auth', {
method: 'POST',
body: JSON.stringify({
nickname: 'student_ivan',
workplaceNumber: 'PC-15' // ✨ Добавлено
})
});
// Новое отображение участников
participants.forEach(p => {
const workplace = p.workplaceNumber ? `[${p.workplaceNumber}] ` : '';
console.log(`${workplace}${p.nickname}: ${p.progressPercent}%`);
// Вывод: "[PC-15] student_ivan: 50%"
});
```
---
## Обратная совместимость
**Все изменения обратно совместимы:**
- Поле `workplaceNumber` опционально при авторизации
- Старый код без `workplaceNumber` продолжит работать
- Если `workplaceNumber` не указан, в ответах будет `undefined`
- Поиск пользователей по-прежнему работает только по `nickname`
---
## Рекомендации
1. **При авторизации**: Всегда передавайте `workplaceNumber`, если он известен (например, определяйте автоматически по IP или позволяйте ученику выбрать)
2. **В UI**: Отображайте номер места рядом с именем ученика для удобства преподавателя
3. **Сортировка**: При отображении списка учеников сортируйте по `workplaceNumber` для соответствия физическому расположению
4. **Валидация**: Проверяйте формат `workplaceNumber` на фронте (например, "PC-01", "Место 15")
5. **Обновление**: Если ученик пересел, просто авторизуйтесь с новым `workplaceNumber` - значение автоматически обновится
---
## Вопросы и поддержка
При возникновении вопросов обращайтесь к бэкенд-команде или создавайте issue в репозитории проекта.
+3 -2
View File
@@ -18,11 +18,12 @@ 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' },
'submissions-polling-interval-ms': { value: '1200' },
},
},
config: {
+236
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 часов (включая тесты)
+202
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 автоматически
+13 -11
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`, без записи прогресса, сразу возвращает результат проверки с учётом временных инструкций.
+33
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",
@@ -140,6 +158,7 @@
"challenge.admin.users.empty.description": "Users will appear after registration",
"challenge.admin.users.search.empty": "Nothing found for \"{query}\"",
"challenge.admin.users.table.nickname": "Nickname",
"challenge.admin.users.table.workplace": "Workplace",
"challenge.admin.users.table.id": "ID",
"challenge.admin.users.table.registered": "Registration date",
"challenge.admin.users.table.actions": "Actions",
@@ -166,8 +185,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",
@@ -179,6 +211,7 @@
"challenge.admin.submissions.search.empty.title": "Nothing found",
"challenge.admin.submissions.search.empty.description": "Try changing filters",
"challenge.admin.submissions.table.user": "User",
"challenge.admin.submissions.table.workplace": "Workplace",
"challenge.admin.submissions.table.task": "Task",
"challenge.admin.submissions.table.status": "Status",
"challenge.admin.submissions.table.attempt": "Attempt",
+33 -1
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": "Не удалось загрузить статистику системы",
@@ -139,6 +157,7 @@
"challenge.admin.users.empty.description": "Пользователи появятся после регистрации",
"challenge.admin.users.search.empty": "По запросу \"{query}\" ничего не найдено",
"challenge.admin.users.table.nickname": "Nickname",
"challenge.admin.users.table.workplace": "Место",
"challenge.admin.users.table.id": "ID",
"challenge.admin.users.table.registered": "Дата регистрации",
"challenge.admin.users.table.actions": "Действия",
@@ -165,9 +184,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": "Принято",
@@ -179,6 +210,7 @@
"challenge.admin.submissions.search.empty.title": "Ничего не найдено",
"challenge.admin.submissions.search.empty.description": "Попробуйте изменить фильтры",
"challenge.admin.submissions.table.user": "Пользователь",
"challenge.admin.submissions.table.workplace": "Место",
"challenge.admin.submissions.table.task": "Задание",
"challenge.admin.submissions.table.status": "Статус",
"challenge.admin.submissions.table.attempt": "Попытка",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "challenge-admin-pl",
"version": "1.1.0",
"version": "1.5.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "challenge-admin-pl",
"version": "1.1.0",
"version": "1.5.2",
"license": "ISC",
"dependencies": {
"@brojs/cli": "^1.9.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "challenge-admin",
"version": "1.1.0",
"version": "1.5.2",
"description": "",
"main": "./src/index.tsx",
"scripts": {
+39 -3
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
+2 -2
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
})
+5 -3
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',
}
+87
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>
)
}
+10 -2
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>
+116
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>
)
}
+8
View File
@@ -130,6 +130,14 @@ export const Dashboard = () => {
</PageWrapper>
}
/>
<Route
path={URLs.submissionsChainPath}
element={
<PageWrapper>
<SubmissionsPage />
</PageWrapper>
}
/>
<Route
path={URLs.submissionDetailsPath}
element={
+39 -17
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>
)
@@ -49,6 +49,11 @@ export const ParticipantsProgress: React.FC<ParticipantsProgressProps> = ({ part
<VStack align="stretch" gap={3}>
{/* Participant Header */}
<Box>
{participant.workplaceNumber && (
<Text fontSize="xs" color="gray.500" mb={1}>
{participant.workplaceNumber}
</Text>
)}
<Text fontSize="lg" fontWeight="bold" color="teal.700">
{participant.nickname}
</Text>
@@ -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)
}
+339 -111
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,61 @@ 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 flags
const featureValue = getFeatureValue('challenge-admin', 'use-chain-submissions-api')
const useNewApi = featureValue?.value === 'true'
const pollingIntervalFeatureValue = getFeatureValue(
'challenge-admin',
'submissions-polling-interval-ms'
)
const pollingIntervalMs = (() => {
const rawValue = pollingIntervalFeatureValue?.value ?? ''
const parsed = Number.parseInt(rawValue, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1200
})()
// Состояние для выбранного пользователя и фильтров
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,
pollingInterval: pollingIntervalMs,
}
)
// Старый API: получаем общую статистику и submissions отдельно
const {
data: stats,
isLoading: isStatsLoading,
error: statsError,
refetch: refetchStats,
} = useGetSystemStatsV2Query(undefined, {
skip: !chainId || useNewApi,
})
const {
data: submissions,
@@ -48,36 +102,78 @@ 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 = () => {
refetchChains()
if (chainId) {
if (useNewApi) {
refetchChainData()
} else {
refetchStats()
if (selectedUserId) {
refetchSubmissions()
}
}
if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.submissions.loading')} />
}
}
if (error || !stats) {
return <ErrorAlert message={t('challenge.admin.submissions.load.error')} onRetry={handleRetry} />
}
// Получаем данные выбранной цепочки из списка chains (для старого API)
const selectedChain = useMemo(() => {
if (!chainId || !chains) return null
return chains.find((c) => c.id === chainId) || null
}, [chainId, chains])
const participants: ActiveParticipant[] = stats.activeParticipants || []
const submissionsList: ChallengeSubmission[] = submissions || []
// Получаем taskIds из текущей цепочки (для старого API)
const chainTaskIds = useMemo(() => {
if (!selectedChain) return new Set<string>()
return new Set(selectedChain.tasks.map((t) => t.id))
}, [selectedChain])
// Старый API: фильтруем участников - только те, кто имеет прогресс в этой цепочке
const chainParticipantsOld = useMemo(() => {
if (!stats?.activeParticipants || !chainId || useNewApi) return []
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])
// Старый API: фильтруем submissions только по заданиям из текущей цепочки
const filteredSubmissionsOld = useMemo(() => {
if (!submissions || chainTaskIds.size === 0 || useNewApi) return []
const normalizedSearchQuery = (searchQuery ?? '').toLowerCase()
const filteredSubmissions = submissionsList.filter((submission) => {
const rawUser = submission.user as ChallengeUser | string | undefined
return submissions.filter((submission) => {
const rawTask = submission.task as ChallengeTask | string | undefined
const taskId =
rawTask && typeof rawTask === 'object' && 'id' in rawTask
? rawTask.id
: typeof rawTask === 'string'
? rawTask
: ''
if (!chainTaskIds.has(taskId)) return false
const rawUser = submission.user as ChallengeUser | string | undefined
const nickname =
rawUser && typeof rawUser === 'object' && 'nickname' in rawUser
? (rawUser.nickname ?? '')
@@ -96,6 +192,52 @@ export const SubmissionsPage: React.FC = () => {
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 +267,133 @@ export const SubmissionsPage: React.FC = () => {
],
})
const userOptions = createListCollection({
items: participants.map((participant) => ({
label: `${participant.nickname} (${participant.userId})`,
value: participant.userId,
})),
})
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,
if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.submissions.loading')} />
}
})
.sort((a, b) => a.overallPercent - b.overallPercent)
if (error) {
return <ErrorAlert message={t('challenge.admin.submissions.load.error')} onRetry={handleRetry} />
}
// Если chainId не указан - показываем выбор цепочки
if (!chainId) {
return (
<Box>
<Box mb={6}>
<Heading mb={2}>{t('challenge.admin.submissions.title')}</Heading>
<Text color="gray.600" fontSize="sm">
{t('challenge.admin.submissions.select.chain')}
</Text>
</Box>
{chains && chains.length > 0 ? (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} gap={6}>
{chains.map((chain) => (
<Link key={chain.id} to={URLs.submissionsChain(chain.id)} style={{ textDecoration: 'none' }}>
<Box
p={6}
bg="white"
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
_hover={{
boxShadow: 'md',
borderColor: 'teal.400',
transform: 'translateY(-2px)',
}}
transition="all 0.2s"
cursor="pointer"
height="100%"
>
<VStack align="start" gap={3}>
<Heading size="md" color="teal.600">
{chain.name}
</Heading>
<HStack>
<Badge colorPalette="teal" size="lg">
{chain.tasks.length} {t('challenge.admin.submissions.chain.tasks')}
</Badge>
{!chain.isActive && (
<Badge colorPalette="gray" size="lg">
{t('challenge.admin.chains.list.status.inactive')}
</Badge>
)}
</HStack>
<Text fontSize="sm" color="gray.600" mt={2}>
{t('challenge.admin.submissions.chain.click')}
</Text>
</VStack>
</Box>
</Link>
))}
</SimpleGrid>
) : (
<EmptyState
title={t('challenge.admin.submissions.no.chains.title')}
description={t('challenge.admin.submissions.no.chains.description')}
/>
)}
</Box>
)
}
// Если цепочка выбрана но данных нет
if (useNewApi && !chainData) {
return (
<Box>
<Link to={URLs.submissions} style={{ textDecoration: 'none', color: '#319795' }}>
<Text fontSize="sm" _hover={{ textDecoration: 'underline' }} mb={4}>
{t('challenge.admin.submissions.back.to.chains')}
</Text>
</Link>
<ErrorAlert message={t('challenge.admin.common.not.found')} onRetry={handleRetry} />
</Box>
)
}
if (!useNewApi && !selectedChain) {
return (
<Box>
<Link to={URLs.submissions} style={{ textDecoration: 'none', color: '#319795' }}>
<Text fontSize="sm" _hover={{ textDecoration: 'underline' }} mb={4}>
{t('challenge.admin.submissions.back.to.chains')}
</Text>
</Link>
<ErrorAlert message={t('challenge.admin.common.not.found')} onRetry={handleRetry} />
</Box>
)
}
const chainName = useNewApi ? chainData?.chain.name : selectedChain?.name
const chainTasksCount = useNewApi ? chainData?.chain.tasks.length : selectedChain?.tasks.length
return (
<Box>
<Heading mb={6}>{t('challenge.admin.submissions.title')}</Heading>
{/* Header с навигацией */}
<Box mb={6}>
<HStack gap={2} mb={2}>
<Link to={URLs.submissions} style={{ textDecoration: 'none', color: '#319795' }}>
<Text fontSize="sm" _hover={{ textDecoration: 'underline' }}>
{t('challenge.admin.submissions.back.to.chains')}
</Text>
</Link>
</HStack>
<Heading mb={2}>{chainName}</Heading>
<Text color="gray.600" fontSize="sm">
{t('challenge.admin.submissions.chain.description', { count: chainTasksCount ?? 0 })}
</Text>
</Box>
{/* Filters */}
{hasParticipants && (
{/* Выбор участника и фильтры */}
{sortedParticipants.length > 0 && (
<VStack mb={4} gap={3} align="stretch">
<HStack gap={4} 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 +404,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 +435,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 +458,57 @@ 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>
<HStack justify="space-between" mb={2} gap={2}>
<VStack align="start" gap={0}>
{participant.workplaceNumber && (
<Text fontSize="xs" color="gray.500">
{row.overallPercent}%
{participant.workplaceNumber}
</Text>
)}
<Text fontSize="sm" fontWeight="medium" truncate maxW="180px">
{participant.nickname}
</Text>
</VStack>
<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}
<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,17 +521,21 @@ 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>
<Table.Row>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.user')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.workplace')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.task')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.status')}</Table.ColumnHeader>
<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>
@@ -331,6 +550,11 @@ export const SubmissionsPage: React.FC = () => {
? rawUser
: ''
const workplaceNumber =
rawUser && typeof rawUser === 'object' && 'workplaceNumber' in rawUser
? rawUser.workplaceNumber ?? ''
: ''
const title =
rawTask && typeof rawTask === 'object' && 'title' in rawTask
? (rawTask.title ?? '')
@@ -341,6 +565,11 @@ export const SubmissionsPage: React.FC = () => {
return (
<Table.Row key={submission.id}>
<Table.Cell fontWeight="medium">{nickname}</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{workplaceNumber || '—'}
</Text>
</Table.Cell>
<Table.Cell>{title}</Table.Cell>
<Table.Cell>
<StatusBadge status={submission.status} />
@@ -365,7 +594,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 +609,3 @@ export const SubmissionsPage: React.FC = () => {
</Box>
)
}
+225 -1
View File
@@ -26,6 +26,20 @@ import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { toaster } from '../../components/ui/toaster'
// Функция для разбиения текста на блоки по 30 строк
const splitTextIntoBlocks = (text: string, linesPerBlock: number = 30): string[] => {
if (!text) return []
const lines = text.split('\n')
const blocks: string[] = []
for (let i = 0; i < lines.length; i += linesPerBlock) {
const block = lines.slice(i, i + linesPerBlock).join('\n')
blocks.push(block)
}
return blocks
}
export const TaskFormPage: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
@@ -40,6 +54,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 +65,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 +122,7 @@ export const TaskFormPage: React.FC = () => {
data: {
title: title.trim(),
description: description.trim(),
learningMaterial: learningMaterial.trim() || undefined,
hiddenInstructions: hiddenInstructions.trim() || undefined,
},
}).unwrap()
@@ -85,6 +135,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 +144,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 +187,7 @@ export const TaskFormPage: React.FC = () => {
taskId: task.id,
result: testAnswer.trim(),
isTest: true,
hiddenInstructions: hiddenInstructions.trim() || undefined,
}).unwrap()
setTestStatus(result.status)
@@ -321,6 +373,178 @@ 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 ? (
<VStack align="stretch" gap={4}>
{splitTextIntoBlocks(learningMaterial, 30).map((block, index) => (
<Box
key={index}
p={4}
borderWidth="2px"
borderColor="teal.200"
borderRadius="md"
bg="white"
position="relative"
className="markdown-preview"
css={{
'& a': {
color: '#0f766e',
textDecoration: 'underline',
cursor: 'pointer',
'&:hover': {
color: '#115e59',
}
}
}}
>
<Box
position="absolute"
top="-10px"
left="10px"
bg="teal.500"
color="white"
px={2}
py={1}
borderRadius="md"
fontSize="xs"
fontWeight="bold"
>
Блок {index + 1} (30 строк)
</Box>
<ReactMarkdown>{block}</ReactMarkdown>
</Box>
))}
</VStack>
) : (
<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 ? (
<VStack align="stretch" gap={4}>
{splitTextIntoBlocks(learningMaterial, 30).map((block, index) => (
<Box
key={index}
p={4}
borderWidth="2px"
borderColor="teal.200"
borderRadius="md"
bg="white"
position="relative"
className="markdown-preview"
css={{
'& a': {
color: '#0f766e',
textDecoration: 'underline',
cursor: 'pointer',
'&:hover': {
color: '#115e59',
}
}
}}
>
<Box
position="absolute"
top="-10px"
left="10px"
bg="teal.500"
color="white"
px={2}
py={1}
borderRadius="md"
fontSize="xs"
fontWeight="bold"
>
Блок {index + 1} (30 строк)
</Box>
<ReactMarkdown>{block}</ReactMarkdown>
</Box>
))}
</VStack>
) : (
<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">
+9 -18
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>
)
}
+6
View File
@@ -61,6 +61,7 @@ export const UsersPage: React.FC = () => {
<Table.Header>
<Table.Row>
<Table.ColumnHeader>{t('challenge.admin.users.table.nickname')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.users.table.workplace')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.users.table.id')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.users.stats.total.submissions')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.users.stats.completed')}</Table.ColumnHeader>
@@ -71,6 +72,11 @@ export const UsersPage: React.FC = () => {
{filteredUsers.map((user) => (
<Table.Row key={user.userId}>
<Table.Cell fontWeight="medium">{user.nickname}</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{user.workplaceNumber || '—'}
</Text>
</Table.Cell>
<Table.Cell>
<Text fontSize="xs" fontFamily="monospace" color="gray.600">
{user.userId}
+44
View File
@@ -4,6 +4,7 @@ export interface ChallengeUser {
_id: string
id: string
nickname: string
workplaceNumber?: string
createdAt: string
}
@@ -12,6 +13,7 @@ export interface ChallengeTask {
id: string
title: string
description: string // Markdown
learningMaterial?: string // Дополнительный учебный материал в Markdown
hiddenInstructions?: string // Только для преподавателей
creator?: {
sub: string
@@ -121,12 +123,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 +146,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'
@@ -167,6 +181,7 @@ export interface ChainProgress {
export interface ActiveParticipant {
userId: string
nickname: string
workplaceNumber?: string
totalSubmissions: number
completedTasks: number
chainProgress: ChainProgress[]
@@ -181,6 +196,7 @@ export interface TaskProgress {
export interface ParticipantProgress {
userId: string
nickname: string
workplaceNumber?: string
taskProgress: TaskProgress[]
completedCount: number
progressPercent: number
@@ -234,6 +250,8 @@ export interface SubmitRequest {
result: string
// Флаг тестового режима: проверка без создания Submission и очереди
isTest?: boolean
// Временные скрытые инструкции для тестовой проверки (не сохраняются в задачу)
hiddenInstructions?: string
}
export interface TestSubmissionResult {
@@ -242,3 +260,29 @@ export interface TestSubmissionResult {
feedback?: string
}
// ========== Chain Submissions API ==========
export interface ChainSubmissionsParticipant {
userId: string
nickname: string
workplaceNumber?: 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
}
}
+19
View File
@@ -203,6 +203,7 @@
{
"userId": "6909b51512c75d75a36a52bf",
"nickname": "Примаков А.А.",
"workplaceNumber": "PC-07",
"totalSubmissions": 14,
"completedTasks": 1,
"chainProgress": [
@@ -225,6 +226,7 @@
{
"userId": "user_1",
"nickname": "alex_dev",
"workplaceNumber": "PC-01",
"totalSubmissions": 18,
"completedTasks": 12,
"chainProgress": [
@@ -247,6 +249,7 @@
{
"userId": "user_2",
"nickname": "maria_coder",
"workplaceNumber": "PC-05",
"totalSubmissions": 15,
"completedTasks": 9,
"chainProgress": [
@@ -269,6 +272,7 @@
{
"userId": "user_3",
"nickname": "ivan_programmer",
"workplaceNumber": "PC-12",
"totalSubmissions": 10,
"completedTasks": 5,
"chainProgress": [
@@ -291,6 +295,7 @@
{
"userId": "user_4",
"nickname": "kate_fullstack",
"workplaceNumber": "PC-03",
"totalSubmissions": 22,
"completedTasks": 15,
"chainProgress": [
@@ -313,6 +318,7 @@
{
"userId": "user_5",
"nickname": "dmitry_backend",
"workplaceNumber": "PC-15",
"totalSubmissions": 12,
"completedTasks": 6,
"chainProgress": [
@@ -335,6 +341,7 @@
{
"userId": "user_6",
"nickname": "anna_react",
"workplaceNumber": "PC-08",
"totalSubmissions": 14,
"completedTasks": 7,
"chainProgress": [
@@ -376,6 +383,7 @@
{
"userId": "user_1",
"nickname": "alex_dev",
"workplaceNumber": "PC-01",
"taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -394,6 +402,7 @@
{
"userId": "user_2",
"nickname": "maria_coder",
"workplaceNumber": "PC-05",
"taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -412,6 +421,7 @@
{
"userId": "user_3",
"nickname": "ivan_programmer",
"workplaceNumber": "PC-12",
"taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -430,6 +440,7 @@
{
"userId": "user_4",
"nickname": "kate_fullstack",
"workplaceNumber": "PC-03",
"taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -448,6 +459,7 @@
{
"userId": "user_5",
"nickname": "dmitry_backend",
"workplaceNumber": "PC-15",
"taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -466,6 +478,7 @@
{
"userId": "user_6",
"nickname": "anna_react",
"workplaceNumber": "PC-08",
"taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "pending" },
@@ -503,6 +516,7 @@
{
"userId": "user_1",
"nickname": "alex_dev",
"workplaceNumber": "PC-01",
"taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -521,6 +535,7 @@
{
"userId": "user_2",
"nickname": "maria_coder",
"workplaceNumber": "PC-05",
"taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -539,6 +554,7 @@
{
"userId": "user_3",
"nickname": "ivan_programmer",
"workplaceNumber": "PC-12",
"taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -557,6 +573,7 @@
{
"userId": "user_4",
"nickname": "kate_fullstack",
"workplaceNumber": "PC-03",
"taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -575,6 +592,7 @@
{
"userId": "user_5",
"nickname": "dmitry_backend",
"workplaceNumber": "PC-15",
"taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "pending" },
@@ -593,6 +611,7 @@
{
"userId": "user_6",
"nickname": "anna_react",
"workplaceNumber": "PC-08",
"taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
+7
View File
@@ -6,6 +6,7 @@
"_id": "user001",
"id": "user001",
"nickname": "alex_student",
"workplaceNumber": "PC-01",
"createdAt": "2024-10-15T08:30:00.000Z"
},
"task": {
@@ -31,6 +32,7 @@
"_id": "user001",
"id": "user001",
"nickname": "alex_student",
"workplaceNumber": "PC-01",
"createdAt": "2024-10-15T08:30:00.000Z"
},
"task": {
@@ -56,6 +58,7 @@
"_id": "user002",
"id": "user002",
"nickname": "maria_dev",
"workplaceNumber": "PC-05",
"createdAt": "2024-10-16T10:15:00.000Z"
},
"task": {
@@ -81,6 +84,7 @@
"_id": "user003",
"id": "user003",
"nickname": "ivan_coder",
"workplaceNumber": "PC-12",
"createdAt": "2024-10-17T14:20:00.000Z"
},
"task": {
@@ -106,6 +110,7 @@
"_id": "user004",
"id": "user004",
"nickname": "olga_js",
"workplaceNumber": "PC-03",
"createdAt": "2024-10-18T09:00:00.000Z"
},
"task": {
@@ -131,6 +136,7 @@
"_id": "user005",
"id": "user005",
"nickname": "dmitry_react",
"workplaceNumber": "PC-15",
"createdAt": "2024-10-20T11:45:00.000Z"
},
"task": {
@@ -156,6 +162,7 @@
"_id": "user006",
"id": "user006",
"nickname": "anna_frontend",
"workplaceNumber": "PC-08",
"createdAt": "2024-10-22T16:30:00.000Z"
},
"task": {
+7
View File
@@ -3,36 +3,42 @@
"_id": "user001",
"id": "user001",
"nickname": "alex_student",
"workplaceNumber": "PC-01",
"createdAt": "2024-10-15T08:30:00.000Z"
},
{
"_id": "user002",
"id": "user002",
"nickname": "maria_dev",
"workplaceNumber": "PC-05",
"createdAt": "2024-10-16T10:15:00.000Z"
},
{
"_id": "user003",
"id": "user003",
"nickname": "ivan_coder",
"workplaceNumber": "PC-12",
"createdAt": "2024-10-17T14:20:00.000Z"
},
{
"_id": "user004",
"id": "user004",
"nickname": "olga_js",
"workplaceNumber": "PC-03",
"createdAt": "2024-10-18T09:00:00.000Z"
},
{
"_id": "user005",
"id": "user005",
"nickname": "dmitry_react",
"workplaceNumber": "PC-15",
"createdAt": "2024-10-20T11:45:00.000Z"
},
{
"_id": "user006",
"id": "user006",
"nickname": "anna_frontend",
"workplaceNumber": "PC-08",
"createdAt": "2024-10-22T16:30:00.000Z"
},
{
@@ -45,6 +51,7 @@
"_id": "user008",
"id": "user008",
"nickname": "elena_fullstack",
"workplaceNumber": "PC-20",
"createdAt": "2024-10-28T10:00:00.000Z"
}
]
+219 -3
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,123 @@ 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 : '';
const subUserWorkplaceNumber = typeof sub.user === 'object' ? sub.user.workplaceNumber : undefined;
// Найти nickname и workplaceNumber если не заполнены
let nickname = subUserNickname;
let workplaceNumber = subUserWorkplaceNumber;
if (!nickname || !workplaceNumber) {
const user = users.find(u => u.id === subUserId);
if (user) {
nickname = nickname || user.nickname || subUserId;
workplaceNumber = workplaceNumber || user.workplaceNumber;
}
}
if (!participantMap.has(subUserId)) {
participantMap.set(subUserId, {
userId: subUserId,
nickname: nickname,
workplaceNumber: workplaceNumber,
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,
workplaceNumber: p.workplaceNumber,
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;