You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
285 lines
10 KiB
285 lines
10 KiB
import { |
|
ArrowLeftOutlined, |
|
CheckCircleTwoTone, |
|
CloseCircleTwoTone, |
|
EditOutlined, |
|
PlayCircleOutlined, |
|
} from '@ant-design/icons' |
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' |
|
import { Alert, Button, Card, Descriptions, List, Space, Spin, Table, Tag, Typography, message } from 'antd' |
|
import type { ColumnsType } from 'antd/es/table' |
|
import { useState } from 'react' |
|
import { useNavigate, useParams } from 'react-router-dom' |
|
|
|
import { Answer, CreateTestDto, TestListItem, testsApi } from '../../api/tests' |
|
import TestForm, { TestFormValues } from '../../components/TestForm' |
|
|
|
const { Title, Text } = Typography |
|
|
|
export default function TestEdit() { |
|
const { id } = useParams<{ id: string }>() |
|
const navigate = useNavigate() |
|
const queryClient = useQueryClient() |
|
const [editMode, setEditMode] = useState(false) |
|
|
|
const { data: test, isLoading } = useQuery({ |
|
queryKey: ['tests', id], |
|
queryFn: () => testsApi.get(Number(id)).then((r) => r.data), |
|
}) |
|
|
|
const { data: versions = [] } = useQuery({ |
|
queryKey: ['tests', id, 'versions'], |
|
queryFn: () => testsApi.versions(Number(id)).then((r) => r.data), |
|
enabled: !editMode, |
|
}) |
|
|
|
const { mutate: activateVersion, isPending: isActivating } = useMutation({ |
|
mutationFn: (versionId: number) => testsApi.activate(versionId).then((r) => r.data), |
|
onSuccess: () => { |
|
queryClient.invalidateQueries({ queryKey: ['tests'] }) |
|
queryClient.invalidateQueries({ queryKey: ['tests', id, 'versions'] }) |
|
queryClient.invalidateQueries({ queryKey: ['tests', id] }) |
|
message.success('Активная версия изменена') |
|
}, |
|
onError: () => message.error('Не удалось изменить активную версию'), |
|
}) |
|
|
|
const { mutate: updateTest, isPending } = useMutation({ |
|
mutationFn: (data: CreateTestDto) => testsApi.update(Number(id), data).then((r) => r.data), |
|
onSuccess: (result) => { |
|
queryClient.invalidateQueries({ queryKey: ['tests'] }) |
|
if (result.is_new_version) { |
|
message.success(`Создана новая версия теста (v${result.test.version})`) |
|
navigate(`/tests/${result.test.id}/edit`) |
|
} else { |
|
message.success('Тест обновлён') |
|
queryClient.invalidateQueries({ queryKey: ['tests', id] }) |
|
setEditMode(false) |
|
} |
|
}, |
|
onError: (error: unknown) => { |
|
const err = error as { response?: { data?: { detail?: string } } } |
|
message.error(err.response?.data?.detail || 'Ошибка при сохранении теста') |
|
}, |
|
}) |
|
|
|
if (isLoading) { |
|
return <Spin size="large" style={{ display: 'block', margin: '48px auto' }} /> |
|
} |
|
|
|
if (!test) return null |
|
|
|
// Режим редактирования — показываем форму с предзаполненными данными |
|
if (editMode) { |
|
const initialValues: TestFormValues = { |
|
title: test.title, |
|
description: test.description ?? undefined, |
|
passing_score: test.passing_score, |
|
has_timer: test.time_limit !== null, |
|
time_limit: test.time_limit ?? undefined, |
|
allow_navigation_back: test.allow_navigation_back, |
|
questions: test.questions.map((q) => ({ |
|
text: q.text, |
|
answers: q.answers.map((a) => ({ text: a.text, is_correct: a.is_correct })), |
|
})), |
|
} |
|
|
|
const onSubmit = (values: TestFormValues) => { |
|
updateTest({ |
|
title: values.title, |
|
description: values.description, |
|
passing_score: values.passing_score, |
|
time_limit: values.has_timer ? values.time_limit : undefined, |
|
allow_navigation_back: values.allow_navigation_back ?? true, |
|
questions: values.questions, |
|
}) |
|
} |
|
|
|
return ( |
|
<TestForm |
|
heading={`Редактирование теста — v${test.version}`} |
|
initialValues={initialValues} |
|
onSubmit={onSubmit} |
|
isPending={isPending} |
|
submitLabel="Сохранить" |
|
onCancel={() => setEditMode(false)} |
|
onBack={() => setEditMode(false)} |
|
backLabel="К просмотру теста" |
|
/> |
|
) |
|
} |
|
|
|
// Режим просмотра (вид автора) |
|
return ( |
|
<div style={{ maxWidth: 820, margin: '0 auto', padding: 24 }}> |
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}> |
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/')}> |
|
К списку тестов |
|
</Button> |
|
<Space> |
|
{versions.length > 1 && (!test.is_active || versions.some(v => v.id !== test.id && v.is_active)) && ( |
|
<Button |
|
loading={isActivating} |
|
onClick={() => activateVersion(test.id)} |
|
> |
|
Сделать активной |
|
</Button> |
|
)} |
|
<Button |
|
icon={<EditOutlined />} |
|
onClick={() => setEditMode(true)} |
|
> |
|
Редактировать |
|
</Button> |
|
<Button |
|
type="primary" |
|
icon={<PlayCircleOutlined />} |
|
onClick={() => navigate(`/tests/${test.id}/take`)} |
|
> |
|
Пройти тест |
|
</Button> |
|
</Space> |
|
</Space> |
|
|
|
<Alert |
|
type="warning" |
|
showIcon |
|
message="Вид автора — правильные ответы отмечены" |
|
style={{ marginBottom: 16 }} |
|
/> |
|
|
|
<Title level={2}>{test.title}</Title> |
|
|
|
{test.description && ( |
|
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}> |
|
{test.description} |
|
</Text> |
|
)} |
|
|
|
<Card style={{ marginBottom: 24 }}> |
|
<Descriptions column={3}> |
|
<Descriptions.Item label="Вопросов">{test.questions.length}</Descriptions.Item> |
|
<Descriptions.Item label="Порог зачёта">{test.passing_score}%</Descriptions.Item> |
|
<Descriptions.Item label="Таймер"> |
|
{test.time_limit ? `${test.time_limit} мин` : 'Без ограничений'} |
|
</Descriptions.Item> |
|
<Descriptions.Item label="Возврат к вопросу"> |
|
{test.allow_navigation_back ? ( |
|
<Tag color="green">Разрешён</Tag> |
|
) : ( |
|
<Tag color="red">Запрещён</Tag> |
|
)} |
|
</Descriptions.Item> |
|
<Descriptions.Item label="Версия">{test.version}</Descriptions.Item> |
|
<Descriptions.Item label="Создан"> |
|
{new Date(test.created_at).toLocaleDateString('ru-RU')} |
|
</Descriptions.Item> |
|
</Descriptions> |
|
</Card> |
|
|
|
{versions.length > 1 && ( |
|
<> |
|
<Title level={3}>История версий</Title> |
|
<Table<TestListItem> |
|
dataSource={versions} |
|
rowKey="id" |
|
size="small" |
|
pagination={false} |
|
style={{ marginBottom: 24 }} |
|
rowClassName={(record) => (record.id === test.id ? 'ant-table-row-selected' : '')} |
|
columns={ |
|
[ |
|
{ |
|
title: 'Версия', |
|
dataIndex: 'version', |
|
width: 70, |
|
render: (v: number) => <Tag color="default">v{v}</Tag>, |
|
}, |
|
{ |
|
title: 'Статус', |
|
dataIndex: 'is_active', |
|
width: 130, |
|
render: (active: boolean) => |
|
active ? ( |
|
<Tag color="green">Активная</Tag> |
|
) : ( |
|
<Tag color="default">Неактивная</Tag> |
|
), |
|
}, |
|
{ |
|
title: 'Дата', |
|
dataIndex: 'created_at', |
|
width: 120, |
|
render: (d: string) => new Date(d).toLocaleDateString('ru-RU'), |
|
}, |
|
{ |
|
title: 'Вопросов', |
|
dataIndex: 'questions_count', |
|
width: 100, |
|
align: 'center' as const, |
|
}, |
|
{ |
|
title: 'Порог зачёта', |
|
dataIndex: 'passing_score', |
|
width: 120, |
|
align: 'center' as const, |
|
render: (s: number) => `${s}%`, |
|
}, |
|
{ |
|
title: '', |
|
key: 'action', |
|
render: (_: unknown, record: TestListItem) => ( |
|
<Space> |
|
{record.id !== test.id && ( |
|
<Button size="small" onClick={() => navigate(`/tests/${record.id}/edit`)}> |
|
Открыть |
|
</Button> |
|
)} |
|
{(record.id !== test.id || !record.is_active) && ( |
|
<Button |
|
size="small" |
|
type={record.is_active ? 'default' : 'primary'} |
|
loading={isActivating} |
|
onClick={() => activateVersion(record.id)} |
|
> |
|
Сделать активной |
|
</Button> |
|
)} |
|
</Space> |
|
), |
|
}, |
|
] as ColumnsType<TestListItem> |
|
} |
|
/> |
|
</> |
|
)} |
|
|
|
<Title level={3}>Вопросы ({test.questions.length})</Title> |
|
|
|
{test.questions.map((question, index) => ( |
|
<Card key={question.id} style={{ marginBottom: 12 }}> |
|
<Text strong> |
|
{index + 1}. {question.text} |
|
</Text> |
|
<List |
|
style={{ marginTop: 10 }} |
|
dataSource={question.answers} |
|
renderItem={(answer: Answer) => ( |
|
<List.Item style={{ padding: '4px 0' }}> |
|
<Space> |
|
{answer.is_correct ? ( |
|
<CheckCircleTwoTone twoToneColor="#52c41a" /> |
|
) : ( |
|
<CloseCircleTwoTone twoToneColor="#d9d9d9" /> |
|
)} |
|
<Text>{answer.text}</Text> |
|
</Space> |
|
</List.Item> |
|
)} |
|
/> |
|
</Card> |
|
))} |
|
|
|
</div> |
|
) |
|
}
|
|
|