Спринт 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:
+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