fc684e7c7d
- Миграция 005: user_id в test_attempts (дефолт 1 = Гость) - GET /api/attempts с фильтрами по тесту, дате и пагинацией - Страница /tracker: таблица попыток, фильтры, пагинация - Ссылка «Трекер» в шапке приложения Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
146 lines
4.0 KiB
TypeScript
146 lines
4.0 KiB
TypeScript
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>
|
||
)
|
||
}
|