33 Commits

Author SHA1 Message Date
primakov b2f947e699 1.5.2 2025-12-15 21:54:39 +03:00
primakov 43f73a129f Refactor TaskFormPage to split learning material into blocks of 30 lines for improved readability. Each block is displayed with a label indicating its order. This enhances the user experience by organizing content more effectively. 2025-12-15 21:53:45 +03:00
primakov 9bc5225c27 1.5.1 2025-12-15 21:25:02 +03:00
primakov b69d00052f Add workplaceNumber field to user authentication and statistics API. Update frontend components and localization to support new field. Enhance user experience by displaying workplace information in relevant areas. 2025-12-15 21:22:06 +03:00
primakov 833d1cc14f 1.5.0 2025-12-14 21:16:58 +03:00
primakov d624d63a37 Implement submissions polling interval feature in SubmissionsPage, allowing dynamic adjustment of API request frequency based on configuration. 2025-12-14 20:51:50 +03:00
primakov 7dab439f3a 1.4.0 2025-12-14 20:33:46 +03:00
primakov c784626b33 Update SubmissionsPage to include polling interval for API requests, enhancing data retrieval efficiency. 2025-12-14 20:33:36 +03:00
primakov 6b7c773977 1.3.1 2025-12-14 16:33:15 +03:00
primakov a748e608cf Comment out navigation to tasks after successful form submission in TaskFormPage to prevent unintended redirects during testing. 2025-12-14 16:25:35 +03:00
primakov d0e26b02c7 1.3.0 2025-12-14 15:43:08 +03:00
primakov 4aae3c154e Add optional learningMaterial field to ChallengeTask model for additional educational content; update API endpoints, TypeScript interfaces, and frontend forms to support this feature. Enhance localization for English and Russian to include new field descriptions and placeholders. 2025-12-14 15:02:43 +03:00
primakov e93de750fc 1.2.0 2025-12-14 14:47:50 +03:00
primakov 5f41c4a943 Enhance dialog components by adding smooth scroll to top functionality upon opening; update ConfirmDialog, ClearSubmissionsDialog, and DuplicateChainDialog for improved user experience. Remove unused ConfirmDialog from ChainsListPage and TasksListPage, streamlining code. 2025-12-14 14:46:28 +03:00
primakov 1d364a2351 Refactor ClearSubmissionsDialog and DuplicateChainDialog components by removing unnecessary whitespace; improve code cleanliness and maintainability. 2025-12-14 13:00:57 +03:00
primakov 88b95a7651 Add duplicate and clear submissions functionality for challenge chains; implement corresponding dialogs and API endpoints, enhancing user experience and task management. Update localization for new features in English and Russian. 2025-12-13 21:32:22 +03:00
primakov 04836ea6ce Implement chain submissions API and update frontend to utilize new endpoint; enhance submissions page with feature flag for API selection, participant progress display, and improved filtering logic. 2025-12-13 20:32:23 +03:00
primakov 18e2ccb6bc Add new API endpoint for retrieving submissions by challenge chain; update frontend to support chain selection and display participant progress. Enhance localization for submissions page in English and Russian. 2025-12-13 20:16:40 +03:00
primakov 9104280325 Update challengePlayer URL key in URLs data structure to use 'link.challenge.main' for improved navigation consistency. 2025-12-13 19:59:35 +03:00
primakov d1bddcf972 Add functionality to restore and save test answers in localStorage for task editing; enhance user experience by preserving input across sessions. 2025-12-10 15:36:13 +03:00
primakov 86dffc802b Refactor API response handling in test submission feature to align with server response structure; update ChainsListPage to use 'disabled' prop for button state instead of 'isDisabled', enhancing code clarity and consistency. 2025-12-10 15:13:05 +03:00
primakov 7b9cb044fa Enhance test submission feature by adding optional hiddenInstructions field for temporary instructions during LLM checks; update API, UI components, and types to support this functionality, improving task evaluation for teachers and challenge authors. 2025-12-10 14:50:17 +03:00
primakov fb25422df1 1.1.0 2025-12-10 12:41:53 +03:00
primakov ec79dd58aa Add test submission feature for LLM checks without creating submissions; update API and UI components to support new functionality, enhancing task evaluation for teachers and challenge authors. Update localization for test check messages in English and Russian. 2025-12-10 12:41:03 +03:00
primakov 173954f685 Add isActive field to challenge chains and update localization; implement functionality to toggle chain status in the UI, enhancing task management and user experience. 2025-12-10 12:02:11 +03:00
primakov 4e1b290f99 Add edit task button to ChainFormPage for improved task management; enhances user interaction by allowing direct navigation to task editing. 2025-12-10 11:23:24 +03:00
primakov 7323e80dcb Add user stats page and refactor user navigation; replace modal with dedicated page for user statistics, enhancing routing and UI consistency. Update localization for new status keys in both English and Russian. 2025-12-10 11:11:17 +03:00
primakov 06bcb6ee51 Update navigation in SubmissionDetailsPage to include userId in URL if available; initialize selectedUserId in SubmissionsPage from search parameters for improved user experience. 2025-12-10 00:36:05 +03:00
primakov 71b6180ab9 Add Submission Details Page and Update Localization for Submissions Overview 2025-12-10 00:25:25 +03:00
primakov 8710718a12 Enhance SubmissionDetailsModal by adding max height and overflow properties to DialogContent for improved usability and responsiveness. 2025-12-10 00:07:32 +03:00
primakov e4a1fe4b23 Refactor submissions page to improve participant progress display; replace table with grid layout for better responsiveness and user interaction. Update data processing for participant overview and overall progress calculation. 2025-12-10 00:01:24 +03:00
primakov b3febaeea1 Refactor submission filtering and details display to handle various user and task data types; improve search functionality and localization for better user experience. 2025-12-09 23:53:42 +03:00
primakov cbf2168e52 Add user filtering and progress overview to submissions page; enhance localization for user selection and progress display 2025-12-09 14:37:04 +03:00
32 changed files with 3548 additions and 489 deletions
+116
View File
@@ -0,0 +1,116 @@
## Overview
This document summarizes the recent changes around submissions/users pages and records guardrails to avoid similar issues in the future.
We:
- Reworked submissions and user stats UIs to use real routes/pages instead of modals.
- Added compact progress overview for participants.
- Introduced deep-linked details pages for submissions and users.
- Fixed Chakra UI dialog misuse and type/translation issues.
## Routing & Page Structure
### Do
- **Define all routes centrally** in `src/__data__/urls.ts` and `src/dashboard.ts`:
- Add both the **URL builder** (e.g. `submissionDetails(userId, submissionId)`) and the **`:param` path**.
- Wrap pages in `PageWrapper` in `dashboard.tsx`.
- **Use real pages for complex views** (details, stats) instead of large modals:
- Submissions details: `SubmissionDetailsPage` with URL `/submissions/:userId/:submissionId`.
- User stats: `UserStatsPage` with URL `/users/:userId`.
- **Pass IDs via URL**, not only component state:
- Use route params for `userId`, `submissionId`, etc.
- For “return and keep selection”, encode it as a query param (e.g. `?userId=...`) and read it on the list page.
### Dont
- **Dont hardcode paths in components** (e.g. `'/submissions/...'`); always use `URLs.*` helpers.
- **Dont rely solely on local React state for deep links**:
- If a view must be shareable/bookmarkable or restorable on reload, it must be addressable by URL.
## Chakra UI Dialogs & Layout
### Do
- **Use dialog subcomponents only inside a dialog root**:
- If you use `DialogBody`, `DialogContent`, etc., they must be wrapped in `<DialogRoot>`.
- For **standalone pages**, use plain layout components:
- `Box`, `Heading`, `VStack`, `Grid`, `Progress`, etc.
- No `Dialog*` components on normal routed pages.
### Dont
- **Dont import or use `DialogBody`, `DialogContent`, `DialogHeader`, etc. on regular pages**:
- This causes `useDialogStyles returned 'undefined'` runtime errors.
- **Dont mix modal patterns and page patterns**:
- Either a true modal (`DialogRoot` + `DialogContent`) over an existing page,
- Or a full page route with normal layout — not both at the same time.
## Data Safety & Types
### Do
- Assume backend fields can be **either object or ID string**, per `ChallengeSubmission` types:
- Example safe access in submissions:
- Guard before reading `user.nickname` or `task.title`.
- Derive strings like:
- `const nickname = typeof rawUser === 'object' && 'nickname' in rawUser ? rawUser.nickname ?? '' : typeof rawUser === 'string' ? rawUser : ''`.
- Normalize strings before calling `.toLowerCase()`:
- `const normalized = (value ?? '').toLowerCase()`.
- When filtering/searching, **never call string methods on possibly `undefined` or non-object values**.
### Dont
- **Dont cast blindly** (`as ChallengeUser`) and then access `.nickname` or `.title` without guards.
- **Dont call `.toLowerCase()` directly on untrusted values** from API or union-typed fields.
## “Back” Navigation & State Restoration
### Do
- For **details pages that should restore list state**:
- Encode the necessary selection into the URL when navigating _to_ details.
- Example: `SubmissionDetailsPage` returns to `URLs.submissions` with `?userId=...`, and `SubmissionsPage` reads `userId` from `useSearchParams` to preselect the user.
- Prefer **semantic back actions** over bare `navigate(-1)` when the previous page/state is known:
- Use `navigate(URLs.submissions + '?userId=...')` or `navigate(URLs.users)` when appropriate.
### Dont
- **Dont rely on `navigate(-1)`** when:
- The previous page might not be the canonical list page,
- You need a specific state (e.g. selected user) restored.
## i18n / Locales
### Do
- **Keep `ru.json` and `en.json` in sync** for any new keys:
- When adding a key under `challenge.admin.*` in one file, add the corresponding entry in the other.
- For **status enums**, ensure all possible values have translations:
- `challenge.admin.users.stats.status.*` must cover all values of `taskStat.status`.
- `challenge.admin.submissions.status.*` must cover all submission statuses.
- Use **consistent key naming patterns**:
- Example: `challenge.admin.users.stats.status.accepted`, `...status.needs_revision`, etc.
### Dont
- **Dont introduce new `t('...')` keys in code without adding them to both locale files**.
- **Dont reuse unrelated keys** just to avoid adding translations — create clear, specific keys.
## UI Patterns for High-Density Overviews
### Do
- For high-density screens (e.g. 100 participants at once):
- Use **compact cards or rows** with:
- Truncated names (`truncate`),
- Thin `Progress` bars,
- Minimal text (percentage + small counters).
- Sort by progress to surface lagging participants.
### Dont
- **Dont use wide tables** when many rows must fit on one screen; prefer grids or narrow rows with fixed-width text columns and flexible progress area.
## When Adding New Features
Before merging:
- **Check routing**:
- New URL added to `URLs`.
- Route wired in `dashboard.tsx`.
- **Check data safety**:
- No unchecked property access on union/nullable types.
- **Check i18n**:
- New keys exist in both `ru.json` and `en.json`.
- **Check Chakra usage**:
- No `Dialog*` components outside a proper `<DialogRoot>` _or_ on standalone pages.
+582
View File
@@ -0,0 +1,582 @@
# API изменения: Поле workplaceNumber
## Обзор
Добавлено новое поле `workplaceNumber` для отслеживания рабочего места (компьютера), за которым работает ученик. Это поле сохраняется при авторизации и возвращается во всех эндпоинтах статистики.
---
## 1. Авторизация пользователя
### `POST /challenge/auth`
Регистрация или авторизация пользователя с указанием рабочего места.
#### Изменения
- ✨ Добавлен опциональный параметр `workplaceNumber`
- При создании нового пользователя сохраняется `workplaceNumber`
- При повторной авторизации существующего пользователя с другим `workplaceNumber` - значение обновляется
- Поиск пользователя по-прежнему выполняется только по `nickname`
#### Request
```http
POST /challenge/auth
Content-Type: application/json
{
"nickname": "student_ivan",
"workplaceNumber": "PC-15" // Опционально
}
```
#### Request Body Parameters
| Параметр | Тип | Обязательный | Описание |
|----------|-----|--------------|----------|
| `nickname` | `string` | ✅ Да | Никнейм пользователя (3-50 символов) |
| `workplaceNumber` | `string` | ❌ Нет | Номер рабочего места/компьютера (макс. 50 символов) |
#### Response
```json
{
"error": null,
"result": {
"ok": true,
"userId": "507f1f77bcf86cd799439011"
}
}
```
#### Примеры использования
**Первая авторизация с рабочим местом:**
```javascript
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname: 'student_ivan',
workplaceNumber: 'PC-15'
})
});
```
**Повторная авторизация с другого места:**
```javascript
// Если пользователь пересел за другой компьютер
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname: 'student_ivan',
workplaceNumber: 'PC-20' // Обновится в базе
})
});
```
**Авторизация без указания места:**
```javascript
// Работает как раньше, workplaceNumber необязателен
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname: 'student_ivan'
})
});
```
---
## 2. Статистика по цепочке заданий
### `GET /challenge/chain/:chainId/submissions`
Получение всех попыток по цепочке с данными о рабочих местах участников.
**Требует права:** `teacher` или `challenge-author`
#### Изменения
-В объекте `user` внутри `submissions` добавлено поле `workplaceNumber`
-В массиве `participants` добавлено поле `workplaceNumber`
#### Request
```http
GET /challenge/chain/507f1f77bcf86cd799439011/submissions?limit=50&offset=0
```
#### Query Parameters
| Параметр | Тип | Описание |
|----------|-----|----------|
| `userId` | `string` | Фильтр по конкретному пользователю |
| `status` | `string` | Фильтр по статусу: `pending`, `in_progress`, `accepted`, `needs_revision` |
| `limit` | `number` | Количество записей (по умолчанию: 100) |
| `offset` | `number` | Смещение для пагинации (по умолчанию: 0) |
#### Response
```json
{
"error": null,
"result": {
"chain": {
"id": "507f1f77bcf86cd799439011",
"name": "Основы Python",
"tasks": [
{
"id": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных"
}
]
},
"participants": [
{
"userId": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15", // ✨ Новое поле
"completedTasks": 5,
"totalTasks": 10,
"progressPercent": 50
},
{
"userId": "507f1f77bcf86cd799439014",
"nickname": "student_maria",
"workplaceNumber": "PC-20", // ✨ Новое поле
"completedTasks": 8,
"totalTasks": 10,
"progressPercent": 80
}
],
"submissions": [
{
"id": "507f1f77bcf86cd799439015",
"user": {
"id": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15" // ✨ Новое поле
},
"task": {
"id": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных"
},
"status": "accepted",
"attemptNumber": 2,
"submittedAt": "2024-01-15T10:30:00.000Z",
"checkedAt": "2024-01-15T10:31:23.000Z",
"feedback": "Отличная работа!"
}
],
"pagination": {
"total": 150,
"limit": 50,
"offset": 0
}
}
}
```
#### Пример использования
```javascript
const chainId = '507f1f77bcf86cd799439011';
const response = await fetch(`/challenge/chain/${chainId}/submissions`, {
headers: {
'Authorization': 'Bearer YOUR_TOKEN' // Требуется токен преподавателя
}
});
const data = await response.json();
// Отобразить список участников с их местами
data.result.participants.forEach(participant => {
console.log(`${participant.nickname} (${participant.workplaceNumber}): ${participant.progressPercent}%`);
// Вывод: "student_ivan (PC-15): 50%"
});
```
---
## 3. Расширенная статистика системы
### `GET /challenge/stats/v2`
Получение детальной статистики с данными о рабочих местах участников.
#### Изменения
-В массиве `activeParticipants` добавлено поле `workplaceNumber`
-В `chainsDetailed[].participantProgress[]` добавлено поле `workplaceNumber`
#### Request
```http
GET /challenge/stats/v2
```
или с фильтром по конкретной цепочке:
```http
GET /challenge/stats/v2?chainId=507f1f77bcf86cd799439011
```
#### Query Parameters
| Параметр | Тип | Описание |
|----------|-----|----------|
| `chainId` | `string` | Опционально: фильтр по конкретной цепочке |
#### Response (фрагмент)
```json
{
"error": null,
"result": {
"users": 25,
"tasks": 50,
"chains": 5,
"submissions": {
"total": 342,
"accepted": 150,
"rejected": 80,
"pending": 12,
"inProgress": 100
},
"averageCheckTimeMs": 2500,
"queue": {
"pending": 5,
"processing": 2,
"completed": 335
},
"tasksTable": [
{
"taskId": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных",
"totalAttempts": 45,
"uniqueUsers": 20,
"acceptedCount": 18,
"successRate": 90,
"averageAttemptsToSuccess": 2.1
}
],
"activeParticipants": [
{
"userId": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15", // ✨ Новое поле
"totalSubmissions": 25,
"completedTasks": 12,
"chainProgress": [
{
"chainId": "507f1f77bcf86cd799439011",
"chainName": "Основы Python",
"totalTasks": 10,
"completedTasks": 8,
"progressPercent": 80
}
]
}
],
"chainsDetailed": [
{
"chainId": "507f1f77bcf86cd799439011",
"name": "Основы Python",
"totalTasks": 10,
"tasks": [
{
"taskId": "507f1f77bcf86cd799439012",
"title": "Переменные и типы данных",
"description": "Изучите основные типы данных..."
}
],
"participantProgress": [
{
"userId": "507f1f77bcf86cd799439013",
"nickname": "student_ivan",
"workplaceNumber": "PC-15", // ✨ Новое поле
"taskProgress": [
{
"taskId": "507f1f77bcf86cd799439012",
"taskTitle": "Переменные и типы данных",
"status": "completed"
}
],
"completedCount": 8,
"progressPercent": 80
}
]
}
]
}
}
```
#### Пример использования
```javascript
const response = await fetch('/challenge/stats/v2');
const data = await response.json();
// Создать карту класса с прогрессом
const classMap = data.result.activeParticipants.map(participant => ({
workplace: participant.workplaceNumber || 'Не указано',
student: participant.nickname,
progress: participant.completedTasks,
chains: participant.chainProgress
}));
// Отсортировать по номеру места
classMap.sort((a, b) => {
const numA = parseInt(a.workplace.replace(/\D/g, '')) || 0;
const numB = parseInt(b.workplace.replace(/\D/g, '')) || 0;
return numA - numB;
});
// Визуализация карты класса
classMap.forEach(item => {
console.log(`[${item.workplace}] ${item.student}: ${item.progress} заданий`);
});
// Вывод:
// [PC-15] student_ivan: 12 заданий
// [PC-20] student_maria: 15 заданий
```
---
## Примеры интеграции на фронтенде
### Компонент авторизации (React)
```jsx
import { useState } from 'react';
function LoginForm() {
const [nickname, setNickname] = useState('');
const [workplaceNumber, setWorkplaceNumber] = useState('');
const handleLogin = async (e) => {
e.preventDefault();
const response = await fetch('/challenge/auth', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nickname,
workplaceNumber: workplaceNumber || undefined
})
});
const data = await response.json();
if (data.result.ok) {
localStorage.setItem('userId', data.result.userId);
localStorage.setItem('nickname', nickname);
localStorage.setItem('workplaceNumber', workplaceNumber);
// Перенаправление на главную страницу
}
};
return (
<form onSubmit={handleLogin}>
<input
type="text"
placeholder="Никнейм"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
required
/>
<input
type="text"
placeholder="Номер компьютера (опционально)"
value={workplaceNumber}
onChange={(e) => setWorkplaceNumber(e.target.value)}
/>
<button type="submit">Войти</button>
</form>
);
}
```
### Отображение карты класса (React)
```jsx
function ClassroomMap({ chainId }) {
const [participants, setParticipants] = useState([]);
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`/challenge/chain/${chainId}/submissions`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
const data = await response.json();
setParticipants(data.result.participants);
};
fetchData();
}, [chainId]);
return (
<div className="classroom-map">
<h2>Карта класса</h2>
<div className="grid">
{participants.map(participant => (
<div
key={participant.userId}
className="student-card"
>
<div className="workplace-badge">
{participant.workplaceNumber || 'N/A'}
</div>
<div className="student-info">
<strong>{participant.nickname}</strong>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${participant.progressPercent}%` }}
/>
</div>
<small>
{participant.completedTasks} / {participant.totalTasks} заданий
</small>
</div>
</div>
))}
</div>
</div>
);
}
```
### TypeScript интерфейсы
```typescript
// Типы для работы с новым API
interface AuthRequest {
nickname: string;
workplaceNumber?: string;
}
interface AuthResponse {
ok: boolean;
userId: string;
}
interface UserInfo {
id: string;
nickname: string;
workplaceNumber?: string; // ✨ Новое поле
}
interface Participant {
userId: string;
nickname: string;
workplaceNumber?: string; // ✨ Новое поле
completedTasks: number;
totalTasks: number;
progressPercent: number;
}
interface Submission {
id: string;
user: UserInfo; // Содержит workplaceNumber
task: {
id: string;
title: string;
};
status: 'pending' | 'in_progress' | 'accepted' | 'needs_revision';
attemptNumber: number;
submittedAt: string;
checkedAt?: string;
feedback?: string;
}
interface ChainSubmissionsResponse {
chain: {
id: string;
name: string;
tasks: Array<{ id: string; title: string }>;
};
participants: Participant[];
submissions: Submission[];
pagination: {
total: number;
limit: number;
offset: number;
};
}
```
---
## Миграция существующего кода
### До (без workplaceNumber)
```javascript
// Старый код авторизации
await fetch('/challenge/auth', {
method: 'POST',
body: JSON.stringify({ nickname: 'student_ivan' })
});
// Старое отображение участников
participants.forEach(p => {
console.log(`${p.nickname}: ${p.progressPercent}%`);
});
```
### После (с workplaceNumber)
```javascript
// Новый код авторизации с местом
await fetch('/challenge/auth', {
method: 'POST',
body: JSON.stringify({
nickname: 'student_ivan',
workplaceNumber: 'PC-15' // ✨ Добавлено
})
});
// Новое отображение участников
participants.forEach(p => {
const workplace = p.workplaceNumber ? `[${p.workplaceNumber}] ` : '';
console.log(`${workplace}${p.nickname}: ${p.progressPercent}%`);
// Вывод: "[PC-15] student_ivan: 50%"
});
```
---
## Обратная совместимость
**Все изменения обратно совместимы:**
- Поле `workplaceNumber` опционально при авторизации
- Старый код без `workplaceNumber` продолжит работать
- Если `workplaceNumber` не указан, в ответах будет `undefined`
- Поиск пользователей по-прежнему работает только по `nickname`
---
## Рекомендации
1. **При авторизации**: Всегда передавайте `workplaceNumber`, если он известен (например, определяйте автоматически по IP или позволяйте ученику выбрать)
2. **В UI**: Отображайте номер места рядом с именем ученика для удобства преподавателя
3. **Сортировка**: При отображении списка учеников сортируйте по `workplaceNumber` для соответствия физическому расположению
4. **Валидация**: Проверяйте формат `workplaceNumber` на фронте (например, "PC-01", "Место 15")
5. **Обновление**: Если ученик пересел, просто авторизуйтесь с новым `workplaceNumber` - значение автоматически обновится
---
## Вопросы и поддержка
При возникновении вопросов обращайтесь к бэкенд-команде или создавайте issue в репозитории проекта.
+3 -2
View File
@@ -18,11 +18,12 @@ module.exports = {
/* use https://admin.bro-js.ru/ to create config, navigations and features */ /* 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: {
+236
View File
@@ -0,0 +1,236 @@
# Техническое задание: Эндпоинт получения попыток по цепочке
## Цель
Создать новый API эндпоинт для получения списка попыток (submissions) участников в рамках конкретной цепочки заданий. Это упростит работу админ-панели и уменьшит объём передаваемых данных.
## Текущая проблема
Сейчас для отображения попыток по цепочке фронтенд должен:
1. Загрузить список цепочек (`GET /challenge/chains/admin`)
2. Загрузить общую статистику (`GET /challenge/stats/v2`)
3. Для каждого участника отдельно загрузить его submissions (`GET /challenge/user/:userId/submissions`)
4. На клиенте фильтровать submissions по taskIds из выбранной цепочки
Это создаёт избыточные запросы и усложняет логику на фронтенде.
---
## Новый эндпоинт
### `GET /challenge/chain/:chainId/submissions`
Возвращает все попытки всех участников для заданий из указанной цепочки.
### Параметры URL
| Параметр | Тип | Обязательный | Описание |
|----------|-----|--------------|----------|
| `chainId` | string | Да | ID цепочки заданий |
### Query параметры (опциональные)
| Параметр | Тип | По умолчанию | Описание |
|----------|-----|--------------|----------|
| `userId` | string | - | Фильтр по конкретному пользователю |
| `status` | string | - | Фильтр по статусу: `pending`, `in_progress`, `accepted`, `needs_revision` |
| `limit` | number | 100 | Лимит записей |
| `offset` | number | 0 | Смещение для пагинации |
### Формат ответа
```typescript
interface ChainSubmissionsResponse {
success: boolean;
body: {
chain: {
id: string;
name: string;
tasks: Array<{
id: string;
title: string;
}>;
};
participants: Array<{
userId: string;
nickname: string;
completedTasks: number;
totalTasks: number;
progressPercent: number;
}>;
submissions: Array<{
id: string;
user: {
id: string;
nickname: string;
};
task: {
id: string;
title: string;
};
status: 'pending' | 'in_progress' | 'accepted' | 'needs_revision';
attemptNumber: number;
submittedAt: string; // ISO date
checkedAt?: string; // ISO date
feedback?: string;
}>;
pagination: {
total: number;
limit: number;
offset: number;
};
};
}
```
### Пример запроса
```bash
GET /api/challenge/chain/607f1f77bcf86cd799439021/submissions?status=needs_revision&limit=50
```
### Пример ответа
```json
{
"success": true,
"body": {
"chain": {
"id": "607f1f77bcf86cd799439021",
"name": "Основы JavaScript",
"tasks": [
{ "id": "507f1f77bcf86cd799439011", "title": "Реализовать сортировку массива" },
{ "id": "507f1f77bcf86cd799439015", "title": "Валидация формы" }
]
},
"participants": [
{
"userId": "user_123",
"nickname": "alex_dev",
"completedTasks": 1,
"totalTasks": 2,
"progressPercent": 50
},
{
"userId": "user_456",
"nickname": "maria_coder",
"completedTasks": 2,
"totalTasks": 2,
"progressPercent": 100
}
],
"submissions": [
{
"id": "sub_001",
"user": {
"id": "user_123",
"nickname": "alex_dev"
},
"task": {
"id": "507f1f77bcf86cd799439011",
"title": "Реализовать сортировку массива"
},
"status": "needs_revision",
"attemptNumber": 2,
"submittedAt": "2024-12-10T14:30:00.000Z",
"checkedAt": "2024-12-10T14:30:45.000Z",
"feedback": "Алгоритм работает неверно для отрицательных чисел"
}
],
"pagination": {
"total": 15,
"limit": 50,
"offset": 0
}
}
}
```
---
## Логика на бэкенде
### Алгоритм
1. Получить цепочку по `chainId`
2. Если цепочка не найдена — вернуть 404
3. Получить список `taskIds` из цепочки
4. Найти все submissions где `task._id` входит в `taskIds`
5. Применить фильтры (`userId`, `status`) если указаны
6. Вычислить прогресс по каждому участнику:
- Найти уникальных пользователей из submissions
- Для каждого посчитать `completedTasks` (количество уникальных tasks со статусом `accepted`)
- Рассчитать `progressPercent = (completedTasks / totalTasks) * 100`
7. Применить пагинацию к submissions
8. Вернуть результат
### Индексы MongoDB (рекомендуется)
```javascript
// Для быстрой выборки submissions по task
db.submissions.createIndex({ "task": 1, "submittedAt": -1 })
// Составной индекс для фильтрации
db.submissions.createIndex({ "task": 1, "status": 1, "submittedAt": -1 })
```
---
## Права доступа
Эндпоинт должен быть доступен только пользователям с ролями:
- `challenge-admin`
- `challenge-teacher`
---
## Коды ошибок
| Код | Описание |
|-----|----------|
| 200 | Успешный ответ |
| 400 | Некорректные параметры запроса |
| 401 | Не авторизован |
| 403 | Недостаточно прав |
| 404 | Цепочка не найдена |
| 500 | Внутренняя ошибка сервера |
---
## Изменения на фронтенде после реализации
После добавления эндпоинта в `src/__data__/api/api.ts` нужно добавить:
```typescript
// В endpoints builder
getChainSubmissions: builder.query<ChainSubmissionsResponse, {
chainId: string;
userId?: string;
status?: SubmissionStatus;
limit?: number;
offset?: number;
}>({
query: ({ chainId, userId, status, limit, offset }) => ({
url: `/challenge/chain/${chainId}/submissions`,
params: { userId, status, limit, offset },
}),
transformResponse: (response: { body: ChainSubmissionsResponse }) => response.body,
providesTags: ['Submission'],
}),
```
Это позволит упростить `SubmissionsPage.tsx`:
- Один запрос вместо нескольких
- Убрать клиентскую фильтрацию по taskIds
- Получать готовый прогресс участников
---
## Приоритет
**Средний** — текущая реализация работает, но создаёт избыточную нагрузку при большом количестве участников.
## Оценка трудозатрат
~4-6 часов (включая тесты)
+202
View File
@@ -0,0 +1,202 @@
# Добавление поля learningMaterial в задачу челленджа
## Описание изменений
В модель задачи челленджа (`ChallengeTask`) добавлено новое необязательное текстовое поле `learningMaterial` для хранения дополнительной обучающей информации в формате Markdown.
## Структура данных
### Модель ChallengeTask
```typescript
{
title: string, // Заголовок задания (обязательное)
description: string, // Основное описание в Markdown (обязательное, видно студентам)
learningMaterial: string, // Дополнительный учебный материал в Markdown (необязательное, видно студентам)
hiddenInstructions: string, // Скрытые инструкции для LLM (необязательное, только для преподавателей)
createdAt: Date, // Дата создания
updatedAt: Date, // Дата последнего обновления
creator: Object // Данные создателя из Keycloak
}
```
## Изменения в API
### 1. Создание задания (POST /challenge/task)
**Добавлено поле в тело запроса:**
```json
{
"title": "Название задания",
"description": "Основное описание в Markdown",
"learningMaterial": "Дополнительный учебный материал в Markdown",
"hiddenInstructions": "Скрытые инструкции для преподавателей"
}
```
**Пример запроса:**
```bash
POST /challenge/task
Content-Type: application/json
{
"title": "Реализация алгоритма сортировки",
"description": "Напишите функцию сортировки массива методом пузырька",
"learningMaterial": "## Теория\n\nМетод пузырьковой сортировки работает путем...\n\n## Полезные ссылки\n- [Википедия](https://ru.wikipedia.org/wiki/Сортировка_пузырьком)\n- [Видео объяснение](https://example.com/video)",
"hiddenInstructions": "Оценить эффективность алгоритма и стиль кода"
}
```
### 2. Обновление задания (PUT /challenge/task/:taskId)
**Добавлено поле в тело запроса:**
```json
{
"title": "Новое название",
"description": "Обновленное описание",
"learningMaterial": "Обновленный учебный материал",
"hiddenInstructions": "Обновленные инструкции"
}
```
## Получение данных
### Получение задания (GET /challenge/task/:taskId)
**Ответ содержит новое поле:**
```json
{
"id": "task_id",
"title": "Название задания",
"description": "Основное описание в Markdown",
"learningMaterial": "Дополнительный учебный материал в Markdown",
"createdAt": "2025-01-15T10:00:00.000Z",
"updatedAt": "2025-01-15T10:30:00.000Z"
}
```
**Важно:** Поле `learningMaterial` видно всем пользователям (студентам и преподавателям), в отличие от `hiddenInstructions`, которое скрывается от студентов.
### Получение всех заданий (GET /challenge/tasks)
Возвращает массив заданий с новым полем `learningMaterial`.
### Получение цепочек (GET /challenge/chains, GET /challenge/chain/:chainId)
При получении цепочек с populate заданий, поле `learningMaterial` будет доступно в каждом задании цепочки.
## Frontend изменения
### Интерфейсы TypeScript
```typescript
interface ChallengeTask {
id: string;
title: string;
description: string; // Markdown
learningMaterial?: string; // Новое поле - дополнительный материал в Markdown
createdAt: string;
updatedAt: string;
}
```
### Формы создания/редактирования заданий
В формах создания и редактирования заданий необходимо добавить поле для ввода `learningMaterial`:
```typescript
// Пример компонента формы
const TaskForm = () => {
const [formData, setFormData] = useState({
title: '',
description: '',
learningMaterial: '', // Новое поле
hiddenInstructions: ''
});
// Визуальный редактор или textarea для learningMaterial
return (
<form>
<input name="title" value={formData.title} />
<textarea name="description" value={formData.description} />
{/* Новое поле для дополнительного материала */}
<label>Дополнительный учебный материал (Markdown)</label>
<textarea
name="learningMaterial"
value={formData.learningMaterial}
placeholder="Дополнительные объяснения, ссылки, примеры..."
/>
{/* Только для преподавателей */}
<textarea name="hiddenInstructions" value={formData.hiddenInstructions} />
</form>
);
};
```
### Отображение заданий
При отображении задания студентам показывать `learningMaterial` как дополнительную информацию:
```typescript
const TaskView = ({ task }: { task: ChallengeTask }) => {
return (
<div>
<h1>{task.title}</h1>
{/* Основное описание */}
<div dangerouslySetInnerHTML={{ __html: marked(task.description) }} />
{/* Дополнительный учебный материал */}
{task.learningMaterial && (
<div className="learning-material">
<h2>Дополнительные материалы</h2>
<div dangerouslySetInnerHTML={{ __html: marked(task.learningMaterial) }} />
</div>
)}
</div>
);
};
```
## Миграция данных
Поле `learningMaterial` добавлено как необязательное с значением по умолчанию `''`, поэтому:
- Существующие задания будут работать без изменений
- Новое поле будет пустым для старых заданий
- Можно постепенно добавлять учебный материал к существующим заданиям
## Тестирование
### Создание задания с учебным материалом
```bash
# Создать задание с дополнительным материалом
POST /challenge/task
{
"title": "Тестовое задание",
"description": "Основное задание",
"learningMaterial": "# Полезная информация\n\nЭто дополнительный материал для студентов"
}
```
### Получение задания
```bash
GET /challenge/task/{taskId}
# Проверить, что learningMaterial присутствует в ответе
```
### Обновление учебного материала
```bash
PUT /challenge/task/{taskId}
{
"learningMaterial": "# Обновленная информация\n\nНовые полезные материалы..."
}
```
## Влияние на существующий код
- Все существующие эндпоинты получения данных автоматически возвращают новое поле
- Создание заданий без указания `learningMaterial` работает как прежде
- Фильтрация и валидация не затрагиваются
- Поле индексируется MongoDB автоматически
+243
View File
@@ -0,0 +1,243 @@
## Обновление API Challenge Service
Документ для frontend-разработчика. Описывает НОВЫЕ возможности и требования к клиенту.
Содержит два блока изменений:
- **Управление видимостью цепочек заданий** (поле `isActive` и новый админский эндпоинт).
- **Тестовая проверка решения задания админом** (флаг `isTest` и опциональные `hiddenInstructions` в `/submit`).
---
## 1. Управление видимостью цепочек заданий
### 1.1. Новое поле в модели цепочки
**Поле `isActive`**
- **Тип**: `boolean`
- **По умолчанию**: `true`
- **Смысл**: определяет, видна ли цепочка обычным пользователям в пользовательском списке.
> В базе: поле уже есть в модели `ChallengeChain`, на фронте его нужно учитывать в админских интерфейсах.
---
### 1.2. Пользовательский список цепочек
#### `GET /api/challenge/chains`
- **Назначение**: список цепочек для студентов/обычных пользователей.
- **Фильтрация на бэке**: возвращаются **только цепочки с `isActive: true`**.
- **Доступ**: без специальных ролей.
**Гарантии для фронтенда:**
- Выключенные / черновые цепочки **никогда** не попадут в этот список.
- Можно строить каталог цепочек, не фильтруя по `isActive` на клиенте.
Упрощённая структура элемента:
```json
{
"id": "...",
"name": "Основы программирования",
"tasks": [
{
"id": "...",
"title": "...",
"description": "..."
// Для не-преподавателей поля hiddenInstructions и creator отсутствуют
}
],
"isActive": true
}
```
**Требования к фронтенду:**
- Для пользовательских экранов достаточно этого эндпоинта, **дополнительную фильтрацию по активности делать не нужно**.
---
### 1.3. Админский список цепочек
#### `GET /api/challenge/chains/admin`
- **Назначение**: полный список цепочек (и включённых, и выключенных) для админских/преподавательских экранов.
- **Фильтрации по активности нет** — возвращаются **все** цепочки.
- **Доступ**: только роли `teacher` или `challenge-author`.
- Включает все данные по задачам, в т.ч. `hiddenInstructions`, `creator`.
Пример ответа (фрагмент):
```json
{
"error": null,
"data": [
{
"id": "...",
"name": "Основы программирования",
"tasks": [
{
"id": "...",
"title": "...",
"description": "...",
"hiddenInstructions": "...",
"creator": { "sub": "...", "preferred_username": "teacher1" }
}
],
"isActive": true,
"createdAt": "2023-10-29T12:00:00.000Z",
"updatedAt": "2023-10-29T12:00:00.000Z"
}
]
}
```
**Требования к фронтенду (админский UI):**
- Использовать этот эндпоинт для экранов управления цепочками.
- Показывать состояние активности (`isActive`) каждой цепочки (badge, тумблер и т.п.).
- При ошибке 403 (нет роли `teacher` / `challenge-author`) отображать сообщение об отсутствии доступа и, при необходимости, перенаправлять на пользовательский список.
---
### 1.4. Создание и обновление цепочек с учётом активности
#### `POST /api/challenge/chain`
**Роли**: `teacher` или `challenge-author`.
**Тело запроса:**
```json
{
"name": "Основы программирования",
"taskIds": ["...", "..."],
"isActive": true // опционально, по умолчанию true
}
```
- Если `isActive` не передан, цепочка создаётся **активной**.
**Требования к фронтенду:**
- На форме создания цепочки можно:
- либо не показывать тумблер активности (все новые будут активными),
- либо добавить переключатель «Активна» и передавать `isActive: false` для черновиков.
#### `PUT /api/challenge/chain/:chainId`
**Роли**: `teacher` или `challenge-author`.
**Тело запроса (все поля опциональны):**
```json
{
"name": "Новое имя",
"taskIds": ["..."],
"isActive": false
}
```
- Если `isActive` передан, его значение меняет активность цепочки.
- Если `isActive` не передан, активность не меняется.
**Сценарии:**
- Включить цепочку: `PUT /api/challenge/chain/:id` с `{ "isActive": true }`.
- Выключить цепочку (спрятать из пользовательского списка): `{ "isActive": false }`.
- Переименовать / поменять задачи без изменения активности: отправлять только `name` / `taskIds` без поля `isActive`.
**Требования к UI:**
- На экране «управление цепочками» (данные из `/chains/admin`):
- показывать `isActive`;
- давать возможность включать/выключать цепочку (тумблер → вызов `PUT /chain/:id` с нужным `isActive`).
---
## 2. Тестовая проверка решения задания (без записи прогресса)
Добавлен режим тестовой проверки решения, который позволяет **преподавателю/автору** проверить ответ через LLM **без создания попытки и без постановки в очередь**.
### 2.1. Расширение эндпоинта отправки решения
#### `POST /api/challenge/submit`
К существующему API добавлены новые опциональные поля в теле запроса:
```json
{
"userId": "...",
"taskId": "...",
"result": "...",
"isTest": true, // НОВОЕ: флаг тестового режима
"hiddenInstructions": "..." // НОВОЕ: опциональные инструкции для проверки
}
```
### 2.2. Обычный режим (без `isTest`)
- Если `isTest` **не передан** или `false` — поведение **НЕ изменилось**:
- проверяется существование пользователя по `userId`;
- считается количество попыток;
- создаётся `ChallengeSubmission`;
- попытка ставится в очередь на проверку через LLM;
- в ответе фронтенд получает `queueId` и `submissionId`.
### 2.3. Тестовый режим (`isTest: true`)
- Доступен только для ролей `teacher` / `challenge-author` (проверка через `isTeacher(req, true)`).
- **Не создаётся** запись `ChallengeSubmission`.
- **Не используется** очередь проверки.
- Проверяется только существование задания (`taskId`), пользователь по `userId` в этом режиме **не ищется и не нужен** (но поле всё ещё формально обязательно по схеме).
- Если переданы `hiddenInstructions`, они используются **вместо** `task.hiddenInstructions` при формировании промпта для LLM.
- Никакие изменения инструкций, переданные через `hiddenInstructions`, **не сохраняются** в базу — это чисто временная инструкция для одной тестовой проверки.
**Пример запроса (тестовый режим):**
```http
POST /api/challenge/submit
Content-Type: application/json
Authorization: Bearer <keycloak_token_teacher_or_author>
{
"userId": "any-or-dummy-id",
"taskId": "507f1f77bcf86cd799439012",
"result": "function solve() { ... }",
"isTest": true,
"hiddenInstructions": "ВРЕМЕННЫЕ инструкции для проверки, не сохраняются"
}
```
**Пример ответа (тестовый режим):**
```json
{
"error": null,
"data": {
"isTest": true,
"status": "accepted", // или "needs_revision"
"feedback": "Развёрнутый комментарий от LLM"
}
}
```
При отсутствии прав (нет роли `teacher` / `challenge-author`) вернётся 403.
### 2.4. Требования к фронтенду
- **Где использовать тестовый режим**:
- только в админских/преподавательских интерфейсах (например, экран настройки задания или предпросмотр проверки);
- использовать флаг `isTest: true`, когда нужно получить мгновенный ответ от LLM без записи в историю;
- при наличии UI-редактора скрытых инструкций использовать `hiddenInstructions` для передачи временного варианта, не сохраняя его.
- **Где НЕ использовать**:
- в пользовательском флоу сдачи заданий студентами — там должен использоваться обычный режим **без** `isTest`.
- **UI-ожидания**:
- показывать администратору статус (`accepted` / `needs_revision`) и `feedback`;
- явно обозначить в интерфейсе, что это «тестовая проверка» и она **не попадает в статистику / попытки**, а переданные `hiddenInstructions` не сохраняются.
---
## 3. Краткое резюме
- Для цепочек:
- пользовательский список: `GET /api/challenge/chains` → только активные (`isActive: true`);
- админский список: `GET /api/challenge/chains/admin` → все цепочки + управление `isActive` через `POST/PUT /chain`.
- Для отправки решений:
- обычный режим без `isTest` — всё как раньше (очередь, попытки, статистика);
- тестовый режим с `isTest: true` + опциональные `hiddenInstructions` — только для `teacher/challenge-author`, без записи прогресса, сразу возвращает результат проверки с учётом временных инструкций.
+60
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...",
@@ -54,6 +57,18 @@
"challenge.admin.tasks.delete.confirm.title": "Delete task", "challenge.admin.tasks.delete.confirm.title": "Delete task",
"challenge.admin.tasks.delete.confirm.message": "Are you sure you want to delete task \"{title}\"? This action cannot be undone.", "challenge.admin.tasks.delete.confirm.message": "Are you sure you want to delete task \"{title}\"? This action cannot be undone.",
"challenge.admin.tasks.delete.confirm.button": "Delete", "challenge.admin.tasks.delete.confirm.button": "Delete",
"challenge.admin.tasks.test.title": "Test check of answer",
"challenge.admin.tasks.test.description": "Send a sample answer to see how the LLM will evaluate this task with hidden instructions applied. This check does not affect statistics or attempt history.",
"challenge.admin.tasks.test.field.answer": "Answer for test check",
"challenge.admin.tasks.test.field.answer.placeholder": "Enter a sample solution as a student would write it...",
"challenge.admin.tasks.test.field.answer.helper": "The answer is sent in test mode (isTest: true) — no submission is created and no queue job is scheduled.",
"challenge.admin.tasks.test.button.run": "Run test check",
"challenge.admin.tasks.test.success": "Test check completed",
"challenge.admin.tasks.test.error": "Failed to run test check",
"challenge.admin.tasks.test.forbidden": "You don't have permissions for test checking. Teacher or challenge-author role is required.",
"challenge.admin.tasks.test.validation.fill.answer": "Enter an answer text for test check",
"challenge.admin.tasks.test.status.accepted": "✅ Answer accepted",
"challenge.admin.tasks.test.status.needs_revision": "⚠️ Answer needs revision",
"challenge.admin.chains.updated": "Chain updated", "challenge.admin.chains.updated": "Chain updated",
"challenge.admin.chains.created": "Chain created", "challenge.admin.chains.created": "Chain created",
"challenge.admin.chains.validation.enter.name": "Enter chain name", "challenge.admin.chains.validation.enter.name": "Enter chain name",
@@ -74,6 +89,8 @@
"challenge.admin.chains.button.add": "+ Add", "challenge.admin.chains.button.add": "+ Add",
"challenge.admin.chains.button.save": "Save changes", "challenge.admin.chains.button.save": "Save changes",
"challenge.admin.chains.button.create": "Create chain", "challenge.admin.chains.button.create": "Create chain",
"challenge.admin.chains.field.isActive": "Active for students",
"challenge.admin.chains.field.isActive.helper": "If disabled, the chain will not appear in the user-facing list.",
"challenge.admin.chains.list.title": "Task Chains", "challenge.admin.chains.list.title": "Task Chains",
"challenge.admin.chains.list.create.button": "+ Create Chain", "challenge.admin.chains.list.create.button": "+ Create Chain",
"challenge.admin.chains.list.search.placeholder": "Search by name...", "challenge.admin.chains.list.search.placeholder": "Search by name...",
@@ -84,8 +101,11 @@
"challenge.admin.chains.list.table.name": "Name", "challenge.admin.chains.list.table.name": "Name",
"challenge.admin.chains.list.table.tasks.count": "Number of tasks", "challenge.admin.chains.list.table.tasks.count": "Number of tasks",
"challenge.admin.chains.list.table.created": "Created date", "challenge.admin.chains.list.table.created": "Created date",
"challenge.admin.chains.list.table.status": "Status",
"challenge.admin.chains.list.table.actions": "Actions", "challenge.admin.chains.list.table.actions": "Actions",
"challenge.admin.chains.list.badge.tasks": "tasks", "challenge.admin.chains.list.badge.tasks": "tasks",
"challenge.admin.chains.list.status.active": "Active",
"challenge.admin.chains.list.status.inactive": "Inactive",
"challenge.admin.chains.list.button.edit": "Edit", "challenge.admin.chains.list.button.edit": "Edit",
"challenge.admin.chains.list.button.delete": "Delete", "challenge.admin.chains.list.button.delete": "Delete",
"challenge.admin.chains.deleted": "Chain deleted", "challenge.admin.chains.deleted": "Chain deleted",
@@ -95,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",
@@ -123,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",
@@ -137,16 +173,33 @@
"challenge.admin.users.stats.chains.progress": "Chain progress", "challenge.admin.users.stats.chains.progress": "Chain progress",
"challenge.admin.users.stats.tasks": "Tasks", "challenge.admin.users.stats.tasks": "Tasks",
"challenge.admin.users.stats.status.completed": "Completed", "challenge.admin.users.stats.status.completed": "Completed",
"challenge.admin.users.stats.status.accepted": "Accepted",
"challenge.admin.users.stats.status.needs_revision": "Revision", "challenge.admin.users.stats.status.needs_revision": "Revision",
"challenge.admin.users.stats.status.in_progress": "In progress", "challenge.admin.users.stats.status.in_progress": "In progress",
"challenge.admin.users.stats.status.pending": "Pending",
"challenge.admin.users.stats.status.not_started": "Not started", "challenge.admin.users.stats.status.not_started": "Not started",
"challenge.admin.users.stats.status.not_attempted": "Not attempted",
"challenge.admin.users.stats.attempts": "Attempts:", "challenge.admin.users.stats.attempts": "Attempts:",
"challenge.admin.users.stats.avg.check.time": "Average check time", "challenge.admin.users.stats.avg.check.time": "Average check time",
"challenge.admin.users.stats.close": "Close", "challenge.admin.users.stats.close": "Close",
"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.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",
@@ -158,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",
@@ -176,6 +230,12 @@
"challenge.admin.submissions.details.solution": "User solution:", "challenge.admin.submissions.details.solution": "User solution:",
"challenge.admin.submissions.details.feedback": "LLM feedback:", "challenge.admin.submissions.details.feedback": "LLM feedback:",
"challenge.admin.submissions.details.close": "Close", "challenge.admin.submissions.details.close": "Close",
"challenge.admin.submissions.details.not.found": "Attempt not found",
"challenge.admin.submissions.overview.title": "Overall participant progress",
"challenge.admin.submissions.overview.description": "Below is an overview of all participants and chains. Select a user above to see their individual attempts.",
"challenge.admin.submissions.overview.table.user": "Participant",
"challenge.admin.submissions.overview.table.chain": "Chain",
"challenge.admin.submissions.overview.table.progress": "Progress",
"challenge.admin.layout.title": "Challenge Admin", "challenge.admin.layout.title": "Challenge Admin",
"challenge.admin.layout.nav.dashboard": "Dashboard", "challenge.admin.layout.nav.dashboard": "Dashboard",
"challenge.admin.layout.nav.detailed.stats": "Detailed Statistics", "challenge.admin.layout.nav.detailed.stats": "Detailed Statistics",
+60
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": "Предпросмотр появится здесь...",
@@ -53,6 +56,18 @@
"challenge.admin.tasks.delete.confirm.title": "Удалить задание", "challenge.admin.tasks.delete.confirm.title": "Удалить задание",
"challenge.admin.tasks.delete.confirm.message": "Вы уверены, что хотите удалить задание \"{title}\"? Это действие нельзя отменить.", "challenge.admin.tasks.delete.confirm.message": "Вы уверены, что хотите удалить задание \"{title}\"? Это действие нельзя отменить.",
"challenge.admin.tasks.delete.confirm.button": "Удалить", "challenge.admin.tasks.delete.confirm.button": "Удалить",
"challenge.admin.tasks.test.title": "Тестовая проверка ответа",
"challenge.admin.tasks.test.description": "Отправьте пример ответа, чтобы проверить, как LLM будет оценивать это задание с учётом скрытых инструкций. Эта проверка не попадает в статистику и историю попыток.",
"challenge.admin.tasks.test.field.answer": "Ответ для тестовой проверки",
"challenge.admin.tasks.test.field.answer.placeholder": "Введите пример решения так, как его написал бы студент...",
"challenge.admin.tasks.test.field.answer.helper": "Ответ отправляется в режиме тестовой проверки (isTest: true) — без создания попытки и постановки в очередь.",
"challenge.admin.tasks.test.button.run": "Проверить ответ",
"challenge.admin.tasks.test.success": "Тестовая проверка выполнена",
"challenge.admin.tasks.test.error": "Не удалось выполнить тестовую проверку",
"challenge.admin.tasks.test.forbidden": "Недостаточно прав для тестовой проверки. Нужна роль преподавателя или автора челленджа.",
"challenge.admin.tasks.test.validation.fill.answer": "Введите текст ответа для тестовой проверки",
"challenge.admin.tasks.test.status.accepted": "✅ Ответ принят (accepted)",
"challenge.admin.tasks.test.status.needs_revision": "⚠️ Ответ требует доработки (needs_revision)",
"challenge.admin.chains.updated": "Цепочка обновлена", "challenge.admin.chains.updated": "Цепочка обновлена",
"challenge.admin.chains.created": "Цепочка создана", "challenge.admin.chains.created": "Цепочка создана",
"challenge.admin.chains.validation.enter.name": "Введите название цепочки", "challenge.admin.chains.validation.enter.name": "Введите название цепочки",
@@ -73,6 +88,8 @@
"challenge.admin.chains.button.add": "+ Добавить", "challenge.admin.chains.button.add": "+ Добавить",
"challenge.admin.chains.button.save": "Сохранить изменения", "challenge.admin.chains.button.save": "Сохранить изменения",
"challenge.admin.chains.button.create": "Создать цепочку", "challenge.admin.chains.button.create": "Создать цепочку",
"challenge.admin.chains.field.isActive": "Активна для студентов",
"challenge.admin.chains.field.isActive.helper": "Если выключить, цепочка не будет отображаться в пользовательском списке.",
"challenge.admin.chains.list.title": "Цепочки заданий", "challenge.admin.chains.list.title": "Цепочки заданий",
"challenge.admin.chains.list.create.button": "+ Создать цепочку", "challenge.admin.chains.list.create.button": "+ Создать цепочку",
"challenge.admin.chains.list.search.placeholder": "Поиск по названию...", "challenge.admin.chains.list.search.placeholder": "Поиск по названию...",
@@ -83,8 +100,11 @@
"challenge.admin.chains.list.table.name": "Название", "challenge.admin.chains.list.table.name": "Название",
"challenge.admin.chains.list.table.tasks.count": "Количество заданий", "challenge.admin.chains.list.table.tasks.count": "Количество заданий",
"challenge.admin.chains.list.table.created": "Дата создания", "challenge.admin.chains.list.table.created": "Дата создания",
"challenge.admin.chains.list.table.status": "Статус",
"challenge.admin.chains.list.table.actions": "Действия", "challenge.admin.chains.list.table.actions": "Действия",
"challenge.admin.chains.list.badge.tasks": "заданий", "challenge.admin.chains.list.badge.tasks": "заданий",
"challenge.admin.chains.list.status.active": "Включена",
"challenge.admin.chains.list.status.inactive": "Выключена",
"challenge.admin.chains.list.button.edit": "Редактировать", "challenge.admin.chains.list.button.edit": "Редактировать",
"challenge.admin.chains.list.button.delete": "Удалить", "challenge.admin.chains.list.button.delete": "Удалить",
"challenge.admin.chains.deleted": "Цепочка удалена", "challenge.admin.chains.deleted": "Цепочка удалена",
@@ -94,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": "Не удалось загрузить статистику системы",
@@ -122,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": "Действия",
@@ -136,16 +172,33 @@
"challenge.admin.users.stats.chains.progress": "Прогресс по цепочкам", "challenge.admin.users.stats.chains.progress": "Прогресс по цепочкам",
"challenge.admin.users.stats.tasks": "Задания", "challenge.admin.users.stats.tasks": "Задания",
"challenge.admin.users.stats.status.completed": "Завершено", "challenge.admin.users.stats.status.completed": "Завершено",
"challenge.admin.users.stats.status.accepted": "Принято",
"challenge.admin.users.stats.status.needs_revision": "Доработка", "challenge.admin.users.stats.status.needs_revision": "Доработка",
"challenge.admin.users.stats.status.in_progress": "В процессе", "challenge.admin.users.stats.status.in_progress": "В процессе",
"challenge.admin.users.stats.status.pending": "Ожидает",
"challenge.admin.users.stats.status.not_started": "Не начато", "challenge.admin.users.stats.status.not_started": "Не начато",
"challenge.admin.users.stats.status.not_attempted": "Не пытался",
"challenge.admin.users.stats.attempts": "Попыток:", "challenge.admin.users.stats.attempts": "Попыток:",
"challenge.admin.users.stats.avg.check.time": "Среднее время проверки", "challenge.admin.users.stats.avg.check.time": "Среднее время проверки",
"challenge.admin.users.stats.close": "Закрыть", "challenge.admin.users.stats.close": "Закрыть",
"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.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": "Принято",
@@ -157,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": "Попытка",
@@ -175,6 +229,12 @@
"challenge.admin.submissions.details.solution": "Решение пользователя:", "challenge.admin.submissions.details.solution": "Решение пользователя:",
"challenge.admin.submissions.details.feedback": "Обратная связь от LLM:", "challenge.admin.submissions.details.feedback": "Обратная связь от LLM:",
"challenge.admin.submissions.details.close": "Закрыть", "challenge.admin.submissions.details.close": "Закрыть",
"challenge.admin.submissions.details.not.found": "Попытка не найдена",
"challenge.admin.submissions.overview.title": "Общий прогресс по участникам",
"challenge.admin.submissions.overview.description": "Ниже — сводка по прогрессу всех участников и цепочек. Выберите пользователя выше, чтобы просмотреть его отдельные попытки.",
"challenge.admin.submissions.overview.table.user": "Участник",
"challenge.admin.submissions.overview.table.chain": "Цепочка",
"challenge.admin.submissions.overview.table.progress": "Прогресс",
"challenge.admin.layout.title": "Challenge Admin", "challenge.admin.layout.title": "Challenge Admin",
"challenge.admin.layout.nav.dashboard": "Dashboard", "challenge.admin.layout.nav.dashboard": "Dashboard",
"challenge.admin.layout.nav.detailed.stats": "Детальная статистика", "challenge.admin.layout.nav.detailed.stats": "Детальная статистика",
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "challenge-admin-pl", "name": "challenge-admin-pl",
"version": "1.0.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.0.0", "version": "1.5.2",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@brojs/cli": "^1.9.4", "@brojs/cli": "^1.9.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "challenge-admin", "name": "challenge-admin",
"version": "1.0.0", "version": "1.5.2",
"description": "", "description": "",
"main": "./src/index.tsx", "main": "./src/index.tsx",
"scripts": { "scripts": {
+56 -1
View File
@@ -13,6 +13,12 @@ import type {
UpdateTaskRequest, UpdateTaskRequest,
CreateChainRequest, CreateChainRequest,
UpdateChainRequest, UpdateChainRequest,
DuplicateChainRequest,
ClearSubmissionsResponse,
SubmitRequest,
TestSubmissionResult,
ChainSubmissionsResponse,
SubmissionStatus,
} from '../../types/challenge' } from '../../types/challenge'
export const api = createApi({ export const api = createApi({
@@ -77,7 +83,7 @@ export const api = createApi({
// Chains // Chains
getChains: builder.query<ChallengeChain[], void>({ getChains: builder.query<ChallengeChain[], void>({
query: () => '/challenge/chains', query: () => '/challenge/chains/admin',
transformResponse: (response: { body: ChallengeChain[] }) => response.body, transformResponse: (response: { body: ChallengeChain[] }) => response.body,
providesTags: ['Chain'], providesTags: ['Chain'],
}), }),
@@ -111,6 +117,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>({
@@ -141,6 +164,34 @@ 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)
testSubmission: builder.mutation<TestSubmissionResult, SubmitRequest>({
query: ({ userId, taskId, result, isTest = true, hiddenInstructions }) => ({
url: '/challenge/submit',
method: 'POST',
body: {
userId,
taskId,
result,
isTest,
hiddenInstructions,
},
}),
// Сервер возвращает { success: boolean; body: TestSubmissionResult }
transformResponse: (response: { success: boolean; body: TestSubmissionResult }) => response.body,
}),
}), }),
}) })
@@ -155,9 +206,13 @@ export const {
useCreateChainMutation, useCreateChainMutation,
useUpdateChainMutation, useUpdateChainMutation,
useDeleteChainMutation, useDeleteChainMutation,
useDuplicateChainMutation,
useClearChainSubmissionsMutation,
useGetSystemStatsQuery, useGetSystemStatsQuery,
useGetSystemStatsV2Query, useGetSystemStatsV2Query,
useGetUserStatsQuery, useGetUserStatsQuery,
useGetUserSubmissionsQuery, useGetUserSubmissionsQuery,
useGetChainSubmissionsQuery,
useTestSubmissionMutation,
} = api } = api
+2 -2
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
}); })
+7 -1
View File
@@ -31,11 +31,17 @@ export const URLs = {
// Users // Users
users: makeUrl('/users'), users: makeUrl('/users'),
userStats: (userId: string) => makeUrl(`/users/${userId}`),
userStatsPath: makeUrl('/users/:userId'),
// Submissions // Submissions
submissions: makeUrl('/submissions'), submissions: makeUrl('/submissions'),
submissionsChain: (chainId: string) => makeUrl(`/submissions/${chainId}`),
submissionsChainPath: makeUrl('/submissions/:chainId'),
submissionDetails: (chainId: string, userId: string, submissionId: string) => makeUrl(`/submissions/${chainId}/${userId}/${submissionId}`),
submissionDetailsPath: makeUrl('/submissions/:chainId/:userId/:submissionId'),
// External links // External links
challengePlayer: navs['link.challenge'] || '/challenge', challengePlayer: navs['link.challenge.main'] || '/challenge',
} }
+87
View File
@@ -0,0 +1,87 @@
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import {
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
Button,
Text,
} from '@chakra-ui/react'
import { useClearChainSubmissionsMutation } from '../__data__/api/api'
import { toaster } from './ui/toaster'
import type { ChallengeChain } from '../types/challenge'
interface ClearSubmissionsDialogProps {
isOpen: boolean
onClose: () => void
chain: ChallengeChain | null
}
export const ClearSubmissionsDialog: React.FC<ClearSubmissionsDialogProps> = ({
isOpen,
onClose,
chain,
}) => {
const { t } = useTranslation()
const [clearSubmissions, { isLoading }] = useClearChainSubmissionsMutation()
// Прокручиваем страницу к началу при открытии диалога
useEffect(() => {
if (isOpen) {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}, [isOpen])
const handleConfirm = async () => {
if (!chain) return
try {
await clearSubmissions(chain.id).unwrap()
toaster.create({
title: t('challenge.admin.common.success'),
description: t('challenge.admin.chains.clear.submissions.success'),
type: 'success',
})
onClose()
} catch (err) {
toaster.create({
title: t('challenge.admin.common.error'),
description: t('challenge.admin.chains.clear.submissions.error'),
type: 'error',
})
}
}
if (!chain) return null
return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} scrollBehavior="inside">
<DialogContent>
<DialogHeader>
<DialogTitle>{t('challenge.admin.chains.clear.submissions.dialog.title')}</DialogTitle>
</DialogHeader>
<DialogBody>
<Text>{t('challenge.admin.chains.clear.submissions.dialog.message', { name: chain.name })}</Text>
</DialogBody>
<DialogFooter>
<DialogActionTrigger asChild>
<Button variant="outline" onClick={onClose} disabled={isLoading}>
{t('challenge.admin.common.cancel')}
</Button>
</DialogActionTrigger>
<Button colorPalette="red" onClick={handleConfirm} disabled={isLoading}>
{t('challenge.admin.chains.clear.submissions.dialog.button.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
)
}
+10 -2
View File
@@ -1,4 +1,4 @@
import React from 'react' import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { 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>
+116
View File
@@ -0,0 +1,116 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import {
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
Button,
Field,
Input,
Text,
VStack,
} from '@chakra-ui/react'
import { useDuplicateChainMutation } from '../__data__/api/api'
import { toaster } from './ui/toaster'
import type { ChallengeChain } from '../types/challenge'
interface DuplicateChainDialogProps {
isOpen: boolean
onClose: () => void
chain: ChallengeChain | null
}
export const DuplicateChainDialog: React.FC<DuplicateChainDialogProps> = ({
isOpen,
onClose,
chain,
}) => {
const { t } = useTranslation()
const [name, setName] = useState('')
const [duplicateChain, { isLoading }] = useDuplicateChainMutation()
// Прокручиваем страницу к началу при открытии диалога
useEffect(() => {
if (isOpen) {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}, [isOpen])
const handleClose = () => {
setName('')
onClose()
}
const handleConfirm = async () => {
if (!chain) return
try {
await duplicateChain({
chainId: chain.id,
name: name.trim() || undefined,
}).unwrap()
toaster.create({
title: t('challenge.admin.common.success'),
description: t('challenge.admin.chains.duplicate.success'),
type: 'success',
})
handleClose()
} catch (err) {
toaster.create({
title: t('challenge.admin.common.error'),
description: t('challenge.admin.chains.duplicate.error'),
type: 'error',
})
}
}
if (!chain) return null
const defaultPlaceholder = t('challenge.admin.chains.duplicate.dialog.field.name.placeholder', {
name: chain.name,
})
return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && handleClose()} scrollBehavior="inside">
<DialogContent>
<DialogHeader>
<DialogTitle>{t('challenge.admin.chains.duplicate.dialog.title')}</DialogTitle>
</DialogHeader>
<DialogBody>
<VStack gap={4} align="stretch">
<Text>{t('challenge.admin.chains.duplicate.dialog.description', { name: chain.name })}</Text>
<Field.Root>
<Field.Label>{t('challenge.admin.chains.duplicate.dialog.field.name')}</Field.Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={defaultPlaceholder}
/>
<Field.HelperText>
{t('challenge.admin.chains.duplicate.dialog.field.name.helper')}
</Field.HelperText>
</Field.Root>
</VStack>
</DialogBody>
<DialogFooter>
<DialogActionTrigger asChild>
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
{t('challenge.admin.common.cancel')}
</Button>
</DialogActionTrigger>
<Button colorPalette="teal" onClick={handleConfirm} disabled={isLoading}>
{t('challenge.admin.chains.duplicate.dialog.button.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</DialogRoot>
)
}
+26
View File
@@ -9,7 +9,9 @@ import { TaskFormPage } from './pages/tasks/TaskFormPage'
import { ChainsListPage } from './pages/chains/ChainsListPage' import { ChainsListPage } from './pages/chains/ChainsListPage'
import { ChainFormPage } from './pages/chains/ChainFormPage' import { ChainFormPage } from './pages/chains/ChainFormPage'
import { UsersPage } from './pages/users/UsersPage' import { UsersPage } from './pages/users/UsersPage'
import { UserStatsPage } from './pages/users/UserStatsPage'
import { SubmissionsPage } from './pages/submissions/SubmissionsPage' import { SubmissionsPage } from './pages/submissions/SubmissionsPage'
import { SubmissionDetailsPage } from './pages/submissions/SubmissionDetailsPage'
import { URLs } from './__data__/urls' import { URLs } from './__data__/urls'
const PageWrapper = ({ children }: React.PropsWithChildren) => ( const PageWrapper = ({ children }: React.PropsWithChildren) => (
@@ -110,6 +112,14 @@ export const Dashboard = () => {
</PageWrapper> </PageWrapper>
} }
/> />
<Route
path={URLs.userStatsPath}
element={
<PageWrapper>
<UserStatsPage />
</PageWrapper>
}
/>
{/* Submissions */} {/* Submissions */}
<Route <Route
@@ -120,6 +130,22 @@ export const Dashboard = () => {
</PageWrapper> </PageWrapper>
} }
/> />
<Route
path={URLs.submissionsChainPath}
element={
<PageWrapper>
<SubmissionsPage />
</PageWrapper>
}
/>
<Route
path={URLs.submissionDetailsPath}
element={
<PageWrapper>
<SubmissionDetailsPage />
</PageWrapper>
}
/>
</Routes> </Routes>
) )
} }
+32
View File
@@ -42,11 +42,13 @@ export const ChainFormPage: React.FC = () => {
const [name, setName] = useState('') const [name, setName] = useState('')
const [selectedTasks, setSelectedTasks] = useState<ChallengeTask[]>([]) const [selectedTasks, setSelectedTasks] = useState<ChallengeTask[]>([])
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [isActive, setIsActive] = useState(true)
useEffect(() => { useEffect(() => {
if (chain) { if (chain) {
setName(chain.name) setName(chain.name)
setSelectedTasks(chain.tasks) setSelectedTasks(chain.tasks)
setIsActive(chain.isActive !== false)
} }
}, [chain]) }, [chain])
@@ -80,6 +82,7 @@ export const ChainFormPage: React.FC = () => {
data: { data: {
name: name.trim(), name: name.trim(),
taskIds: taskIds, taskIds: taskIds,
isActive,
}, },
}).unwrap() }).unwrap()
toaster.create({ toaster.create({
@@ -91,6 +94,7 @@ export const ChainFormPage: React.FC = () => {
await createChain({ await createChain({
name: name.trim(), name: name.trim(),
taskIds: taskIds, taskIds: taskIds,
isActive,
}).unwrap() }).unwrap()
toaster.create({ toaster.create({
title: t('challenge.admin.common.success'), title: t('challenge.admin.common.success'),
@@ -191,6 +195,25 @@ export const ChainFormPage: React.FC = () => {
/> />
</Field.Root> </Field.Root>
{/* Active flag */}
<Field.Root>
<HStack justify="space-between" align="flex-start">
<HStack gap={2}>
<input
type="checkbox"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
disabled={isLoading}
style={{ cursor: isLoading ? 'not-allowed' : 'pointer' }}
/>
<Text>{t('challenge.admin.chains.field.isActive')}</Text>
</HStack>
<Text fontSize="sm" color="gray.500" maxW="md">
{t('challenge.admin.chains.field.isActive.helper')}
</Text>
</HStack>
</Field.Root>
{/* Selected Tasks */} {/* Selected Tasks */}
<Box> <Box>
<Text fontWeight="bold" mb={3}> <Text fontWeight="bold" mb={3}>
@@ -227,6 +250,15 @@ export const ChainFormPage: React.FC = () => {
<Text fontWeight="medium">{task.title}</Text> <Text fontWeight="medium">{task.title}</Text>
</HStack> </HStack>
<HStack gap={1}> <HStack gap={1}>
<IconButton
size="sm"
variant="ghost"
onClick={() => navigate(URLs.taskEdit(task.id))}
disabled={isLoading}
aria-label="Edit task"
>
</IconButton>
<IconButton <IconButton
size="sm" size="sm"
variant="ghost" variant="ghost"
+85 -17
View File
@@ -12,12 +12,13 @@ import {
Text, Text,
Badge, Badge,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useGetChainsQuery, useDeleteChainMutation } from '../../__data__/api/api' import { useGetChainsQuery, useDeleteChainMutation, useUpdateChainMutation } from '../../__data__/api/api'
import { URLs } from '../../__data__/urls' import { 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,22 +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 [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'),
@@ -50,6 +57,30 @@ export const ChainsListPage: React.FC = () => {
} }
} }
const handleToggleActive = async (chain: ChallengeChain, nextValue: boolean) => {
setUpdatingChainId(chain.id)
try {
await updateChain({
id: chain.id,
data: { isActive: nextValue },
}).unwrap()
toaster.create({
title: t('challenge.admin.common.success'),
description: t('challenge.admin.chains.updated'),
type: 'success',
})
} catch {
toaster.create({
title: t('challenge.admin.common.error'),
description: t('challenge.admin.chains.save.error'),
type: 'error',
})
} finally {
setUpdatingChainId(null)
}
}
if (isLoading) { if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.chains.list.loading')} /> return <LoadingSpinner message={t('challenge.admin.chains.list.loading')} />
} }
@@ -110,6 +141,7 @@ export const ChainsListPage: React.FC = () => {
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.name')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.chains.list.table.name')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.tasks.count')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.chains.list.table.tasks.count')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.created')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.chains.list.table.created')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.status')}</Table.ColumnHeader>
<Table.ColumnHeader textAlign="right">{t('challenge.admin.chains.list.table.actions')}</Table.ColumnHeader> <Table.ColumnHeader textAlign="right">{t('challenge.admin.chains.list.table.actions')}</Table.ColumnHeader>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
@@ -127,6 +159,25 @@ export const ChainsListPage: React.FC = () => {
{formatDate(chain.createdAt)} {formatDate(chain.createdAt)}
</Text> </Text>
</Table.Cell> </Table.Cell>
<Table.Cell>
<HStack gap={3} justify="flex-start">
<Badge colorPalette={chain.isActive ? 'green' : 'gray'} variant="subtle">
{chain.isActive
? t('challenge.admin.chains.list.status.active')
: t('challenge.admin.chains.list.status.inactive')}
</Badge>
<Button
size="xs"
variant="outline"
onClick={() => handleToggleActive(chain, !chain.isActive)}
disabled={updatingChainId === chain.id}
>
{chain.isActive
? t('challenge.admin.chains.list.status.inactive')
: t('challenge.admin.chains.list.status.active')}
</Button>
</HStack>
</Table.Cell>
<Table.Cell textAlign="right"> <Table.Cell textAlign="right">
<HStack gap={2} justify="flex-end"> <HStack gap={2} justify="flex-end">
<Button <Button
@@ -136,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>
@@ -153,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>
) )
@@ -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>
@@ -0,0 +1,239 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useParams, useNavigate } from 'react-router-dom'
import { Box, Heading, Text, Button, HStack, VStack } from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown'
import { useGetUserSubmissionsQuery } from '../../__data__/api/api'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { StatusBadge } from '../../components/StatusBadge'
import type { ChallengeTask, ChallengeUser } from '../../types/challenge'
import { URLs } from '../../__data__/urls'
export const SubmissionDetailsPage: React.FC = () => {
const { t } = useTranslation()
const { chainId, userId, submissionId } = useParams<{ chainId: string; userId: string; submissionId: string }>()
const navigate = useNavigate()
// Получаем submissions для конкретного пользователя
const { data: submissions, isLoading, error } = useGetUserSubmissionsQuery(
{ userId: userId!, taskId: undefined },
{ skip: !userId }
)
const submission = submissions?.find((s) => s.id === submissionId)
const handleBack = () => {
if (chainId) {
navigate(URLs.submissionsChain(chainId))
} else {
navigate(URLs.submissions)
}
}
if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.submissions.loading')} />
}
if (error) {
return <ErrorAlert message={t('challenge.admin.submissions.load.error')} onRetry={handleBack} />
}
if (!submission) {
return (
<Box>
<Button variant="ghost" onClick={handleBack} mb={4}>
{t('challenge.admin.common.close')}
</Button>
<ErrorAlert
message={t('challenge.admin.submissions.details.not.found')}
onRetry={handleBack}
/>
</Box>
)
}
const rawUser = submission.user as ChallengeUser | string | undefined
const rawTask = submission.task as ChallengeTask | string | undefined
const userNickname =
rawUser && typeof rawUser === 'object' && 'nickname' in rawUser
? rawUser.nickname ?? ''
: typeof rawUser === 'string'
? rawUser
: ''
const taskTitle =
rawTask && typeof rawTask === 'object' && 'title' in rawTask
? rawTask.title ?? ''
: typeof rawTask === 'string'
? rawTask
: ''
const taskDescription =
rawTask && typeof rawTask === 'object' && 'description' in rawTask
? rawTask.description ?? ''
: ''
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
const getCheckTimeValue = () => {
if (!submission.checkedAt) return null
const submitted = new Date(submission.submittedAt).getTime()
const checked = new Date(submission.checkedAt).getTime()
return ((checked - submitted) / 1000).toFixed(2)
}
return (
<Box>
{/* Header with back button */}
<HStack mb={6}>
<Button variant="ghost" onClick={handleBack}>
{t('challenge.admin.common.close')}
</Button>
</HStack>
<Heading mb={6}>
{t('challenge.admin.submissions.details.title')} #{submission.attemptNumber}
</Heading>
<VStack gap={6} align="stretch">
{/* Meta */}
<Box
p={6}
bg="white"
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<HStack mb={4} justify="space-between">
<Box>
<Text fontSize="sm" color="gray.600" mb={1}>
{t('challenge.admin.submissions.details.user')}
</Text>
<Text fontWeight="bold">{userNickname}</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600" mb={1}>
{t('challenge.admin.submissions.details.status')}
</Text>
<StatusBadge status={submission.status} />
</Box>
</HStack>
<VStack align="stretch" gap={2}>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.submitted')}</strong>{' '}
{formatDate(submission.submittedAt)}
</Text>
{submission.checkedAt && (
<>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.checked')}</strong>{' '}
{formatDate(submission.checkedAt)}
</Text>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.check.time')}</strong>{' '}
{t('challenge.admin.submissions.check.time', { time: getCheckTimeValue() })}
</Text>
</>
)}
</VStack>
</Box>
{/* Task */}
<Box
p={6}
bg="white"
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<Text fontWeight="bold" mb={4}>
{t('challenge.admin.submissions.details.task')} {taskTitle}
</Text>
<Box
p={4}
bg="gray.50"
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
maxH="400px"
overflowY="auto"
>
<ReactMarkdown>{taskDescription}</ReactMarkdown>
</Box>
</Box>
{/* Solution */}
<Box
p={6}
bg="white"
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<Text fontWeight="bold" mb={4}>
{t('challenge.admin.submissions.details.solution')}
</Text>
<Box
p={4}
bg="blue.50"
borderRadius="md"
borderWidth="1px"
borderColor="blue.200"
maxH="500px"
overflowY="auto"
>
<Text
fontFamily="monospace"
fontSize="sm"
whiteSpace="pre-wrap"
wordBreak="break-word"
>
{submission.result}
</Text>
</Box>
</Box>
{/* Feedback */}
{submission.feedback && (
<Box
p={6}
bg="white"
borderRadius="lg"
boxShadow="sm"
borderWidth="1px"
borderColor="gray.200"
>
<Text fontWeight="bold" mb={4}>
{t('challenge.admin.submissions.details.feedback')}
</Text>
<Box
p={4}
bg={submission.status === 'accepted' ? 'green.50' : 'red.50'}
borderRadius="md"
borderWidth="1px"
borderColor={submission.status === 'accepted' ? 'green.200' : 'red.200'}
>
<Text>{submission.feedback}</Text>
</Box>
</Box>
)}
</VStack>
</Box>
)
}
+436 -245
View File
@@ -1,5 +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, useParams, Link } from 'react-router-dom'
import { import {
Box, Box,
Heading, Heading,
@@ -9,24 +10,26 @@ import {
Button, Button,
HStack, HStack,
VStack, VStack,
Badge,
Progress,
Grid,
SimpleGrid,
Select, Select,
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
createListCollection, createListCollection,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import ReactMarkdown from 'react-markdown' import { getFeatureValue } from '@brojs/cli'
import { useGetSystemStatsV2Query, useGetUserSubmissionsQuery } from '../../__data__/api/api' 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 type { import type {
ActiveParticipant,
ChallengeSubmission, ChallengeSubmission,
SubmissionStatus, SubmissionStatus,
ChallengeTask, ChallengeTask,
@@ -35,13 +38,62 @@ import type {
export const SubmissionsPage: React.FC = () => { export const SubmissionsPage: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: stats, isLoading: isStatsLoading, error: statsError, refetch: refetchStats } = const navigate = useNavigate()
useGetSystemStatsV2Query(undefined) const { chainId } = useParams<{ chainId?: string }>()
// Проверяем feature flags
const featureValue = getFeatureValue('challenge-admin', 'use-chain-submissions-api')
const useNewApi = featureValue?.value === 'true'
const pollingIntervalFeatureValue = getFeatureValue(
'challenge-admin',
'submissions-polling-interval-ms'
)
const pollingIntervalMs = (() => {
const rawValue = pollingIntervalFeatureValue?.value ?? ''
const parsed = Number.parseInt(rawValue, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1200
})()
// Состояние для выбранного пользователя и фильтров
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<SubmissionStatus | 'all'>('all') const [statusFilter, setStatusFilter] = useState<SubmissionStatus | 'all'>('all')
const [selectedSubmission, setSelectedSubmission] = useState<ChallengeSubmission | null>(null)
const [selectedUserId, setSelectedUserId] = useState<string | null>(null) // Получаем список цепочек
const {
data: chains,
isLoading: isChainsLoading,
error: chainsError,
refetch: refetchChains,
} = useGetChainsQuery()
// Новый API: получаем данные по цепочке через новый эндпоинт
const {
data: chainData,
isLoading: isChainDataLoading,
error: chainDataError,
refetch: refetchChainData,
} = useGetChainSubmissionsQuery(
{
chainId: chainId!,
userId: selectedUserId || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
},
{
skip: !chainId || !useNewApi,
pollingInterval: pollingIntervalMs,
}
)
// Старый API: получаем общую статистику и submissions отдельно
const {
data: stats,
isLoading: isStatsLoading,
error: statsError,
refetch: refetchStats,
} = useGetSystemStatsV2Query(undefined, {
skip: !chainId || useNewApi,
})
const { const {
data: submissions, data: submissions,
@@ -50,42 +102,142 @@ 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 = () => {
refetchStats() refetchChains()
if (selectedUserId) { if (chainId) {
refetchSubmissions() if (useNewApi) {
refetchChainData()
} else {
refetchStats()
if (selectedUserId) {
refetchSubmissions()
}
}
} }
} }
if (isLoading) { // Получаем данные выбранной цепочки из списка chains (для старого API)
return <LoadingSpinner message={t('challenge.admin.submissions.loading')} /> const selectedChain = useMemo(() => {
} if (!chainId || !chains) return null
return chains.find((c) => c.id === chainId) || null
}, [chainId, chains])
if (error || !stats) { // Получаем taskIds из текущей цепочки (для старого API)
return <ErrorAlert message={t('challenge.admin.submissions.load.error')} onRetry={handleRetry} /> const chainTaskIds = useMemo(() => {
} if (!selectedChain) return new Set<string>()
return new Set(selectedChain.tasks.map((t) => t.id))
}, [selectedChain])
const participants: ActiveParticipant[] = stats.activeParticipants || [] // Старый API: фильтруем участников - только те, кто имеет прогресс в этой цепочке
const submissionsList: ChallengeSubmission[] = submissions || [] const chainParticipantsOld = useMemo(() => {
if (!stats?.activeParticipants || !chainId || useNewApi) return []
const filteredSubmissions = submissionsList.filter((submission) => { return stats.activeParticipants
const user = submission.user as ChallengeUser .map((participant) => {
const task = submission.task as ChallengeTask const chainProgress = participant.chainProgress?.find((cp) => cp.chainId === chainId)
return {
...participant,
progressPercent: chainProgress?.progressPercent ?? 0,
completedTasks: chainProgress?.completedTasks ?? 0,
totalTasks: selectedChain?.tasks.length ?? 0,
}
})
.sort((a, b) => a.progressPercent - b.progressPercent)
}, [stats?.activeParticipants, chainId, selectedChain, useNewApi])
const matchesSearch = // Старый API: фильтруем submissions только по заданиям из текущей цепочки
user.nickname.toLowerCase().includes(searchQuery.toLowerCase()) || const filteredSubmissionsOld = useMemo(() => {
task.title.toLowerCase().includes(searchQuery.toLowerCase()) if (!submissions || chainTaskIds.size === 0 || useNewApi) return []
const matchesStatus = statusFilter === 'all' || submission.status === statusFilter const normalizedSearchQuery = (searchQuery ?? '').toLowerCase()
return matchesSearch && matchesStatus return submissions.filter((submission) => {
}) const rawTask = submission.task as ChallengeTask | string | undefined
const taskId =
rawTask && typeof rawTask === 'object' && 'id' in rawTask
? rawTask.id
: typeof rawTask === 'string'
? rawTask
: ''
if (!chainTaskIds.has(taskId)) return false
const rawUser = submission.user as ChallengeUser | string | undefined
const nickname =
rawUser && typeof rawUser === 'object' && 'nickname' in rawUser
? (rawUser.nickname ?? '')
: ''
const title =
rawTask && typeof rawTask === 'object' && 'title' in rawTask
? (rawTask.title ?? '')
: ''
const matchesSearch =
nickname.toLowerCase().includes(normalizedSearchQuery) ||
title.toLowerCase().includes(normalizedSearchQuery)
const matchesStatus = statusFilter === 'all' || submission.status === statusFilter
return matchesSearch && matchesStatus
})
}, [submissions, chainTaskIds, searchQuery, statusFilter, useNewApi])
// Новый API: фильтруем submissions по поисковому запросу (статус уже отфильтрован на сервере)
const filteredSubmissionsNew = useMemo(() => {
if (!chainData?.submissions || !useNewApi) return []
const normalizedSearchQuery = (searchQuery ?? '').toLowerCase()
if (!normalizedSearchQuery) return chainData.submissions
return chainData.submissions.filter((submission) => {
const rawUser = submission.user as ChallengeUser | string | undefined
const rawTask = submission.task as ChallengeTask | string | undefined
const nickname =
rawUser && typeof rawUser === 'object' && 'nickname' in rawUser
? (rawUser.nickname ?? '')
: typeof rawUser === 'string'
? rawUser
: ''
const title =
rawTask && typeof rawTask === 'object' && 'title' in rawTask
? (rawTask.title ?? '')
: typeof rawTask === 'string'
? rawTask
: ''
return (
nickname.toLowerCase().includes(normalizedSearchQuery) ||
title.toLowerCase().includes(normalizedSearchQuery)
)
})
}, [chainData?.submissions, searchQuery, useNewApi])
// Выбираем данные в зависимости от фичи
const filteredSubmissions = useNewApi ? filteredSubmissionsNew : filteredSubmissionsOld
// Сортируем участников по прогрессу
const sortedParticipants = useMemo(() => {
if (useNewApi) {
if (!chainData?.participants) return []
return [...chainData.participants].sort((a, b) => a.progressPercent - b.progressPercent)
} else {
return chainParticipantsOld
}
}, [chainData?.participants, chainParticipantsOld, useNewApi])
const formatDate = (dateStr: string) => { const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('ru-RU', { return new Date(dateStr).toLocaleString('ru-RU', {
@@ -115,49 +267,150 @@ 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 if (error) {
const hasSelectedUser = !!selectedUserId 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}> <HStack gap={4} align="center" wrap="wrap">
<Select.Root {selectedUserId && (
collection={userOptions} <Button
value={selectedUserId ? [selectedUserId] : []} size="sm"
onValueChange={(e) => setSelectedUserId(e.value[0] ?? null)} variant="outline"
maxW="300px" colorPalette="teal"
> onClick={() => {
<Select.Trigger> setSelectedUserId(null)
<Select.ValueText placeholder={t('challenge.admin.submissions.filter.user')} /> setSearchQuery('')
</Select.Trigger> setStatusFilter('all')
<Select.Content> }}
{userOptions.items.map((option) => ( >
<Select.Item key={option.value} item={option}> {t('challenge.admin.submissions.filter.user.clear')}
{option.label} </Button>
</Select.Item> )}
))}
</Select.Content>
</Select.Root>
{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}
@@ -182,44 +435,142 @@ export const SubmissionsPage: React.FC = () => {
</VStack> </VStack>
)} )}
{!hasParticipants ? ( {/* Если не выбран пользователь - показываем обзор участников */}
<EmptyState {!selectedUserId ? (
title={t('challenge.admin.submissions.empty.title')} <Box>
description={t('challenge.admin.submissions.empty.description')} <Heading size="md" mb={4}>
/> {t('challenge.admin.submissions.participants.title')}
) : !hasSelectedUser ? ( </Heading>
<EmptyState <Text mb={4} color="gray.600">
title={t('challenge.admin.submissions.empty.title')} {t('challenge.admin.submissions.participants.description')}
description={t('challenge.admin.submissions.filter.user')} </Text>
/>
{sortedParticipants.length === 0 ? (
<EmptyState
title={t('challenge.admin.submissions.participants.empty.title')}
description={t('challenge.admin.submissions.participants.empty.description')}
/>
) : (
<Grid
templateColumns={{
base: 'repeat(1, minmax(0, 1fr))',
md: 'repeat(2, minmax(0, 1fr))',
lg: 'repeat(3, minmax(0, 1fr))',
xl: 'repeat(4, minmax(0, 1fr))',
}}
gap={3}
>
{sortedParticipants.map((participant) => {
const colorPalette =
participant.progressPercent >= 70
? 'green'
: participant.progressPercent >= 40
? 'orange'
: 'red'
return (
<Box
key={participant.userId}
p={3}
borderWidth="1px"
borderRadius="md"
borderColor="gray.200"
bg="white"
_hover={{ bg: 'gray.50', borderColor: 'teal.300' }}
cursor="pointer"
onClick={() => setSelectedUserId(participant.userId)}
transition="all 0.2s"
>
<HStack justify="space-between" mb={2} gap={2}>
<VStack align="start" gap={0}>
{participant.workplaceNumber && (
<Text fontSize="xs" color="gray.500">
{participant.workplaceNumber}
</Text>
)}
<Text fontSize="sm" fontWeight="medium" truncate maxW="180px">
{participant.nickname}
</Text>
</VStack>
<Badge colorPalette={colorPalette} size="sm">
{participant.progressPercent}%
</Badge>
</HStack>
<Progress.Root value={participant.progressPercent} size="sm" colorPalette={colorPalette}>
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
<HStack justify="space-between" mt={2}>
<Text fontSize="xs" color="gray.500">
{participant.completedTasks} / {participant.totalTasks}
</Text>
<Text fontSize="xs" color="gray.400">
{t('challenge.admin.submissions.participants.click.to.view')}
</Text>
</HStack>
</Box>
)
})}
</Grid>
)}
</Box>
) : filteredSubmissions.length === 0 ? ( ) : filteredSubmissions.length === 0 ? (
<EmptyState <EmptyState
title={t('challenge.admin.submissions.search.empty.title')} title={t('challenge.admin.submissions.search.empty.title')}
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>
{filteredSubmissions.map((submission) => { {filteredSubmissions.map((submission) => {
const user = submission.user as ChallengeUser const rawUser = submission.user as ChallengeUser | string | undefined
const task = submission.task as ChallengeTask const rawTask = submission.task as ChallengeTask | string | undefined
const nickname =
rawUser && typeof rawUser === 'object' && 'nickname' in rawUser
? (rawUser.nickname ?? '')
: typeof rawUser === 'string'
? rawUser
: ''
const workplaceNumber =
rawUser && typeof rawUser === 'object' && 'workplaceNumber' in rawUser
? rawUser.workplaceNumber ?? ''
: ''
const title =
rawTask && typeof rawTask === 'object' && 'title' in rawTask
? (rawTask.title ?? '')
: typeof rawTask === 'string'
? rawTask
: ''
return ( return (
<Table.Row key={submission.id}> <Table.Row key={submission.id}>
<Table.Cell fontWeight="medium">{user.nickname}</Table.Cell> <Table.Cell fontWeight="medium">{nickname}</Table.Cell>
<Table.Cell>{task.title}</Table.Cell> <Table.Cell>
<Text fontSize="sm" color="gray.600">
{workplaceNumber || '—'}
</Text>
</Table.Cell>
<Table.Cell>{title}</Table.Cell>
<Table.Cell> <Table.Cell>
<StatusBadge status={submission.status} /> <StatusBadge status={submission.status} />
</Table.Cell> </Table.Cell>
@@ -243,7 +594,7 @@ export const SubmissionsPage: React.FC = () => {
size="sm" size="sm"
variant="ghost" variant="ghost"
colorPalette="teal" colorPalette="teal"
onClick={() => setSelectedSubmission(submission)} onClick={() => navigate(URLs.submissionDetails(chainId!, selectedUserId, submission.id))}
> >
{t('challenge.admin.submissions.button.details')} {t('challenge.admin.submissions.button.details')}
</Button> </Button>
@@ -255,166 +606,6 @@ export const SubmissionsPage: React.FC = () => {
</Table.Root> </Table.Root>
</Box> </Box>
)} )}
{/* Submission Details Modal */}
<SubmissionDetailsModal
submission={selectedSubmission}
isOpen={!!selectedSubmission}
onClose={() => setSelectedSubmission(null)}
/>
</Box> </Box>
) )
} }
interface SubmissionDetailsModalProps {
submission: ChallengeSubmission | null
isOpen: boolean
onClose: () => void
}
const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
submission,
isOpen,
onClose,
}) => {
const { t } = useTranslation()
if (!submission) return null
const user = submission.user as ChallengeUser
const task = submission.task as ChallengeTask
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
const getCheckTimeValue = () => {
if (!submission.checkedAt) return null
const submitted = new Date(submission.submittedAt).getTime()
const checked = new Date(submission.checkedAt).getTime()
return ((checked - submitted) / 1000).toFixed(2)
}
return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
<DialogContent>
<DialogHeader>
<DialogTitle>{t('challenge.admin.submissions.details.title')} #{submission.attemptNumber}</DialogTitle>
</DialogHeader>
<DialogBody>
<VStack gap={6} align="stretch">
{/* Meta */}
<Box>
<HStack mb={4} justify="space-between">
<Box>
<Text fontSize="sm" color="gray.600" mb={1}>
{t('challenge.admin.submissions.details.user')}
</Text>
<Text fontWeight="bold">{user.nickname}</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600" mb={1}>
{t('challenge.admin.submissions.details.status')}
</Text>
<StatusBadge status={submission.status} />
</Box>
</HStack>
<VStack align="stretch" gap={2}>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.submitted')}</strong> {formatDate(submission.submittedAt)}
</Text>
{submission.checkedAt && (
<>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.checked')}</strong> {formatDate(submission.checkedAt)}
</Text>
<Text fontSize="sm" color="gray.600">
<strong>{t('challenge.admin.submissions.details.check.time')}</strong> {t('challenge.admin.submissions.check.time', { time: getCheckTimeValue() })}
</Text>
</>
)}
</VStack>
</Box>
{/* Task */}
<Box>
<Text fontWeight="bold" mb={2}>
{t('challenge.admin.submissions.details.task')} {task.title}
</Text>
<Box
p={4}
bg="gray.50"
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
maxH="200px"
overflowY="auto"
>
<ReactMarkdown>{task.description}</ReactMarkdown>
</Box>
</Box>
{/* Solution */}
<Box>
<Text fontWeight="bold" mb={2}>
{t('challenge.admin.submissions.details.solution')}
</Text>
<Box
p={4}
bg="blue.50"
borderRadius="md"
borderWidth="1px"
borderColor="blue.200"
maxH="300px"
overflowY="auto"
>
<Text
fontFamily="monospace"
fontSize="sm"
whiteSpace="pre-wrap"
wordBreak="break-word"
>
{submission.result}
</Text>
</Box>
</Box>
{/* Feedback */}
{submission.feedback && (
<Box>
<Text fontWeight="bold" mb={2}>
{t('challenge.admin.submissions.details.feedback')}
</Text>
<Box
p={4}
bg={submission.status === 'accepted' ? 'green.50' : 'red.50'}
borderRadius="md"
borderWidth="1px"
borderColor={submission.status === 'accepted' ? 'green.200' : 'red.200'}
>
<Text>{submission.feedback}</Text>
</Box>
</Box>
)}
</VStack>
</DialogBody>
<DialogFooter>
<DialogActionTrigger asChild>
<Button variant="outline" onClick={onClose}>
{t('challenge.admin.submissions.details.close')}
</Button>
</DialogActionTrigger>
</DialogFooter>
</DialogContent>
</DialogRoot>
)
}
+333 -1
View File
@@ -19,12 +19,27 @@ import {
useGetTaskQuery, useGetTaskQuery,
useCreateTaskMutation, useCreateTaskMutation,
useUpdateTaskMutation, useUpdateTaskMutation,
useTestSubmissionMutation,
} from '../../__data__/api/api' } from '../../__data__/api/api'
import { URLs } from '../../__data__/urls' 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 { 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()
@@ -35,20 +50,59 @@ export const TaskFormPage: React.FC = () => {
}) })
const [createTask, { isLoading: isCreating }] = useCreateTaskMutation() const [createTask, { isLoading: isCreating }] = useCreateTaskMutation()
const [updateTask, { isLoading: isUpdating }] = useUpdateTaskMutation() const [updateTask, { isLoading: isUpdating }] = useUpdateTaskMutation()
const [testSubmission, { isLoading: isTesting }] = useTestSubmissionMutation()
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 [testStatus, setTestStatus] = useState<string | null>(null)
const [testFeedback, setTestFeedback] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
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()
@@ -68,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()
@@ -80,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({
@@ -88,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 &&
@@ -106,6 +162,59 @@ export const TaskFormPage: React.FC = () => {
} }
} }
const handleTestSubmit = async () => {
if (!task || !id) {
return
}
if (!testAnswer.trim()) {
toaster.create({
title: t('challenge.admin.common.validation.error'),
description: t('challenge.admin.tasks.test.validation.fill.answer'),
type: 'error',
})
return
}
setTestStatus(null)
setTestFeedback(null)
try {
const dummyUserId = task.creator?.sub || task.id
const result = await testSubmission({
userId: dummyUserId,
taskId: task.id,
result: testAnswer.trim(),
isTest: true,
hiddenInstructions: hiddenInstructions.trim() || undefined,
}).unwrap()
setTestStatus(result.status)
setTestFeedback(result.feedback ?? null)
toaster.create({
title: t('challenge.admin.common.success'),
description: t('challenge.admin.tasks.test.success'),
type: 'success',
})
} catch (err: unknown) {
const isForbidden =
err &&
typeof err === 'object' &&
'status' in err &&
(err as { status?: number }).status === 403
toaster.create({
title: t('challenge.admin.common.error'),
description: isForbidden
? t('challenge.admin.tasks.test.forbidden')
: t('challenge.admin.tasks.test.error'),
type: 'error',
})
}
}
if (isEdit && isLoadingTask) { if (isEdit && isLoadingTask) {
return <LoadingSpinner message={t('challenge.admin.tasks.loading')} /> return <LoadingSpinner message={t('challenge.admin.tasks.loading')} />
} }
@@ -264,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">
@@ -309,6 +590,57 @@ export const TaskFormPage: React.FC = () => {
</Box> </Box>
)} )}
{/* Test submission (LLM check) */}
{isEdit && task && (
<Box p={4} bg="teal.50" borderRadius="md" borderWidth="1px" borderColor="teal.200">
<Text fontWeight="bold" mb={2} color="teal.900">
{t('challenge.admin.tasks.test.title')}
</Text>
<Text fontSize="sm" mb={3} color="teal.800">
{t('challenge.admin.tasks.test.description')}
</Text>
<Field.Root>
<Field.Label>{t('challenge.admin.tasks.test.field.answer')}</Field.Label>
<Textarea
value={testAnswer}
onChange={(e) => setTestAnswer(e.target.value)}
placeholder={t('challenge.admin.tasks.test.field.answer.placeholder')}
rows={6}
fontFamily="monospace"
disabled={isTesting}
/>
<Field.HelperText>
{t('challenge.admin.tasks.test.field.answer.helper')}
</Field.HelperText>
</Field.Root>
<HStack mt={3} align="flex-start" justify="space-between" gap={4}>
<Button
onClick={handleTestSubmit}
colorPalette="teal"
disabled={isTesting || !testAnswer.trim()}
>
{t('challenge.admin.tasks.test.button.run')}
</Button>
{(testStatus || testFeedback) && (
<Box flex="1" p={3} bg="white" borderRadius="md" borderWidth="1px" borderColor="teal.100">
{testStatus && (
<Text fontSize="sm" fontWeight="medium" mb={1}>
{t(`challenge.admin.tasks.test.status.${testStatus}`)}
</Text>
)}
{testFeedback && (
<Text fontSize="sm" whiteSpace="pre-wrap">
{testFeedback}
</Text>
)}
</Box>
)}
</HStack>
</Box>
)}
{/* Actions */} {/* Actions */}
<HStack gap={3} justify="flex-end"> <HStack gap={3} justify="flex-end">
<Button variant="outline" onClick={() => navigate(URLs.tasks)} disabled={isLoading}> <Button variant="outline" onClick={() => navigate(URLs.tasks)} disabled={isLoading}>
+9 -18
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>
) )
} }
+196
View File
@@ -0,0 +1,196 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate, useParams } from 'react-router-dom'
import { Box, Heading, Text, Button, Grid, VStack, HStack, Badge, Progress } from '@chakra-ui/react'
import { useGetUserStatsQuery } from '../../__data__/api/api'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert'
import { URLs } from '../../__data__/urls'
interface RouteParams {
userId: string
}
export const UserStatsPage: React.FC = () => {
const { t } = useTranslation()
const navigate = useNavigate()
const { userId } = useParams<RouteParams>()
const { data: stats, isLoading, error, refetch } = useGetUserStatsQuery(userId!, {
skip: !userId,
})
const handleBack = () => {
navigate(URLs.users)
}
if (!userId) {
return (
<Box>
<Button variant="ghost" onClick={handleBack} mb={4}>
{t('challenge.admin.common.close')}
</Button>
<ErrorAlert message={t('challenge.admin.users.stats.no.data')} onRetry={handleBack} />
</Box>
)
}
if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.users.stats.loading')} />
}
if (error) {
return (
<Box>
<Button variant="ghost" onClick={handleBack} mb={4}>
{t('challenge.admin.common.close')}
</Button>
<ErrorAlert message={t('challenge.admin.users.load.error')} onRetry={refetch} />
</Box>
)
}
if (!stats) {
return (
<Box>
<Button variant="ghost" onClick={handleBack} mb={4}>
{t('challenge.admin.common.close')}
</Button>
<Text color="gray.600">{t('challenge.admin.users.stats.no.data')}</Text>
</Box>
)
}
return (
<Box>
<HStack mb={6}>
<Button variant="ghost" onClick={handleBack}>
{t('challenge.admin.common.close')}
</Button>
</HStack>
<Heading mb={6}>{t('challenge.admin.users.stats.title')}</Heading>
<VStack gap={6} align="stretch">
{/* Overview */}
<Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.completed')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="green.600">
{stats.completedTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.total.submissions')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.totalSubmissions}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.in.progress')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{stats.inProgressTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.needs.revision')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="red.600">
{stats.needsRevisionTasks}
</Text>
</Box>
</Grid>
{/* Chains Progress */}
{stats.chainStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
{t('challenge.admin.users.stats.chains.progress')}
</Text>
<VStack gap={3} align="stretch">
{stats.chainStats.map((chain) => (
<Box key={chain.chainId}>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">
{chain.chainName}
</Text>
<Text fontSize="sm" color="gray.600">
{chain.completedTasks} / {chain.totalTasks}
</Text>
</HStack>
<Progress.Root value={chain.progress} colorPalette="teal" size="sm">
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
</Box>
))}
</VStack>
</Box>
)}
{/* Task Stats */}
{stats.taskStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
{t('challenge.admin.users.stats.tasks')}
</Text>
<VStack gap={2} align="stretch" maxH="300px" overflowY="auto">
{stats.taskStats.map((taskStat) => {
const getBadgeColor = () => {
if (taskStat.status === 'completed') return 'green'
if (taskStat.status === 'needs_revision') return 'red'
return 'gray'
}
return (
<Box
key={taskStat.taskId}
p={3}
bg="gray.50"
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">
{taskStat.taskTitle}
</Text>
<Badge colorPalette={getBadgeColor()}>
{t(`challenge.admin.users.stats.status.${taskStat.status}`)}
</Badge>
</HStack>
<Text fontSize="xs" color="gray.600">
{t('challenge.admin.users.stats.attempts')} {taskStat.totalAttempts}
</Text>
</Box>
)
})}
</VStack>
</Box>
)}
{/* Average Check Time */}
<Box p={3} bg="purple.50" borderRadius="md">
<Text fontSize="sm" color="gray.700" mb={1}>
{t('challenge.admin.users.stats.avg.check.time')}
</Text>
<Text fontSize="lg" fontWeight="bold" color="purple.700">
{t('challenge.admin.dashboard.check.time.value', {
time: (stats.averageCheckTimeMs / 1000).toFixed(2),
})}
</Text>
</Box>
</VStack>
</Box>
)
}
+15 -185
View File
@@ -1,38 +1,21 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import { useNavigate } from 'react-router-dom'
Box, import { Box, Heading, Table, Input, Text, Button } from '@chakra-ui/react'
Heading, import { useGetSystemStatsV2Query } from '../../__data__/api/api'
Table,
Input,
Text,
Button,
DialogRoot,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
DialogActionTrigger,
Grid,
VStack,
HStack,
Badge,
Progress,
} from '@chakra-ui/react'
import { useGetSystemStatsV2Query, useGetUserStatsQuery } from '../../__data__/api/api'
import type { ActiveParticipant } from '../../types/challenge' import type { ActiveParticipant } from '../../types/challenge'
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 { URLs } from '../../__data__/urls'
export const UsersPage: React.FC = () => { export const UsersPage: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate()
const { data: stats, isLoading, error, refetch } = useGetSystemStatsV2Query(undefined, { const { data: stats, isLoading, error, refetch } = useGetSystemStatsV2Query(undefined, {
pollingInterval: 10000, pollingInterval: 10000,
}) })
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
if (isLoading) { if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.users.loading')} /> return <LoadingSpinner message={t('challenge.admin.users.loading')} />
@@ -44,8 +27,10 @@ export const UsersPage: React.FC = () => {
const users: ActiveParticipant[] = stats.activeParticipants || [] const users: ActiveParticipant[] = stats.activeParticipants || []
const normalizedQuery = (searchQuery ?? '').toLowerCase()
const filteredUsers = users.filter((user) => const filteredUsers = users.filter((user) =>
user.nickname.toLowerCase().includes(searchQuery.toLowerCase()) (user.nickname ?? '').toLowerCase().includes(normalizedQuery)
) )
return ( return (
@@ -76,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>
@@ -86,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}
@@ -106,7 +97,7 @@ export const UsersPage: React.FC = () => {
size="sm" size="sm"
variant="ghost" variant="ghost"
colorPalette="teal" colorPalette="teal"
onClick={() => setSelectedUserId(user.userId)} onClick={() => navigate(URLs.userStats(user.userId))}
> >
{t('challenge.admin.users.button.stats')} {t('challenge.admin.users.button.stats')}
</Button> </Button>
@@ -118,167 +109,6 @@ export const UsersPage: React.FC = () => {
</Box> </Box>
)} )}
{/* User Stats Modal */}
<UserStatsModal
userId={selectedUserId}
isOpen={!!selectedUserId}
onClose={() => setSelectedUserId(null)}
/>
</Box> </Box>
) )
} }
interface UserStatsModalProps {
userId: string | null
isOpen: boolean
onClose: () => void
}
const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose }) => {
const { t } = useTranslation()
const { data: stats, isLoading } = useGetUserStatsQuery(userId!, {
skip: !userId,
})
return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
<DialogContent>
<DialogHeader>
<DialogTitle>{t('challenge.admin.users.stats.title')}</DialogTitle>
</DialogHeader>
<DialogBody>
{isLoading ? (
<LoadingSpinner message={t('challenge.admin.users.stats.loading')} />
) : !stats ? (
<Text color="gray.600">{t('challenge.admin.users.stats.no.data')}</Text>
) : (
<VStack gap={6} align="stretch">
{/* Overview */}
<Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.completed')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="green.600">
{stats.completedTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.total.submissions')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.totalSubmissions}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.in.progress')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
{stats.inProgressTasks}
</Text>
</Box>
<Box>
<Text fontSize="sm" color="gray.600">
{t('challenge.admin.users.stats.needs.revision')}
</Text>
<Text fontSize="2xl" fontWeight="bold" color="red.600">
{stats.needsRevisionTasks}
</Text>
</Box>
</Grid>
{/* Chains Progress */}
{stats.chainStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
{t('challenge.admin.users.stats.chains.progress')}
</Text>
<VStack gap={3} align="stretch">
{stats.chainStats.map((chain) => (
<Box key={chain.chainId}>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">
{chain.chainName}
</Text>
<Text fontSize="sm" color="gray.600">
{chain.completedTasks} / {chain.totalTasks}
</Text>
</HStack>
<Progress.Root value={chain.progress} colorPalette="teal" size="sm">
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
</Box>
))}
</VStack>
</Box>
)}
{/* Task Stats */}
{stats.taskStats.length > 0 && (
<Box>
<Text fontWeight="bold" mb={3}>
{t('challenge.admin.users.stats.tasks')}
</Text>
<VStack gap={2} align="stretch" maxH="300px" overflowY="auto">
{stats.taskStats.map((taskStat) => {
const getBadgeColor = () => {
if (taskStat.status === 'completed') return 'green'
if (taskStat.status === 'needs_revision') return 'red'
return 'gray'
}
return (
<Box
key={taskStat.taskId}
p={3}
bg="gray.50"
borderRadius="md"
borderWidth="1px"
borderColor="gray.200"
>
<HStack justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">
{taskStat.taskTitle}
</Text>
<Badge colorPalette={getBadgeColor()}>
{t(`challenge.admin.users.stats.status.${taskStat.status}`)}
</Badge>
</HStack>
<Text fontSize="xs" color="gray.600">
{t('challenge.admin.users.stats.attempts')} {taskStat.totalAttempts}
</Text>
</Box>
)
})}
</VStack>
</Box>
)}
{/* Average Check Time */}
<Box p={3} bg="purple.50" borderRadius="md">
<Text fontSize="sm" color="gray.700" mb={1}>
{t('challenge.admin.users.stats.avg.check.time')}
</Text>
<Text fontSize="lg" fontWeight="bold" color="purple.700">
{t('challenge.admin.dashboard.check.time.value', { time: (stats.averageCheckTimeMs / 1000).toFixed(2) })}
</Text>
</Box>
</VStack>
)}
</DialogBody>
<DialogFooter>
<DialogActionTrigger asChild>
<Button variant="outline" onClick={onClose}>
Закрыть
</Button>
</DialogActionTrigger>
</DialogFooter>
</DialogContent>
</DialogRoot>
)
}
+63
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
@@ -27,6 +29,7 @@ export interface ChallengeChain {
id: string id: string
name: string name: string
tasks: ChallengeTask[] // Populated tasks: ChallengeTask[] // Populated
isActive: boolean
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
@@ -120,23 +123,37 @@ 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
} }
export interface CreateChainRequest { export interface CreateChainRequest {
name: string name: string
taskIds: string[] // Array of task IDs taskIds: string[] // Array of task IDs
isActive?: boolean
} }
export interface UpdateChainRequest { export interface UpdateChainRequest {
name?: string name?: string
taskIds?: string[] taskIds?: string[]
isActive?: boolean
}
export interface DuplicateChainRequest {
name?: string
}
export interface ClearSubmissionsResponse {
deletedCount: number
chainId: string
userId?: string
} }
// ========== Stats v2 Types ========== // ========== Stats v2 Types ==========
@@ -164,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[]
@@ -178,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
@@ -223,3 +242,47 @@ export interface SystemStatsV2 {
chainsDetailed: ChainDetailed[] chainsDetailed: ChainDetailed[]
} }
// ========== Submissions / Checking ==========
export interface SubmitRequest {
userId: string
taskId: string
result: string
// Флаг тестового режима: проверка без создания Submission и очереди
isTest?: boolean
// Временные скрытые инструкции для тестовой проверки (не сохраняются в задачу)
hiddenInstructions?: string
}
export interface TestSubmissionResult {
isTest: true
status: Exclude<SubmissionStatus, 'pending' | 'in_progress'>
feedback?: string
}
// ========== Chain Submissions API ==========
export interface ChainSubmissionsParticipant {
userId: string
nickname: string
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
}
}
+3
View File
@@ -21,6 +21,7 @@
"updatedAt": "2024-11-05T11:00:00.000Z" "updatedAt": "2024-11-05T11:00:00.000Z"
} }
], ],
"isActive": true,
"createdAt": "2024-11-01T09:00:00.000Z", "createdAt": "2024-11-01T09:00:00.000Z",
"updatedAt": "2024-11-05T12:00:00.000Z" "updatedAt": "2024-11-05T12:00:00.000Z"
}, },
@@ -38,6 +39,7 @@
"updatedAt": "2024-11-03T09:15:00.000Z" "updatedAt": "2024-11-03T09:15:00.000Z"
} }
], ],
"isActive": false,
"createdAt": "2024-11-03T08:00:00.000Z", "createdAt": "2024-11-03T08:00:00.000Z",
"updatedAt": "2024-11-03T09:30:00.000Z" "updatedAt": "2024-11-03T09:30:00.000Z"
}, },
@@ -63,6 +65,7 @@
"updatedAt": "2024-11-04T14:20:00.000Z" "updatedAt": "2024-11-04T14:20:00.000Z"
} }
], ],
"isActive": true,
"createdAt": "2024-11-02T11:00:00.000Z", "createdAt": "2024-11-02T11:00:00.000Z",
"updatedAt": "2024-11-04T15:00:00.000Z" "updatedAt": "2024-11-04T15:00:00.000Z"
} }
+41
View File
@@ -200,9 +200,33 @@
} }
], ],
"activeParticipants": [ "activeParticipants": [
{
"userId": "6909b51512c75d75a36a52bf",
"nickname": "Примаков А.А.",
"workplaceNumber": "PC-07",
"totalSubmissions": 14,
"completedTasks": 1,
"chainProgress": [
{
"chainId": "6909ad8612c75d75a36a4c58",
"chainName": "Это тестовая цепочка заданий",
"totalTasks": 2,
"completedTasks": 1,
"progressPercent": 50
},
{
"chainId": "690a30b1e723507972c44098",
"chainName": "Навыки работы с нейросетями для начинающих (пошагово)",
"totalTasks": 20,
"completedTasks": 1,
"progressPercent": 5
}
]
},
{ {
"userId": "user_1", "userId": "user_1",
"nickname": "alex_dev", "nickname": "alex_dev",
"workplaceNumber": "PC-01",
"totalSubmissions": 18, "totalSubmissions": 18,
"completedTasks": 12, "completedTasks": 12,
"chainProgress": [ "chainProgress": [
@@ -225,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": [
@@ -247,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": [
@@ -269,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": [
@@ -291,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": [
@@ -313,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": [
@@ -354,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" },
@@ -372,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" },
@@ -390,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" },
@@ -408,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" },
@@ -426,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" },
@@ -444,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" },
@@ -481,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" },
@@ -499,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" },
@@ -517,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" },
@@ -535,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" },
@@ -553,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" },
@@ -571,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" },
+7
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": {
+7
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"
} }
] ]
+268 -12
View File
@@ -61,6 +61,32 @@ const getStatsV2 = () => {
return statsV2Cache; return statsV2Cache;
}; };
// Enrich SystemStatsV2 with real user ids/nicknames from users.json
const getStatsV2WithUsers = () => {
const statsV2 = getStatsV2();
const users = getUsers();
const mapParticipant = (participant, index) => {
const user = users[index];
if (!user) return participant;
return {
...participant,
userId: user.id,
nickname: user.nickname,
};
};
return {
...statsV2,
activeParticipants: statsV2.activeParticipants.map(mapParticipant),
chainsDetailed: statsV2.chainsDetailed.map((chain) => ({
...chain,
participantProgress: chain.participantProgress.map(mapParticipant),
})),
};
};
router.use(timer()); router.use(timer());
// ============= TASKS ============= // ============= TASKS =============
@@ -158,8 +184,15 @@ router.delete('/challenge/task/:id', (req, res) => {
// ============= CHAINS ============= // ============= CHAINS =============
// GET /api/challenge/chains // GET /api/challenge/chains (user-facing list: only active chains)
router.get('/challenge/chains', (req, res) => { router.get('/challenge/chains', (req, res) => {
const chains = getChains();
const activeChains = chains.filter(c => c.isActive !== false);
respond(res, activeChains);
});
// GET /api/challenge/chains/admin (admin list: all chains)
router.get('/challenge/chains/admin', (req, res) => {
const chains = getChains(); const chains = getChains();
respond(res, chains); respond(res, chains);
}); });
@@ -178,17 +211,17 @@ router.get('/challenge/chain/:id', (req, res) => {
// POST /api/challenge/chain // POST /api/challenge/chain
router.post('/challenge/chain', (req, res) => { router.post('/challenge/chain', (req, res) => {
const { name, tasks } = req.body; const { name, taskIds, isActive } = req.body;
if (!name || !tasks || !Array.isArray(tasks)) { if (!name || !taskIds || !Array.isArray(taskIds)) {
return respondError(res, 'Name and tasks array are required'); return respondError(res, 'Name and taskIds array are required');
} }
const chains = getChains(); const chains = getChains();
const allTasks = getTasks(); const allTasks = getTasks();
// Populate tasks // Populate tasks
const populatedTasks = tasks.map(taskId => { const populatedTasks = taskIds.map(taskId => {
const task = allTasks.find(t => t.id === taskId); const task = allTasks.find(t => t.id === taskId);
return task ? { return task ? {
_id: task._id, _id: task._id,
@@ -205,6 +238,7 @@ router.post('/challenge/chain', (req, res) => {
id: `chain_${Date.now()}`, id: `chain_${Date.now()}`,
name, name,
tasks: populatedTasks, tasks: populatedTasks,
isActive: isActive !== undefined ? !!isActive : true,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
@@ -227,14 +261,16 @@ router.put('/challenge/chain/:id', (req, res) => {
return respondError(res, 'Chain not found', 404); return respondError(res, 'Chain not found', 404);
} }
const { name, tasks } = req.body; const { name, taskIds, tasks, isActive } = req.body;
const chain = chains[chainIndex]; const chain = chains[chainIndex];
if (name) chain.name = name; if (name) chain.name = name;
if (tasks && Array.isArray(tasks)) { const effectiveTaskIds = Array.isArray(taskIds) ? taskIds : (Array.isArray(tasks) ? tasks : null);
if (effectiveTaskIds) {
const allTasks = getTasks(); const allTasks = getTasks();
const populatedTasks = tasks.map(taskId => { const populatedTasks = effectiveTaskIds.map(taskId => {
const task = allTasks.find(t => t.id === taskId); const task = allTasks.find(t => t.id === taskId);
return task ? { return task ? {
_id: task._id, _id: task._id,
@@ -248,6 +284,10 @@ router.put('/challenge/chain/:id', (req, res) => {
chain.tasks = populatedTasks; chain.tasks = populatedTasks;
} }
if (isActive !== undefined) {
chain.isActive = !!isActive;
}
chain.updatedAt = new Date().toISOString(); chain.updatedAt = new Date().toISOString();
@@ -272,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
@@ -282,7 +400,7 @@ router.get('/challenge/stats', (req, res) => {
// GET /api/challenge/stats/v2 // GET /api/challenge/stats/v2
router.get('/challenge/stats/v2', (req, res) => { router.get('/challenge/stats/v2', (req, res) => {
const statsV2 = getStatsV2(); const statsV2 = getStatsV2WithUsers();
const chainId = req.query.chainId; const chainId = req.query.chainId;
// Если chainId не передан, возвращаем все данные // Если chainId не передан, возвращаем все данные
@@ -291,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));
@@ -421,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;