Спринт 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** | Прохождение теста + результаты и разбор ошибок | ✅ |
|
||||
| **3** | Редактирование тестов + версионность | ✅ |
|
||||
| **4** | AI-помощник при создании/редактировании тестов (DeepSeek) | ✅ |
|
||||
| **5** | Трекер результатов | ⬜ |
|
||||
| **5** | Трекер результатов | ✅ |
|
||||
| **6** | Авторизация, роли, подразделения, управление пользователями | ⬜ |
|
||||
| **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
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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.test import Question, Test
|
||||
from app.schemas.attempt import (
|
||||
AnswerForTest,
|
||||
AnswerResult,
|
||||
AttemptListItem,
|
||||
AttemptListResponse,
|
||||
AttemptResult,
|
||||
AttemptStart,
|
||||
AttemptStarted,
|
||||
AttemptSubmitDto,
|
||||
AnswerForTest,
|
||||
AnswerResult,
|
||||
QuestionForTest,
|
||||
QuestionResult,
|
||||
)
|
||||
|
||||
# Временно: до Sprint 6 (авторизация) все попытки принадлежат гостю
|
||||
GUEST_USER_ID = 1
|
||||
GUEST_USER_NAME = "Гость"
|
||||
|
||||
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:
|
||||
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)
|
||||
await db.commit()
|
||||
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)
|
||||
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)
|
||||
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(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -87,3 +87,29 @@ class AttemptResult(BaseModel):
|
||||
questions: list[QuestionResult]
|
||||
|
||||
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 { Button, ConfigProvider, Layout } from 'antd'
|
||||
import { Button, ConfigProvider, Layout, Space } from 'antd'
|
||||
import ruRU from 'antd/locale/ru_RU'
|
||||
import { BrowserRouter, Route, Routes, useNavigate } from 'react-router-dom'
|
||||
|
||||
import AttemptResult from './pages/AttemptResult'
|
||||
import Settings from './pages/Settings'
|
||||
import Tracker from './pages/Tracker'
|
||||
import TestCreate from './pages/TestCreate'
|
||||
import TestDetail from './pages/TestDetail'
|
||||
import TestEdit from './pages/TestEdit'
|
||||
@@ -35,14 +36,22 @@ function AppHeader() {
|
||||
>
|
||||
QA Test App
|
||||
</span>
|
||||
<Button
|
||||
icon={<SettingOutlined />}
|
||||
type="text"
|
||||
onClick={() => navigate('/settings')}
|
||||
title="Настройки"
|
||||
>
|
||||
Настройки
|
||||
</Button>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<BarChartOutlined />}
|
||||
type="text"
|
||||
onClick={() => navigate('/tracker')}
|
||||
>
|
||||
Трекер
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SettingOutlined />}
|
||||
type="text"
|
||||
onClick={() => navigate('/settings')}
|
||||
>
|
||||
Настройки
|
||||
</Button>
|
||||
</Space>
|
||||
</Header>
|
||||
)
|
||||
}
|
||||
@@ -62,6 +71,7 @@ export default function App() {
|
||||
<Route path="/tests/:id/edit" element={<TestEdit />} />
|
||||
<Route path="/tests/:testId/take" element={<TestTake />} />
|
||||
<Route path="/attempts/:attemptId/result" element={<AttemptResult />} />
|
||||
<Route path="/tracker" element={<Tracker />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</Content>
|
||||
|
||||
@@ -56,6 +56,36 @@ export interface AttemptResult {
|
||||
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 = {
|
||||
start: (test_id: number) =>
|
||||
client.post<AttemptStarted>('/attempts', { test_id }),
|
||||
@@ -65,4 +95,7 @@ export const attemptsApi = {
|
||||
|
||||
getResult: (attempt_id: number) =>
|
||||
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