23 Commits
v1.1.0 ... main

Author SHA1 Message Date
d983e1dce7 Enhance authentication flow by adding token refresh mechanism and improving error handling. Implement checks to prevent authentication loops during API calls and ensure token is updated before requests. This improves user experience and security in the application. 2025-12-18 12:11:31 +03:00
b2f947e699 1.5.2 2025-12-15 21:54:39 +03:00
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
9bc5225c27 1.5.1 2025-12-15 21:25:02 +03:00
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
833d1cc14f 1.5.0 2025-12-14 21:16:58 +03:00
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
7dab439f3a 1.4.0 2025-12-14 20:33:46 +03:00
c784626b33 Update SubmissionsPage to include polling interval for API requests, enhancing data retrieval efficiency. 2025-12-14 20:33:36 +03:00
6b7c773977 1.3.1 2025-12-14 16:33:15 +03:00
a748e608cf Comment out navigation to tasks after successful form submission in TaskFormPage to prevent unintended redirects during testing. 2025-12-14 16:25:35 +03:00
d0e26b02c7 1.3.0 2025-12-14 15:43:08 +03:00
4aae3c154e Add optional learningMaterial field to ChallengeTask model for additional educational content; update API endpoints, TypeScript interfaces, and frontend forms to support this feature. Enhance localization for English and Russian to include new field descriptions and placeholders. 2025-12-14 15:02:43 +03:00
e93de750fc 1.2.0 2025-12-14 14:47:50 +03:00
5f41c4a943 Enhance dialog components by adding smooth scroll to top functionality upon opening; update ConfirmDialog, ClearSubmissionsDialog, and DuplicateChainDialog for improved user experience. Remove unused ConfirmDialog from ChainsListPage and TasksListPage, streamlining code. 2025-12-14 14:46:28 +03:00
1d364a2351 Refactor ClearSubmissionsDialog and DuplicateChainDialog components by removing unnecessary whitespace; improve code cleanliness and maintainability. 2025-12-14 13:00:57 +03:00
88b95a7651 Add duplicate and clear submissions functionality for challenge chains; implement corresponding dialogs and API endpoints, enhancing user experience and task management. Update localization for new features in English and Russian. 2025-12-13 21:32:22 +03:00
04836ea6ce Implement chain submissions API and update frontend to utilize new endpoint; enhance submissions page with feature flag for API selection, participant progress display, and improved filtering logic. 2025-12-13 20:32:23 +03:00
18e2ccb6bc Add new API endpoint for retrieving submissions by challenge chain; update frontend to support chain selection and display participant progress. Enhance localization for submissions page in English and Russian. 2025-12-13 20:16:40 +03:00
9104280325 Update challengePlayer URL key in URLs data structure to use 'link.challenge.main' for improved navigation consistency. 2025-12-13 19:59:35 +03:00
d1bddcf972 Add functionality to restore and save test answers in localStorage for task editing; enhance user experience by preserving input across sessions. 2025-12-10 15:36:13 +03:00
86dffc802b Refactor API response handling in test submission feature to align with server response structure; update ChainsListPage to use 'disabled' prop for button state instead of 'isDisabled', enhancing code clarity and consistency. 2025-12-10 15:13:05 +03:00
7b9cb044fa Enhance test submission feature by adding optional hiddenInstructions field for temporary instructions during LLM checks; update API, UI components, and types to support this functionality, improving task evaluation for teachers and challenge authors. 2025-12-10 14:50:17 +03:00
29 changed files with 2349 additions and 202 deletions

582
WORKPLACE_NUMBER_API.md Normal file
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 в репозитории проекта.

View File

