Спринт 5: Трекер результатов
- Миграция 005: user_id в test_attempts (дефолт 1 = Гость) - GET /api/attempts с фильтрами по тесту, дате и пагинацией - Страница /tracker: таблица попыток, фильтры, пагинация - Ссылка «Трекер» в шапке приложения Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
|||||||
|
# Итоги разработки — 21 марта 2026
|
||||||
|
|
||||||
|
## Общая информация
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|----------|---------|
|
||||||
|
| Дата | 21 марта 2026 |
|
||||||
|
| Начало работы | 10:56 |
|
||||||
|
| Текущее состояние | Спринты 1–5 завершены |
|
||||||
|
| Затрачено времени | ~5 часов |
|
||||||
|
| Коммитов | 26 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Хронология по коммитам
|
||||||
|
|
||||||
|
| Время | Этап |
|
||||||
|
|-------|------|
|
||||||
|
| 10:56–11:41 | Подготовка: структура проекта, ТЗ v1.1, стек, план спринтов, README |
|
||||||
|
| 12:05–12:46 | **Спринт 1** — инфраструктура + создание тестов |
|
||||||
|
| 12:53–13:18 | **Спринт 2** — прохождение теста + результаты + UX |
|
||||||
|
| 13:22–14:08 | **Спринт 3** — редактирование + версионирование |
|
||||||
|
| 14:08–15:11 | **Спринт 4** — AI-помощник (DeepSeek) |
|
||||||
|
| 15:11–16:00 | **Спринт 5** — трекер результатов |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что реализовано
|
||||||
|
|
||||||
|
### Спринт 1 — Инфраструктура + создание тестов (~40 мин)
|
||||||
|
- Docker Compose: PostgreSQL 16, FastAPI, React + Vite, Nginx
|
||||||
|
- Alembic: миграция `001_init` (таблицы tests, questions, answers)
|
||||||
|
- API: `POST /api/tests`, `GET /api/tests`, `GET /api/tests/{id}`
|
||||||
|
- Фронт: создание теста, список тестов, просмотр теста
|
||||||
|
- Исправлено 4 бага при тестировании (entrypoint, PYTHONPATH, nginx DNS, FastAPI docs URL)
|
||||||
|
|
||||||
|
### Спринт 2 — Прохождение теста + результаты (~25 мин)
|
||||||
|
- Миграция `002_attempts` (таблицы test_attempts, attempt_answers)
|
||||||
|
- API: `POST /api/attempts`, `POST /api/attempts/{id}/submit`, `GET /api/attempts/{id}/result`
|
||||||
|
- Прохождение: случайный порядок вопросов, таймер с автосабмитом, навигация назад
|
||||||
|
- Страница результатов: балл, зачёт/незачёт, разбор ошибок по каждому вопросу
|
||||||
|
- UX: разделение вида сотрудника (`/tests/:id`) и автора (`/tests/:id/edit`), выпадающее меню «⋯» в списке тестов
|
||||||
|
|
||||||
|
### Спринт 3 — Редактирование + версионирование (~46 мин)
|
||||||
|
- Миграция `003_test_versioning` (поле `parent_id` в tests)
|
||||||
|
- API: `PUT /api/tests/{id}`, `GET /api/tests/{id}/versions`, `POST /api/tests/{id}/activate`
|
||||||
|
- Логика: нет попыток → редактировать на месте; есть попытки → создать новую версию (`version+1`, `parent_id=old.id`)
|
||||||
|
- Фронт: общий компонент `TestForm`, страница редактирования с историей версий и активацией
|
||||||
|
- Исправлено 2 бага (FK cascade при bulk DELETE, отображение статуса «Активная»)
|
||||||
|
|
||||||
|
### Спринт 4 — AI-помощник (DeepSeek) (~63 мин)
|
||||||
|
- Миграция `004_settings` (таблица settings, key-value)
|
||||||
|
- Страница `/settings`: ввод API ключа DeepSeek + кнопка «Проверить подключение»
|
||||||
|
- 6 AI-эндпоинтов:
|
||||||
|
- `POST /api/llm/check` — проверка подключения
|
||||||
|
- `POST /api/llm/generate` — генерация вопросов по теме (= название теста)
|
||||||
|
- `POST /api/llm/improve` — улучшение формулировки вопроса + ответов
|
||||||
|
- `POST /api/llm/distractors` — генерация дистракторов
|
||||||
|
- `POST /api/llm/review` — рецензия всего теста
|
||||||
|
- `POST /api/llm/improve_all` — улучшение всего теста целиком
|
||||||
|
- В форме теста: 4 AI-кнопки с модалами и постатейным сравнением (галочки для применения изменений)
|
||||||
|
- Шапка приложения с навигацией
|
||||||
|
|
||||||
|
### Спринт 5 — Трекер результатов (~49 мин)
|
||||||
|
- Миграция `005_attempt_user` (поле `user_id` в test_attempts, дефолт 1 = «Гость»)
|
||||||
|
- API: `GET /api/attempts` с фильтрами (test_id, date_from, date_to) и пагинацией
|
||||||
|
- Страница `/tracker`: таблица всех попыток, фильтр по тесту и диапазону дат, пагинация
|
||||||
|
- Колонки: Сотрудник / Тест + версия / Начало / Завершение / Результат / Зачёт
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Объём кода
|
||||||
|
|
||||||
|
| Слой | Файлы |
|
||||||
|
|------|-------|
|
||||||
|
| Backend — модели | `test.py`, `attempt.py`, `setting.py` |
|
||||||
|
| Backend — схемы | `test.py`, `attempt.py`, `setting.py` |
|
||||||
|
| Backend — API роутеры | `tests.py`, `attempts.py`, `llm.py`, `settings.py` |
|
||||||
|
| Backend — сервисы | `llm.py` |
|
||||||
|
| Backend — миграции | `001` → `005` |
|
||||||
|
| Frontend — страницы | TestList, TestCreate, TestDetail, TestEdit, TestTake, AttemptResult, Settings, Tracker |
|
||||||
|
| Frontend — компоненты | `TestForm` (с AI-функциями) |
|
||||||
|
| Frontend — API клиенты | `tests.ts`, `attempts.ts`, `llm.ts`, `settings.ts`, `client.ts` |
|
||||||
|
| Документация | ТЗ v1.2, СТЕК, СПРИНТЫ, README, 12 файлов ШАГИ |
|
||||||
|
| **Итого** | **~46 файлов** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Текущий статус спринтов
|
||||||
|
|
||||||
|
| Спринт | Содержание | Статус |
|
||||||
|
|--------|-----------|--------|
|
||||||
|
| 1 | Инфраструктура + создание тестов | ✅ Завершён |
|
||||||
|
| 2 | Прохождение теста + результаты | ✅ Завершён |
|
||||||
|
| 3 | Редактирование + версионирование | ✅ Завершён |
|
||||||
|
| 4 | AI-помощник (DeepSeek) | ✅ Завершён |
|
||||||
|
| 5 | Трекер результатов | ✅ Завершён |
|
||||||
|
| 6 | Авторизация, роли, подразделения | ⬜ Следующий |
|
||||||
|
| 7 | Уведомления в MAX | ⬜ Запланирован |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Технический долг перед Спринтом 6
|
||||||
|
|
||||||
|
- `user_id = 1` (Гость) в `test_attempts` — заменить на ID авторизованного пользователя
|
||||||
|
- `GUEST_USER_NAME = "Гость"` в `api/attempts.py` — заменить на JOIN с таблицей `users`
|
||||||
|
- Все эндпоинты открыты без авторизации — добавить JWT Middleware
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Запуск проекта
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Приложение: `http://localhost`
|
||||||
|
API документация: `http://localhost/api/docs`
|
||||||
+11
-6
@@ -140,15 +140,20 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Спринт 5 — Трекер результатов
|
## Спринт 5 — Трекер результатов ✅
|
||||||
|
|
||||||
**Результат:** Таблица всех попыток прохождения тестов.
|
**Результат:** Таблица всех попыток прохождения тестов.
|
||||||
|
**Статус:** Завершён и протестирован вручную в браузере.
|
||||||
|
|
||||||
- [ ] API: `GET /api/attempts` — все попытки (с фильтрами по тесту, дате)
|
- [x] Миграция `005`: поле `user_id` в `test_attempts` (дефолт 1 = «Гость»)
|
||||||
- [ ] Фронт: страница трекера
|
- [x] API: `GET /api/attempts` — все попытки (с фильтрами по тесту, дате, пагинацией)
|
||||||
- Таблица: тест, версия, дата начала, дата завершения, результат, зачёт
|
- [x] Фронт: страница `/tracker`
|
||||||
- Фильтрация по тесту и дате
|
- Таблица: сотрудник, тест + версия, дата начала, дата завершения, результат, зачёт
|
||||||
- Пагинация
|
- Фильтрация по тесту и диапазону дат
|
||||||
|
- Пагинация (20 записей на страницу)
|
||||||
|
- [x] Ссылка «Трекер» в шапке приложения
|
||||||
|
|
||||||
|
**Примечание:** `user_id = 1` («Гость») — временно до Спринта 6 (авторизация).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# ШАГ 013 — Спринт 5: Трекер результатов
|
||||||
|
|
||||||
|
**Дата:** 2026-03-21
|
||||||
|
**Контекст:** Мастер-класс по разработке системы тестирования сотрудников клиники.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Запрос
|
||||||
|
|
||||||
|
> Оставляем текущий спринт. Введём user_id здесь вручную — пусть это будет гость.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Реализовано
|
||||||
|
|
||||||
|
Страница трекера всех попыток с фильтрацией и пагинацией. Все попытки пока принадлежат одному пользователю — «Гость» (user_id = 1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Новые файлы
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/alembic/versions/005_attempt_user.py ← добавляет user_id в test_attempts
|
||||||
|
frontend/src/pages/Tracker/index.tsx ← страница /tracker
|
||||||
|
```
|
||||||
|
|
||||||
|
## Изменённые файлы
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/app/models/attempt.py ← поле user_id (default=1, server_default='1')
|
||||||
|
backend/app/schemas/attempt.py ← AttemptListItem, AttemptListResponse
|
||||||
|
backend/app/api/attempts.py ← GET /api/attempts + GUEST_USER_ID/NAME константы
|
||||||
|
frontend/src/api/attempts.ts ← AttemptListItem, AttemptListResponse, list()
|
||||||
|
frontend/src/App.tsx ← роут /tracker, кнопка «Трекер» в шапке
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API эндпоинты (новые)
|
||||||
|
|
||||||
|
| Метод | URL | Параметры | Описание |
|
||||||
|
|-------|-----|-----------|----------|
|
||||||
|
| GET | `/api/attempts` | test_id, date_from, date_to, page, page_size | Список завершённых попыток |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Схема БД (изменено)
|
||||||
|
|
||||||
|
```
|
||||||
|
test_attempts
|
||||||
|
+ user_id INTEGER NOT NULL DEFAULT 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Технический долг (Sprint 6)
|
||||||
|
|
||||||
|
- `user_id = 1` → заменить на ID из JWT токена
|
||||||
|
- `GUEST_USER_NAME` → JOIN с таблицей users
|
||||||
|
- Все эндпоинты открыты → добавить JWT Middleware
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Следующие шаги
|
||||||
|
|
||||||
|
- [x] Спринт 1: Инфраструктура + Создание тестов
|
||||||
|
- [x] Спринт 2: Прохождение теста + результат
|
||||||
|
- [x] Спринт 3: Редактирование + версионность
|
||||||
|
- [x] Спринт 4: AI-помощник (DeepSeek)
|
||||||
|
- [x] Спринт 5: Трекер результатов
|
||||||
|
- [ ] Спринт 6: Авторизация и роли
|
||||||
|
- [ ] Спринт 7: Уведомления в MAX
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
| **2** | Прохождение теста + результаты и разбор ошибок | ✅ |
|
| **2** | Прохождение теста + результаты и разбор ошибок | ✅ |
|
||||||
| **3** | Редактирование тестов + версионность | ✅ |
|
| **3** | Редактирование тестов + версионность | ✅ |
|
||||||
| **4** | AI-помощник при создании/редактировании тестов (DeepSeek) | ✅ |
|
| **4** | AI-помощник при создании/редактировании тестов (DeepSeek) | ✅ |
|
||||||
| **5** | Трекер результатов | ⬜ |
|
| **5** | Трекер результатов | ✅ |
|
||||||
| **6** | Авторизация, роли, подразделения, управление пользователями | ⬜ |
|
| **6** | Авторизация, роли, подразделения, управление пользователями | ⬜ |
|
||||||
| **7** | Уведомления в MAX | ⬜ |
|
| **7** | Уведомления в MAX | ⬜ |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""005_attempt_user
|
||||||
|
|
||||||
|
Revision ID: 005
|
||||||
|
Revises: 004
|
||||||
|
Create Date: 2026-03-21
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "005"
|
||||||
|
down_revision: Union[str, None] = "004"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"test_attempts",
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default="1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("test_attempts", "user_id")
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import random
|
import random
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
@@ -10,16 +11,22 @@ from app.database import get_db
|
|||||||
from app.models.attempt import AttemptAnswer, TestAttempt
|
from app.models.attempt import AttemptAnswer, TestAttempt
|
||||||
from app.models.test import Question, Test
|
from app.models.test import Question, Test
|
||||||
from app.schemas.attempt import (
|
from app.schemas.attempt import (
|
||||||
|
AnswerForTest,
|
||||||
|
AnswerResult,
|
||||||
|
AttemptListItem,
|
||||||
|
AttemptListResponse,
|
||||||
AttemptResult,
|
AttemptResult,
|
||||||
AttemptStart,
|
AttemptStart,
|
||||||
AttemptStarted,
|
AttemptStarted,
|
||||||
AttemptSubmitDto,
|
AttemptSubmitDto,
|
||||||
AnswerForTest,
|
|
||||||
AnswerResult,
|
|
||||||
QuestionForTest,
|
QuestionForTest,
|
||||||
QuestionResult,
|
QuestionResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Временно: до Sprint 6 (авторизация) все попытки принадлежат гостю
|
||||||
|
GUEST_USER_ID = 1
|
||||||
|
GUEST_USER_NAME = "Гость"
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/attempts", tags=["attempts"])
|
router = APIRouter(prefix="/api/attempts", tags=["attempts"])
|
||||||
|
|
||||||
|
|
||||||
@@ -36,7 +43,7 @@ async def start_attempt(data: AttemptStart, db: AsyncSession = Depends(get_db)):
|
|||||||
if not test:
|
if not test:
|
||||||
raise HTTPException(status_code=404, detail="Тест не найден")
|
raise HTTPException(status_code=404, detail="Тест не найден")
|
||||||
|
|
||||||
attempt = TestAttempt(test_id=test.id, status="in_progress")
|
attempt = TestAttempt(test_id=test.id, status="in_progress", user_id=GUEST_USER_ID)
|
||||||
db.add(attempt)
|
db.add(attempt)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(attempt)
|
await db.refresh(attempt)
|
||||||
@@ -162,6 +169,58 @@ async def submit_attempt(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=AttemptListResponse)
|
||||||
|
async def list_attempts(
|
||||||
|
test_id: Optional[int] = Query(None),
|
||||||
|
date_from: Optional[datetime] = Query(None),
|
||||||
|
date_to: Optional[datetime] = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Трекер попыток: все завершённые попытки с фильтрацией и пагинацией."""
|
||||||
|
base = (
|
||||||
|
select(TestAttempt)
|
||||||
|
.options(selectinload(TestAttempt.test))
|
||||||
|
.where(TestAttempt.status == "finished")
|
||||||
|
)
|
||||||
|
if test_id is not None:
|
||||||
|
base = base.where(TestAttempt.test_id == test_id)
|
||||||
|
if date_from is not None:
|
||||||
|
base = base.where(TestAttempt.started_at >= date_from)
|
||||||
|
if date_to is not None:
|
||||||
|
base = base.where(TestAttempt.started_at <= date_to)
|
||||||
|
|
||||||
|
total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar_one()
|
||||||
|
|
||||||
|
rows_result = await db.execute(
|
||||||
|
base.order_by(TestAttempt.started_at.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
attempts_list = rows_result.scalars().all()
|
||||||
|
|
||||||
|
items = [
|
||||||
|
AttemptListItem(
|
||||||
|
id=a.id,
|
||||||
|
test_id=a.test_id,
|
||||||
|
test_title=a.test.title,
|
||||||
|
test_version=a.test.version,
|
||||||
|
user_id=a.user_id,
|
||||||
|
user_name=GUEST_USER_NAME, # TODO Sprint 6: заменить на JOIN с таблицей users
|
||||||
|
started_at=a.started_at,
|
||||||
|
finished_at=a.finished_at,
|
||||||
|
score=a.score,
|
||||||
|
correct_count=a.correct_count,
|
||||||
|
total_count=a.total_count,
|
||||||
|
passed=a.passed,
|
||||||
|
)
|
||||||
|
for a in attempts_list
|
||||||
|
]
|
||||||
|
|
||||||
|
return AttemptListResponse(items=items, total=total, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{attempt_id}/result", response_model=AttemptResult)
|
@router.get("/{attempt_id}/result", response_model=AttemptResult)
|
||||||
async def get_result(attempt_id: int, db: AsyncSession = Depends(get_db)):
|
async def get_result(attempt_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
"""Получить результат завершённой попытки."""
|
"""Получить результат завершённой попытки."""
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class TestAttempt(Base):
|
|||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False)
|
test_id: Mapped[int] = mapped_column(Integer, ForeignKey("tests.id"), nullable=False)
|
||||||
|
user_id: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
|
||||||
started_at: Mapped[datetime] = mapped_column(
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now()
|
DateTime(timezone=True), server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -87,3 +87,29 @@ class AttemptResult(BaseModel):
|
|||||||
questions: list[QuestionResult]
|
questions: list[QuestionResult]
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Трекер результатов ────────────────────────────────────────
|
||||||
|
|
||||||
|
class AttemptListItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
test_id: int
|
||||||
|
test_title: str
|
||||||
|
test_version: int
|
||||||
|
user_id: int
|
||||||
|
user_name: str
|
||||||
|
started_at: datetime
|
||||||
|
finished_at: Optional[datetime]
|
||||||
|
score: Optional[float]
|
||||||
|
correct_count: Optional[int]
|
||||||
|
total_count: Optional[int]
|
||||||
|
passed: Optional[bool]
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AttemptListResponse(BaseModel):
|
||||||
|
items: list[AttemptListItem]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|||||||
+20
-10
@@ -1,11 +1,12 @@
|
|||||||
import { SettingOutlined } from '@ant-design/icons'
|
import { BarChartOutlined, SettingOutlined } from '@ant-design/icons'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { Button, ConfigProvider, Layout } from 'antd'
|
import { Button, ConfigProvider, Layout, Space } from 'antd'
|
||||||
import ruRU from 'antd/locale/ru_RU'
|
import ruRU from 'antd/locale/ru_RU'
|
||||||
import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom'
|
import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
import AttemptResult from './pages/AttemptResult'
|
import AttemptResult from './pages/AttemptResult'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
|
import Tracker from './pages/Tracker'
|
||||||
import TestCreate from './pages/TestCreate'
|
import TestCreate from './pages/TestCreate'
|
||||||
import TestDetail from './pages/TestDetail'
|
import TestDetail from './pages/TestDetail'
|
||||||
import TestEdit from './pages/TestEdit'
|
import TestEdit from './pages/TestEdit'
|
||||||
@@ -35,14 +36,22 @@ function AppHeader() {
|
|||||||
>
|
>
|
||||||
QA Test App
|
QA Test App
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Space>
|
||||||
icon={<SettingOutlined />}
|
<Button
|
||||||
type="text"
|
icon={<BarChartOutlined />}
|
||||||
onClick={() => navigate('/settings')}
|
type="text"
|
||||||
title="Настройки"
|
onClick={() => navigate('/tracker')}
|
||||||
>
|
>
|
||||||
Настройки
|
Трекер
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<SettingOutlined />}
|
||||||
|
type="text"
|
||||||
|
onClick={() => navigate('/settings')}
|
||||||
|
>
|
||||||
|
Настройки
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
</Header>
|
</Header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -62,6 +71,7 @@ export default function App() {
|
|||||||
<Route path="/tests/:id/edit" element={<TestEdit />} />
|
<Route path="/tests/:id/edit" element={<TestEdit />} />
|
||||||
<Route path="/tests/:testId/take" element={<TestTake />} />
|
<Route path="/tests/:testId/take" element={<TestTake />} />
|
||||||
<Route path="/attempts/:attemptId/result" element={<AttemptResult />} />
|
<Route path="/attempts/:attemptId/result" element={<AttemptResult />} />
|
||||||
|
<Route path="/tracker" element={<Tracker />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Content>
|
</Content>
|
||||||
|
|||||||
@@ -56,6 +56,36 @@ export interface AttemptResult {
|
|||||||
questions: QuestionResult[]
|
questions: QuestionResult[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AttemptListItem {
|
||||||
|
id: number
|
||||||
|
test_id: number
|
||||||
|
test_title: string
|
||||||
|
test_version: number
|
||||||
|
user_id: number
|
||||||
|
user_name: string
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
score: number | null
|
||||||
|
correct_count: number | null
|
||||||
|
total_count: number | null
|
||||||
|
passed: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttemptListResponse {
|
||||||
|
items: AttemptListItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttemptListParams {
|
||||||
|
test_id?: number
|
||||||
|
date_from?: string
|
||||||
|
date_to?: string
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
export const attemptsApi = {
|
export const attemptsApi = {
|
||||||
start: (test_id: number) =>
|
start: (test_id: number) =>
|
||||||
client.post<AttemptStarted>('/attempts', { test_id }),
|
client.post<AttemptStarted>('/attempts', { test_id }),
|
||||||
@@ -65,4 +95,7 @@ export const attemptsApi = {
|
|||||||
|
|
||||||
getResult: (attempt_id: number) =>
|
getResult: (attempt_id: number) =>
|
||||||
client.get<AttemptResult>(`/attempts/${attempt_id}/result`),
|
client.get<AttemptResult>(`/attempts/${attempt_id}/result`),
|
||||||
|
|
||||||
|
list: (params: AttemptListParams = {}) =>
|
||||||
|
client.get<AttemptListResponse>('/attempts', { params }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { CheckCircleTwoTone, CloseCircleTwoTone } from '@ant-design/icons'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Button, DatePicker, Select, Space, Table, Tag, Typography } from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import dayjs, { Dayjs } from 'dayjs'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { AttemptListItem, attemptsApi } from '../../api/attempts'
|
||||||
|
import { testsApi } from '../../api/tests'
|
||||||
|
|
||||||
|
const { Title } = Typography
|
||||||
|
const { RangePicker } = DatePicker
|
||||||
|
|
||||||
|
export default function Tracker() {
|
||||||
|
const [testId, setTestId] = useState<number | undefined>()
|
||||||
|
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const pageSize = 20
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
test_id: testId,
|
||||||
|
date_from: dateRange?.[0].startOf('day').toISOString(),
|
||||||
|
date_to: dateRange?.[1].endOf('day').toISOString(),
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['attempts', params],
|
||||||
|
queryFn: () => attemptsApi.list(params).then((r) => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: testsData } = useQuery({
|
||||||
|
queryKey: ['tests'],
|
||||||
|
queryFn: () => testsApi.list().then((r) => r.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setTestId(undefined)
|
||||||
|
setDateRange(null)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<AttemptListItem> = [
|
||||||
|
{
|
||||||
|
title: 'Сотрудник',
|
||||||
|
dataIndex: 'user_name',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Тест',
|
||||||
|
key: 'test',
|
||||||
|
render: (_, r) => (
|
||||||
|
<span>
|
||||||
|
{r.test_title}{' '}
|
||||||
|
<Tag color="default" style={{ fontSize: 11 }}>
|
||||||
|
v{r.test_version}
|
||||||
|
</Tag>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Начало',
|
||||||
|
dataIndex: 'started_at',
|
||||||
|
width: 160,
|
||||||
|
render: (v: string) => dayjs(v).format('DD.MM.YYYY HH:mm'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Завершение',
|
||||||
|
dataIndex: 'finished_at',
|
||||||
|
width: 160,
|
||||||
|
render: (v: string | null) => (v ? dayjs(v).format('DD.MM.YYYY HH:mm') : '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Результат',
|
||||||
|
key: 'result',
|
||||||
|
width: 140,
|
||||||
|
render: (_, r) =>
|
||||||
|
r.correct_count != null && r.total_count != null
|
||||||
|
? `${r.correct_count} / ${r.total_count} (${r.score?.toFixed(1)}%)`
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Зачёт',
|
||||||
|
dataIndex: 'passed',
|
||||||
|
width: 90,
|
||||||
|
render: (passed: boolean | null) => {
|
||||||
|
if (passed == null) return '—'
|
||||||
|
return passed ? (
|
||||||
|
<Space size={4}>
|
||||||
|
<CheckCircleTwoTone twoToneColor="#52c41a" />
|
||||||
|
<Tag color="success">Сдал</Tag>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Space size={4}>
|
||||||
|
<CloseCircleTwoTone twoToneColor="#ff4d4f" />
|
||||||
|
<Tag color="error">Не сдал</Tag>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1000, margin: '32px auto', padding: '0 24px' }}>
|
||||||
|
<Title level={2}>Трекер результатов</Title>
|
||||||
|
|
||||||
|
{/* Фильтры */}
|
||||||
|
<Space wrap style={{ marginBottom: 16 }}>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="Все тесты"
|
||||||
|
style={{ width: 260 }}
|
||||||
|
value={testId}
|
||||||
|
onChange={(v) => { setTestId(v); setPage(1) }}
|
||||||
|
options={(testsData ?? []).map((t) => ({
|
||||||
|
value: t.id,
|
||||||
|
label: `${t.title} (v${t.version})`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<RangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={(v) => { setDateRange(v as [Dayjs, Dayjs] | null); setPage(1) }}
|
||||||
|
format="DD.MM.YYYY"
|
||||||
|
placeholder={['Дата от', 'Дата до']}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleReset}>Сбросить</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
onChange: setPage,
|
||||||
|
showTotal: (total) => `Всего: ${total}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user