@@ -18,11 +18,12 @@ module.exports = {
/* use https://admin.bro-js.ru/ to create config, navigations and features */ /* use https://admin.bro-js.ru/ to create config, navigations and features */
navigations: { navigations: {
'challenge-admin.main': '/challenge-admin', 'challenge-admin.main': '/challenge-admin',
'link.challenge': '/challenge', 'link.challenge.main': '/challenge',
}, },
features: { features: {
'challenge-admin': { '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: { config: {

View File

@@ -0,0 +1,236 @@
# Техническое задание: Эндпоинт получения попыток по цепочке
## Цель
Создать новый API эндпоинт для получения списка попыток (submissions) участников в рамках конкретной цепочки заданий. Это упростит работу админ-панели и уменьшит объём передаваемых данных.
## Текущая проблема
Сейчас для отображения попыток по цепочке фронтенд должен:
1. Загрузить список цепочек (`GET /challenge/chains/admin`)
2. Загрузить общую статистику (`GET /challenge/stats/v2`)
3. Для каждого участника отдельно загрузить его submissions (`GET /challenge/user/:userId/submissions`)
4. На клиенте фильтровать submissions по taskIds из выбранной цепочки
Это создаёт избыточные запросы и усложняет логику на фронтенде.
---
## Новый эндпоинт
### `GET /challenge/chain/:chainId/submissions`
Возвращает все попытки всех участников для заданий из указанной цепочки.
### Параметры URL
| Параметр | Тип | Обязательный | Описание |
|----------|-----|--------------|----------|
| `chainId` | string | Да | ID цепочки заданий |
### Query параметры (опциональные)
| Параметр | Тип | По умолчанию | Описание |
|----------|-----|--------------|----------|
| `userId` | string | - | Фильтр по конкретному пользователю |
| `status` | string | - | Фильтр по статусу: `pending`, `in_progress`, `accepted`, `needs_revision` |
| `limit` | number | 100 | Лимит записей |
| `offset` | number | 0 | Смещение для пагинации |
### Формат ответа
```typescript
interface ChainSubmissionsResponse {
success: boolean;
body: {
chain: {
id: string;
name: string;
tasks: Array<{
id: string;
title: string;
}>;
};
participants: Array<{
userId: string;
nickname: string;
completedTasks: number;
totalTasks: number;
progressPercent: number;
}>;
submissions: Array<{
id: string;
user: {
id: string;
nickname: string;
};
task: {
id: string;
title: string;
};
status: 'pending' | 'in_progress' | 'accepted' | 'needs_revision';
attemptNumber: number;
submittedAt: string; // ISO date
checkedAt?: string; // ISO date
feedback?: string;
}>;
pagination: {
total: number;
limit: number;
offset: number;
};
};
}
```
### Пример запроса
```bash
GET /api/challenge/chain/607f1f77bcf86cd799439021/submissions?status=needs_revision&limit=50
```
### Пример ответа
```json
{
"success": true,
"body": {
"chain": {
"id": "607f1f77bcf86cd799439021",
"name": "Основы JavaScript",
"tasks": [
{ "id": "507f1f77bcf86cd799439011", "title": "Реализовать сортировку массива" },
{ "id": "507f1f77bcf86cd799439015", "title": "Валидация формы" }
]
},
"participants": [
{
"userId": "user_123",
"nickname": "alex_dev",
"completedTasks": 1,
"totalTasks": 2,
"progressPercent": 50
},
{
"userId": "user_456",
"nickname": "maria_coder",
"completedTasks": 2,
"totalTasks": 2,
"progressPercent": 100
}
],
"submissions": [
{
"id": "sub_001",
"user": {
"id": "user_123",
"nickname": "alex_dev"
},
"task": {
"id": "507f1f77bcf86cd799439011",
"title": "Реализовать сортировку массива"
},
"status": "needs_revision",
"attemptNumber": 2,
"submittedAt": "2024-12-10T14:30:00.000Z",
"checkedAt": "2024-12-10T14:30:45.000Z",
"feedback": "Алгоритм работает неверно для отрицательных чисел"
}
],
"pagination": {
"total": 15,
"limit": 50,
"offset": 0
}
}
}
```
---
## Логика на бэкенде
### Алгоритм
1. Получить цепочку по `chainId`
2. Если цепочка не найдена — вернуть 404
3. Получить список `taskIds` из цепочки
4. Найти все submissions где `task._id` входит в `taskIds`
5. Применить фильтры (`userId`, `status`) если указаны
6. Вычислить прогресс по каждому участнику:
- Найти уникальных пользователей из submissions
- Для каждого посчитать `completedTasks` (количество уникальных tasks со статусом `accepted`)
- Рассчитать `progressPercent = (completedTasks / totalTasks) * 100`
7. Применить пагинацию к submissions
8. Вернуть результат
### Индексы MongoDB (рекомендуется)
```javascript
// Для быстрой выборки submissions по task
db.submissions.createIndex({ "task": 1, "submittedAt": -1 })
// Составной индекс для фильтрации
db.submissions.createIndex({ "task": 1, "status": 1, "submittedAt": -1 })
```
---
## Права доступа
Эндпоинт должен быть доступен только пользователям с ролями:
- `challenge-admin`
- `challenge-teacher`
---
## Коды ошибок
| Код | Описание |
|-----|----------|
| 200 | Успешный ответ |
| 400 | Некорректные параметры запроса |
| 401 | Не авторизован |
| 403 | Недостаточно прав |
| 404 | Цепочка не найдена |
| 500 | Внутренняя ошибка сервера |
---
## Изменения на фронтенде после реализации
После добавления эндпоинта в `src/__data__/api/api.ts` нужно добавить:
```typescript
// В endpoints builder
getChainSubmissions: builder.query<ChainSubmissionsResponse, {
chainId: string;
userId?: string;
status?: SubmissionStatus;
limit?: number;
offset?: number;
}>({
query: ({ chainId, userId, status, limit, offset }) => ({
url: `/challenge/chain/${chainId}/submissions`,
params: { userId, status, limit, offset },
}),
transformResponse: (response: { body: ChainSubmissionsResponse }) => response.body,
providesTags: ['Submission'],
}),
```
Это позволит упростить `SubmissionsPage.tsx`:
- Один запрос вместо нескольких
- Убрать клиентскую фильтрацию по taskIds
- Получать готовый прогресс участников
---
## Приоритет
**Средний** — текущая реализация работает, но создаёт избыточную нагрузку при большом количестве участников.
## Оценка трудозатрат
~4-6 часов (включая тесты)

View File

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

View File

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

View File

@@ -20,6 +20,9 @@
"challenge.admin.tasks.field.description": "Description (Markdown)", "challenge.admin.tasks.field.description": "Description (Markdown)",
"challenge.admin.tasks.field.description.placeholder": "# Task title\n\nTask description in Markdown format...", "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.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.editor": "Editor",
"challenge.admin.tasks.tab.preview": "Preview", "challenge.admin.tasks.tab.preview": "Preview",
"challenge.admin.tasks.preview.empty": "Preview will appear here...", "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.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.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.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.title": "Dashboard",
"challenge.admin.dashboard.loading": "Loading statistics...", "challenge.admin.dashboard.loading": "Loading statistics...",
"challenge.admin.dashboard.load.error": "Failed to load system 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.empty.description": "Users will appear after registration",
"challenge.admin.users.search.empty": "Nothing found for \"{query}\"", "challenge.admin.users.search.empty": "Nothing found for \"{query}\"",
"challenge.admin.users.table.nickname": "Nickname", "challenge.admin.users.table.nickname": "Nickname",
"challenge.admin.users.table.workplace": "Workplace",
"challenge.admin.users.table.id": "ID", "challenge.admin.users.table.id": "ID",
"challenge.admin.users.table.registered": "Registration date", "challenge.admin.users.table.registered": "Registration date",
"challenge.admin.users.table.actions": "Actions", "challenge.admin.users.table.actions": "Actions",
@@ -166,8 +185,21 @@
"challenge.admin.submissions.title": "Solution attempts", "challenge.admin.submissions.title": "Solution attempts",
"challenge.admin.submissions.loading": "Loading attempts...", "challenge.admin.submissions.loading": "Loading attempts...",
"challenge.admin.submissions.load.error": "Failed to load attempts list", "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.search.placeholder": "Search by user or task...",
"challenge.admin.submissions.filter.user": "Select user", "challenge.admin.submissions.filter.user": "Select user",
"challenge.admin.submissions.filter.user.clear": "← All participants",
"challenge.admin.submissions.filter.status": "Status", "challenge.admin.submissions.filter.status": "Status",
"challenge.admin.submissions.status.all": "All statuses", "challenge.admin.submissions.status.all": "All statuses",
"challenge.admin.submissions.status.accepted": "Accepted", "challenge.admin.submissions.status.accepted": "Accepted",
@@ -179,6 +211,7 @@
"challenge.admin.submissions.search.empty.title": "Nothing found", "challenge.admin.submissions.search.empty.title": "Nothing found",
"challenge.admin.submissions.search.empty.description": "Try changing filters", "challenge.admin.submissions.search.empty.description": "Try changing filters",
"challenge.admin.submissions.table.user": "User", "challenge.admin.submissions.table.user": "User",
"challenge.admin.submissions.table.workplace": "Workplace",
"challenge.admin.submissions.table.task": "Task", "challenge.admin.submissions.table.task": "Task",
"challenge.admin.submissions.table.status": "Status", "challenge.admin.submissions.table.status": "Status",
"challenge.admin.submissions.table.attempt": "Attempt", "challenge.admin.submissions.table.attempt": "Attempt",

View File

@@ -19,6 +19,9 @@
"challenge.admin.tasks.field.description": "Описание (Markdown)", "challenge.admin.tasks.field.description": "Описание (Markdown)",
"challenge.admin.tasks.field.description.placeholder": "# Заголовок задания\n\nОписание задания в формате Markdown...", "challenge.admin.tasks.field.description.placeholder": "# Заголовок задания\n\nОписание задания в формате Markdown...",
"challenge.admin.tasks.field.description.helper": "Используйте 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.editor": "Редактор",
"challenge.admin.tasks.tab.preview": "Превью", "challenge.admin.tasks.tab.preview": "Превью",
"challenge.admin.tasks.preview.empty": "Предпросмотр появится здесь...", "challenge.admin.tasks.preview.empty": "Предпросмотр появится здесь...",
@@ -111,6 +114,21 @@
"challenge.admin.chains.delete.confirm.title": "Удалить цепочку", "challenge.admin.chains.delete.confirm.title": "Удалить цепочку",
"challenge.admin.chains.delete.confirm.message": "Вы уверены, что хотите удалить цепочку \"{name}\"? Это действие нельзя отменить.", "challenge.admin.chains.delete.confirm.message": "Вы уверены, что хотите удалить цепочку \"{name}\"? Это действие нельзя отменить.",
"challenge.admin.chains.delete.confirm.button": "Удалить", "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.title": "Dashboard",
"challenge.admin.dashboard.loading": "Загрузка статистики...", "challenge.admin.dashboard.loading": "Загрузка статистики...",
"challenge.admin.dashboard.load.error": "Не удалось загрузить статистику системы", "challenge.admin.dashboard.load.error": "Не удалось загрузить статистику системы",
@@ -139,6 +157,7 @@
"challenge.admin.users.empty.description": "Пользователи появятся после регистрации", "challenge.admin.users.empty.description": "Пользователи появятся после регистрации",
"challenge.admin.users.search.empty": "По запросу \"{query}\" ничего не найдено", "challenge.admin.users.search.empty": "По запросу \"{query}\" ничего не найдено",
"challenge.admin.users.table.nickname": "Nickname", "challenge.admin.users.table.nickname": "Nickname",
"challenge.admin.users.table.workplace": "Место",
"challenge.admin.users.table.id": "ID", "challenge.admin.users.table.id": "ID",
"challenge.admin.users.table.registered": "Дата регистрации", "challenge.admin.users.table.registered": "Дата регистрации",
"challenge.admin.users.table.actions": "Действия", "challenge.admin.users.table.actions": "Действия",
@@ -165,9 +184,21 @@
"challenge.admin.submissions.title": "Попытки решений", "challenge.admin.submissions.title": "Попытки решений",
"challenge.admin.submissions.loading": "Загрузка попыток...", "challenge.admin.submissions.loading": "Загрузка попыток...",
"challenge.admin.submissions.load.error": "Не удалось загрузить список попыток", "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.search.placeholder": "Поиск по пользователю или заданию...",
"challenge.admin.submissions.filter.user": "Выберите пользователя", "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.filter.status": "Статус",
"challenge.admin.submissions.status.all": "Все статусы", "challenge.admin.submissions.status.all": "Все статусы",
"challenge.admin.submissions.status.accepted": "Принято", "challenge.admin.submissions.status.accepted": "Принято",
@@ -179,6 +210,7 @@
"challenge.admin.submissions.search.empty.title": "Ничего не найдено", "challenge.admin.submissions.search.empty.title": "Ничего не найдено",
"challenge.admin.submissions.search.empty.description": "Попробуйте изменить фильтры", "challenge.admin.submissions.search.empty.description": "Попробуйте изменить фильтры",
"challenge.admin.submissions.table.user": "Пользователь", "challenge.admin.submissions.table.user": "Пользователь",
"challenge.admin.submissions.table.workplace": "Место",
"challenge.admin.submissions.table.task": "Задание", "challenge.admin.submissions.table.task": "Задание",
"challenge.admin.submissions.table.status": "Статус", "challenge.admin.submissions.table.status": "Статус",
"challenge.admin.submissions.table.attempt": "Попытка", "challenge.admin.submissions.table.attempt": "Попытка",

4
package-lock.json generated
View File

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

View File

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

View File

@@ -13,9 +13,12 @@ import type {
UpdateTaskRequest, UpdateTaskRequest,
CreateChainRequest, CreateChainRequest,
UpdateChainRequest, UpdateChainRequest,
DuplicateChainRequest,
ClearSubmissionsResponse,
SubmitRequest, SubmitRequest,
TestSubmissionResult, TestSubmissionResult,
APIResponse, ChainSubmissionsResponse,
SubmissionStatus,
} from '../../types/challenge' } from '../../types/challenge'
export const api = createApi({ export const api = createApi({
@@ -28,15 +31,35 @@ export const api = createApi({
) => { ) => {
const response = await fetch(input, init) const response = await fetch(input, init)
if (response.status === 403) keycloak.login() if (response.status === 401 || response.status === 403) {
const { isAuthLoopBlocked, recordAuthAttempt } = await import('../../utils/authLoopGuard')
if (!isAuthLoopBlocked()) {
recordAuthAttempt()
keycloak.login()
} else {
console.error('Auth loop detected, not redirecting to login')
}
}
return response return response
}, },
headers: { headers: {
'Content-Type': 'application/json;charset=utf-8', 'Content-Type': 'application/json;charset=utf-8',
}, },
prepareHeaders: (headers) => { prepareHeaders: async (headers) => {
try {
// Обновить токен, если он истекает в течение 30 секунд
await keycloak.updateToken(30)
} catch (error) {
console.error('Failed to refresh token:', error)
}
if (keycloak.token) {
headers.set('Authorization', `Bearer ${keycloak.token}`) headers.set('Authorization', `Bearer ${keycloak.token}`)
}
return headers
}, },
}), }),
tagTypes: ['Task', 'Chain', 'User', 'Submission', 'Stats'], tagTypes: ['Task', 'Chain', 'User', 'Submission', 'Stats'],
@@ -114,6 +137,23 @@ export const api = createApi({
}), }),
invalidatesTags: ['Chain'], 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 // Statistics
getSystemStats: builder.query<SystemStats, void>({ getSystemStats: builder.query<SystemStats, void>({
@@ -144,10 +184,21 @@ export const api = createApi({
transformResponse: (response: { body: ChallengeSubmission[] }) => response.body, transformResponse: (response: { body: ChallengeSubmission[] }) => response.body,
providesTags: ['Submission'], 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) // Test submission (LLM check without creating a real submission)
testSubmission: builder.mutation<TestSubmissionResult, SubmitRequest>({ testSubmission: builder.mutation<TestSubmissionResult, SubmitRequest>({
query: ({ userId, taskId, result, isTest = true }) => ({ query: ({ userId, taskId, result, isTest = true, hiddenInstructions }) => ({
url: '/challenge/submit', url: '/challenge/submit',
method: 'POST', method: 'POST',
body: { body: {
@@ -155,9 +206,11 @@ export const api = createApi({
taskId, taskId,
result, result,
isTest, isTest,
hiddenInstructions,
}, },
}), }),
transformResponse: (response: APIResponse<TestSubmissionResult>) => response.data, // Сервер возвращает { success: boolean; body: TestSubmissionResult }
transformResponse: (response: { success: boolean; body: TestSubmissionResult }) => response.body,
}), }),
}), }),
}) })
@@ -173,10 +226,13 @@ export const {
useCreateChainMutation, useCreateChainMutation,
useUpdateChainMutation, useUpdateChainMutation,
useDeleteChainMutation, useDeleteChainMutation,
useDuplicateChainMutation,
useClearChainSubmissionsMutation,
useGetSystemStatsQuery, useGetSystemStatsQuery,
useGetSystemStatsV2Query, useGetSystemStatsV2Query,
useGetUserStatsQuery, useGetUserStatsQuery,
useGetUserSubmissionsQuery, useGetUserSubmissionsQuery,
useGetChainSubmissionsQuery,
useTestSubmissionMutation, useTestSubmissionMutation,
} = api } = api

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,9 +34,22 @@ export const mount = async (Component, element = document.getElementById('app'))
recordAuthAttempt() recordAuthAttempt()
await keycloak.init({ await keycloak.init({
onLoad: 'login-required' onLoad: 'login-required',
checkLoginIframe: false
}) })
// Настройка автоматического обновления токена
setInterval(() => {
keycloak.updateToken(70).then((refreshed) => {
if (refreshed) {
console.log('Token was successfully refreshed')
}
}).catch(() => {
console.error('Failed to refresh token, redirecting to login')
keycloak.login()
})
}, 60000) // Проверять каждую минуту
const userInfo = await keycloak.loadUserInfo() const userInfo = await keycloak.loadUserInfo()
if (userInfo && keycloak.tokenParsed) { if (userInfo && keycloak.tokenParsed) {

View File

@@ -17,7 +17,8 @@ import { URLs } from '../../__data__/urls'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert' import { ErrorAlert } from '../../components/ErrorAlert'
import { EmptyState } from '../../components/EmptyState' 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 type { ChallengeChain } from '../../types/challenge'
import { toaster } from '../../components/ui/toaster' import { toaster } from '../../components/ui/toaster'
@@ -25,24 +26,28 @@ export const ChainsListPage: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation() const { t } = useTranslation()
const { data: chains, isLoading, error, refetch } = useGetChainsQuery() const { data: chains, isLoading, error, refetch } = useGetChainsQuery()
const [deleteChain, { isLoading: isDeleting }] = useDeleteChainMutation() const [deleteChain] = useDeleteChainMutation()
const [searchQuery, setSearchQuery] = useState('') 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 [updatingChainId, setUpdatingChainId] = useState<string | null>(null)
const [updateChain] = useUpdateChainMutation() const [updateChain] = useUpdateChainMutation()
const handleDeleteChain = async () => { const handleDeleteChain = async (chain: ChallengeChain) => {
if (!chainToDelete) return const confirmed = window.confirm(
t('challenge.admin.chains.delete.confirm.message', { name: chain.name })
)
if (!confirmed) return
try { try {
await deleteChain(chainToDelete.id).unwrap() await deleteChain(chain.id).unwrap()
toaster.create({ toaster.create({
title: t('challenge.admin.common.success'), title: t('challenge.admin.common.success'),
description: t('challenge.admin.chains.deleted'), description: t('challenge.admin.chains.deleted'),
type: 'success', type: 'success',
}) })
setChainToDelete(null)
} catch (err) { } catch (err) {
toaster.create({ toaster.create({
title: t('challenge.admin.common.error'), title: t('challenge.admin.common.error'),
@@ -165,7 +170,7 @@ export const ChainsListPage: React.FC = () => {
size="xs" size="xs"
variant="outline" variant="outline"
onClick={() => handleToggleActive(chain, !chain.isActive)} onClick={() => handleToggleActive(chain, !chain.isActive)}
isDisabled={updatingChainId === chain.id} disabled={updatingChainId === chain.id}
> >
{chain.isActive {chain.isActive
? t('challenge.admin.chains.list.status.inactive') ? t('challenge.admin.chains.list.status.inactive')
@@ -182,11 +187,26 @@ export const ChainsListPage: React.FC = () => {
> >
{t('challenge.admin.chains.list.button.edit')} {t('challenge.admin.chains.list.button.edit')}
</Button> </Button>
<Button
size="sm"
variant="ghost"
onClick={() => setChainToDuplicate(chain)}
>
{t('challenge.admin.chains.duplicate.button')}
</Button>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
colorPalette="red" 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')} {t('challenge.admin.chains.list.button.delete')}
</Button> </Button>
@@ -199,14 +219,16 @@ export const ChainsListPage: React.FC = () => {
</Box> </Box>
)} )}
<ConfirmDialog <DuplicateChainDialog
isOpen={!!chainToDelete} isOpen={!!chainToDuplicate}
onClose={() => setChainToDelete(null)} onClose={() => setChainToDuplicate(null)}
onConfirm={handleDeleteChain} chain={chainToDuplicate}
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')} <ClearSubmissionsDialog
isLoading={isDeleting} isOpen={!!chainToClearSubmissions}
onClose={() => setChainToClearSubmissions(null)}
chain={chainToClearSubmissions}
/> />
</Box> </Box>
) )

View File

@@ -49,6 +49,11 @@ export const ParticipantsProgress: React.FC<ParticipantsProgressProps> = ({ part
<VStack align="stretch" gap={3}> <VStack align="stretch" gap={3}>
{/* Participant Header */} {/* Participant Header */}
<Box> <Box>
{participant.workplaceNumber && (
<Text fontSize="xs" color="gray.500" mb={1}>
{participant.workplaceNumber}
</Text>
)}
<Text fontSize="lg" fontWeight="bold" color="teal.700"> <Text fontSize="lg" fontWeight="bold" color="teal.700">
{participant.nickname} {participant.nickname}
</Text> </Text>

View File

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

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react' import React, { useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useParams, Link } from 'react-router-dom'
import { import {
Box, Box,
Heading, Heading,
@@ -10,19 +10,26 @@ import {
Button, Button,
HStack, HStack,
VStack, VStack,
Select, Badge,
Progress, Progress,
Grid, Grid,
SimpleGrid,
Select,
createListCollection, createListCollection,
} from '@chakra-ui/react' } 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 { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert' import { ErrorAlert } from '../../components/ErrorAlert'
import { EmptyState } from '../../components/EmptyState' import { EmptyState } from '../../components/EmptyState'
import { StatusBadge } from '../../components/StatusBadge' import { StatusBadge } from '../../components/StatusBadge'
import { URLs } from '../../__data__/urls' import { URLs } from '../../__data__/urls'
import type { import type {
ActiveParticipant,
ChallengeSubmission, ChallengeSubmission,
SubmissionStatus, SubmissionStatus,
ChallengeTask, ChallengeTask,
@@ -32,14 +39,61 @@ import type {
export const SubmissionsPage: React.FC = () => { export const SubmissionsPage: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const { chainId } = useParams<{ chainId?: string }>()
const initialUserId = searchParams.get('userId')
const { data: stats, isLoading: isStatsLoading, error: statsError, refetch: refetchStats } =
useGetSystemStatsV2Query(undefined)
// Проверяем 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 [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<SubmissionStatus | 'all'>('all') 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 { const {
data: submissions, data: submissions,
@@ -48,36 +102,78 @@ export const SubmissionsPage: React.FC = () => {
refetch: refetchSubmissions, refetch: refetchSubmissions,
} = useGetUserSubmissionsQuery( } = useGetUserSubmissionsQuery(
{ userId: selectedUserId!, taskId: undefined }, { userId: selectedUserId!, taskId: undefined },
{ skip: !selectedUserId } { skip: !selectedUserId || useNewApi }
) )
const isLoading = isStatsLoading || (selectedUserId && isSubmissionsLoading) const isLoading =
const error = statsError || submissionsError isChainsLoading ||
(chainId && useNewApi && isChainDataLoading) ||
(chainId && !useNewApi && isStatsLoading) ||
(selectedUserId && !useNewApi && isSubmissionsLoading)
const error = chainsError || (useNewApi ? chainDataError : statsError || submissionsError)
const handleRetry = () => { const handleRetry = () => {
refetchChains()
if (chainId) {
if (useNewApi) {
refetchChainData()
} else {
refetchStats() refetchStats()
if (selectedUserId) { if (selectedUserId) {
refetchSubmissions() refetchSubmissions()
} }
} }
}
if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.submissions.loading')} />
} }
if (error || !stats) { // Получаем данные выбранной цепочки из списка chains (для старого API)
return <ErrorAlert message={t('challenge.admin.submissions.load.error')} onRetry={handleRetry} /> const selectedChain = useMemo(() => {
} if (!chainId || !chains) return null
return chains.find((c) => c.id === chainId) || null
}, [chainId, chains])
const participants: ActiveParticipant[] = stats.activeParticipants || [] // Получаем taskIds из текущей цепочки (для старого API)
const submissionsList: ChallengeSubmission[] = submissions || [] 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 normalizedSearchQuery = (searchQuery ?? '').toLowerCase()
const filteredSubmissions = submissionsList.filter((submission) => { return submissions.filter((submission) => {
const rawUser = submission.user as ChallengeUser | string | undefined
const rawTask = submission.task as ChallengeTask | string | undefined 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 = const nickname =
rawUser && typeof rawUser === 'object' && 'nickname' in rawUser rawUser && typeof rawUser === 'object' && 'nickname' in rawUser
? (rawUser.nickname ?? '') ? (rawUser.nickname ?? '')
@@ -96,6 +192,52 @@ export const SubmissionsPage: React.FC = () => {
return matchesSearch && matchesStatus 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) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('ru-RU', { return new Date(dateStr).toLocaleString('ru-RU', {
@@ -125,70 +267,133 @@ export const SubmissionsPage: React.FC = () => {
], ],
}) })
const userOptions = createListCollection({ if (isLoading) {
items: participants.map((participant) => ({ return <LoadingSpinner message={t('challenge.admin.submissions.loading')} />
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,
} }
})
.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 ( return (
<Box> <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"> <VStack mb={4} gap={3} align="stretch">
<HStack gap={4} align="center"> <HStack gap={4} align="center" wrap="wrap">
<Select.Root {selectedUserId && (
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 && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="outline"
colorPalette="teal"
onClick={() => { onClick={() => {
setSelectedUserId(null) setSelectedUserId(null)
setSearchQuery('') setSearchQuery('')
@@ -199,13 +404,13 @@ export const SubmissionsPage: React.FC = () => {
</Button> </Button>
)} )}
{submissionsList.length > 0 && ( {selectedUserId && filteredSubmissions.length > 0 && (
<> <>
<Input <Input
placeholder={t('challenge.admin.submissions.search.placeholder')} placeholder={t('challenge.admin.submissions.search.placeholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
maxW="400px" maxW="300px"
/> />
<Select.Root <Select.Root
collection={statusOptions} collection={statusOptions}
@@ -230,24 +435,20 @@ export const SubmissionsPage: React.FC = () => {
</VStack> </VStack>
)} )}
{!hasParticipants ? ( {/* Если не выбран пользователь - показываем обзор участников */}
<EmptyState {!selectedUserId ? (
title={t('challenge.admin.submissions.empty.title')}
description={t('challenge.admin.submissions.empty.description')}
/>
) : !hasSelectedUser ? (
<Box> <Box>
<Heading size="md" mb={4}> <Heading size="md" mb={4}>
{t('challenge.admin.submissions.overview.title')} {t('challenge.admin.submissions.participants.title')}
</Heading> </Heading>
<Text mb={4} color="gray.600"> <Text mb={4} color="gray.600">
{t('challenge.admin.submissions.overview.description')} {t('challenge.admin.submissions.participants.description')}
</Text> </Text>
{participantOverviewRows.length === 0 ? ( {sortedParticipants.length === 0 ? (
<EmptyState <EmptyState
title={t('challenge.admin.detailed.stats.participants.empty')} title={t('challenge.admin.submissions.participants.empty.title')}
description={t('challenge.admin.detailed.stats.chains.empty')} description={t('challenge.admin.submissions.participants.empty.description')}
/> />
) : ( ) : (
<Grid <Grid
@@ -257,43 +458,57 @@ export const SubmissionsPage: React.FC = () => {
lg: 'repeat(3, minmax(0, 1fr))', lg: 'repeat(3, minmax(0, 1fr))',
xl: 'repeat(4, minmax(0, 1fr))', xl: 'repeat(4, minmax(0, 1fr))',
}} }}
gap={2} gap={3}
> >
{participantOverviewRows.map((row) => { {sortedParticipants.map((participant) => {
const colorPalette = const colorPalette =
row.overallPercent >= 70 participant.progressPercent >= 70
? 'green' ? 'green'
: row.overallPercent >= 40 : participant.progressPercent >= 40
? 'orange' ? 'orange'
: 'red' : 'red'
return ( return (
<Box <Box
key={row.userId} key={participant.userId}
p={2} p={3}
borderWidth="1px" borderWidth="1px"
borderRadius="md" borderRadius="md"
borderColor="gray.200" borderColor="gray.200"
_hover={{ bg: 'gray.50' }} bg="white"
_hover={{ bg: 'gray.50', borderColor: 'teal.300' }}
cursor="pointer" cursor="pointer"
onClick={() => setSelectedUserId(row.userId)} onClick={() => setSelectedUserId(participant.userId)}
transition="all 0.2s"
> >
<HStack justify="space-between" mb={1} gap={2}> <HStack justify="space-between" mb={2} gap={2}>
<Text fontSize="xs" fontWeight="medium" truncate maxW="150px"> <VStack align="start" gap={0}>
{row.nickname} {participant.workplaceNumber && (
</Text>
<Text fontSize="xs" color="gray.500"> <Text fontSize="xs" color="gray.500">
{row.overallPercent}% {participant.workplaceNumber}
</Text> </Text>
)}
<Text fontSize="sm" fontWeight="medium" truncate maxW="180px">
{participant.nickname}
</Text>
</VStack>
<Badge colorPalette={colorPalette} size="sm">
{participant.progressPercent}%
</Badge>
</HStack> </HStack>
<Progress.Root value={row.overallPercent} size="xs" colorPalette={colorPalette}> <Progress.Root value={participant.progressPercent} size="sm" colorPalette={colorPalette}>
<Progress.Track> <Progress.Track>
<Progress.Range /> <Progress.Range />
</Progress.Track> </Progress.Track>
</Progress.Root> </Progress.Root>
<Text fontSize="xs" color="gray.500" mt={1}> <HStack justify="space-between" mt={2}>
{row.completedTasks} / {row.totalTasks} <Text fontSize="xs" color="gray.500">
{participant.completedTasks} / {participant.totalTasks}
</Text> </Text>
<Text fontSize="xs" color="gray.400">
{t('challenge.admin.submissions.participants.click.to.view')}
</Text>
</HStack>
</Box> </Box>
) )
})} })}
@@ -306,17 +521,21 @@ export const SubmissionsPage: React.FC = () => {
description={t('challenge.admin.submissions.search.empty.description')} description={t('challenge.admin.submissions.search.empty.description')}
/> />
) : ( ) : (
/* Таблица попыток выбранного пользователя */
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto"> <Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
<Table.Root size="sm"> <Table.Root size="sm">
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.user')}</Table.ColumnHeader> <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.task')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.status')}</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.attempt')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.submissions.table.submitted')}</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>{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.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
@@ -331,6 +550,11 @@ export const SubmissionsPage: React.FC = () => {
? rawUser ? rawUser
: '' : ''
const workplaceNumber =
rawUser && typeof rawUser === 'object' && 'workplaceNumber' in rawUser
? rawUser.workplaceNumber ?? ''
: ''
const title = const title =
rawTask && typeof rawTask === 'object' && 'title' in rawTask rawTask && typeof rawTask === 'object' && 'title' in rawTask
? (rawTask.title ?? '') ? (rawTask.title ?? '')
@@ -341,6 +565,11 @@ export const SubmissionsPage: React.FC = () => {
return ( return (
<Table.Row key={submission.id}> <Table.Row key={submission.id}>
<Table.Cell fontWeight="medium">{nickname}</Table.Cell> <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>{title}</Table.Cell>
<Table.Cell> <Table.Cell>
<StatusBadge status={submission.status} /> <StatusBadge status={submission.status} />
@@ -365,7 +594,7 @@ export const SubmissionsPage: React.FC = () => {
size="sm" size="sm"
variant="ghost" variant="ghost"
colorPalette="teal" colorPalette="teal"
onClick={() => navigate(URLs.submissionDetails(selectedUserId!, submission.id))} onClick={() => navigate(URLs.submissionDetails(chainId!, selectedUserId, submission.id))}
> >
{t('challenge.admin.submissions.button.details')} {t('challenge.admin.submissions.button.details')}
</Button> </Button>
@@ -380,4 +609,3 @@ export const SubmissionsPage: React.FC = () => {
</Box> </Box>
) )
} }

View File

@@ -26,6 +26,20 @@ import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert' import { ErrorAlert } from '../../components/ErrorAlert'
import { toaster } from '../../components/ui/toaster' 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 = () => { export const TaskFormPage: React.FC = () => {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
@@ -40,6 +54,7 @@ export const TaskFormPage: React.FC = () => {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [learningMaterial, setLearningMaterial] = useState('')
const [hiddenInstructions, setHiddenInstructions] = useState('') const [hiddenInstructions, setHiddenInstructions] = useState('')
const [showDescPreview, setShowDescPreview] = useState(false) const [showDescPreview, setShowDescPreview] = useState(false)
const [testAnswer, setTestAnswer] = useState('') const [testAnswer, setTestAnswer] = useState('')
@@ -50,10 +65,44 @@ export const TaskFormPage: React.FC = () => {
if (task) { if (task) {
setTitle(task.title) setTitle(task.title)
setDescription(task.description) setDescription(task.description)
setLearningMaterial(task.learningMaterial || '')
setHiddenInstructions(task.hiddenInstructions || '') setHiddenInstructions(task.hiddenInstructions || '')
} }
}, [task]) }, [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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -73,6 +122,7 @@ export const TaskFormPage: React.FC = () => {
data: { data: {
title: title.trim(), title: title.trim(),
description: description.trim(), description: description.trim(),
learningMaterial: learningMaterial.trim() || undefined,
hiddenInstructions: hiddenInstructions.trim() || undefined, hiddenInstructions: hiddenInstructions.trim() || undefined,
}, },
}).unwrap() }).unwrap()
@@ -85,6 +135,7 @@ export const TaskFormPage: React.FC = () => {
await createTask({ await createTask({
title: title.trim(), title: title.trim(),
description: description.trim(), description: description.trim(),
learningMaterial: learningMaterial.trim() || undefined,
hiddenInstructions: hiddenInstructions.trim() || undefined, hiddenInstructions: hiddenInstructions.trim() || undefined,
}).unwrap() }).unwrap()
toaster.create({ toaster.create({
@@ -93,7 +144,7 @@ export const TaskFormPage: React.FC = () => {
type: 'success', type: 'success',
}) })
} }
navigate(URLs.tasks) // navigate(URLs.tasks)
} catch (err: unknown) { } catch (err: unknown) {
const errorMessage = const errorMessage =
(err && typeof err === 'object' && 'data' in err && (err && typeof err === 'object' && 'data' in err &&
@@ -136,6 +187,7 @@ export const TaskFormPage: React.FC = () => {
taskId: task.id, taskId: task.id,
result: testAnswer.trim(), result: testAnswer.trim(),
isTest: true, isTest: true,
hiddenInstructions: hiddenInstructions.trim() || undefined,
}).unwrap() }).unwrap()
setTestStatus(result.status) setTestStatus(result.status)
@@ -321,6 +373,178 @@ export const TaskFormPage: React.FC = () => {
<Field.HelperText>{t('challenge.admin.tasks.field.description.helper')}</Field.HelperText> <Field.HelperText>{t('challenge.admin.tasks.field.description.helper')}</Field.HelperText>
</Field.Root> </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 */} {/* Hidden Instructions */}
<Field.Root> <Field.Root>
<Box bg="purple.50" p={4} borderRadius="md" borderWidth="1px" borderColor="purple.200"> <Box bg="purple.50" p={4} borderRadius="md" borderWidth="1px" borderColor="purple.200">

View File

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

View File

@@ -61,6 +61,7 @@ export const UsersPage: React.FC = () => {
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.ColumnHeader>{t('challenge.admin.users.table.nickname')}</Table.ColumnHeader> <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.table.id')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.users.stats.total.submissions')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.users.stats.total.submissions')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.users.stats.completed')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.users.stats.completed')}</Table.ColumnHeader>
@@ -71,6 +72,11 @@ export const UsersPage: React.FC = () => {
{filteredUsers.map((user) => ( {filteredUsers.map((user) => (
<Table.Row key={user.userId}> <Table.Row key={user.userId}>
<Table.Cell fontWeight="medium">{user.nickname}</Table.Cell> <Table.Cell fontWeight="medium">{user.nickname}</Table.Cell>
<Table.Cell>
<Text fontSize="sm" color="gray.600">
{user.workplaceNumber || '—'}
</Text>
</Table.Cell>
<Table.Cell> <Table.Cell>
<Text fontSize="xs" fontFamily="monospace" color="gray.600"> <Text fontSize="xs" fontFamily="monospace" color="gray.600">
{user.userId} {user.userId}

View File

@@ -4,6 +4,7 @@ export interface ChallengeUser {
_id: string _id: string
id: string id: string
nickname: string nickname: string
workplaceNumber?: string
createdAt: string createdAt: string
} }
@@ -12,6 +13,7 @@ export interface ChallengeTask {
id: string id: string
title: string title: string
description: string // Markdown description: string // Markdown
learningMaterial?: string // Дополнительный учебный материал в Markdown
hiddenInstructions?: string // Только для преподавателей hiddenInstructions?: string // Только для преподавателей
creator?: { creator?: {
sub: string sub: string
@@ -121,12 +123,14 @@ export interface APIResponse<T> {
export interface CreateTaskRequest { export interface CreateTaskRequest {
title: string title: string
description: string description: string
learningMaterial?: string
hiddenInstructions?: string hiddenInstructions?: string
} }
export interface UpdateTaskRequest { export interface UpdateTaskRequest {
title?: string title?: string
description?: string description?: string
learningMaterial?: string
hiddenInstructions?: string hiddenInstructions?: string
} }
@@ -142,6 +146,16 @@ export interface UpdateChainRequest {
isActive?: boolean isActive?: boolean
} }
export interface DuplicateChainRequest {
name?: string
}
export interface ClearSubmissionsResponse {
deletedCount: number
chainId: string
userId?: string
}
// ========== Stats v2 Types ========== // ========== Stats v2 Types ==========
export type TaskProgressStatus = 'not_started' | 'pending' | 'in_progress' | 'needs_revision' | 'completed' export type TaskProgressStatus = 'not_started' | 'pending' | 'in_progress' | 'needs_revision' | 'completed'
@@ -167,6 +181,7 @@ export interface ChainProgress {
export interface ActiveParticipant { export interface ActiveParticipant {
userId: string userId: string
nickname: string nickname: string
workplaceNumber?: string
totalSubmissions: number totalSubmissions: number
completedTasks: number completedTasks: number
chainProgress: ChainProgress[] chainProgress: ChainProgress[]
@@ -181,6 +196,7 @@ export interface TaskProgress {
export interface ParticipantProgress { export interface ParticipantProgress {
userId: string userId: string
nickname: string nickname: string
workplaceNumber?: string
taskProgress: TaskProgress[] taskProgress: TaskProgress[]
completedCount: number completedCount: number
progressPercent: number progressPercent: number
@@ -234,6 +250,8 @@ export interface SubmitRequest {
result: string result: string
// Флаг тестового режима: проверка без создания Submission и очереди // Флаг тестового режима: проверка без создания Submission и очереди
isTest?: boolean isTest?: boolean
// Временные скрытые инструкции для тестовой проверки (не сохраняются в задачу)
hiddenInstructions?: string
} }
export interface TestSubmissionResult { export interface TestSubmissionResult {
@@ -242,3 +260,29 @@ export interface TestSubmissionResult {
feedback?: string 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
}
}

View File

@@ -203,6 +203,7 @@
{ {
"userId": "6909b51512c75d75a36a52bf", "userId": "6909b51512c75d75a36a52bf",
"nickname": "Примаков А.А.", "nickname": "Примаков А.А.",
"workplaceNumber": "PC-07",
"totalSubmissions": 14, "totalSubmissions": 14,
"completedTasks": 1, "completedTasks": 1,
"chainProgress": [ "chainProgress": [
@@ -225,6 +226,7 @@
{ {
"userId": "user_1", "userId": "user_1",
"nickname": "alex_dev", "nickname": "alex_dev",
"workplaceNumber": "PC-01",
"totalSubmissions": 18, "totalSubmissions": 18,
"completedTasks": 12, "completedTasks": 12,
"chainProgress": [ "chainProgress": [
@@ -247,6 +249,7 @@
{ {
"userId": "user_2", "userId": "user_2",
"nickname": "maria_coder", "nickname": "maria_coder",
"workplaceNumber": "PC-05",
"totalSubmissions": 15, "totalSubmissions": 15,
"completedTasks": 9, "completedTasks": 9,
"chainProgress": [ "chainProgress": [
@@ -269,6 +272,7 @@
{ {
"userId": "user_3", "userId": "user_3",
"nickname": "ivan_programmer", "nickname": "ivan_programmer",
"workplaceNumber": "PC-12",
"totalSubmissions": 10, "totalSubmissions": 10,
"completedTasks": 5, "completedTasks": 5,
"chainProgress": [ "chainProgress": [
@@ -291,6 +295,7 @@
{ {
"userId": "user_4", "userId": "user_4",
"nickname": "kate_fullstack", "nickname": "kate_fullstack",
"workplaceNumber": "PC-03",
"totalSubmissions": 22, "totalSubmissions": 22,
"completedTasks": 15, "completedTasks": 15,
"chainProgress": [ "chainProgress": [
@@ -313,6 +318,7 @@
{ {
"userId": "user_5", "userId": "user_5",
"nickname": "dmitry_backend", "nickname": "dmitry_backend",
"workplaceNumber": "PC-15",
"totalSubmissions": 12, "totalSubmissions": 12,
"completedTasks": 6, "completedTasks": 6,
"chainProgress": [ "chainProgress": [
@@ -335,6 +341,7 @@
{ {
"userId": "user_6", "userId": "user_6",
"nickname": "anna_react", "nickname": "anna_react",
"workplaceNumber": "PC-08",
"totalSubmissions": 14, "totalSubmissions": 14,
"completedTasks": 7, "completedTasks": 7,
"chainProgress": [ "chainProgress": [
@@ -376,6 +383,7 @@
{ {
"userId": "user_1", "userId": "user_1",
"nickname": "alex_dev", "nickname": "alex_dev",
"workplaceNumber": "PC-01",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" }, { "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" }, { "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -394,6 +402,7 @@
{ {
"userId": "user_2", "userId": "user_2",
"nickname": "maria_coder", "nickname": "maria_coder",
"workplaceNumber": "PC-05",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" }, { "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" }, { "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -412,6 +421,7 @@
{ {
"userId": "user_3", "userId": "user_3",
"nickname": "ivan_programmer", "nickname": "ivan_programmer",
"workplaceNumber": "PC-12",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" }, { "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" }, { "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -430,6 +440,7 @@
{ {
"userId": "user_4", "userId": "user_4",
"nickname": "kate_fullstack", "nickname": "kate_fullstack",
"workplaceNumber": "PC-03",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" }, { "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" }, { "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -448,6 +459,7 @@
{ {
"userId": "user_5", "userId": "user_5",
"nickname": "dmitry_backend", "nickname": "dmitry_backend",
"workplaceNumber": "PC-15",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" }, { "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" }, { "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "completed" },
@@ -466,6 +478,7 @@
{ {
"userId": "user_6", "userId": "user_6",
"nickname": "anna_react", "nickname": "anna_react",
"workplaceNumber": "PC-08",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" }, { "taskId": "task_1", "taskTitle": "Создание REST API", "status": "completed" },
{ "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "pending" }, { "taskId": "task_2", "taskTitle": "Работа с базой данных MongoDB", "status": "pending" },
@@ -503,6 +516,7 @@
{ {
"userId": "user_1", "userId": "user_1",
"nickname": "alex_dev", "nickname": "alex_dev",
"workplaceNumber": "PC-01",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" }, { "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" }, { "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -521,6 +535,7 @@
{ {
"userId": "user_2", "userId": "user_2",
"nickname": "maria_coder", "nickname": "maria_coder",
"workplaceNumber": "PC-05",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" }, { "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" }, { "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -539,6 +554,7 @@
{ {
"userId": "user_3", "userId": "user_3",
"nickname": "ivan_programmer", "nickname": "ivan_programmer",
"workplaceNumber": "PC-12",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" }, { "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" }, { "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -557,6 +573,7 @@
{ {
"userId": "user_4", "userId": "user_4",
"nickname": "kate_fullstack", "nickname": "kate_fullstack",
"workplaceNumber": "PC-03",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" }, { "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" }, { "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },
@@ -575,6 +592,7 @@
{ {
"userId": "user_5", "userId": "user_5",
"nickname": "dmitry_backend", "nickname": "dmitry_backend",
"workplaceNumber": "PC-15",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" }, { "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "pending" }, { "taskId": "task_12", "taskTitle": "React Hooks", "status": "pending" },
@@ -593,6 +611,7 @@
{ {
"userId": "user_6", "userId": "user_6",
"nickname": "anna_react", "nickname": "anna_react",
"workplaceNumber": "PC-08",
"taskProgress": [ "taskProgress": [
{ "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" }, { "taskId": "task_11", "taskTitle": "React компоненты", "status": "completed" },
{ "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" }, { "taskId": "task_12", "taskTitle": "React Hooks", "status": "completed" },

View File

@@ -6,6 +6,7 @@
"_id": "user001", "_id": "user001",
"id": "user001", "id": "user001",
"nickname": "alex_student", "nickname": "alex_student",
"workplaceNumber": "PC-01",
"createdAt": "2024-10-15T08:30:00.000Z" "createdAt": "2024-10-15T08:30:00.000Z"
}, },
"task": { "task": {
@@ -31,6 +32,7 @@
"_id": "user001", "_id": "user001",
"id": "user001", "id": "user001",
"nickname": "alex_student", "nickname": "alex_student",
"workplaceNumber": "PC-01",
"createdAt": "2024-10-15T08:30:00.000Z" "createdAt": "2024-10-15T08:30:00.000Z"
}, },
"task": { "task": {
@@ -56,6 +58,7 @@
"_id": "user002", "_id": "user002",
"id": "user002", "id": "user002",
"nickname": "maria_dev", "nickname": "maria_dev",
"workplaceNumber": "PC-05",
"createdAt": "2024-10-16T10:15:00.000Z" "createdAt": "2024-10-16T10:15:00.000Z"
}, },
"task": { "task": {
@@ -81,6 +84,7 @@
"_id": "user003", "_id": "user003",
"id": "user003", "id": "user003",
"nickname": "ivan_coder", "nickname": "ivan_coder",
"workplaceNumber": "PC-12",
"createdAt": "2024-10-17T14:20:00.000Z" "createdAt": "2024-10-17T14:20:00.000Z"
}, },
"task": { "task": {
@@ -106,6 +110,7 @@
"_id": "user004", "_id": "user004",
"id": "user004", "id": "user004",
"nickname": "olga_js", "nickname": "olga_js",
"workplaceNumber": "PC-03",
"createdAt": "2024-10-18T09:00:00.000Z" "createdAt": "2024-10-18T09:00:00.000Z"
}, },
"task": { "task": {
@@ -131,6 +136,7 @@
"_id": "user005", "_id": "user005",
"id": "user005", "id": "user005",
"nickname": "dmitry_react", "nickname": "dmitry_react",
"workplaceNumber": "PC-15",
"createdAt": "2024-10-20T11:45:00.000Z" "createdAt": "2024-10-20T11:45:00.000Z"
}, },
"task": { "task": {
@@ -156,6 +162,7 @@
"_id": "user006", "_id": "user006",
"id": "user006", "id": "user006",
"nickname": "anna_frontend", "nickname": "anna_frontend",
"workplaceNumber": "PC-08",
"createdAt": "2024-10-22T16:30:00.000Z" "createdAt": "2024-10-22T16:30:00.000Z"
}, },
"task": { "task": {

View File

@@ -3,36 +3,42 @@
"_id": "user001", "_id": "user001",
"id": "user001", "id": "user001",
"nickname": "alex_student", "nickname": "alex_student",
"workplaceNumber": "PC-01",
"createdAt": "2024-10-15T08:30:00.000Z" "createdAt": "2024-10-15T08:30:00.000Z"
}, },
{ {
"_id": "user002", "_id": "user002",
"id": "user002", "id": "user002",
"nickname": "maria_dev", "nickname": "maria_dev",
"workplaceNumber": "PC-05",
"createdAt": "2024-10-16T10:15:00.000Z" "createdAt": "2024-10-16T10:15:00.000Z"
}, },
{ {
"_id": "user003", "_id": "user003",
"id": "user003", "id": "user003",
"nickname": "ivan_coder", "nickname": "ivan_coder",
"workplaceNumber": "PC-12",
"createdAt": "2024-10-17T14:20:00.000Z" "createdAt": "2024-10-17T14:20:00.000Z"
}, },
{ {
"_id": "user004", "_id": "user004",
"id": "user004", "id": "user004",
"nickname": "olga_js", "nickname": "olga_js",
"workplaceNumber": "PC-03",
"createdAt": "2024-10-18T09:00:00.000Z" "createdAt": "2024-10-18T09:00:00.000Z"
}, },
{ {
"_id": "user005", "_id": "user005",
"id": "user005", "id": "user005",
"nickname": "dmitry_react", "nickname": "dmitry_react",
"workplaceNumber": "PC-15",
"createdAt": "2024-10-20T11:45:00.000Z" "createdAt": "2024-10-20T11:45:00.000Z"
}, },
{ {
"_id": "user006", "_id": "user006",
"id": "user006", "id": "user006",
"nickname": "anna_frontend", "nickname": "anna_frontend",
"workplaceNumber": "PC-08",
"createdAt": "2024-10-22T16:30:00.000Z" "createdAt": "2024-10-22T16:30:00.000Z"
}, },
{ {
@@ -45,6 +51,7 @@
"_id": "user008", "_id": "user008",
"id": "user008", "id": "user008",
"nickname": "elena_fullstack", "nickname": "elena_fullstack",
"workplaceNumber": "PC-20",
"createdAt": "2024-10-28T10:00:00.000Z" "createdAt": "2024-10-28T10:00:00.000Z"
} }
] ]

View File

@@ -312,6 +312,84 @@ router.delete('/challenge/chain/:id', (req, res) => {
respond(res, { success: true }); 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 ============= // ============= STATS =============
// GET /api/challenge/stats // GET /api/challenge/stats
@@ -331,13 +409,32 @@ router.get('/challenge/stats/v2', (req, res) => {
return; return;
} }
// Фильтруем данные по выбранной цепочке // Сначала проверяем наличие цепочки в chains.json
const filteredChain = statsV2.chainsDetailed.find(c => c.chainId === chainId); const chains = getChains();
const chain = chains.find(c => c.id === chainId);
if (!filteredChain) { if (!chain) {
return respondError(res, 'Chain not found', 404); 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 - только задания из этой цепочки // Фильтруем tasksTable - только задания из этой цепочки
const chainTaskIds = new Set(filteredChain.tasks.map(t => t.taskId)); const chainTaskIds = new Set(filteredChain.tasks.map(t => t.taskId));
const filteredTasksTable = statsV2.tasksTable.filter(t => chainTaskIds.has(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); 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; module.exports = router;