From dc209b8ddb1dcbf89d5b32b0969bddb406b101c0 Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Fri, 15 Aug 2025 13:28:02 +0300 Subject: [PATCH 01/10] =?UTF-8?q?QAGDEV-723=20-=20[FE]=20=D0=9F=D1=80?= =?UTF-8?q?=D0=B8=D0=BA=D1=80=D0=B5=D0=BF=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=BA=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=D0=BC=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../graphql/test/delete-test-answer.graphql | 3 + .../graphql/test/delete-test-group.graphql | 3 + .../graphql/test/delete-test-question.graphql | 3 + src/api/graphql/test/lecture-test.graphql | 6 + .../test/send-test-answer-to-review.graphql | 34 ++ src/api/graphql/test/send-test-answer.graphql | 33 ++ src/api/graphql/test/start-test.graphql | 10 + .../test/test-answer-by-question.graphql | 7 + .../test/test-attempt-questions.graphql | 21 + src/api/graphql/test/test-attempt.graphql | 29 ++ src/api/graphql/test/test-attempts.graphql | 10 + src/api/graphql/test/test-questions.graphql | 10 + .../test/test-test-groups-by-id.graphql | 15 + src/api/graphql/test/test-test-groups.graphql | 7 + .../graphql/test/update-test-answer.graphql | 7 + .../graphql/test/update-test-group.graphql | 15 + .../graphql/test/update-test-question.graphql | 10 + src/features/admin-panel/admin-panel.tsx | 7 + .../components/create-test-form.tsx | 491 ++++++++++++++++++ .../containers/create-test-container.tsx | 208 ++++++++ .../tests-admin/containers/index.ts | 1 + .../tests-admin/create-test-form.tsx | 411 +++++++++++++++ src/features/admin-panel/tests-admin/index.ts | 1 + .../admin-panel/tests-admin/tests-admin.tsx | 173 ++++++ src/features/admin-panel/tests-admin/types.ts | 11 + .../test/containers/test-container.tsx | 7 +- src/pages/create-test.tsx | 12 + src/routes/admin.tsx | 10 + src/routes/student.tsx | 2 + 29 files changed, 1554 insertions(+), 3 deletions(-) create mode 100644 src/api/graphql/test/delete-test-answer.graphql create mode 100644 src/api/graphql/test/delete-test-group.graphql create mode 100644 src/api/graphql/test/delete-test-question.graphql create mode 100644 src/api/graphql/test/lecture-test.graphql create mode 100644 src/api/graphql/test/send-test-answer-to-review.graphql create mode 100644 src/api/graphql/test/send-test-answer.graphql create mode 100644 src/api/graphql/test/start-test.graphql create mode 100644 src/api/graphql/test/test-answer-by-question.graphql create mode 100644 src/api/graphql/test/test-attempt-questions.graphql create mode 100644 src/api/graphql/test/test-attempt.graphql create mode 100644 src/api/graphql/test/test-attempts.graphql create mode 100644 src/api/graphql/test/test-questions.graphql create mode 100644 src/api/graphql/test/test-test-groups-by-id.graphql create mode 100644 src/api/graphql/test/test-test-groups.graphql create mode 100644 src/api/graphql/test/update-test-answer.graphql create mode 100644 src/api/graphql/test/update-test-group.graphql create mode 100644 src/api/graphql/test/update-test-question.graphql create mode 100644 src/features/admin-panel/tests-admin/components/create-test-form.tsx create mode 100644 src/features/admin-panel/tests-admin/containers/create-test-container.tsx create mode 100644 src/features/admin-panel/tests-admin/containers/index.ts create mode 100644 src/features/admin-panel/tests-admin/create-test-form.tsx create mode 100644 src/features/admin-panel/tests-admin/index.ts create mode 100644 src/features/admin-panel/tests-admin/tests-admin.tsx create mode 100644 src/features/admin-panel/tests-admin/types.ts create mode 100644 src/pages/create-test.tsx diff --git a/src/api/graphql/test/delete-test-answer.graphql b/src/api/graphql/test/delete-test-answer.graphql new file mode 100644 index 00000000..fca621ad --- /dev/null +++ b/src/api/graphql/test/delete-test-answer.graphql @@ -0,0 +1,3 @@ +mutation deleteTestAnswer($id: ID!) { + deleteTestAnswer(id: $id) +} diff --git a/src/api/graphql/test/delete-test-group.graphql b/src/api/graphql/test/delete-test-group.graphql new file mode 100644 index 00000000..d22f01a4 --- /dev/null +++ b/src/api/graphql/test/delete-test-group.graphql @@ -0,0 +1,3 @@ +mutation deleteTestGroup($id: ID!) { + deleteTestGroup(id: $id) +} diff --git a/src/api/graphql/test/delete-test-question.graphql b/src/api/graphql/test/delete-test-question.graphql new file mode 100644 index 00000000..1b94486d --- /dev/null +++ b/src/api/graphql/test/delete-test-question.graphql @@ -0,0 +1,3 @@ +mutation deleteTestQuestion($id: ID!) { + deleteTestQuestion(id: $id) +} diff --git a/src/api/graphql/test/lecture-test.graphql b/src/api/graphql/test/lecture-test.graphql new file mode 100644 index 00000000..c6b4b219 --- /dev/null +++ b/src/api/graphql/test/lecture-test.graphql @@ -0,0 +1,6 @@ +query lectureTest($lectureId: ID) { + lectureTest(lectureId: $lectureId) { + id + testName + } +} diff --git a/src/api/graphql/test/send-test-answer-to-review.graphql b/src/api/graphql/test/send-test-answer-to-review.graphql new file mode 100644 index 00000000..5e1f8156 --- /dev/null +++ b/src/api/graphql/test/send-test-answer-to-review.graphql @@ -0,0 +1,34 @@ +mutation sendTestAnswerToReview($attemptId: ID!) { + sendTestAnswerToReview(attemptId: $attemptId) { + id + status + answer + lecture { + id + subject + } + training { + id + name + } + student { + id + firstName + lastName + } + mentor { + id + firstName + lastName + } + creationDate + testAttempts { + id + startTime + endTime + result + successfulCount + errorsCount + } + } +} diff --git a/src/api/graphql/test/send-test-answer.graphql b/src/api/graphql/test/send-test-answer.graphql new file mode 100644 index 00000000..71972eb8 --- /dev/null +++ b/src/api/graphql/test/send-test-answer.graphql @@ -0,0 +1,33 @@ +mutation sendTestAnswer( + $questionId: ID! + $attemptId: ID! + $testAnswerIds: [ID]! +) { + sendTestAnswer( + questionId: $questionId + attemptId: $attemptId + testAnswerIds: $testAnswerIds + ) { + id + startTime + endTime + successfulCount + errorsCount + result + testAttemptQuestionResults { + testQuestion { + id + text + } + result + testAnswerResults { + testAnswer { + id + text + } + result + answer + } + } + } +} diff --git a/src/api/graphql/test/start-test.graphql b/src/api/graphql/test/start-test.graphql new file mode 100644 index 00000000..a207c1a0 --- /dev/null +++ b/src/api/graphql/test/start-test.graphql @@ -0,0 +1,10 @@ +mutation startTest($lectureId: ID!, $trainingId: ID!) { + startTest(lectureId: $lectureId, trainingId: $trainingId) { + id + startTime + endTime + successfulCount + errorsCount + result + } +} diff --git a/src/api/graphql/test/test-answer-by-question.graphql b/src/api/graphql/test/test-answer-by-question.graphql new file mode 100644 index 00000000..430233e3 --- /dev/null +++ b/src/api/graphql/test/test-answer-by-question.graphql @@ -0,0 +1,7 @@ +query testAnswerByQuestion($questionId: ID) { + testAnswerByQuestion(questionId: $questionId) { + id + text + correct + } +} diff --git a/src/api/graphql/test/test-attempt-questions.graphql b/src/api/graphql/test/test-attempt-questions.graphql new file mode 100644 index 00000000..4afa6a59 --- /dev/null +++ b/src/api/graphql/test/test-attempt-questions.graphql @@ -0,0 +1,21 @@ +query testAttemptQuestions($attemptId: ID!) { + testAttemptQuestions(attemptId: $attemptId) { + testQuestion { + id + text + testAnswers { + id + text + } + } + result + testAnswerResults { + testAnswer { + id + text + } + result + answer + } + } +} diff --git a/src/api/graphql/test/test-attempt.graphql b/src/api/graphql/test/test-attempt.graphql new file mode 100644 index 00000000..bbe11fac --- /dev/null +++ b/src/api/graphql/test/test-attempt.graphql @@ -0,0 +1,29 @@ +query testAttempt($id: ID!) { + testAttempt(id: $id) { + id + startTime + endTime + successfulCount + errorsCount + result + testAttemptQuestionResults { + testQuestion { + id + text + testAnswers { + id + text + } + } + result + testAnswerResults { + testAnswer { + id + text + } + result + answer + } + } + } +} diff --git a/src/api/graphql/test/test-attempts.graphql b/src/api/graphql/test/test-attempts.graphql new file mode 100644 index 00000000..cf39eeb5 --- /dev/null +++ b/src/api/graphql/test/test-attempts.graphql @@ -0,0 +1,10 @@ +query testAttempts($lectureId: ID!, $trainingId: ID!) { + testAttempts(lectureId: $lectureId, trainingId: $trainingId) { + id + startTime + endTime + successfulCount + errorsCount + result + } +} diff --git a/src/api/graphql/test/test-questions.graphql b/src/api/graphql/test/test-questions.graphql new file mode 100644 index 00000000..ff17f451 --- /dev/null +++ b/src/api/graphql/test/test-questions.graphql @@ -0,0 +1,10 @@ +query testQuestions { + testQuestions { + id + text + testAnswers { + id + text + } + } +} diff --git a/src/api/graphql/test/test-test-groups-by-id.graphql b/src/api/graphql/test/test-test-groups-by-id.graphql new file mode 100644 index 00000000..32998f13 --- /dev/null +++ b/src/api/graphql/test/test-test-groups-by-id.graphql @@ -0,0 +1,15 @@ +query testTestGroupsById($id: ID) { + testTestGroupsById(id: $id) { + id + testName + successThreshold + testQuestions { + id + text + testAnswers { + id + text + } + } + } +} diff --git a/src/api/graphql/test/test-test-groups.graphql b/src/api/graphql/test/test-test-groups.graphql new file mode 100644 index 00000000..4852ee23 --- /dev/null +++ b/src/api/graphql/test/test-test-groups.graphql @@ -0,0 +1,7 @@ +query testTestGroups { + testTestGroups { + id + testName + successThreshold + } +} diff --git a/src/api/graphql/test/update-test-answer.graphql b/src/api/graphql/test/update-test-answer.graphql new file mode 100644 index 00000000..f8ca8f02 --- /dev/null +++ b/src/api/graphql/test/update-test-answer.graphql @@ -0,0 +1,7 @@ +mutation updateTestAnswer($input: TestAnswerInput!) { + updateTestAnswer(input: $input) { + id + text + correct + } +} diff --git a/src/api/graphql/test/update-test-group.graphql b/src/api/graphql/test/update-test-group.graphql new file mode 100644 index 00000000..da8c7497 --- /dev/null +++ b/src/api/graphql/test/update-test-group.graphql @@ -0,0 +1,15 @@ +mutation updateTestGroup($input: TestGroupInput!) { + updateTestGroup(input: $input) { + id + testName + successThreshold + testQuestions { + id + text + testAnswers { + id + text + } + } + } +} diff --git a/src/api/graphql/test/update-test-question.graphql b/src/api/graphql/test/update-test-question.graphql new file mode 100644 index 00000000..e9264fbd --- /dev/null +++ b/src/api/graphql/test/update-test-question.graphql @@ -0,0 +1,10 @@ +mutation updateTestQuestion($input: TestQuestionInput!) { + updateTestQuestion(input: $input) { + id + text + testAnswers { + id + text + } + } +} diff --git a/src/features/admin-panel/admin-panel.tsx b/src/features/admin-panel/admin-panel.tsx index a34a0086..ddf131e4 100644 --- a/src/features/admin-panel/admin-panel.tsx +++ b/src/features/admin-panel/admin-panel.tsx @@ -11,6 +11,7 @@ import { useResponsive } from "shared/hooks"; import UsersAdmin from "./users-admin"; import CourseAdmin from "./courses-admin"; import StatisticsAdmin from "./statistics-admin/statistics-admin"; +import TestsAdmin from "./tests-admin"; import { StyledTabPanel, StyledTypography } from "./admin-panel.styled"; const AdminPanel: FC = () => { @@ -37,6 +38,12 @@ const AdminPanel: FC = () => { path: "/statistics", component: StatisticsAdmin, }, + { + label: "Тесты", + value: "4", + path: "/tests", + component: TestsAdmin, + }, ]; const currentRoute = diff --git a/src/features/admin-panel/tests-admin/components/create-test-form.tsx b/src/features/admin-panel/tests-admin/components/create-test-form.tsx new file mode 100644 index 00000000..ac69765e --- /dev/null +++ b/src/features/admin-panel/tests-admin/components/create-test-form.tsx @@ -0,0 +1,491 @@ +import React, { FC, useState, useEffect } from "react"; +import { + Box, + Button, + TextField, + Typography, + Accordion, + AccordionSummary, + AccordionDetails, + IconButton, + FormControlLabel, + Checkbox, + Grid, + Card, + CardContent, + CardHeader, + Chip, +} from "@mui/material"; +import { + ExpandMore as ExpandMoreIcon, + Add as AddIcon, + Delete as DeleteIcon, + QuestionAnswer as QuestionIcon, +} from "@mui/icons-material"; + +import { TestGroupDto } from "api/graphql/generated/graphql"; + +import { QuestionForm } from "../types"; + +interface CreateTestFormProps { + existingTest?: TestGroupDto | null; + onSave: ( + testName: string, + successThreshold: number, + questions: QuestionForm[] + ) => void; + onCancel: () => void; + isLoading?: boolean; +} + +const CreateTestForm: FC = ({ + existingTest, + onSave, + onCancel, + isLoading = false, +}) => { + const [testName, setTestName] = useState(""); + const [successThreshold, setSuccessThreshold] = useState(70); + const [questions, setQuestions] = useState([]); + const [expandedQuestion, setExpandedQuestion] = useState( + false + ); + + // Загружаем данные существующего теста для редактирования + useEffect(() => { + if (existingTest) { + setTestName(existingTest.testName || ""); + setSuccessThreshold(existingTest.successThreshold || 70); + + const loadedQuestions: QuestionForm[] = + existingTest.testQuestions?.map((q, index) => ({ + id: q?.id || "", + text: q?.text || "", + answers: q?.testAnswers?.map((a) => ({ + id: a?.id || "", + text: a?.text || "", + correct: false, // В схеме нет поля correct для TestAnswerShortDto, будем получать отдельно + })) || [ + { text: "", correct: true }, + { text: "", correct: false }, + ], + })) || []; + + setQuestions(loadedQuestions); + // Открываем первый вопрос по умолчанию + if (loadedQuestions.length > 0) { + setExpandedQuestion(`question-0`); + } + } + }, [existingTest]); + + const addQuestion = () => { + const newQuestionIndex = questions.length; + const newQuestion: QuestionForm = { + text: "", + answers: [ + { text: "", correct: true }, + { text: "", correct: false }, + ], + }; + setQuestions([...questions, newQuestion]); + setExpandedQuestion(`question-${newQuestionIndex}`); + }; + + const removeQuestion = (questionIndex: number) => { + const updatedQuestions = questions.filter( + (_, index) => index !== questionIndex + ); + setQuestions(updatedQuestions); + + // Если удаляем активный вопрос, открываем предыдущий или следующий + if (expandedQuestion === `question-${questionIndex}`) { + if (updatedQuestions.length > 0) { + const newIndex = questionIndex > 0 ? questionIndex - 1 : 0; + setExpandedQuestion(`question-${newIndex}`); + } else { + setExpandedQuestion(false); + } + } + }; + + const updateQuestion = (questionIndex: number, text: string) => { + const updatedQuestions = [...questions]; + updatedQuestions[questionIndex].text = text; + setQuestions(updatedQuestions); + }; + + const addAnswer = (questionIndex: number) => { + const updatedQuestions = [...questions]; + updatedQuestions[questionIndex].answers.push({ + text: "", + correct: false, + }); + setQuestions(updatedQuestions); + }; + + const removeAnswer = (questionIndex: number, answerIndex: number) => { + const updatedQuestions = [...questions]; + updatedQuestions[questionIndex].answers = updatedQuestions[ + questionIndex + ].answers.filter((_, index) => index !== answerIndex); + setQuestions(updatedQuestions); + }; + + const updateAnswer = ( + questionIndex: number, + answerIndex: number, + field: "text" | "correct", + value: string | boolean + ) => { + const updatedQuestions = [...questions]; + if (field === "text") { + updatedQuestions[questionIndex].answers[answerIndex].text = + value as string; + } else { + updatedQuestions[questionIndex].answers[answerIndex].correct = + value as boolean; + } + setQuestions(updatedQuestions); + }; + + const validateForm = (): string | null => { + if (!testName.trim()) { + return "Название теста обязательно"; + } + + if (successThreshold < 0 || successThreshold > 100) { + return "Проходной балл должен быть от 0 до 100%"; + } + + if (questions.length === 0) { + return "Добавьте хотя бы один вопрос"; + } + + for (let i = 0; i < questions.length; i++) { + const question = questions[i]; + if (!question.text.trim()) { + return `Текст вопроса ${i + 1} не может быть пустым`; + } + + if (question.answers.length < 2) { + return `В вопросе ${i + 1} должно быть минимум 2 варианта ответа`; + } + + const correctAnswers = question.answers.filter((a) => a.correct); + if (correctAnswers.length === 0) { + return `В вопросе ${i + 1} должен быть хотя бы один правильный ответ`; + } + + for (let j = 0; j < question.answers.length; j++) { + if (!question.answers[j].text.trim()) { + return `Текст ответа ${j + 1} в вопросе ${ + i + 1 + } не может быть пустым`; + } + } + } + + return null; + }; + + const handleSave = () => { + const validationError = validateForm(); + if (validationError) { + alert(validationError); // Можно заменить на более красивое уведомление + return; + } + + onSave(testName, successThreshold, questions); + }; + + const handleAccordionChange = + (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => { + setExpandedQuestion(isExpanded ? panel : false); + }; + + return ( + + {/* Основная информация о тесте */} + + + + + + setTestName(e.target.value)} + placeholder="Например: Тест по основам JavaScript" + required + disabled={isLoading} + /> + + + setSuccessThreshold(Number(e.target.value))} + required + disabled={isLoading} + inputProps={{ min: 0, max: 100 }} + helperText="Минимальный процент правильных ответов для прохождения" + /> + + + + + + {/* Секция вопросов */} + + + + Вопросы теста + + + } + action={ + + } + /> + + {questions.length === 0 ? ( + + + + Вопросов еще нет + + + Добавьте первый вопрос для начала создания теста + + + + ) : ( + questions.map((question, questionIndex) => ( + + }> + + + Вопрос {questionIndex + 1}:{" "} + {question.text || "Новый вопрос"} + + + + a.correct).length + } правильных`} + size="small" + color={ + question.answers.filter((a) => a.correct).length > 0 + ? "success" + : "error" + } + /> + + { + e.stopPropagation(); + removeQuestion(questionIndex); + }} + color="error" + disabled={isLoading} + > + + + + + + + + updateQuestion(questionIndex, e.target.value) + } + margin="normal" + multiline + rows={2} + placeholder="Введите текст вопроса..." + disabled={isLoading} + /> + + + + + Варианты ответов: + + + + + + {question.answers.map((answer, answerIndex) => ( + + + updateAnswer( + questionIndex, + answerIndex, + "correct", + e.target.checked + ) + } + disabled={isLoading} + color="success" + /> + } + label="Правильный" + /> + + updateAnswer( + questionIndex, + answerIndex, + "text", + e.target.value + ) + } + placeholder="Введите вариант ответа..." + disabled={isLoading} + /> + + removeAnswer(questionIndex, answerIndex) + } + color="error" + disabled={question.answers.length <= 2 || isLoading} + title={ + question.answers.length <= 2 + ? "Минимум 2 ответа" + : "Удалить ответ" + } + > + + + + ))} + + + + )) + )} + + + + {/* Кнопки действий */} + + + + + + ); +}; + +export default CreateTestForm; diff --git a/src/features/admin-panel/tests-admin/containers/create-test-container.tsx b/src/features/admin-panel/tests-admin/containers/create-test-container.tsx new file mode 100644 index 00000000..6ff11ca5 --- /dev/null +++ b/src/features/admin-panel/tests-admin/containers/create-test-container.tsx @@ -0,0 +1,208 @@ +import React, { FC, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Container, + Typography, + Box, + Button, + Alert, + Paper, + Breadcrumbs, + Link, +} from "@mui/material"; +import { ArrowBack as ArrowBackIcon } from "@mui/icons-material"; + +import { + useUpdateTestGroupMutation, + useTestTestGroupsByIdQuery, + useUpdateTestQuestionMutation, + useUpdateTestAnswerMutation, + TestGroupInput, +} from "api/graphql/generated/graphql"; +import { AppSpinner } from "shared/components/spinners"; +import NoDataErrorMessage from "shared/components/no-data-error-message"; + +import CreateTestForm from "../components/create-test-form"; +import { QuestionForm } from "../types"; + +interface CreateTestContainerProps { + testId?: string; +} + +const CreateTestContainer: FC = ({ testId }) => { + const navigate = useNavigate(); + const isEditing = Boolean(testId); + + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // Загружаем существующий тест для редактирования + const { + data: existingTestData, + loading: loadingTest, + error: loadError, + } = useTestTestGroupsByIdQuery({ + variables: { id: testId || "" }, + skip: !testId, + }); + + const [updateTestGroup, { loading: savingTest }] = + useUpdateTestGroupMutation(); + const [updateTestQuestion] = useUpdateTestQuestionMutation(); + const [updateTestAnswer] = useUpdateTestAnswerMutation(); + + const handleGoBack = () => { + navigate("/tests"); + }; + + const handleTestSaved = async ( + testName: string, + successThreshold: number, + questions: QuestionForm[] + ) => { + try { + setError(null); + setSuccess(null); + + // Сначала сохраняем вопросы и ответы + const savedQuestions = []; + + for (const question of questions) { + // Сохраняем вопрос + const questionResult = await updateTestQuestion({ + variables: { + input: { + id: question.id, + text: question.text, + }, + }, + }); + + const questionId = questionResult.data?.updateTestQuestion?.id; + if (!questionId) throw new Error("Не удалось сохранить вопрос"); + + // Сохраняем ответы для этого вопроса + for (const answer of question.answers) { + await updateTestAnswer({ + variables: { + input: { + id: answer.id, + text: answer.text, + correct: answer.correct, + testQuestion: { id: questionId }, + }, + }, + }); + } + + savedQuestions.push({ id: questionId }); + } + + // Затем сохраняем тестовую группу + const testGroupInput: TestGroupInput = { + id: testId || undefined, + testName, + successThreshold, + testQuestions: savedQuestions, + }; + + const result = await updateTestGroup({ + variables: { input: testGroupInput }, + }); + + const savedTestId = result.data?.updateTestGroup?.id; + + if (isEditing) { + setSuccess("Тест успешно обновлен!"); + } else { + setSuccess(`Тест успешно создан! ID: ${savedTestId}`); + } + + // Через 2 секунды перенаправляем обратно к списку тестов + setTimeout(() => { + navigate("/tests"); + }, 2000); + } catch (error: any) { + console.error("Ошибка при сохранении теста:", error); + setError( + error.message || "Ошибка при сохранении теста. Попробуйте еще раз." + ); + } + }; + + if (loadingTest) return ; + if (loadError) return ; + + const existingTest = existingTestData?.testTestGroupsById; + + return ( + + + {/* Breadcrumbs и навигация */} + + + + Управление тестами + + + {isEditing ? "Редактирование теста" : "Создание теста"} + + + + + {/* Заголовок страницы */} + + + + {isEditing + ? `Редактирование теста "${existingTest?.testName}"` + : "Создание нового теста"} + + + + {/* Сообщения об ошибках и успехе */} + {error && ( + + {error} + + )} + + {success && ( + + {success} + + )} + + {/* Форма создания/редактирования */} + + + + + + ); +}; + +export default CreateTestContainer; diff --git a/src/features/admin-panel/tests-admin/containers/index.ts b/src/features/admin-panel/tests-admin/containers/index.ts new file mode 100644 index 00000000..b0466c7e --- /dev/null +++ b/src/features/admin-panel/tests-admin/containers/index.ts @@ -0,0 +1 @@ +export { default as CreateTestContainer } from "./create-test-container"; diff --git a/src/features/admin-panel/tests-admin/create-test-form.tsx b/src/features/admin-panel/tests-admin/create-test-form.tsx new file mode 100644 index 00000000..3615c0fb --- /dev/null +++ b/src/features/admin-panel/tests-admin/create-test-form.tsx @@ -0,0 +1,411 @@ +import { FC, useState, useEffect } from "react"; +import { + Box, + Button, + TextField, + Typography, + Accordion, + AccordionSummary, + AccordionDetails, + IconButton, + FormControlLabel, + Checkbox, + Alert, + Divider, + Grid, +} from "@mui/material"; +import { + ExpandMore as ExpandMoreIcon, + Add as AddIcon, + Delete as DeleteIcon, +} from "@mui/icons-material"; + +import { + useUpdateTestGroupMutation, + useTestTestGroupsByIdQuery, + useUpdateTestQuestionMutation, + useUpdateTestAnswerMutation, + TestGroupInput, + TestQuestionInput, +} from "api/graphql/generated/graphql"; + +import { QuestionForm } from "./types"; + +interface CreateTestFormProps { + testId?: string | null; + onTestSaved: () => void; + onCancel: () => void; +} + +const CreateTestForm: FC = ({ + testId, + onTestSaved, + onCancel, +}) => { + const [testName, setTestName] = useState(""); + const [successThreshold, setSuccessThreshold] = useState(70); + const [questions, setQuestions] = useState([]); + const [error, setError] = useState(null); + + const { data: existingTestData } = useTestTestGroupsByIdQuery({ + variables: { id: testId || "" }, + skip: !testId, + }); + + const [updateTestGroup, { loading: savingTest }] = + useUpdateTestGroupMutation(); + const [updateTestQuestion] = useUpdateTestQuestionMutation(); + const [updateTestAnswer] = useUpdateTestAnswerMutation(); + + // Загружаем данные существующего теста для редактирования + useEffect(() => { + if (existingTestData?.testTestGroupsById) { + const test = existingTestData.testTestGroupsById; + setTestName(test.testName || ""); + setSuccessThreshold(test.successThreshold || 70); + + const loadedQuestions: QuestionForm[] = + test.testQuestions?.map((q) => ({ + id: q?.id || "", + text: q?.text || "", + answers: + q?.testAnswers?.map((a) => ({ + id: a?.id || "", + text: a?.text || "", + correct: false, // В схеме нет поля correct для TestAnswerShortDto + })) || [], + })) || []; + + setQuestions(loadedQuestions); + } + }, [existingTestData]); + + const addQuestion = () => { + setQuestions([ + ...questions, + { + text: "", + answers: [ + { text: "", correct: true }, + { text: "", correct: false }, + ], + }, + ]); + }; + + const removeQuestion = (questionIndex: number) => { + setQuestions(questions.filter((_, index) => index !== questionIndex)); + }; + + const updateQuestion = (questionIndex: number, text: string) => { + const updatedQuestions = [...questions]; + updatedQuestions[questionIndex].text = text; + setQuestions(updatedQuestions); + }; + + const addAnswer = (questionIndex: number) => { + const updatedQuestions = [...questions]; + updatedQuestions[questionIndex].answers.push({ + text: "", + correct: false, + }); + setQuestions(updatedQuestions); + }; + + const removeAnswer = (questionIndex: number, answerIndex: number) => { + const updatedQuestions = [...questions]; + updatedQuestions[questionIndex].answers = updatedQuestions[ + questionIndex + ].answers.filter((_, index) => index !== answerIndex); + setQuestions(updatedQuestions); + }; + + const updateAnswer = ( + questionIndex: number, + answerIndex: number, + field: "text" | "correct", + value: string | boolean + ) => { + const updatedQuestions = [...questions]; + if (field === "text") { + updatedQuestions[questionIndex].answers[answerIndex].text = + value as string; + } else { + updatedQuestions[questionIndex].answers[answerIndex].correct = + value as boolean; + } + setQuestions(updatedQuestions); + }; + + const validateForm = (): boolean => { + if (!testName.trim()) { + setError("Название теста обязательно"); + return false; + } + + if (questions.length === 0) { + setError("Добавьте хотя бы один вопрос"); + return false; + } + + for (let i = 0; i < questions.length; i++) { + const question = questions[i]; + if (!question.text.trim()) { + setError(`Текст вопроса ${i + 1} не может быть пустым`); + return false; + } + + if (question.answers.length < 2) { + setError(`В вопросе ${i + 1} должно быть минимум 2 варианта ответа`); + return false; + } + + const correctAnswers = question.answers.filter((a) => a.correct); + if (correctAnswers.length === 0) { + setError( + `В вопросе ${i + 1} должен быть хотя бы один правильный ответ` + ); + return false; + } + + for (let j = 0; j < question.answers.length; j++) { + if (!question.answers[j].text.trim()) { + setError( + `Текст ответа ${j + 1} в вопросе ${i + 1} не может быть пустым` + ); + return false; + } + } + } + + setError(null); + return true; + }; + + const handleSave = async () => { + if (!validateForm()) return; + + try { + // Сначала сохраняем вопросы и ответы + const savedQuestions: TestQuestionInput[] = []; + + for (const question of questions) { + // Сохраняем вопрос + const questionResult = await updateTestQuestion({ + variables: { + input: { + id: question.id, + text: question.text, + }, + }, + }); + + const questionId = questionResult.data?.updateTestQuestion?.id; + if (!questionId) throw new Error("Не удалось сохранить вопрос"); + + // Сохраняем ответы для этого вопроса + for (const answer of question.answers) { + await updateTestAnswer({ + variables: { + input: { + id: answer.id, + text: answer.text, + correct: answer.correct, + testQuestion: { id: questionId }, + }, + }, + }); + } + + savedQuestions.push({ id: questionId }); + } + + // Затем сохраняем тестовую группу + const testGroupInput: TestGroupInput = { + id: testId || undefined, + testName, + successThreshold, + testQuestions: savedQuestions, + }; + + await updateTestGroup({ + variables: { input: testGroupInput }, + }); + + onTestSaved(); + } catch (error) { + console.error("Ошибка при сохранении теста:", error); + setError("Ошибка при сохранении теста. Попробуйте еще раз."); + } + }; + + return ( + + {error && ( + + {error} + + )} + + + + setTestName(e.target.value)} + margin="normal" + required + /> + + + setSuccessThreshold(Number(e.target.value))} + margin="normal" + required + inputProps={{ min: 0, max: 100 }} + /> + + + + + + + Вопросы + + + + {questions.map((question, questionIndex) => ( + + }> + + + Вопрос {questionIndex + 1}: {question.text || "Новый вопрос"} + + { + e.stopPropagation(); + removeQuestion(questionIndex); + }} + color="error" + > + + + + + + updateQuestion(questionIndex, e.target.value)} + margin="normal" + multiline + rows={2} + /> + + + + Варианты ответов: + + + + {question.answers.map((answer, answerIndex) => ( + + + updateAnswer( + questionIndex, + answerIndex, + "correct", + e.target.checked + ) + } + /> + } + label="Правильный" + /> + + updateAnswer( + questionIndex, + answerIndex, + "text", + e.target.value + ) + } + /> + removeAnswer(questionIndex, answerIndex)} + color="error" + disabled={question.answers.length <= 2} + > + + + + ))} + + + + + ))} + + {questions.length === 0 && ( + + + Вопросов еще нет + + + + )} + + + + + + + ); +}; + +export default CreateTestForm; diff --git a/src/features/admin-panel/tests-admin/index.ts b/src/features/admin-panel/tests-admin/index.ts new file mode 100644 index 00000000..d55ba4fb --- /dev/null +++ b/src/features/admin-panel/tests-admin/index.ts @@ -0,0 +1 @@ +export { default } from "./tests-admin"; diff --git a/src/features/admin-panel/tests-admin/tests-admin.tsx b/src/features/admin-panel/tests-admin/tests-admin.tsx new file mode 100644 index 00000000..8d4f3729 --- /dev/null +++ b/src/features/admin-panel/tests-admin/tests-admin.tsx @@ -0,0 +1,173 @@ +import React, { FC } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Box, + Button, + Card, + CardContent, + Typography, + Grid, + Fab, + IconButton, + Chip, +} from "@mui/material"; +import { + Add as AddIcon, + Edit as EditIcon, + Delete as DeleteIcon, +} from "@mui/icons-material"; + +import { + useTestTestGroupsQuery, + useDeleteTestGroupMutation, +} from "api/graphql/generated/graphql"; +import { AppSpinner } from "shared/components/spinners"; +import NoDataErrorMessage from "shared/components/no-data-error-message"; + +const TestsAdmin: FC = () => { + const navigate = useNavigate(); + + const { data: testsData, loading, error, refetch } = useTestTestGroupsQuery(); + const [deleteTestGroup] = useDeleteTestGroupMutation({ + onCompleted: () => { + refetch(); + }, + }); + + const handleCreateTest = () => { + navigate("/tests/create"); + }; + + const handleEditTest = (testId: string) => { + navigate(`/tests/edit/${testId}`); + }; + + const handleDeleteTest = async (testId: string) => { + if (window.confirm("Вы уверены, что хотите удалить этот тест?")) { + try { + await deleteTestGroup({ + variables: { id: testId }, + }); + } catch (error) { + console.error("Ошибка при удалении теста:", error); + } + } + }; + + if (loading) return ; + if (error) return ; + + const tests = testsData?.testTestGroups || []; + + return ( + + + Управление тестами + + + + {tests.length === 0 ? ( + + + + Тестов пока нет + + + Создайте первый тест для начала работы + + + + + ) : ( + + {tests.map((test) => ( + + + + + + {test?.testName} + + + handleEditTest(test?.id!)} + color="primary" + > + + + handleDeleteTest(test?.id!)} + color="error" + > + + + + + + + + Проходной балл: + + + + + + ID: {test?.id} + + + + + ))} + + )} + + + + + + ); +}; + +export default TestsAdmin; diff --git a/src/features/admin-panel/tests-admin/types.ts b/src/features/admin-panel/tests-admin/types.ts new file mode 100644 index 00000000..19d86da9 --- /dev/null +++ b/src/features/admin-panel/tests-admin/types.ts @@ -0,0 +1,11 @@ +export interface QuestionForm { + id?: string; + text: string; + answers: AnswerForm[]; +} + +export interface AnswerForm { + id?: string; + text: string; + correct: boolean; +} diff --git a/src/features/test/containers/test-container.tsx b/src/features/test/containers/test-container.tsx index a51f09f1..0960a20c 100644 --- a/src/features/test/containers/test-container.tsx +++ b/src/features/test/containers/test-container.tsx @@ -29,8 +29,8 @@ interface UserAnswer { } const TestContainer: FC = () => { - // const { testId } = useParams<{ testId: string }>(); - const testId = "3"; + // Для тестирования используем захардкоженный ID + const testId = "5"; const [userAnswers, setUserAnswers] = useState([]); const [isCompleted, setIsCompleted] = useState(false); const [score, setScore] = useState(0); @@ -41,8 +41,9 @@ const TestContainer: FC = () => { const [allLoadedAnswers, setAllLoadedAnswers] = useState([]); const [answersLoading, setAnswersLoading] = useState(false); + // Получаем детали тестовой группы const { data: testData, loading: testLoading } = useTestTestGroupsByIdQuery({ - variables: { id: testId ?? "" }, + variables: { id: testId }, }); const [getTestAnswers] = useLazyQuery(TestAnswerByQuestionDocument); diff --git a/src/pages/create-test.tsx b/src/pages/create-test.tsx new file mode 100644 index 00000000..7a9f102a --- /dev/null +++ b/src/pages/create-test.tsx @@ -0,0 +1,12 @@ +import { FC } from "react"; +import { useParams } from "react-router-dom"; + +import { CreateTestContainer } from "features/admin-panel/tests-admin/containers"; + +const CreateTestPage: FC = () => { + const { testId } = useParams<{ testId?: string }>(); + + return ; +}; + +export default CreateTestPage; diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index fbd11a76..d89c0221 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -20,6 +20,8 @@ import { KanbanStudentPage, } from "pages/kanban-student"; import InfoSystemPage from "pages/info-system"; +import TestPage from "pages/test"; +import CreateTestPage from "pages/create-test"; const AdminRoutes = [ } />, @@ -29,6 +31,13 @@ const AdminRoutes = [ path="/statistics" element={} />, + } />, + } />, + } + />, } />, } />, + } />, ]; export default AdminRoutes; diff --git a/src/routes/student.tsx b/src/routes/student.tsx index decff53b..55fd6085 100644 --- a/src/routes/student.tsx +++ b/src/routes/student.tsx @@ -13,6 +13,7 @@ import { KanbanStudentHomeworkDetailsFullPage, } from "pages/kanban-student"; import InfoSystemPage from "pages/info-system"; +import TestPage from "pages/test"; const StudentRoutes = [ } />, @@ -26,6 +27,7 @@ const StudentRoutes = [ path="/training/:trainingId/:lectureId/:modalId?" element={} />, + } />, Date: Fri, 15 Aug 2025 13:38:53 +0300 Subject: [PATCH 02/10] Merge conflict --- src/features/test/views/test-view.tsx | 166 ++++++++++++++++++++++++++ src/pages/test.tsx | 9 ++ 2 files changed, 175 insertions(+) create mode 100644 src/features/test/views/test-view.tsx create mode 100644 src/pages/test.tsx diff --git a/src/features/test/views/test-view.tsx b/src/features/test/views/test-view.tsx new file mode 100644 index 00000000..dd12f8b8 --- /dev/null +++ b/src/features/test/views/test-view.tsx @@ -0,0 +1,166 @@ +import { FC } from "react"; +import { + Box, + Button, + Card, + CardContent, + FormControl, + FormControlLabel, + Radio, + RadioGroup, + Typography, + Alert, + LinearProgress, +} from "@mui/material"; + +import { TestGroupDto } from "api/graphql/generated/graphql"; + +// Типы (дублируем из контейнера, потом вынесем в отдельный файл) +interface TestQuestion { + id: string; + text: string; +} + +interface TestAnswer { + id: string; + text: string; + correct: boolean; + testQuestion: TestQuestion; +} + +interface UserAnswer { + questionId: string; + answerId: string; +} + +interface TestViewProps { + testData: TestGroupDto; + testAnswers: TestAnswer[]; + userAnswers: UserAnswer[]; + isCompleted: boolean; + score: number; + currentQuestion: TestQuestion; + currentQuestionIndex: number; + totalQuestions: number; + isCurrentQuestionAnswered: boolean; + onAnswerSelect: (questionId: string, answerId: string) => void; + onNextQuestion: () => void; + onSubmitTest: () => void; +} + +const TestView: FC = ({ + testData, + testAnswers, + userAnswers, + isCompleted, + score, + currentQuestion, + currentQuestionIndex, + totalQuestions, + isCurrentQuestionAnswered, + onAnswerSelect, + onNextQuestion, + onSubmitTest, +}) => { + const getUserAnswerForQuestion = (questionId: string) => { + return userAnswers.find((ua) => ua.questionId === questionId)?.answerId; + }; + + const successThreshold = testData.successThreshold ?? 0; + const progress = ((currentQuestionIndex + 1) / totalQuestions) * 100; + const isPassed = score >= successThreshold; + + if (isCompleted) { + return ( + + + + + Тест завершён! + + + + {isPassed + ? `Поздравляем! Вы прошли тест с результатом ${score}/${totalQuestions}` + : `Тест не пройден. Результат: ${score}/${totalQuestions}. Требуется: ${successThreshold}`} + + + + Правильных ответов: {score} из {totalQuestions} + + + Проходной балл: {successThreshold} + + + + + ); + } + + const selectedAnswer = getUserAnswerForQuestion(currentQuestion.id); + const isLastQuestion = currentQuestionIndex === totalQuestions - 1; + + return ( + + + {testData.testName} + + + + + Вопрос {currentQuestionIndex + 1} из {totalQuestions} + + + + + + + + {currentQuestionIndex + 1}. {currentQuestion.text} + + + + + onAnswerSelect(currentQuestion.id, e.target.value) + } + > + {testAnswers.map((answer) => ( + } + label={answer.text} + /> + ))} + + + + + + + + {userAnswers.length} из {totalQuestions} вопросов отвечено + + + + + + ); +}; + +export default TestView; diff --git a/src/pages/test.tsx b/src/pages/test.tsx new file mode 100644 index 00000000..d1f9955b --- /dev/null +++ b/src/pages/test.tsx @@ -0,0 +1,9 @@ +import { FC } from "react"; + +import TestContainer from "features/test/containers/test-container"; + +const TestPage: FC = () => { + return ; +}; + +export default TestPage; From 3800d225664ecba2e87574986deb8107a9c638d4 Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Sat, 16 Aug 2025 14:14:19 +0300 Subject: [PATCH 03/10] =?UTF-8?q?QAGDEV-723=20-=20[FE]=20=D0=9F=D1=80?= =?UTF-8?q?=D0=B8=D0=BA=D1=80=D0=B5=D0=BF=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=BA=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=D0=BC=20v3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/graphql/lecture/lecture.graphql | 13 +++ .../graphql/lecture/update-lecture.graphql | 5 + .../edit-training/containers/index.ts | 1 + .../edit-training/containers/select-tests.tsx | 102 ++++++++++++++++ .../views/edit-lecture/edit-lecture.tsx | 28 ++++- .../views/edit-lecture/edit-lecture.types.ts | 1 + .../views/lecture-detail/lecture-detail.tsx | 15 ++- .../views/lecture-test-section/index.ts | 1 + .../lecture-test-section.tsx | 110 ++++++++++++++++++ .../test/containers/test-container.tsx | 24 +++- src/features/test/views/test-view.tsx | 41 +++++++ src/pages/test.tsx | 15 ++- src/routes/admin.tsx | 6 +- src/routes/student.tsx | 6 +- 14 files changed, 358 insertions(+), 10 deletions(-) create mode 100644 src/features/edit-training/containers/select-tests.tsx create mode 100644 src/features/lecture-detail/views/lecture-test-section/index.ts create mode 100644 src/features/lecture-detail/views/lecture-test-section/lecture-test-section.tsx diff --git a/src/api/graphql/lecture/lecture.graphql b/src/api/graphql/lecture/lecture.graphql index 7f49c08f..f4cc2236 100644 --- a/src/api/graphql/lecture/lecture.graphql +++ b/src/api/graphql/lecture/lecture.graphql @@ -14,6 +14,19 @@ query lecture($id: ID!) { subject description content + testGroup { + id + testName + successThreshold + testQuestions { + id + text + testAnswers { + id + text + } + } + } files { id homeWork diff --git a/src/api/graphql/lecture/update-lecture.graphql b/src/api/graphql/lecture/update-lecture.graphql index 0e4060ac..4e2cf5b6 100644 --- a/src/api/graphql/lecture/update-lecture.graphql +++ b/src/api/graphql/lecture/update-lecture.graphql @@ -5,6 +5,11 @@ mutation updateLecture($input: LectureInput!) { description content contentHomeWork + testGroup { + id + testName + successThreshold + } speakers { id firstName diff --git a/src/features/edit-training/containers/index.ts b/src/features/edit-training/containers/index.ts index cd0b95f5..95071e39 100644 --- a/src/features/edit-training/containers/index.ts +++ b/src/features/edit-training/containers/index.ts @@ -7,3 +7,4 @@ export { default as DeleteLecture } from "./delete-lecture"; export { default as CreateLecture } from "./create-lecture"; export { default as SelectLecture } from "./select-lecture"; export { default as AddLecture } from "./add-lecture"; +export { default as SelectTests } from "./select-tests"; diff --git a/src/features/edit-training/containers/select-tests.tsx b/src/features/edit-training/containers/select-tests.tsx new file mode 100644 index 00000000..e78f1f73 --- /dev/null +++ b/src/features/edit-training/containers/select-tests.tsx @@ -0,0 +1,102 @@ +import { FC } from "react"; +import { Control , Controller } from "react-hook-form"; +import { + FormControl, + InputLabel, + Select, + MenuItem, + FormHelperText, + Box, + Typography, + Chip, +} from "@mui/material"; + +import { useTestTestGroupsQuery } from "api/graphql/generated/graphql"; +import { AppSpinner } from "shared/components/spinners"; + +interface SelectTestsProps { + name: string; + control: Control; + label?: string; + helperText?: string; +} + +const SelectTests: FC = ({ + name, + control, + label = "Тест для лекции", + helperText, +}) => { + const { data: testsData, loading, error } = useTestTestGroupsQuery(); + + const tests = testsData?.testTestGroups || []; + + if (loading) { + return ( + + + + Загрузка тестов... + + + ); + } + + if (error) { + return ( + + Ошибка загрузки тестов + + ); + } + + return ( + ( + + {label} + + {(fieldError || helperText) && ( + {fieldError?.message || helperText} + )} + + )} + /> + ); +}; + +export default SelectTests; diff --git a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx index 9536c710..74a935cd 100644 --- a/src/features/edit-training/views/edit-lecture/edit-lecture.tsx +++ b/src/features/edit-training/views/edit-lecture/edit-lecture.tsx @@ -24,7 +24,7 @@ import { useLectureHomeworkFileUpload, } from "shared/hooks"; -import { SelectLectors } from "../../containers"; +import { SelectLectors, SelectTests } from "../../containers"; import { StyledButtonsStack, StyledContinueButton, @@ -42,7 +42,13 @@ const EditLecture: FC = ({ updateLecture, dataLectureHomework, }) => { - const { id: lectureId, subject, speakers, content } = dataLecture.lecture!; + const { + id: lectureId, + subject, + speakers, + content, + testGroup, + } = dataLecture.lecture!; const contentHomework = dataLectureHomework.lectureHomeWork; const { enqueueSnackbar } = useSnackbar(); const navigate = useNavigate(); @@ -66,7 +72,13 @@ const EditLecture: FC = ({ const rteRefContentHomeWork = useRef(null); const { handleSubmit, control } = useForm({ - defaultValues: { id: lectureId, subject, description, speakers }, + defaultValues: { + id: lectureId, + subject, + description, + speakers, + testGroupId: testGroup?.id || "", + }, }); const onSubmit: SubmitHandler = async (data) => { @@ -248,6 +260,16 @@ const EditLecture: FC = ({ /> + + + Тест для лекции + + + Материалы урока diff --git a/src/features/edit-training/views/edit-lecture/edit-lecture.types.ts b/src/features/edit-training/views/edit-lecture/edit-lecture.types.ts index 82dca398..26f79428 100644 --- a/src/features/edit-training/views/edit-lecture/edit-lecture.types.ts +++ b/src/features/edit-training/views/edit-lecture/edit-lecture.types.ts @@ -23,4 +23,5 @@ export type LectureInput = { id?: InputMaybe; speakers?: InputMaybe>>; subject?: InputMaybe; + testGroupId?: InputMaybe; }; diff --git a/src/features/lecture-detail/views/lecture-detail/lecture-detail.tsx b/src/features/lecture-detail/views/lecture-detail/lecture-detail.tsx index 42fa62b1..31af46b8 100644 --- a/src/features/lecture-detail/views/lecture-detail/lecture-detail.tsx +++ b/src/features/lecture-detail/views/lecture-detail/lecture-detail.tsx @@ -11,6 +11,7 @@ import LectureContent from "../lecture-content"; import { HomeworksFormProvider } from "../../context/homeworks-other-students-form-context"; import StepperButtons from "../stepper-buttons"; import HomeworkSection from "../homework-section"; +import LectureTestSection from "../lecture-test-section"; const LectureDetail: FC = (props) => { const { @@ -20,10 +21,12 @@ const LectureDetail: FC = (props) => { tariffHomework, trainingId, } = props; - const { subject, description, speakers, content } = dataLecture.lecture || {}; + const { subject, description, speakers, content, testGroup } = + dataLecture.lecture || {}; const lectureHomeWork = dataLectureHomework?.lectureHomeWork; const hasHomework = !!lectureHomeWork; + const hasTest = !!testGroup; const [view, setView] = useState("kanban"); @@ -41,6 +44,15 @@ const LectureDetail: FC = (props) => { /> ); + const renderTest = () => + hasTest && ( + + ); + return ( @@ -48,6 +60,7 @@ const LectureDetail: FC = (props) => { + {renderTest()} {!tariffHomework ? : renderHomework()} = ({ + testGroup, + trainingId, + lectureId, +}) => { + const navigate = useNavigate(); + + const handleStartTest = () => { + // Проверяем наличие всех необходимых параметров + if (!testGroup?.id || !trainingId || !lectureId) { + console.error("Missing required parameters for test navigation:", { + testId: testGroup?.id, + trainingId, + lectureId, + }); + return; + } + + // Переходим на страницу теста с параметрами + navigate(`/test/${testGroup.id}/${trainingId}/${lectureId}`); + }; + + return ( + + + + Тест по лекции + + + + + + + {testGroup.testName} + + + + } + label={`Проходной балл: ${testGroup.successThreshold}%`} + color="primary" + variant="outlined" + /> + + + + + + + Пройдите тест, чтобы закрепить изученный материал. Для успешного + прохождения необходимо набрать минимум{" "} + {testGroup.successThreshold}% правильных ответов. + + + + + + + + + + ); +}; + +export default LectureTestSection; diff --git a/src/features/test/containers/test-container.tsx b/src/features/test/containers/test-container.tsx index 0960a20c..a2f1084e 100644 --- a/src/features/test/containers/test-container.tsx +++ b/src/features/test/containers/test-container.tsx @@ -28,9 +28,25 @@ interface UserAnswer { answerId: string; } -const TestContainer: FC = () => { - // Для тестирования используем захардкоженный ID - const testId = "5"; +interface TestContainerProps { + testId: string; + trainingId: string; + lectureId: string; +} + +const TestContainer: FC = ({ + testId, + trainingId, + lectureId, +}) => { + // Отладочная информация + console.log("🔍 TestContainer Debug Info:", { + testId, + trainingId, + lectureId, + }); + + // Убираем захардкоженный testId const [userAnswers, setUserAnswers] = useState([]); const [isCompleted, setIsCompleted] = useState(false); const [score, setScore] = useState(0); @@ -166,6 +182,8 @@ const TestContainer: FC = () => { currentQuestionIndex={currentQuestionIndex} totalQuestions={testQuestions.length} isCurrentQuestionAnswered={isCurrentQuestionAnswered} + trainingId={trainingId} + lectureId={lectureId} onAnswerSelect={handleAnswerSelect} onNextQuestion={handleNextQuestion} onSubmitTest={handleSubmitTest} diff --git a/src/features/test/views/test-view.tsx b/src/features/test/views/test-view.tsx index dd12f8b8..c517102d 100644 --- a/src/features/test/views/test-view.tsx +++ b/src/features/test/views/test-view.tsx @@ -1,4 +1,5 @@ import { FC } from "react"; +import { useNavigate } from "react-router-dom"; import { Box, Button, @@ -43,6 +44,8 @@ interface TestViewProps { currentQuestionIndex: number; totalQuestions: number; isCurrentQuestionAnswered: boolean; + trainingId?: string; + lectureId?: string; onAnswerSelect: (questionId: string, answerId: string) => void; onNextQuestion: () => void; onSubmitTest: () => void; @@ -58,10 +61,20 @@ const TestView: FC = ({ currentQuestionIndex, totalQuestions, isCurrentQuestionAnswered, + trainingId, + lectureId, onAnswerSelect, onNextQuestion, onSubmitTest, }) => { + const navigate = useNavigate(); + + const handleBackToLecture = () => { + if (trainingId && lectureId) { + navigate(`/training/${trainingId}/${lectureId}`); + } + }; + const getUserAnswerForQuestion = (questionId: string) => { return userAnswers.find((ua) => ua.questionId === questionId)?.answerId; }; @@ -91,6 +104,16 @@ const TestView: FC = ({ Проходной балл: {successThreshold} + + {trainingId && lectureId && ( + + )} @@ -106,6 +129,24 @@ const TestView: FC = ({ {testData.testName} + {trainingId && lectureId && ( + + + Курс ID: {trainingId} | Лекция ID: {lectureId} + + + + )} + Вопрос {currentQuestionIndex + 1} из {totalQuestions} diff --git a/src/pages/test.tsx b/src/pages/test.tsx index d1f9955b..a0fdb4c1 100644 --- a/src/pages/test.tsx +++ b/src/pages/test.tsx @@ -1,9 +1,22 @@ import { FC } from "react"; +import { useParams } from "react-router-dom"; import TestContainer from "features/test/containers/test-container"; const TestPage: FC = () => { - return ; + const { testId, trainingId, lectureId } = useParams<{ + testId: string; + trainingId: string; + lectureId: string; + }>(); + + return ( + + ); }; export default TestPage; diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index d89c0221..8959ff9e 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -98,7 +98,11 @@ const AdminRoutes = [ element={} />, } />, - } />, + } + />, ]; export default AdminRoutes; diff --git a/src/routes/student.tsx b/src/routes/student.tsx index 55fd6085..350de0c3 100644 --- a/src/routes/student.tsx +++ b/src/routes/student.tsx @@ -27,7 +27,11 @@ const StudentRoutes = [ path="/training/:trainingId/:lectureId/:modalId?" element={} />, - } />, + } + />, Date: Sun, 17 Aug 2025 14:39:45 +0300 Subject: [PATCH 04/10] =?UTF-8?q?QAGDEV-723=20-=20[FE]=20=D0=9F=D1=80?= =?UTF-8?q?=D0=B8=D0=BA=D1=80=D0=B5=D0=BF=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=BA=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=D0=BC=20v4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/create-test-form.tsx | 68 +++++++++++++------ .../tests-admin/create-test-form.tsx | 44 ++++++++---- .../test/containers/test-container.tsx | 10 +++ src/features/test/views/test-view.tsx | 22 ++++-- 4 files changed, 106 insertions(+), 38 deletions(-) diff --git a/src/features/admin-panel/tests-admin/components/create-test-form.tsx b/src/features/admin-panel/tests-admin/components/create-test-form.tsx index ac69765e..9e69d95d 100644 --- a/src/features/admin-panel/tests-admin/components/create-test-form.tsx +++ b/src/features/admin-panel/tests-admin/components/create-test-form.tsx @@ -23,7 +23,10 @@ import { QuestionAnswer as QuestionIcon, } from "@mui/icons-material"; -import { TestGroupDto } from "api/graphql/generated/graphql"; +import { + TestGroupDto, + useTestAnswerByQuestionLazyQuery, +} from "api/graphql/generated/graphql"; import { QuestionForm } from "../types"; @@ -51,33 +54,56 @@ const CreateTestForm: FC = ({ false ); + const [getTestAnswers] = useTestAnswerByQuestionLazyQuery(); + // Загружаем данные существующего теста для редактирования useEffect(() => { if (existingTest) { setTestName(existingTest.testName || ""); setSuccessThreshold(existingTest.successThreshold || 70); - const loadedQuestions: QuestionForm[] = - existingTest.testQuestions?.map((q, index) => ({ - id: q?.id || "", - text: q?.text || "", - answers: q?.testAnswers?.map((a) => ({ - id: a?.id || "", - text: a?.text || "", - correct: false, // В схеме нет поля correct для TestAnswerShortDto, будем получать отдельно - })) || [ - { text: "", correct: true }, - { text: "", correct: false }, - ], - })) || []; - - setQuestions(loadedQuestions); - // Открываем первый вопрос по умолчанию - if (loadedQuestions.length > 0) { - setExpandedQuestion(`question-0`); - } + // Загружаем вопросы и ответы с правильностью + const loadQuestionsWithAnswers = async () => { + const loadedQuestions: QuestionForm[] = []; + + for (const question of existingTest.testQuestions || []) { + if (!question?.id) continue; + + // Получаем полную информацию об ответах для каждого вопроса + const { data: answersData } = await getTestAnswers({ + variables: { questionId: question.id }, + }); + + const answers = + answersData?.testAnswerByQuestion?.map((a) => ({ + id: a?.id || "", + text: a?.text || "", + correct: a?.correct || false, + })) || []; + + loadedQuestions.push({ + id: question.id, + text: question.text || "", + answers: + answers.length > 0 + ? answers + : [ + { text: "", correct: true }, + { text: "", correct: false }, + ], + }); + } + + setQuestions(loadedQuestions); + // Открываем первый вопрос по умолчанию + if (loadedQuestions.length > 0) { + setExpandedQuestion(`question-0`); + } + }; + + loadQuestionsWithAnswers(); } - }, [existingTest]); + }, [existingTest, getTestAnswers]); const addQuestion = () => { const newQuestionIndex = questions.length; diff --git a/src/features/admin-panel/tests-admin/create-test-form.tsx b/src/features/admin-panel/tests-admin/create-test-form.tsx index 3615c0fb..1598cd77 100644 --- a/src/features/admin-panel/tests-admin/create-test-form.tsx +++ b/src/features/admin-panel/tests-admin/create-test-form.tsx @@ -26,7 +26,7 @@ import { useUpdateTestQuestionMutation, useUpdateTestAnswerMutation, TestGroupInput, - TestQuestionInput, + useTestAnswerByQuestionLazyQuery, } from "api/graphql/generated/graphql"; import { QuestionForm } from "./types"; @@ -56,6 +56,7 @@ const CreateTestForm: FC = ({ useUpdateTestGroupMutation(); const [updateTestQuestion] = useUpdateTestQuestionMutation(); const [updateTestAnswer] = useUpdateTestAnswerMutation(); + const [getTestAnswers] = useTestAnswerByQuestionLazyQuery(); // Загружаем данные существующего теста для редактирования useEffect(() => { @@ -64,21 +65,38 @@ const CreateTestForm: FC = ({ setTestName(test.testName || ""); setSuccessThreshold(test.successThreshold || 70); - const loadedQuestions: QuestionForm[] = - test.testQuestions?.map((q) => ({ - id: q?.id || "", - text: q?.text || "", - answers: - q?.testAnswers?.map((a) => ({ + // Загружаем вопросы и ответы с правильностью + const loadQuestionsWithAnswers = async () => { + const loadedQuestions: QuestionForm[] = []; + + for (const question of test.testQuestions || []) { + if (!question?.id) continue; + + // Получаем полную информацию об ответах для каждого вопроса + const { data: answersData } = await getTestAnswers({ + variables: { questionId: question.id }, + }); + + const answers = + answersData?.testAnswerByQuestion?.map((a) => ({ id: a?.id || "", text: a?.text || "", - correct: false, // В схеме нет поля correct для TestAnswerShortDto - })) || [], - })) || []; + correct: a?.correct || false, + })) || []; + + loadedQuestions.push({ + id: question.id, + text: question.text || "", + answers: answers.length > 0 ? answers : [], + }); + } + + setQuestions(loadedQuestions); + }; - setQuestions(loadedQuestions); + loadQuestionsWithAnswers(); } - }, [existingTestData]); + }, [existingTestData, getTestAnswers]); const addQuestion = () => { setQuestions([ @@ -187,7 +205,7 @@ const CreateTestForm: FC = ({ try { // Сначала сохраняем вопросы и ответы - const savedQuestions: TestQuestionInput[] = []; + const savedQuestions: { id: string }[] = []; for (const question of questions) { // Сохраняем вопрос diff --git a/src/features/test/containers/test-container.tsx b/src/features/test/containers/test-container.tsx index a2f1084e..54d41285 100644 --- a/src/features/test/containers/test-container.tsx +++ b/src/features/test/containers/test-container.tsx @@ -151,6 +151,16 @@ const TestContainer: FC = ({ } }); + const scorePercentage = (correctAnswers / testQuestions.length) * 100; + + console.log("🔍 Test Result Debug:", { + correctAnswers, + totalQuestions: testQuestions.length, + scorePercentage: scorePercentage.toFixed(1) + "%", + userAnswers: userAnswers.length, + allLoadedAnswers: allLoadedAnswers.length, + }); + setScore(correctAnswers); setIsCompleted(true); }; diff --git a/src/features/test/views/test-view.tsx b/src/features/test/views/test-view.tsx index c517102d..6e5a985e 100644 --- a/src/features/test/views/test-view.tsx +++ b/src/features/test/views/test-view.tsx @@ -81,7 +81,17 @@ const TestView: FC = ({ const successThreshold = testData.successThreshold ?? 0; const progress = ((currentQuestionIndex + 1) / totalQuestions) * 100; - const isPassed = score >= successThreshold; + const scorePercentage = (score / totalQuestions) * 100; + const isPassed = scorePercentage >= successThreshold; + + // Отладочная информация + console.log("🔍 TestView Debug:", { + score, + totalQuestions, + successThreshold, + scorePercentage: scorePercentage.toFixed(1) + "%", + isPassed, + }); if (isCompleted) { return ( @@ -94,15 +104,19 @@ const TestView: FC = ({ {isPassed - ? `Поздравляем! Вы прошли тест с результатом ${score}/${totalQuestions}` - : `Тест не пройден. Результат: ${score}/${totalQuestions}. Требуется: ${successThreshold}`} + ? `Поздравляем! Вы прошли тест с результатом ${scorePercentage.toFixed( + 0 + )}% (${score}/${totalQuestions})` + : `Тест не пройден. Результат: ${scorePercentage.toFixed( + 0 + )}% (${score}/${totalQuestions}). Требуется: ${successThreshold}%`} Правильных ответов: {score} из {totalQuestions} - Проходной балл: {successThreshold} + Проходной балл: {successThreshold}% {trainingId && lectureId && ( From 97ecebe821728bed6e32a7481e9145ecd041d987 Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Sun, 17 Aug 2025 19:43:08 +0300 Subject: [PATCH 05/10] =?UTF-8?q?QAGDEV-723=20-=20[FE]=20=D0=9F=D1=80?= =?UTF-8?q?=D0=B8=D0=BA=D1=80=D0=B5=D0=BF=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=BA=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=D0=BC=20v5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/send-test-answer-to-review.graphql | 6 - src/api/graphql/test/start-test.graphql | 1 - ...pt.graphql => test-attempt-detail.graphql} | 4 +- .../test/test-attempt-questions.graphql | 21 -- .../graphql/test/test-attempts-all.graphql | 15 + src/api/graphql/test/test-attempts.graphql | 10 - src/api/schema.graphql | 26 ++ .../tests-admin/test-attempt-detail-view.tsx | 331 +++++++++++++++++ .../tests-admin/test-attempt-detail.tsx | 331 +++++++++++++++++ .../tests-admin/test-attempts-list.tsx | 343 ++++++++++++++++++ .../admin-panel/tests-admin/tests-admin.tsx | 76 ++-- .../test/containers/test-container.tsx | 192 ++++++++-- src/features/test/views/test-view.tsx | 28 +- src/pages/test-attempt-detail.tsx | 9 + src/pages/test-attempts-list.tsx | 9 + src/routes/admin.tsx | 31 +- 16 files changed, 1304 insertions(+), 129 deletions(-) rename src/api/graphql/test/{test-attempt.graphql => test-attempt-detail.graphql} (84%) delete mode 100644 src/api/graphql/test/test-attempt-questions.graphql create mode 100644 src/api/graphql/test/test-attempts-all.graphql delete mode 100644 src/api/graphql/test/test-attempts.graphql create mode 100644 src/features/admin-panel/tests-admin/test-attempt-detail-view.tsx create mode 100644 src/features/admin-panel/tests-admin/test-attempt-detail.tsx create mode 100644 src/features/admin-panel/tests-admin/test-attempts-list.tsx create mode 100644 src/pages/test-attempt-detail.tsx create mode 100644 src/pages/test-attempts-list.tsx diff --git a/src/api/graphql/test/send-test-answer-to-review.graphql b/src/api/graphql/test/send-test-answer-to-review.graphql index 5e1f8156..04ce1bbc 100644 --- a/src/api/graphql/test/send-test-answer-to-review.graphql +++ b/src/api/graphql/test/send-test-answer-to-review.graphql @@ -4,20 +4,16 @@ mutation sendTestAnswerToReview($attemptId: ID!) { status answer lecture { - id subject } training { - id name } student { - id firstName lastName } mentor { - id firstName lastName } @@ -27,8 +23,6 @@ mutation sendTestAnswerToReview($attemptId: ID!) { startTime endTime result - successfulCount - errorsCount } } } diff --git a/src/api/graphql/test/start-test.graphql b/src/api/graphql/test/start-test.graphql index a207c1a0..83f63c81 100644 --- a/src/api/graphql/test/start-test.graphql +++ b/src/api/graphql/test/start-test.graphql @@ -2,7 +2,6 @@ mutation startTest($lectureId: ID!, $trainingId: ID!) { startTest(lectureId: $lectureId, trainingId: $trainingId) { id startTime - endTime successfulCount errorsCount result diff --git a/src/api/graphql/test/test-attempt.graphql b/src/api/graphql/test/test-attempt-detail.graphql similarity index 84% rename from src/api/graphql/test/test-attempt.graphql rename to src/api/graphql/test/test-attempt-detail.graphql index bbe11fac..3736362b 100644 --- a/src/api/graphql/test/test-attempt.graphql +++ b/src/api/graphql/test/test-attempt-detail.graphql @@ -1,5 +1,5 @@ -query testAttempt($id: ID!) { - testAttempt(id: $id) { +query testAttemptDetail($id: ID!) { + testAttemptForAdmin(id: $id) { id startTime endTime diff --git a/src/api/graphql/test/test-attempt-questions.graphql b/src/api/graphql/test/test-attempt-questions.graphql deleted file mode 100644 index 4afa6a59..00000000 --- a/src/api/graphql/test/test-attempt-questions.graphql +++ /dev/null @@ -1,21 +0,0 @@ -query testAttemptQuestions($attemptId: ID!) { - testAttemptQuestions(attemptId: $attemptId) { - testQuestion { - id - text - testAnswers { - id - text - } - } - result - testAnswerResults { - testAnswer { - id - text - } - result - answer - } - } -} diff --git a/src/api/graphql/test/test-attempts-all.graphql b/src/api/graphql/test/test-attempts-all.graphql new file mode 100644 index 00000000..410fd980 --- /dev/null +++ b/src/api/graphql/test/test-attempts-all.graphql @@ -0,0 +1,15 @@ +query testAttemptsAll($offset: Int!, $limit: Int!, $sort: TestAttemptSort) { + testAttemptsAll(offset: $offset, limit: $limit, sort: $sort) { + items { + id + startTime + endTime + successfulCount + errorsCount + result + } + offset + limit + totalElements + } +} diff --git a/src/api/graphql/test/test-attempts.graphql b/src/api/graphql/test/test-attempts.graphql deleted file mode 100644 index cf39eeb5..00000000 --- a/src/api/graphql/test/test-attempts.graphql +++ /dev/null @@ -1,10 +0,0 @@ -query testAttempts($lectureId: ID!, $trainingId: ID!) { - testAttempts(lectureId: $lectureId, trainingId: $trainingId) { - id - startTime - endTime - successfulCount - errorsCount - result - } -} diff --git a/src/api/schema.graphql b/src/api/schema.graphql index 0b39548a..fe90b8f5 100644 --- a/src/api/schema.graphql +++ b/src/api/schema.graphql @@ -195,7 +195,13 @@ type Query { student TestAttempt section """ testAttempts(lectureId: ID!, trainingId: ID!): [TestAttemptShortDto] + testAttemptsAll( + offset: Int! + limit: Int! + sort: TestAttemptSort + ): TestAttemptsDto testAttempt(id: ID!): TestAttemptDto + testAttemptForAdmin(id: ID!): TestAttemptDto testAttemptQuestions(attemptId: ID!): [TestAttemptQuestionResultDto] """ commentHomeWork section @@ -659,6 +665,26 @@ type TestAttemptDto { testAttemptQuestionResults: [TestAttemptQuestionResultDto] } +type TestAttemptsDto { + items: [TestAttemptShortDto] + offset: Int + limit: Int + totalElements: Long +} + +input TestAttemptSort { + field: TestAttemptSortField + order: Order +} + +enum TestAttemptSortField { + START_TIME + END_TIME + SUCCESSFUL_COUNT + ERRORS_COUNT + RESULT +} + type TestAttemptQuestionResultDto { testQuestion: TestQuestionDto result: Boolean diff --git a/src/features/admin-panel/tests-admin/test-attempt-detail-view.tsx b/src/features/admin-panel/tests-admin/test-attempt-detail-view.tsx new file mode 100644 index 00000000..8605e8f0 --- /dev/null +++ b/src/features/admin-panel/tests-admin/test-attempt-detail-view.tsx @@ -0,0 +1,331 @@ +import React, { FC } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { + Box, + Button, + Card, + CardContent, + Typography, + Grid, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Chip, + Alert, + Breadcrumbs, + Link, +} from "@mui/material"; +import { + ArrowBack as ArrowBackIcon, + CheckCircle as CheckCircleIcon, + Cancel as CancelIcon, + Schedule as ScheduleIcon, +} from "@mui/icons-material"; + +import { useTestAttemptDetailQuery } from "api/graphql/generated/graphql"; +import { AppSpinner } from "shared/components/spinners"; +import NoDataErrorMessage from "shared/components/no-data-error-message"; + +const TestAttemptDetailView: FC = () => { + const { attemptId } = useParams<{ attemptId: string }>(); + const navigate = useNavigate(); + + const { + data: attemptData, + loading, + error, + } = useTestAttemptDetailQuery({ + variables: { id: attemptId! }, + skip: !attemptId, + }); + + const handleGoBack = () => { + navigate(-1); + }; + + if (loading) return ; + if (error || !attemptData?.testAttemptForAdmin) return ; + + const attempt = attemptData.testAttemptForAdmin; + + const formatDate = (dateString: string | null | undefined) => { + if (!dateString) return "Не завершен"; + return new Date(dateString).toLocaleString("ru-RU"); + }; + + const getDuration = () => { + if (!attempt.startTime || !attempt.endTime) return "Не определено"; + const start = new Date(attempt.startTime); + const end = new Date(attempt.endTime); + const diffMs = end.getTime() - start.getTime(); + const diffMins = Math.round(diffMs / 60000); + return `${diffMins} минут`; + }; + + const getScorePercentage = () => { + const total = (attempt.successfulCount || 0) + (attempt.errorsCount || 0); + if (total === 0) return 0; + return Math.round(((attempt.successfulCount || 0) / total) * 100); + }; + + return ( + + {/* Хлебные крошки */} + + + Результаты тестирования + + Попытка {attempt.id} + + + {/* Заголовок */} + + + Детали попытки тестирования #{attempt.id} + + + + + {/* Основная информация о попытке */} + + + + + + Общая информация + + + + + Время начала: + + + {formatDate(attempt.startTime)} + + + + + Время завершения: + + + {formatDate(attempt.endTime)} + + + + + Длительность: + + {getDuration()} + + + + Результат: + + + {attempt.result ? ( + <> + + + Пройден + + + ) : attempt.result === false ? ( + <> + + + Не пройден + + + ) : ( + <> + + + В процессе + + + )} + + + + + + + + + + + + Статистика + + + + + Правильных ответов: + + + + + + Ошибок: + + + + + + Процент правильных ответов: + + + {getScorePercentage()}% + + + + + + + + + {/* Детали по вопросам */} + + + + Детали по вопросам + + + {attempt.testAttemptQuestionResults && + attempt.testAttemptQuestionResults.length > 0 ? ( + + + + + Вопрос + Варианты ответов + Выбранные ответы + Результат + + + + {attempt.testAttemptQuestionResults.map( + (questionResult, index) => { + if (!questionResult) return null; + + return ( + + + + {index + 1}. {questionResult.testQuestion?.text} + + + + + {questionResult.testQuestion?.testAnswers?.map( + (answer) => ( + + ) + )} + + + + + {questionResult.testAnswerResults?.map( + (answerResult) => { + if (!answerResult) return null; + + return ( + + ); + } + )} + + + + + + + ); + } + )} + +
+
+ ) : ( + + Детальная информация по вопросам недоступна + + )} +
+
+
+ ); +}; + +export default TestAttemptDetailView; diff --git a/src/features/admin-panel/tests-admin/test-attempt-detail.tsx b/src/features/admin-panel/tests-admin/test-attempt-detail.tsx new file mode 100644 index 00000000..1703f15e --- /dev/null +++ b/src/features/admin-panel/tests-admin/test-attempt-detail.tsx @@ -0,0 +1,331 @@ +import React, { FC } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { + Box, + Button, + Card, + CardContent, + Typography, + Grid, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Chip, + Alert, + Breadcrumbs, + Link, +} from "@mui/material"; +import { + ArrowBack as ArrowBackIcon, + CheckCircle as CheckCircleIcon, + Cancel as CancelIcon, + Schedule as ScheduleIcon, +} from "@mui/icons-material"; + +import { + useTestAttemptQuery, + useTestAttemptQuestionsQuery, +} from "api/graphql/generated/graphql"; +import { AppSpinner } from "shared/components/spinners"; +import NoDataErrorMessage from "shared/components/no-data-error-message"; + +const TestAttemptDetail: FC = () => { + const { attemptId } = useParams<{ attemptId: string }>(); + const navigate = useNavigate(); + + const { + data: attemptData, + loading: attemptLoading, + error: attemptError, + } = useTestAttemptQuery({ + variables: { id: attemptId! }, + skip: !attemptId, + }); + + const { data: questionsData, loading: questionsLoading } = + useTestAttemptQuestionsQuery({ + variables: { attemptId: attemptId! }, + skip: !attemptId, + }); + + const handleGoBack = () => { + navigate(-1); + }; + + if (attemptLoading || questionsLoading) return ; + if (attemptError || !attemptData?.testAttempt) return ; + + const attempt = attemptData.testAttempt; + const questions = questionsData?.testAttemptQuestions || []; + + const formatDate = (dateString: string | null | undefined) => { + if (!dateString) return "Не завершен"; + return new Date(dateString).toLocaleString("ru-RU"); + }; + + const getDuration = () => { + if (!attempt.startTime || !attempt.endTime) return "Не определено"; + const start = new Date(attempt.startTime); + const end = new Date(attempt.endTime); + const diffMs = end.getTime() - start.getTime(); + const diffMins = Math.round(diffMs / 60000); + return `${diffMins} минут`; + }; + + const getScorePercentage = () => { + const total = (attempt.successfulCount || 0) + (attempt.errorsCount || 0); + if (total === 0) return 0; + return Math.round(((attempt.successfulCount || 0) / total) * 100); + }; + + return ( + + {/* Хлебные крошки */} + + + Результаты тестирования + + Попытка {attempt.id} + + + {/* Заголовок */} + + + Детали попытки тестирования #{attempt.id} + + + + + {/* Основная информация о попытке */} + + + + + + Общая информация + + + + + Время начала: + + + {formatDate(attempt.startTime)} + + + + + Время завершения: + + + {formatDate(attempt.endTime)} + + + + + Длительность: + + {getDuration()} + + + + Результат: + + + {attempt.result ? ( + <> + + + Пройден + + + ) : attempt.result === false ? ( + <> + + + Не пройден + + + ) : ( + <> + + + В процессе + + + )} + + + + + + + + + + + + Статистика + + + + + Правильных ответов: + + + + + + Ошибок: + + + + + + Процент правильных ответов: + + + {getScorePercentage()}% + + + + + + + + + {/* Детали по вопросам */} + + + + Детали по вопросам + + + {questions.length === 0 ? ( + + Детальная информация по вопросам недоступна + + ) : ( + + + + + Вопрос + Варианты ответов + Выбранные ответы + Результат + + + + {questions.map((questionResult, index) => { + const question = questionResult.testQuestion; + if (!question) return null; + + return ( + + + + {index + 1}. {question.text} + + + + + {question.testAnswers?.map((answer) => ( + + ))} + + + + + {questionResult.testAnswerResults?.map( + (answerResult) => ( + + ) + )} + + + + + + + ); + })} + +
+
+ )} +
+
+
+ ); +}; + +export default TestAttemptDetail; diff --git a/src/features/admin-panel/tests-admin/test-attempts-list.tsx b/src/features/admin-panel/tests-admin/test-attempts-list.tsx new file mode 100644 index 00000000..9d4b0b89 --- /dev/null +++ b/src/features/admin-panel/tests-admin/test-attempts-list.tsx @@ -0,0 +1,343 @@ +import React, { FC, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Box, + Button, + Card, + CardContent, + Typography, + Grid, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Chip, + IconButton, + Tooltip, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + SelectChangeEvent, + Pagination, +} from "@mui/material"; +import { + Visibility as VisibilityIcon, + Refresh as RefreshIcon, + Search as SearchIcon, + Sort as SortIcon, +} from "@mui/icons-material"; + +import { + useTestAttemptsAllQuery, + TestAttemptSort, +} from "api/graphql/generated/graphql"; +import { AppSpinner } from "shared/components/spinners"; +import NoDataErrorMessage from "shared/components/no-data-error-message"; + +const TestAttemptsList: FC = () => { + const navigate = useNavigate(); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(20); + const [searchTerm, setSearchTerm] = useState(""); + const [sortField, setSortField] = + useState("START_TIME"); + const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC"); + + const { + data: attemptsData, + loading, + error, + refetch, + } = useTestAttemptsAllQuery({ + variables: { + offset: page * pageSize, + limit: pageSize, + sort: { + field: sortField, + order: sortOrder, + }, + }, + }); + + const handleViewAttempt = (attemptId: string) => { + navigate(`/test-attempts/${attemptId}`); + }; + + const handleRefresh = () => { + refetch(); + }; + + const handlePageChange = (_: React.ChangeEvent, newPage: number) => { + setPage(newPage - 1); + }; + + const handlePageSizeChange = (event: SelectChangeEvent) => { + const newSize = Number(event.target.value); + setPageSize(newSize); + setPage(0); + }; + + const handleSortChange = (field: keyof TestAttemptSort) => { + if (sortField === field) { + setSortOrder(sortOrder === "ASC" ? "DESC" : "ASC"); + } else { + setSortField(field); + setSortOrder("DESC"); + } + }; + + const filteredAttempts = + attemptsData?.testAttemptsAll?.items?.filter((attempt) => { + if (!attempt) return false; + + // Фильтр по поиску (можно добавить поиск по ID попытки) + if (searchTerm && !attempt.id?.includes(searchTerm)) return false; + + return true; + }) || []; + + const getStatusChip = (attempt: any) => { + if (!attempt.result && !attempt.endTime) { + return ; + } else if (attempt.result) { + return ; + } else { + return ; + } + }; + + const formatDate = (dateString: string | null | undefined) => { + if (!dateString) return "Не завершен"; + return new Date(dateString).toLocaleString("ru-RU"); + }; + + const getScorePercentage = (attempt: any) => { + const total = (attempt.successfulCount || 0) + (attempt.errorsCount || 0); + if (total === 0) return 0; + return Math.round(((attempt.successfulCount || 0) / total) * 100); + }; + + if (loading) return ; + if (error) return ; + + const pagination = attemptsData?.testAttemptsAll; + const totalPages = pagination + ? Math.ceil(pagination.totalElements / pageSize) + : 0; + const currentPage = page + 1; + + return ( + + + Результаты тестирования + + + + {/* Фильтры и поиск */} + + + + + setSearchTerm(e.target.value)} + InputProps={{ + startAdornment: ( + + ), + }} + /> + + + + Сортировка + + + + + + + + {sortOrder === "ASC" ? "По возрастанию" : "По убыванию"} + + + + + + + + {/* Статистика */} + + + + + + Всего попыток: + + + {pagination?.totalElements || 0} + + + + + Страница: + + + {currentPage} из {totalPages} + + + + + Размер страницы: + + {pageSize} + + + + Показано: + + {filteredAttempts.length} + + + + + + {/* Таблица попыток */} + + + + + ID попытки + Время начала + Время завершения + Правильных ответов + Ошибок + Процент + Результат + Статус + Действия + + + + {filteredAttempts.map((attempt) => { + if (!attempt) return null; + + return ( + + {attempt.id} + + {attempt.startTime ? formatDate(attempt.startTime) : "-"} + + + {attempt.endTime ? formatDate(attempt.endTime) : "-"} + + + + + + + + + + {getScorePercentage(attempt)}% + + + + {attempt.result !== null ? ( + + ) : ( + + )} + + {getStatusChip(attempt)} + + + handleViewAttempt(attempt.id!)} + > + + + + + + ); + })} + +
+
+ + {/* Пагинация */} + {pagination && ( + + + + )} + + {filteredAttempts.length === 0 && ( + + + Попытки тестирования не найдены + + + )} +
+ ); +}; + +export default TestAttemptsList; diff --git a/src/features/admin-panel/tests-admin/tests-admin.tsx b/src/features/admin-panel/tests-admin/tests-admin.tsx index 8d4f3729..aaf23979 100644 --- a/src/features/admin-panel/tests-admin/tests-admin.tsx +++ b/src/features/admin-panel/tests-admin/tests-admin.tsx @@ -1,4 +1,4 @@ -import React, { FC } from "react"; +import { FC } from "react"; import { useNavigate } from "react-router-dom"; import { Box, @@ -15,6 +15,7 @@ import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon, + Assessment as AssessmentIcon, } from "@mui/icons-material"; import { @@ -57,8 +58,6 @@ const TestsAdmin: FC = () => { if (loading) return ; if (error) return ; - const tests = testsData?.testTestGroups || []; - return ( { mb: 3, }} > - Управление тестами - + Тесты + + + + - {tests.length === 0 ? ( - - - - Тестов пока нет - - - Создайте первый тест для начала работы - - - - - ) : ( + {testsData?.testTestGroups && testsData.testTestGroups.length > 0 ? ( - {tests.map((test) => ( + {testsData.testTestGroups.map((test) => ( @@ -144,7 +134,11 @@ const TestsAdmin: FC = () => { /> - + ID: {test?.id} @@ -152,6 +146,24 @@ const TestsAdmin: FC = () => { ))} + ) : ( + + + + Тестов пока нет + + + Создайте первый тест для начала работы + + + + )} = ({ lectureId, }); - // Убираем захардкоженный testId + // Состояние теста const [userAnswers, setUserAnswers] = useState([]); const [isCompleted, setIsCompleted] = useState(false); const [score, setScore] = useState(0); @@ -57,6 +60,15 @@ const TestContainer: FC = ({ const [allLoadedAnswers, setAllLoadedAnswers] = useState([]); const [answersLoading, setAnswersLoading] = useState(false); + // Состояние попытки тестирования + const [testAttemptId, setTestAttemptId] = useState(null); + const [testStarted, setTestStarted] = useState(false); + + // GraphQL мутации + const [startTest] = useStartTestMutation(); + const [sendTestAnswer] = useSendTestAnswerMutation(); + const [sendTestAnswerToReview] = useSendTestAnswerToReviewMutation(); + // Получаем детали тестовой группы const { data: testData, loading: testLoading } = useTestTestGroupsByIdQuery({ variables: { id: testId }, @@ -69,6 +81,106 @@ const TestContainer: FC = ({ testData?.testTestGroupsById?.testQuestions?.filter((q) => q != null) ?? []; const currentQuestion = testQuestions[currentQuestionIndex]; + // Начинаем тест при загрузке + useEffect(() => { + if (!testStarted && testData?.testTestGroupsById && !testLoading) { + handleStartTest(); + } + }, [testData, testLoading, testStarted]); + + // Обработчик начала теста + const handleStartTest = async () => { + try { + console.log("🚀 Начинаем тест для:", { lectureId, trainingId }); + + const { data } = await startTest({ + variables: { + lectureId, + trainingId, + }, + }); + + if (data?.startTest?.id) { + setTestAttemptId(data.startTest.id); + setTestStarted(true); + console.log("✅ Тест начат, ID попытки:", data.startTest.id); + } + } catch (error) { + console.error("❌ Ошибка при начале теста:", error); + } + }; + + // Обработчик отправки ответа + const handleSendAnswer = async (questionId: string, answerIds: string[]) => { + if (!testAttemptId) { + console.error("❌ Нет ID попытки теста"); + return; + } + + try { + console.log("📝 Отправляем ответ:", { + questionId, + answerIds, + testAttemptId, + }); + + const { data } = await sendTestAnswer({ + variables: { + questionId, + attemptId: testAttemptId, + testAnswerIds: answerIds, + }, + }); + + if (data?.sendTestAnswer) { + // Обновляем состояние на основе ответа сервера + const attempt = data.sendTestAnswer; + setScore(attempt.successfulCount || 0); + + // Если тест завершен + if (attempt.result !== null) { + setIsCompleted(true); + console.log("🏁 Тест завершен, результат:", attempt.result); + } + + console.log( + "✅ Ответ отправлен, обновленный счет:", + attempt.successfulCount + ); + } + } catch (error) { + console.error("❌ Ошибка при отправке ответа:", error); + } + }; + + // Обработчик завершения теста + const handleFinishTest = async () => { + if (!testAttemptId) { + console.error("❌ Нет ID попытки теста"); + return; + } + + try { + console.log("🏁 Завершаем тест, ID попытки:", testAttemptId); + + const { data } = await sendTestAnswerToReview({ + variables: { + attemptId: testAttemptId, + }, + }); + + if (data?.sendTestAnswerToReview) { + console.log( + "✅ Тест отправлен на проверку:", + data.sendTestAnswerToReview + ); + // Можно показать уведомление об успешной отправке + } + } catch (error) { + console.error("❌ Ошибка при завершении теста:", error); + } + }; + // Загружаем ответы для текущего вопроса useEffect(() => { if (!currentQuestion?.id) return; @@ -123,46 +235,50 @@ const TestContainer: FC = ({ fetchCurrentAnswers(); }, [currentQuestion, getTestAnswers]); - const handleAnswerSelect = (questionId: string, answerId: string) => { - setUserAnswers((prev) => { - const filtered = prev.filter((ua) => ua.questionId !== questionId); - return [...filtered, { questionId, answerId }]; - }); - }; + // Обработчик выбора ответа + const handleAnswerSelect = (answerId: string) => { + if (!currentQuestion?.id) return; - const handleNextQuestion = () => { - if (currentQuestionIndex < testQuestions.length - 1) { - setCurrentQuestionIndex((prev) => prev + 1); + const existingAnswerIndex = userAnswers.findIndex( + (answer) => answer.questionId === currentQuestion.id + ); + + if (existingAnswerIndex >= 0) { + // Обновляем существующий ответ + const updatedAnswers = [...userAnswers]; + updatedAnswers[existingAnswerIndex] = { + questionId: currentQuestion.id, + answerId, + }; + setUserAnswers(updatedAnswers); } else { - // Завершаем тест - handleSubmitTest(); + // Добавляем новый ответ + setUserAnswers([ + ...userAnswers, + { questionId: currentQuestion.id, answerId }, + ]); } }; - const handleSubmitTest = () => { - let correctAnswers = 0; - - userAnswers.forEach((userAnswer) => { - const answer = allLoadedAnswers.find( - (ta) => ta.id === userAnswer.answerId - ); - if (answer?.correct) { - correctAnswers++; - } - }); + // Обработчик перехода к следующему вопросу + const handleNextQuestion = async () => { + if (!currentQuestion?.id) return; - const scorePercentage = (correctAnswers / testQuestions.length) * 100; + // Отправляем ответ на текущий вопрос + const currentAnswer = userAnswers.find( + (answer) => answer.questionId === currentQuestion.id + ); - console.log("🔍 Test Result Debug:", { - correctAnswers, - totalQuestions: testQuestions.length, - scorePercentage: scorePercentage.toFixed(1) + "%", - userAnswers: userAnswers.length, - allLoadedAnswers: allLoadedAnswers.length, - }); + if (currentAnswer) { + await handleSendAnswer(currentQuestion.id, [currentAnswer.answerId]); + } - setScore(correctAnswers); - setIsCompleted(true); + if (currentQuestionIndex < testQuestions.length - 1) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + } else { + // Завершаем тест + await handleFinishTest(); + } }; // Проверяем, ответил ли пользователь на текущий вопрос @@ -183,20 +299,24 @@ const TestContainer: FC = ({ return ( ); }; diff --git a/src/features/test/views/test-view.tsx b/src/features/test/views/test-view.tsx index 6e5a985e..fbc1282d 100644 --- a/src/features/test/views/test-view.tsx +++ b/src/features/test/views/test-view.tsx @@ -46,7 +46,8 @@ interface TestViewProps { isCurrentQuestionAnswered: boolean; trainingId?: string; lectureId?: string; - onAnswerSelect: (questionId: string, answerId: string) => void; + testStarted: boolean; + onAnswerSelect: (answerId: string) => void; onNextQuestion: () => void; onSubmitTest: () => void; } @@ -63,6 +64,7 @@ const TestView: FC = ({ isCurrentQuestionAnswered, trainingId, lectureId, + testStarted, onAnswerSelect, onNextQuestion, onSubmitTest, @@ -91,8 +93,28 @@ const TestView: FC = ({ successThreshold, scorePercentage: scorePercentage.toFixed(1) + "%", isPassed, + testStarted, }); + // Показываем загрузку, если тест еще не начат + if (!testStarted) { + return ( + + + + + Подготовка к тесту... + + + + Инициализация теста... + + + + + ); + } + if (isCompleted) { return ( @@ -177,9 +199,7 @@ const TestView: FC = ({ - onAnswerSelect(currentQuestion.id, e.target.value) - } + onChange={(e) => onAnswerSelect(e.target.value)} > {testAnswers.map((answer) => ( { + return ; +}; + +export default TestAttemptDetailPage; diff --git a/src/pages/test-attempts-list.tsx b/src/pages/test-attempts-list.tsx new file mode 100644 index 00000000..c3e70162 --- /dev/null +++ b/src/pages/test-attempts-list.tsx @@ -0,0 +1,9 @@ +import { FC } from "react"; + +import TestAttemptsList from "features/admin-panel/tests-admin/test-attempts-list"; + +const TestAttemptsListPage: FC = () => { + return ; +}; + +export default TestAttemptsListPage; diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 8959ff9e..91e71e73 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -1,13 +1,8 @@ import { Route } from "react-router-dom"; -import { KanbanPage, KanbanHomeworkDetailsFullPage } from "pages/kanban"; -import LectureDetailPage from "pages/lecture-detail"; -import TrainingLecturesPage from "pages/training-lectures"; -import Profile from "pages/profile"; import AdminPanelPage from "pages/admin-panel"; import TopUsersPage from "pages/top-users"; import UserDetail from "pages/user-detail"; -import EditProfilePage from "pages/edit-profile"; import EditTrainingPage from "pages/edit-training"; import EditLecturesPage from "pages/edit-lectures"; import EditLecturePage from "pages/edit-lecture"; @@ -22,6 +17,10 @@ import { import InfoSystemPage from "pages/info-system"; import TestPage from "pages/test"; import CreateTestPage from "pages/create-test"; +import TestAttemptsListPage from "pages/test-attempts-list"; +import TestAttemptDetailPage from "pages/test-attempt-detail"; +import TrainingLecturesPage from "pages/training-lectures"; +import LectureDetailPage from "pages/lecture-detail"; const AdminRoutes = [ } />, @@ -38,6 +37,16 @@ const AdminRoutes = [ path="/tests/edit/:testId" element={} />, + } + />, + } + />, } />, - } />, } />, - } - />, - } />, - } - />, } />, } />, Date: Mon, 18 Aug 2025 01:06:42 +0300 Subject: [PATCH 06/10] =?UTF-8?q?QAGDEV-723=20-=20[FE]=20=D0=9F=D1=80?= =?UTF-8?q?=D0=B8=D0=BA=D1=80=D0=B5=D0=BF=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=BA=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=D0=BC=20v6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/graphql/test/test-attempt.graphql | 25 ++ .../test/test-attempts-by-lecture.graphql | 10 + .../components/create-test-form.tsx | 47 +-- .../tests-admin/test-attempt-detail-view.tsx | 19 +- .../tests-admin/test-attempt-detail.tsx | 161 +++++----- .../tests-admin/test-attempts-list.tsx | 21 +- .../admin-panel/tests-admin/tests-admin.tsx | 2 +- .../edit-training/containers/select-tests.tsx | 4 +- .../views/lecture-detail/lecture-detail.tsx | 2 +- .../lecture-test-section.tsx | 293 +++++++++++++++++- .../test/containers/test-container.tsx | 254 ++++++++++----- src/features/test/views/test-view.tsx | 64 ++-- src/pages/test.tsx | 5 + 13 files changed, 652 insertions(+), 255 deletions(-) create mode 100644 src/api/graphql/test/test-attempt.graphql create mode 100644 src/api/graphql/test/test-attempts-by-lecture.graphql diff --git a/src/api/graphql/test/test-attempt.graphql b/src/api/graphql/test/test-attempt.graphql new file mode 100644 index 00000000..03e66666 --- /dev/null +++ b/src/api/graphql/test/test-attempt.graphql @@ -0,0 +1,25 @@ +query testAttempt($id: ID!) { + testAttempt(id: $id) { + id + startTime + endTime + successfulCount + errorsCount + result + testAttemptQuestionResults { + testQuestion { + id + text + } + result + testAnswerResults { + testAnswer { + id + text + } + result + answer + } + } + } +} diff --git a/src/api/graphql/test/test-attempts-by-lecture.graphql b/src/api/graphql/test/test-attempts-by-lecture.graphql new file mode 100644 index 00000000..10435f8c --- /dev/null +++ b/src/api/graphql/test/test-attempts-by-lecture.graphql @@ -0,0 +1,10 @@ +query testAttemptsByLecture($lectureId: ID!, $trainingId: ID!) { + testAttempts(lectureId: $lectureId, trainingId: $trainingId) { + id + startTime + endTime + successfulCount + errorsCount + result + } +} diff --git a/src/features/admin-panel/tests-admin/components/create-test-form.tsx b/src/features/admin-panel/tests-admin/components/create-test-form.tsx index 9e69d95d..b4df2f16 100644 --- a/src/features/admin-panel/tests-admin/components/create-test-form.tsx +++ b/src/features/admin-panel/tests-admin/components/create-test-form.tsx @@ -175,43 +175,19 @@ const CreateTestForm: FC = ({ setQuestions(updatedQuestions); }; - const validateForm = (): string | null => { + const validateForm = () => { if (!testName.trim()) { return "Название теста обязательно"; } - - if (successThreshold < 0 || successThreshold > 100) { - return "Проходной балл должен быть от 0 до 100%"; + if (successThreshold < 1) { + return "Проходной балл должен быть не менее 1 правильного ответа"; + } + if (successThreshold > questions.length) { + return "Проходной балл не может быть больше количества вопросов"; } - if (questions.length === 0) { return "Добавьте хотя бы один вопрос"; } - - for (let i = 0; i < questions.length; i++) { - const question = questions[i]; - if (!question.text.trim()) { - return `Текст вопроса ${i + 1} не может быть пустым`; - } - - if (question.answers.length < 2) { - return `В вопросе ${i + 1} должно быть минимум 2 варианта ответа`; - } - - const correctAnswers = question.answers.filter((a) => a.correct); - if (correctAnswers.length === 0) { - return `В вопросе ${i + 1} должен быть хотя бы один правильный ответ`; - } - - for (let j = 0; j < question.answers.length; j++) { - if (!question.answers[j].text.trim()) { - return `Текст ответа ${j + 1} в вопросе ${ - i + 1 - } не может быть пустым`; - } - } - } - return null; }; @@ -253,15 +229,14 @@ const CreateTestForm: FC = ({ setSuccessThreshold(Number(e.target.value))} - required - disabled={isLoading} - inputProps={{ min: 0, max: 100 }} - helperText="Минимальный процент правильных ответов для прохождения" + fullWidth + margin="normal" + helperText="Минимальное количество правильных ответов для прохождения теста" + inputProps={{ min: 1, max: questions.length }} /> diff --git a/src/features/admin-panel/tests-admin/test-attempt-detail-view.tsx b/src/features/admin-panel/tests-admin/test-attempt-detail-view.tsx index 8605e8f0..e0c1b941 100644 --- a/src/features/admin-panel/tests-admin/test-attempt-detail-view.tsx +++ b/src/features/admin-panel/tests-admin/test-attempt-detail-view.tsx @@ -52,8 +52,7 @@ const TestAttemptDetailView: FC = () => { const attempt = attemptData.testAttemptForAdmin; - const formatDate = (dateString: string | null | undefined) => { - if (!dateString) return "Не завершен"; + const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString("ru-RU"); }; @@ -66,10 +65,12 @@ const TestAttemptDetailView: FC = () => { return `${diffMins} минут`; }; - const getScorePercentage = () => { + const getScoreDisplay = () => { + if (!attemptData?.testAttemptForAdmin) return "Нет данных"; + const attempt = attemptData.testAttemptForAdmin; const total = (attempt.successfulCount || 0) + (attempt.errorsCount || 0); - if (total === 0) return 0; - return Math.round(((attempt.successfulCount || 0) / total) * 100); + if (total === 0) return "0 правильных ответов"; + return `${attempt.successfulCount} из ${total} правильных ответов`; }; return ( @@ -202,12 +203,12 @@ const TestAttemptDetailView: FC = () => { sx={{ fontSize: "1.2rem", py: 1 }} /> - - - Процент правильных ответов: + + + Результат теста: - {getScorePercentage()}% + {getScoreDisplay()} diff --git a/src/features/admin-panel/tests-admin/test-attempt-detail.tsx b/src/features/admin-panel/tests-admin/test-attempt-detail.tsx index 1703f15e..d5c6b257 100644 --- a/src/features/admin-panel/tests-admin/test-attempt-detail.tsx +++ b/src/features/admin-panel/tests-admin/test-attempt-detail.tsx @@ -26,10 +26,7 @@ import { Schedule as ScheduleIcon, } from "@mui/icons-material"; -import { - useTestAttemptQuery, - useTestAttemptQuestionsQuery, -} from "api/graphql/generated/graphql"; +import { useTestAttemptQuery } from "api/graphql/generated/graphql"; import { AppSpinner } from "shared/components/spinners"; import NoDataErrorMessage from "shared/components/no-data-error-message"; @@ -46,21 +43,15 @@ const TestAttemptDetail: FC = () => { skip: !attemptId, }); - const { data: questionsData, loading: questionsLoading } = - useTestAttemptQuestionsQuery({ - variables: { attemptId: attemptId! }, - skip: !attemptId, - }); - const handleGoBack = () => { navigate(-1); }; - if (attemptLoading || questionsLoading) return ; + if (attemptLoading || !attemptData?.testAttempt) return ; if (attemptError || !attemptData?.testAttempt) return ; const attempt = attemptData.testAttempt; - const questions = questionsData?.testAttemptQuestions || []; + const questions = attempt.testAttemptQuestionResults || []; const formatDate = (dateString: string | null | undefined) => { if (!dateString) return "Не завершен"; @@ -197,7 +188,7 @@ const TestAttemptDetail: FC = () => { @@ -208,7 +199,7 @@ const TestAttemptDetail: FC = () => { @@ -249,75 +240,81 @@ const TestAttemptDetail: FC = () => { - {questions.map((questionResult, index) => { - const question = questionResult.testQuestion; - if (!question) return null; + {questions + .filter((questionResult) => questionResult !== null) + .map((questionResult, index) => { + const question = questionResult!.testQuestion; + if (!question) return null; - return ( - - - - {index + 1}. {question.text} - - - - - {question.testAnswers?.map((answer) => ( - - ))} - - - - - {questionResult.testAnswerResults?.map( - (answerResult) => ( - - ) - )} - - - - - - - ); - })} + return ( + + + + {index + 1}. {question.text} + + + + + {questionResult.testAnswerResults?.map( + (answerResult) => ( + + ) + )} + + + + + {questionResult.testAnswerResults?.map( + (answerResult) => ( + + ) + )} + + + + + + + ); + })} diff --git a/src/features/admin-panel/tests-admin/test-attempts-list.tsx b/src/features/admin-panel/tests-admin/test-attempts-list.tsx index 9d4b0b89..9fe61513 100644 --- a/src/features/admin-panel/tests-admin/test-attempts-list.tsx +++ b/src/features/admin-panel/tests-admin/test-attempts-list.tsx @@ -44,8 +44,7 @@ const TestAttemptsList: FC = () => { const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(20); const [searchTerm, setSearchTerm] = useState(""); - const [sortField, setSortField] = - useState("START_TIME"); + const [sortField, setSortField] = useState("START_TIME"); const [sortOrder, setSortOrder] = useState<"ASC" | "DESC">("DESC"); const { @@ -58,8 +57,8 @@ const TestAttemptsList: FC = () => { offset: page * pageSize, limit: pageSize, sort: { - field: sortField, - order: sortOrder, + field: sortField as any, + order: sortOrder as any, }, }, }); @@ -116,10 +115,13 @@ const TestAttemptsList: FC = () => { return new Date(dateString).toLocaleString("ru-RU"); }; - const getScorePercentage = (attempt: any) => { - const total = (attempt.successfulCount || 0) + (attempt.errorsCount || 0); - if (total === 0) return 0; - return Math.round(((attempt.successfulCount || 0) / total) * 100); + const getScoreDisplay = (attempt: any) => { + if (attempt.successfulCount === null || attempt.errorsCount === null) { + return "Тест не завершен"; + } + const total = attempt.successfulCount + attempt.errorsCount; + if (total === 0) return "0 правильных ответов"; + return `${attempt.successfulCount} из ${total} правильных ответов`; }; if (loading) return ; @@ -246,7 +248,6 @@ const TestAttemptsList: FC = () => { Время завершения Правильных ответов Ошибок - Процент Результат Статус Действия @@ -283,7 +284,7 @@ const TestAttemptsList: FC = () => { - {getScorePercentage(attempt)}% + {getScoreDisplay(attempt)} diff --git a/src/features/admin-panel/tests-admin/tests-admin.tsx b/src/features/admin-panel/tests-admin/tests-admin.tsx index aaf23979..cd9f5d81 100644 --- a/src/features/admin-panel/tests-admin/tests-admin.tsx +++ b/src/features/admin-panel/tests-admin/tests-admin.tsx @@ -127,7 +127,7 @@ const TestsAdmin: FC = () => { Проходной балл: = ({ {test?.testName}
= (props) => { ); diff --git a/src/features/lecture-detail/views/lecture-test-section/lecture-test-section.tsx b/src/features/lecture-detail/views/lecture-test-section/lecture-test-section.tsx index c71396a1..6b2d3445 100644 --- a/src/features/lecture-detail/views/lecture-test-section/lecture-test-section.tsx +++ b/src/features/lecture-detail/views/lecture-test-section/lecture-test-section.tsx @@ -1,4 +1,4 @@ -import { FC } from "react"; +import { FC, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Box, @@ -14,9 +14,14 @@ import { Quiz as QuizIcon, PlayArrow as PlayIcon, EmojiEvents as TrophyIcon, + CheckCircle as CheckIcon, + Refresh as RefreshIcon, } from "@mui/icons-material"; -import { TestGroupDto } from "api/graphql/generated/graphql"; +import { + TestGroupDto, + useTestAttemptsByLectureQuery, +} from "api/graphql/generated/graphql"; interface LectureTestSectionProps { testGroup: TestGroupDto; @@ -30,6 +35,80 @@ const LectureTestSection: FC = ({ lectureId, }) => { const navigate = useNavigate(); + const [completedAttempt, setCompletedAttempt] = useState(null); + + // Проверяем попытки тестирования для этой лекции + const { + data: attemptsData, + loading: attemptsLoading, + error: attemptsError, + } = useTestAttemptsByLectureQuery({ + variables: { + lectureId: lectureId || "", + trainingId: trainingId || "", + }, + skip: !lectureId || !trainingId, + }); + + // Ищем завершенную успешную попытку + useEffect(() => { + if (attemptsData?.testAttempts) { + // Сортируем попытки по времени начала (новые сначала) + const sortedAttempts = attemptsData.testAttempts + .filter((attempt) => attempt && attempt.endTime !== null) // Только завершенные + .sort((a, b) => { + if (!a || !b) return 0; + return ( + new Date(b.startTime).getTime() - new Date(a.startTime).getTime() + ); + }); + + // Ищем последнюю успешную попытку + const lastSuccessful = sortedAttempts.find( + (attempt) => attempt && attempt.result === true + ); + + // Если нет успешных, ищем последнюю неуспешную + const lastUnsuccessful = !lastSuccessful + ? sortedAttempts.find((attempt) => attempt && attempt.result === false) + : null; + + // Устанавливаем попытку для отображения + setCompletedAttempt(lastSuccessful || lastUnsuccessful || null); + } + }, [attemptsData]); + + // Проверяем, есть ли незавершенная попытка + const hasUnfinishedAttempt = attemptsData?.testAttempts?.some( + (attempt) => attempt && attempt.result === null && attempt.endTime === null + ); + + // Определяем статус теста для отображения + const getTestStatus = () => { + if (completedAttempt) { + if (completedAttempt.result === true) { + return { + status: "success", + title: "Тест пройден", + message: "🎉 Поздравляем! Вы успешно прошли тест по этой лекции.", + icon: , + color: "success" as const, + }; + } else { + return { + status: "failed", + title: "Тест не пройден", + message: + "📚 Тест был пройден, но порог прохождения не достигнут. Попробуйте пройти тест заново.", + icon: , + color: "warning" as const, + }; + } + } + return null; + }; + + const testStatus = getTestStatus(); const handleStartTest = () => { // Проверяем наличие всех необходимых параметров @@ -42,10 +121,174 @@ const LectureTestSection: FC = ({ return; } - // Переходим на страницу теста с параметрами + // Если есть незавершенная попытка, продолжаем её + if (hasUnfinishedAttempt) { + const unfinishedAttempt = attemptsData?.testAttempts?.find( + (attempt) => + attempt && attempt.result === null && attempt.endTime === null + ); + + if (unfinishedAttempt) { + // Переходим на страницу теста с ID незавершенной попытки + navigate( + `/test/${testGroup.id}/${trainingId}/${lectureId}?attemptId=${unfinishedAttempt.id}` + ); + return; + } + } + + // Иначе начинаем новый тест navigate(`/test/${testGroup.id}/${trainingId}/${lectureId}`); }; + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString("ru-RU"); + }; + + // Если тест уже пройден, показываем результат + if (testStatus) { + return ( + + + + Тест по лекции + + + + + + + {testGroup.testName} + + + + + } + label={`Проходной балл: ${testGroup.successThreshold} правильных ответов`} + color="primary" + variant="outlined" + /> + + + + + + {testStatus.message} + + + {/* Показываем предупреждение о незавершенных попытках */} + {hasUnfinishedAttempt && ( + + + ⚠️ У вас есть незавершенная попытка тестирования. + + + )} + + + + Результат тестирования: + + + Время начала:{" "} + {formatDate(completedAttempt.startTime)} + + + Время завершения:{" "} + {formatDate(completedAttempt.endTime)} + + + Правильных ответов:{" "} + {completedAttempt.successfulCount} из{" "} + {testGroup.testQuestions?.length || 0} + + + Ошибок: {completedAttempt.errorsCount} + + + {/* Показываем статистику по всем попыткам */} + {attemptsData?.testAttempts && + attemptsData.testAttempts.length > 1 && ( + + + Статистика по всем попыткам: + + + Всего попыток: {attemptsData.testAttempts.length} + + + Успешных:{" "} + { + attemptsData.testAttempts.filter( + (a) => a && a.result === true + ).length + } + + + Неуспешных:{" "} + { + attemptsData.testAttempts.filter( + (a) => a && a.result === false + ).length + } + + {hasUnfinishedAttempt && ( + + Незавершенных:{" "} + { + attemptsData.testAttempts.filter( + (a) => a && a.result === null + ).length + } + + )} + + )} + + + + + + + + + ); + } + + // Если тест не пройден, показываем стандартный интерфейс return ( = ({ } - label={`Проходной балл: ${testGroup.successThreshold}%`} + label={`Проходной балл: ${testGroup.successThreshold} правильных ответов`} color="primary" variant="outlined" /> @@ -78,19 +321,43 @@ const LectureTestSection: FC = ({ - - - Пройдите тест, чтобы закрепить изученный материал. Для успешного - прохождения необходимо набрать минимум{" "} - {testGroup.successThreshold}% правильных ответов. - - + {/* Показываем ошибку, если есть */} + {attemptsError && ( + + + Ошибка при загрузке попыток тестирования:{" "} + {attemptsError.message} + + + )} + + {/* Показываем загрузку */} + {attemptsLoading && ( + + + Загрузка информации о попытках тестирования... + + + )} + + {/* Показываем основную информацию */} + {!attemptsLoading && !attemptsError && ( + + + {hasUnfinishedAttempt + ? "У вас есть незавершенная попытка тестирования." + : "Пройдите тест, чтобы закрепить изученный материал. Для прохождения теста необходимо ответить правильно на " + + testGroup.successThreshold + + " вопросов."} + + + )} diff --git a/src/features/test/containers/test-container.tsx b/src/features/test/containers/test-container.tsx index 20053884..73048300 100644 --- a/src/features/test/containers/test-container.tsx +++ b/src/features/test/containers/test-container.tsx @@ -1,5 +1,6 @@ import { FC, useState, useEffect } from "react"; import { useLazyQuery } from "@apollo/client"; +import { useSearchParams } from "react-router-dom"; import { AppSpinner } from "shared/components/spinners"; import NoDataErrorMessage from "shared/components/no-data-error-message"; @@ -8,7 +9,7 @@ import { TestAnswerByQuestionDocument, useStartTestMutation, useSendTestAnswerMutation, - useSendTestAnswerToReviewMutation, + useTestAttemptQuery, } from "api/graphql/generated/graphql"; import TestView from "../views/test-view"; @@ -42,12 +43,9 @@ const TestContainer: FC = ({ trainingId, lectureId, }) => { - // Отладочная информация - console.log("🔍 TestContainer Debug Info:", { - testId, - trainingId, - lectureId, - }); + // Получаем attemptId из URL параметров + const [searchParams] = useSearchParams(); + const attemptIdFromUrl = searchParams.get("attemptId"); // Состояние теста const [userAnswers, setUserAnswers] = useState([]); @@ -64,16 +62,25 @@ const TestContainer: FC = ({ const [testAttemptId, setTestAttemptId] = useState(null); const [testStarted, setTestStarted] = useState(false); - // GraphQL мутации + // Состояние для уведомлений + const [errorMessage, setErrorMessage] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + // GraphQL мутации и запросы const [startTest] = useStartTestMutation(); const [sendTestAnswer] = useSendTestAnswerMutation(); - const [sendTestAnswerToReview] = useSendTestAnswerToReviewMutation(); // Получаем детали тестовой группы const { data: testData, loading: testLoading } = useTestTestGroupsByIdQuery({ variables: { id: testId }, }); + // Получаем детали существующей попытки, если есть attemptId + const { data: attemptData, loading: attemptLoading } = useTestAttemptQuery({ + variables: { id: attemptIdFromUrl! }, + skip: !attemptIdFromUrl, + }); + const [getTestAnswers] = useLazyQuery(TestAnswerByQuestionDocument); // Получаем список вопросов @@ -88,11 +95,73 @@ const TestContainer: FC = ({ } }, [testData, testLoading, testStarted]); + // Восстанавливаем состояние из существующей попытки + useEffect(() => { + if (attemptData?.testAttempt && attemptIdFromUrl) { + const attempt = attemptData.testAttempt; + + // Устанавливаем ID попытки + if (attempt.id) { + setTestAttemptId(attempt.id); + } + setTestStarted(true); + + // Восстанавливаем счет + setScore(attempt.successfulCount || 0); + + // Восстанавливаем ответы пользователя + if (attempt.testAttemptQuestionResults) { + const restoredAnswers: UserAnswer[] = []; + attempt.testAttemptQuestionResults.forEach((questionResult) => { + if ( + questionResult && + questionResult.testQuestion && + questionResult.testAnswerResults + ) { + const question = questionResult.testQuestion; + questionResult.testAnswerResults.forEach((answerResult) => { + if ( + answerResult && + answerResult.testAnswer && + answerResult.answer === true + ) { + if (question.id && answerResult.testAnswer.id) { + restoredAnswers.push({ + questionId: question.id, + answerId: answerResult.testAnswer.id, + }); + } + } + }); + } + }); + setUserAnswers(restoredAnswers); + + // Определяем текущий вопрос (первый неотвеченный) + const answeredQuestionIds = new Set( + restoredAnswers.map((a) => a.questionId) + ); + const nextQuestionIndex = testQuestions.findIndex( + (q) => q && q.id && !answeredQuestionIds.has(q.id) + ); + if (nextQuestionIndex !== -1) { + setCurrentQuestionIndex(nextQuestionIndex); + } + } + } + }, [attemptData, attemptIdFromUrl]); + // Обработчик начала теста const handleStartTest = async () => { try { - console.log("🚀 Начинаем тест для:", { lectureId, trainingId }); + // Если есть attemptId в URL, продолжаем существующий тест + if (attemptIdFromUrl) { + setTestAttemptId(attemptIdFromUrl); + setTestStarted(true); + return; + } + // Иначе начинаем новый тест const { data } = await startTest({ variables: { lectureId, @@ -103,10 +172,19 @@ const TestContainer: FC = ({ if (data?.startTest?.id) { setTestAttemptId(data.startTest.id); setTestStarted(true); - console.log("✅ Тест начат, ID попытки:", data.startTest.id); } - } catch (error) { + } catch (error: any) { console.error("❌ Ошибка при начале теста:", error); + + // Если ошибка о незавершенном тесте, предлагаем продолжить + if (error.message?.includes("unfinished test")) { + setErrorMessage( + "⚠️ У вас есть незавершенная попытка тестирования. " + + "Вернитесь на страницу лекции и нажмите 'Продолжить тест'." + ); + } else { + setErrorMessage(`❌ Ошибка при начале теста: ${error.message}`); + } } }; @@ -118,12 +196,6 @@ const TestContainer: FC = ({ } try { - console.log("📝 Отправляем ответ:", { - questionId, - answerIds, - testAttemptId, - }); - const { data } = await sendTestAnswer({ variables: { questionId, @@ -137,47 +209,24 @@ const TestContainer: FC = ({ const attempt = data.sendTestAnswer; setScore(attempt.successfulCount || 0); - // Если тест завершен + // Проверяем, завершился ли тест автоматически if (attempt.result !== null) { setIsCompleted(true); - console.log("🏁 Тест завершен, результат:", attempt.result); - } - - console.log( - "✅ Ответ отправлен, обновленный счет:", - attempt.successfulCount - ); - } - } catch (error) { - console.error("❌ Ошибка при отправке ответа:", error); - } - }; - - // Обработчик завершения теста - const handleFinishTest = async () => { - if (!testAttemptId) { - console.error("❌ Нет ID попытки теста"); - return; - } - try { - console.log("🏁 Завершаем тест, ID попытки:", testAttemptId); - - const { data } = await sendTestAnswerToReview({ - variables: { - attemptId: testAttemptId, - }, - }); + // Если тест пройден, показываем сообщение об успехе + if (attempt.result === true) { + setSuccessMessage("✅ Тест успешно завершен!"); + } else { + setErrorMessage( + "❌ Тест не пройден - недостаточно правильных ответов" + ); + } - if (data?.sendTestAnswerToReview) { - console.log( - "✅ Тест отправлен на проверку:", - data.sendTestAnswerToReview - ); - // Можно показать уведомление об успешной отправке + + } } } catch (error) { - console.error("❌ Ошибка при завершении теста:", error); + console.error("❌ Ошибка при отправке ответа:", error); } }; @@ -276,8 +325,60 @@ const TestContainer: FC = ({ if (currentQuestionIndex < testQuestions.length - 1) { setCurrentQuestionIndex(currentQuestionIndex + 1); } else { - // Завершаем тест - await handleFinishTest(); + // Это последний вопрос - НЕ завершаем тест автоматически + // Пользователь должен нажать кнопку "Завершить тест" отдельно + } + }; + + // Обработчик завершения теста + const handleFinishTest = async () => { + if (!testAttemptId) { + const errorMsg = "❌ Нет ID попытки теста"; + console.error(errorMsg); + setErrorMessage(errorMsg); + return; + } + + // Проверяем, что все вопросы отвечены + if (userAnswers.length < testQuestions.length) { + const errorMsg = `❌ Не все вопросы отвечены: ${userAnswers.length} из ${testQuestions.length}`; + console.error(errorMsg); + setErrorMessage(errorMsg); + return; + } + + // Проверяем, достигнут ли порог прохождения + const successThreshold = + testData?.testTestGroupsById?.successThreshold ?? 0; + if (score < successThreshold) { + const errorMsg = `❌ Порог прохождения не достигнут: ${score} правильных ответов из ${successThreshold} требуемых. Тест не может быть завершен.`; + console.error(errorMsg); + setErrorMessage(errorMsg); + return; + } + + try { + console.log("🏁 Завершаем тест, ID попытки:", testAttemptId); + console.log("📊 Статистика ответов:", { + всего: testQuestions.length, + отвечено: userAnswers.length, + правильных: score, + порог: successThreshold, + достигнут: score >= successThreshold, + }); + + // Тест завершается автоматически бэком при ответе на последний вопрос + // Здесь просто показываем успешное завершение + setIsCompleted(true); + setSuccessMessage("✅ Тест успешно завершен!"); + setErrorMessage(null); + } catch (error: any) { + console.error("❌ Ошибка при завершении теста:", error); + + const errorMsg = `❌ Ошибка при завершении теста: ${ + error.message || "Неизвестная ошибка" + }`; + setErrorMessage(errorMsg); } }; @@ -298,26 +399,29 @@ const TestContainer: FC = ({ }; return ( - + <> + + ); }; diff --git a/src/features/test/views/test-view.tsx b/src/features/test/views/test-view.tsx index fbc1282d..0cf18c8d 100644 --- a/src/features/test/views/test-view.tsx +++ b/src/features/test/views/test-view.tsx @@ -49,7 +49,8 @@ interface TestViewProps { testStarted: boolean; onAnswerSelect: (answerId: string) => void; onNextQuestion: () => void; - onSubmitTest: () => void; + errorMessage?: string | null; + successMessage?: string | null; } const TestView: FC = ({ @@ -67,7 +68,8 @@ const TestView: FC = ({ testStarted, onAnswerSelect, onNextQuestion, - onSubmitTest, + errorMessage, + successMessage, }) => { const navigate = useNavigate(); @@ -83,18 +85,10 @@ const TestView: FC = ({ const successThreshold = testData.successThreshold ?? 0; const progress = ((currentQuestionIndex + 1) / totalQuestions) * 100; - const scorePercentage = (score / totalQuestions) * 100; - const isPassed = scorePercentage >= successThreshold; - - // Отладочная информация - console.log("🔍 TestView Debug:", { - score, - totalQuestions, - successThreshold, - scorePercentage: scorePercentage.toFixed(1) + "%", - isPassed, - testStarted, - }); + + // Исправляем логику: successThreshold - это количество правильных ответов, а не процент + const isPassed = score >= successThreshold; + // Убираем scorePercentage - он больше не нужен // Показываем загрузку, если тест еще не начат if (!testStarted) { @@ -126,19 +120,15 @@ const TestView: FC = ({ {isPassed - ? `Поздравляем! Вы прошли тест с результатом ${scorePercentage.toFixed( - 0 - )}% (${score}/${totalQuestions})` - : `Тест не пройден. Результат: ${scorePercentage.toFixed( - 0 - )}% (${score}/${totalQuestions}). Требуется: ${successThreshold}%`} + ? `Поздравляем! Вы прошли тест с результатом ${score} правильных ответов из ${totalQuestions}` + : `Тест не пройден. Результат: ${score} правильных ответов из ${totalQuestions}. Требуется: ${successThreshold} правильных ответов`} Правильных ответов: {score} из {totalQuestions} - Проходной балл: {successThreshold}% + Проходной балл: {successThreshold} правильных ответов {trainingId && lectureId && ( @@ -165,6 +155,19 @@ const TestView: FC = ({ {testData.testName} + {/* Отображение уведомлений */} + {errorMessage && ( + + {errorMessage} + + )} + + {successMessage && ( + + {successMessage} + + )} + {trainingId && lectureId && ( = ({ )} - - - Вопрос {currentQuestionIndex + 1} из {totalQuestions} + {/* Показываем текущий прогресс */} + + + Прогресс: {currentQuestionIndex + 1} из{" "} + {totalQuestions} вопросов + + + Правильных ответов: {score} из {successThreshold}{" "} + необходимых + + + Статус:{" "} + {isPassed ? "✅ Тест пройден" : "❌ Тест не пройден"} - @@ -228,7 +240,7 @@ const TestView: FC = ({ - {/* Основная информация о попытке */} @@ -217,7 +214,6 @@ const TestAttemptDetailView: FC = () => { - {/* Детали по вопросам */} diff --git a/src/features/admin-panel/tests-admin/test-attempt-detail.tsx b/src/features/admin-panel/tests-admin/test-attempt-detail.tsx index 6bb8d2c8..fc7590a8 100644 --- a/src/features/admin-panel/tests-admin/test-attempt-detail.tsx +++ b/src/features/admin-panel/tests-admin/test-attempt-detail.tsx @@ -75,7 +75,6 @@ const TestAttemptDetail: FC = () => { return ( - {/* Хлебные крошки */} { Попытка {attempt.id} - {/* Заголовок */} { - {/* Основная информация о попытке */} diff --git a/src/features/admin-panel/tests-admin/test-attempts-list.tsx b/src/features/admin-panel/tests-admin/test-attempts-list.tsx index 9fe61513..06b94ac7 100644 --- a/src/features/admin-panel/tests-admin/test-attempts-list.tsx +++ b/src/features/admin-panel/tests-admin/test-attempts-list.tsx @@ -94,7 +94,6 @@ const TestAttemptsList: FC = () => { attemptsData?.testAttemptsAll?.items?.filter((attempt) => { if (!attempt) return false; - // Фильтр по поиску (можно добавить поиск по ID попытки) if (searchTerm && !attempt.id?.includes(searchTerm)) return false; return true; @@ -153,7 +152,6 @@ const TestAttemptsList: FC = () => { - {/* Фильтры и поиск */} @@ -202,7 +200,6 @@ const TestAttemptsList: FC = () => { - {/* Статистика */} @@ -238,7 +235,6 @@ const TestAttemptsList: FC = () => { - {/* Таблица попыток */} @@ -316,7 +312,6 @@ const TestAttemptsList: FC = () => {
- {/* Пагинация */} {pagination && ( = ({ onChange={(e) => field.onChange(e.target.value)} displayEmpty > - - Без теста - {tests.map((test) => ( = ({ />
- - - Тест для лекции - - - Материалы урока @@ -294,6 +284,16 @@ const EditLecture: FC = ({ /> + + + Тест для лекции + + + diff --git a/src/features/lecture-detail/views/homework-section/homework-section.tsx b/src/features/lecture-detail/views/homework-section/homework-section.tsx index a5996fd9..3735e3a6 100644 --- a/src/features/lecture-detail/views/homework-section/homework-section.tsx +++ b/src/features/lecture-detail/views/homework-section/homework-section.tsx @@ -15,6 +15,9 @@ const HomeworkSection: FC = ({ view, onKanbanView, onListView, + testGroup, + trainingId, + lectureId, }) => { const hasHomework = lectureHomeWork?.length > 0; @@ -30,7 +33,11 @@ const HomeworkSection: FC = ({ const homework = ( <> - + void; onListView: () => void; + testGroup?: TestGroupDto; + trainingId?: string; + lectureId?: string; } diff --git a/src/features/lecture-detail/views/lecture-detail/lecture-detail.tsx b/src/features/lecture-detail/views/lecture-detail/lecture-detail.tsx index 2a74d28b..559a1229 100644 --- a/src/features/lecture-detail/views/lecture-detail/lecture-detail.tsx +++ b/src/features/lecture-detail/views/lecture-detail/lecture-detail.tsx @@ -11,7 +11,6 @@ import LectureContent from "../lecture-content"; import { HomeworksFormProvider } from "../../context/homeworks-other-students-form-context"; import StepperButtons from "../stepper-buttons"; import HomeworkSection from "../homework-section"; -import LectureTestSection from "../lecture-test-section"; const LectureDetail: FC = (props) => { const { @@ -26,7 +25,6 @@ const LectureDetail: FC = (props) => { const lectureHomeWork = dataLectureHomework?.lectureHomeWork; const hasHomework = !!lectureHomeWork; - const hasTest = !!testGroup; const [view, setView] = useState("kanban"); @@ -41,13 +39,7 @@ const LectureDetail: FC = (props) => { view={view} onKanbanView={handleKanbanView} onListView={handleListView} - /> - ); - - const renderTest = () => - hasTest && ( - @@ -60,7 +52,6 @@ const LectureDetail: FC = (props) => { - {renderTest()} {!tariffHomework ? : renderHomework()} = ({ const navigate = useNavigate(); const [completedAttempt, setCompletedAttempt] = useState(null); - // Проверяем попытки тестирования для этой лекции const { data: attemptsData, loading: attemptsLoading, @@ -50,12 +49,10 @@ const LectureTestSection: FC = ({ skip: !lectureId || !trainingId, }); - // Ищем завершенную успешную попытку useEffect(() => { if (attemptsData?.testAttempts) { - // Сортируем попытки по времени начала (новые сначала) const sortedAttempts = attemptsData.testAttempts - .filter((attempt) => attempt && attempt.endTime !== null) // Только завершенные + .filter((attempt) => attempt && attempt.endTime !== null) .sort((a, b) => { if (!a || !b) return 0; return ( @@ -63,27 +60,22 @@ const LectureTestSection: FC = ({ ); }); - // Ищем последнюю успешную попытку const lastSuccessful = sortedAttempts.find( (attempt) => attempt && attempt.result === true ); - // Если нет успешных, ищем последнюю неуспешную const lastUnsuccessful = !lastSuccessful ? sortedAttempts.find((attempt) => attempt && attempt.result === false) : null; - // Устанавливаем попытку для отображения setCompletedAttempt(lastSuccessful || lastUnsuccessful || null); } }, [attemptsData]); - // Проверяем, есть ли незавершенная попытка const hasUnfinishedAttempt = attemptsData?.testAttempts?.some( (attempt) => attempt && attempt.result === null && attempt.endTime === null ); - // Определяем статус теста для отображения const getTestStatus = () => { if (completedAttempt) { if (completedAttempt.result === true) { @@ -111,7 +103,6 @@ const LectureTestSection: FC = ({ const testStatus = getTestStatus(); const handleStartTest = () => { - // Проверяем наличие всех необходимых параметров if (!testGroup?.id || !trainingId || !lectureId) { console.error("Missing required parameters for test navigation:", { testId: testGroup?.id, @@ -121,7 +112,6 @@ const LectureTestSection: FC = ({ return; } - // Если есть незавершенная попытка, продолжаем её if (hasUnfinishedAttempt) { const unfinishedAttempt = attemptsData?.testAttempts?.find( (attempt) => @@ -129,7 +119,6 @@ const LectureTestSection: FC = ({ ); if (unfinishedAttempt) { - // Переходим на страницу теста с ID незавершенной попытки navigate( `/test/${testGroup.id}/${trainingId}/${lectureId}?attemptId=${unfinishedAttempt.id}` ); @@ -137,7 +126,6 @@ const LectureTestSection: FC = ({ } } - // Иначе начинаем новый тест navigate(`/test/${testGroup.id}/${trainingId}/${lectureId}`); }; @@ -145,7 +133,6 @@ const LectureTestSection: FC = ({ return new Date(dateString).toLocaleString("ru-RU"); }; - // Если тест уже пройден, показываем результат if (testStatus) { return ( @@ -185,13 +172,28 @@ const LectureTestSection: FC = ({ - + {testStatus.message} - {/* Показываем предупреждение о незавершенных попытках */} {hasUnfinishedAttempt && ( - + ⚠️ У вас есть незавершенная попытка тестирования. @@ -202,40 +204,46 @@ const LectureTestSection: FC = ({ Результат тестирования: - + Время начала:{" "} {formatDate(completedAttempt.startTime)} - + Время завершения:{" "} {formatDate(completedAttempt.endTime)} - + Правильных ответов:{" "} {completedAttempt.successfulCount} из{" "} {testGroup.testQuestions?.length || 0} - + Ошибок: {completedAttempt.errorsCount} - {/* Показываем статистику по всем попыткам */} {attemptsData?.testAttempts && attemptsData.testAttempts.length > 1 && ( Статистика по всем попыткам: - + Всего попыток: {attemptsData.testAttempts.length} - + Успешных:{" "} { attemptsData.testAttempts.filter( @@ -243,7 +251,7 @@ const LectureTestSection: FC = ({ ).length } - + Неуспешных:{" "} { attemptsData.testAttempts.filter( @@ -252,7 +260,7 @@ const LectureTestSection: FC = ({ } {hasUnfinishedAttempt && ( - + Незавершенных:{" "} { attemptsData.testAttempts.filter( @@ -288,7 +296,6 @@ const LectureTestSection: FC = ({ ); } - // Если тест не пройден, показываем стандартный интерфейс return ( = ({ - {/* Показываем ошибку, если есть */} {attemptsError && ( - + Ошибка при загрузке попыток тестирования:{" "} {attemptsError.message} @@ -331,18 +345,32 @@ const LectureTestSection: FC = ({ )} - {/* Показываем загрузку */} {attemptsLoading && ( - + Загрузка информации о попытках тестирования... )} - {/* Показываем основную информацию */} {!attemptsLoading && !attemptsError && ( - + {hasUnfinishedAttempt ? "У вас есть незавершенная попытка тестирования." diff --git a/src/features/test/containers/test-container.tsx b/src/features/test/containers/test-container.tsx index 73048300..2195e55a 100644 --- a/src/features/test/containers/test-container.tsx +++ b/src/features/test/containers/test-container.tsx @@ -14,7 +14,6 @@ import { import TestView from "../views/test-view"; -// Типы для теста (пока без GraphQL, потом можно заменить на сгенерированные) interface TestQuestion { id: string; text: string; @@ -43,11 +42,9 @@ const TestContainer: FC = ({ trainingId, lectureId, }) => { - // Получаем attemptId из URL параметров const [searchParams] = useSearchParams(); const attemptIdFromUrl = searchParams.get("attemptId"); - // Состояние теста const [userAnswers, setUserAnswers] = useState([]); const [isCompleted, setIsCompleted] = useState(false); const [score, setScore] = useState(0); @@ -58,24 +55,19 @@ const TestContainer: FC = ({ const [allLoadedAnswers, setAllLoadedAnswers] = useState([]); const [answersLoading, setAnswersLoading] = useState(false); - // Состояние попытки тестирования const [testAttemptId, setTestAttemptId] = useState(null); const [testStarted, setTestStarted] = useState(false); - // Состояние для уведомлений const [errorMessage, setErrorMessage] = useState(null); const [successMessage, setSuccessMessage] = useState(null); - // GraphQL мутации и запросы const [startTest] = useStartTestMutation(); const [sendTestAnswer] = useSendTestAnswerMutation(); - // Получаем детали тестовой группы const { data: testData, loading: testLoading } = useTestTestGroupsByIdQuery({ variables: { id: testId }, }); - // Получаем детали существующей попытки, если есть attemptId const { data: attemptData, loading: attemptLoading } = useTestAttemptQuery({ variables: { id: attemptIdFromUrl! }, skip: !attemptIdFromUrl, @@ -83,33 +75,27 @@ const TestContainer: FC = ({ const [getTestAnswers] = useLazyQuery(TestAnswerByQuestionDocument); - // Получаем список вопросов const testQuestions = testData?.testTestGroupsById?.testQuestions?.filter((q) => q != null) ?? []; const currentQuestion = testQuestions[currentQuestionIndex]; - // Начинаем тест при загрузке useEffect(() => { if (!testStarted && testData?.testTestGroupsById && !testLoading) { handleStartTest(); } }, [testData, testLoading, testStarted]); - // Восстанавливаем состояние из существующей попытки useEffect(() => { if (attemptData?.testAttempt && attemptIdFromUrl) { const attempt = attemptData.testAttempt; - // Устанавливаем ID попытки if (attempt.id) { setTestAttemptId(attempt.id); } setTestStarted(true); - // Восстанавливаем счет setScore(attempt.successfulCount || 0); - // Восстанавливаем ответы пользователя if (attempt.testAttemptQuestionResults) { const restoredAnswers: UserAnswer[] = []; attempt.testAttemptQuestionResults.forEach((questionResult) => { @@ -137,7 +123,6 @@ const TestContainer: FC = ({ }); setUserAnswers(restoredAnswers); - // Определяем текущий вопрос (первый неотвеченный) const answeredQuestionIds = new Set( restoredAnswers.map((a) => a.questionId) ); @@ -151,17 +136,14 @@ const TestContainer: FC = ({ } }, [attemptData, attemptIdFromUrl]); - // Обработчик начала теста const handleStartTest = async () => { try { - // Если есть attemptId в URL, продолжаем существующий тест if (attemptIdFromUrl) { setTestAttemptId(attemptIdFromUrl); setTestStarted(true); return; } - // Иначе начинаем новый тест const { data } = await startTest({ variables: { lectureId, @@ -176,7 +158,6 @@ const TestContainer: FC = ({ } catch (error: any) { console.error("❌ Ошибка при начале теста:", error); - // Если ошибка о незавершенном тесте, предлагаем продолжить if (error.message?.includes("unfinished test")) { setErrorMessage( "⚠️ У вас есть незавершенная попытка тестирования. " + @@ -188,7 +169,6 @@ const TestContainer: FC = ({ } }; - // Обработчик отправки ответа const handleSendAnswer = async (questionId: string, answerIds: string[]) => { if (!testAttemptId) { console.error("❌ Нет ID попытки теста"); @@ -205,15 +185,12 @@ const TestContainer: FC = ({ }); if (data?.sendTestAnswer) { - // Обновляем состояние на основе ответа сервера const attempt = data.sendTestAnswer; setScore(attempt.successfulCount || 0); - // Проверяем, завершился ли тест автоматически if (attempt.result !== null) { setIsCompleted(true); - // Если тест пройден, показываем сообщение об успехе if (attempt.result === true) { setSuccessMessage("✅ Тест успешно завершен!"); } else { @@ -221,8 +198,6 @@ const TestContainer: FC = ({ "❌ Тест не пройден - недостаточно правильных ответов" ); } - - } } } catch (error) { @@ -230,11 +205,9 @@ const TestContainer: FC = ({ } }; - // Загружаем ответы для текущего вопроса useEffect(() => { if (!currentQuestion?.id) return; - // Проверяем, есть ли уже загруженные ответы для этого вопроса const existingAnswers = allLoadedAnswers.filter( (answer) => answer.testQuestion.id === currentQuestion.id ); @@ -267,7 +240,6 @@ const TestContainer: FC = ({ })); setCurrentQuestionAnswers(answers); - // Сохраняем все ответы для финальной проверки setAllLoadedAnswers((prev) => [...prev, ...answers]); } } catch (error) { @@ -284,7 +256,6 @@ const TestContainer: FC = ({ fetchCurrentAnswers(); }, [currentQuestion, getTestAnswers]); - // Обработчик выбора ответа const handleAnswerSelect = (answerId: string) => { if (!currentQuestion?.id) return; @@ -293,7 +264,6 @@ const TestContainer: FC = ({ ); if (existingAnswerIndex >= 0) { - // Обновляем существующий ответ const updatedAnswers = [...userAnswers]; updatedAnswers[existingAnswerIndex] = { questionId: currentQuestion.id, @@ -301,7 +271,6 @@ const TestContainer: FC = ({ }; setUserAnswers(updatedAnswers); } else { - // Добавляем новый ответ setUserAnswers([ ...userAnswers, { questionId: currentQuestion.id, answerId }, @@ -309,11 +278,9 @@ const TestContainer: FC = ({ } }; - // Обработчик перехода к следующему вопросу const handleNextQuestion = async () => { if (!currentQuestion?.id) return; - // Отправляем ответ на текущий вопрос const currentAnswer = userAnswers.find( (answer) => answer.questionId === currentQuestion.id ); @@ -325,12 +292,9 @@ const TestContainer: FC = ({ if (currentQuestionIndex < testQuestions.length - 1) { setCurrentQuestionIndex(currentQuestionIndex + 1); } else { - // Это последний вопрос - НЕ завершаем тест автоматически - // Пользователь должен нажать кнопку "Завершить тест" отдельно } }; - // Обработчик завершения теста const handleFinishTest = async () => { if (!testAttemptId) { const errorMsg = "❌ Нет ID попытки теста"; @@ -339,7 +303,6 @@ const TestContainer: FC = ({ return; } - // Проверяем, что все вопросы отвечены if (userAnswers.length < testQuestions.length) { const errorMsg = `❌ Не все вопросы отвечены: ${userAnswers.length} из ${testQuestions.length}`; console.error(errorMsg); @@ -347,7 +310,6 @@ const TestContainer: FC = ({ return; } - // Проверяем, достигнут ли порог прохождения const successThreshold = testData?.testTestGroupsById?.successThreshold ?? 0; if (score < successThreshold) { @@ -367,8 +329,6 @@ const TestContainer: FC = ({ достигнут: score >= successThreshold, }); - // Тест завершается автоматически бэком при ответе на последний вопрос - // Здесь просто показываем успешное завершение setIsCompleted(true); setSuccessMessage("✅ Тест успешно завершен!"); setErrorMessage(null); @@ -382,7 +342,6 @@ const TestContainer: FC = ({ } }; - // Проверяем, ответил ли пользователь на текущий вопрос const currentAnswer = userAnswers.find( (ua) => ua.questionId === currentQuestion?.id ); @@ -392,7 +351,6 @@ const TestContainer: FC = ({ if (!testData?.testTestGroupsById || !currentQuestion) return ; - // Приводим currentQuestion к правильному типу const typedCurrentQuestion: TestQuestion = { id: currentQuestion.id!, text: currentQuestion.text!, diff --git a/src/features/test/views/test-view.tsx b/src/features/test/views/test-view.tsx index 0cf18c8d..7763f68b 100644 --- a/src/features/test/views/test-view.tsx +++ b/src/features/test/views/test-view.tsx @@ -16,7 +16,6 @@ import { import { TestGroupDto } from "api/graphql/generated/graphql"; -// Типы (дублируем из контейнера, потом вынесем в отдельный файл) interface TestQuestion { id: string; text: string; @@ -84,13 +83,9 @@ const TestView: FC = ({ }; const successThreshold = testData.successThreshold ?? 0; - const progress = ((currentQuestionIndex + 1) / totalQuestions) * 100; - // Исправляем логику: successThreshold - это количество правильных ответов, а не процент const isPassed = score >= successThreshold; - // Убираем scorePercentage - он больше не нужен - // Показываем загрузку, если тест еще не начат if (!testStarted) { return ( @@ -155,7 +150,6 @@ const TestView: FC = ({ {testData.testName} - {/* Отображение уведомлений */} {errorMessage && ( {errorMessage} @@ -186,7 +180,6 @@ const TestView: FC = ({ )} - {/* Показываем текущий прогресс */} Прогресс: {currentQuestionIndex + 1} из{" "} diff --git a/src/shared/components/send-buttons/send-buttons.tsx b/src/shared/components/send-buttons/send-buttons.tsx index b570831c..49be3590 100644 --- a/src/shared/components/send-buttons/send-buttons.tsx +++ b/src/shared/components/send-buttons/send-buttons.tsx @@ -15,6 +15,7 @@ const SendButtons: FC = ({ onCancel, hideCancel, loading, + disabled, }) => { const { isMobileOrTablet } = useResponsive(); @@ -34,7 +35,7 @@ const SendButtons: FC = ({ const renderSendButton = () => isMobileOrTablet ? ( - + ) : ( @@ -42,6 +43,7 @@ const SendButtons: FC = ({ variant="contained" onClick={onReply} loading={loading} + disabled={disabled} > Отправить diff --git a/src/shared/components/send-buttons/send-buttons.types.ts b/src/shared/components/send-buttons/send-buttons.types.ts index ea410d55..4506dc68 100644 --- a/src/shared/components/send-buttons/send-buttons.types.ts +++ b/src/shared/components/send-buttons/send-buttons.types.ts @@ -2,5 +2,6 @@ export interface ISendButtons { onReply: () => void; onCancel?: () => void; hideCancel?: boolean; - loading: boolean; + loading?: boolean; + disabled?: boolean; } diff --git a/src/shared/features/homework-content/homework-content.tsx b/src/shared/features/homework-content/homework-content.tsx index 5e4017ea..03ae879a 100644 --- a/src/shared/features/homework-content/homework-content.tsx +++ b/src/shared/features/homework-content/homework-content.tsx @@ -7,8 +7,16 @@ import CreateHomeworkItem from "shared/features/send-homework/container"; import { IHomeworkContent } from "./homework-content.types"; const HomeworkContent: FC = (props) => { - const { status, answer, openHomeWorkEdit, setOpenHomeWorkEdit, homeWorkId } = - props; + const { + status, + answer, + openHomeWorkEdit, + setOpenHomeWorkEdit, + homeWorkId, + testGroup, + trainingId, + lectureId, + } = props; let homeworkContent; if (status && !openHomeWorkEdit) { @@ -22,7 +30,13 @@ const HomeworkContent: FC = (props) => { /> ); } else { - homeworkContent = ; + homeworkContent = ( + + ); } return <>{homeworkContent}; diff --git a/src/shared/features/homework-content/homework-content.types.ts b/src/shared/features/homework-content/homework-content.types.ts index f07d8e6d..a19dc939 100644 --- a/src/shared/features/homework-content/homework-content.types.ts +++ b/src/shared/features/homework-content/homework-content.types.ts @@ -1,6 +1,10 @@ import { Dispatch, SetStateAction } from "react"; -import { StudentHomeWorkStatus, Maybe } from "api/graphql/generated/graphql"; +import { + StudentHomeWorkStatus, + Maybe, + TestGroupDto, +} from "api/graphql/generated/graphql"; export interface IHomeworkContent { status?: Maybe; @@ -8,4 +12,7 @@ export interface IHomeworkContent { openHomeWorkEdit: boolean; setOpenHomeWorkEdit: Dispatch>; homeWorkId?: Maybe; + testGroup?: TestGroupDto; + trainingId?: string; + lectureId?: string; } diff --git a/src/shared/features/homework-item/homework-item.tsx b/src/shared/features/homework-item/homework-item.tsx index 87463789..dbe227d7 100644 --- a/src/shared/features/homework-item/homework-item.tsx +++ b/src/shared/features/homework-item/homework-item.tsx @@ -5,6 +5,7 @@ import { useReactiveVar } from "@apollo/client"; import { userIdVar } from "cache"; import StatusText from "shared/components/status-text"; import UserRow from "shared/components/user-row"; +import LectureTestSection from "features/lecture-detail/views/lecture-test-section"; import { IHomeworkItem } from "./homework-item.types"; import { @@ -18,7 +19,13 @@ import HomeworkContent from "../homework-content"; import ButtonEdit from "../../components/button-edit"; const HomeworkItem: FC = (props) => { - const { dataHomeWorkByLectureAndTraining, hideMentorAndStudent } = props; + const { + dataHomeWorkByLectureAndTraining, + hideMentorAndStudent, + testGroup, + trainingId, + lectureId, + } = props; const { status, startCheckingDate, @@ -70,6 +77,15 @@ const HomeworkItem: FC = (props) => { {renderStatusAndMentor()} + + {testGroup && ( + + )} + {renderStudent()} = (props) => { openHomeWorkEdit={openHomeWorkEdit} setOpenHomeWorkEdit={setOpenHomeWorkEdit} homeWorkId={homeWorkId} + testGroup={testGroup} + trainingId={trainingId} + lectureId={lectureId} /> diff --git a/src/shared/features/homework-item/homework-item.types.ts b/src/shared/features/homework-item/homework-item.types.ts index f8ee16ec..bdab21f7 100644 --- a/src/shared/features/homework-item/homework-item.types.ts +++ b/src/shared/features/homework-item/homework-item.types.ts @@ -1,4 +1,7 @@ -import { StudentHomeWorkStatus } from "api/graphql/generated/graphql"; +import { + StudentHomeWorkStatus, + TestGroupDto, +} from "api/graphql/generated/graphql"; export interface IHomeworkItem { dataHomeWorkByLectureAndTraining?: { @@ -35,4 +38,7 @@ export interface IHomeworkItem { } | null; } | null; hideMentorAndStudent?: boolean; + testGroup?: TestGroupDto; + trainingId?: string; + lectureId?: string; } diff --git a/src/shared/features/homework/container/homework-container.tsx b/src/shared/features/homework/container/homework-container.tsx index 4137ee3f..39b9a80c 100644 --- a/src/shared/features/homework/container/homework-container.tsx +++ b/src/shared/features/homework/container/homework-container.tsx @@ -7,14 +7,27 @@ import { AppSpinner } from "shared/components/spinners"; import Homework from "../view"; -const HomeworkContainer: FC = () => { - const { lectureId, trainingId } = useParams(); +interface HomeworkContainerProps { + testGroup?: any; + trainingId?: string; + lectureId?: string; +} + +const HomeworkContainer: FC = ({ + testGroup, + trainingId, + lectureId, +}) => { + const params = useParams(); + const currentLectureId = lectureId || params.lectureId; + const currentTrainingId = trainingId || params.trainingId; const { data: dataHomeWorkByLectureAndTraining, loading: loadingHomeWorkByLectureAndTraining, } = useHomeWorkByLectureAndTrainingQuery({ - variables: { lectureId: lectureId!, trainingId: trainingId! }, + variables: { lectureId: currentLectureId!, trainingId: currentTrainingId! }, + skip: !currentLectureId || !currentTrainingId, }); if (loadingHomeWorkByLectureAndTraining) return ; @@ -25,6 +38,9 @@ const HomeworkContainer: FC = () => { dataHomeWorkByLectureAndTraining={ dataHomeWorkByLectureAndTraining?.homeWorkByLectureAndTraining } + testGroup={testGroup} + trainingId={currentTrainingId} + lectureId={currentLectureId} /> ); }; diff --git a/src/shared/features/homework/view/homework.tsx b/src/shared/features/homework/view/homework.tsx index 7858811d..57c380b4 100644 --- a/src/shared/features/homework/view/homework.tsx +++ b/src/shared/features/homework/view/homework.tsx @@ -20,7 +20,13 @@ import { import { IHomework } from "./homework.types"; const Homework: FC = (props) => { - const { dataHomeWorkByLectureAndTraining, hideMentorAndStudent } = props; + const { + dataHomeWorkByLectureAndTraining, + hideMentorAndStudent, + testGroup, + trainingId, + lectureId, + } = props; const { isMobile } = useResponsive(); const [totalElements, setTotalElements] = useState(0); @@ -40,6 +46,9 @@ const Homework: FC = (props) => { @@ -62,6 +71,9 @@ const Homework: FC = (props) => { {dataHomeWorkByLectureAndTraining?.id && ( <> diff --git a/src/shared/features/homework/view/homework.types.ts b/src/shared/features/homework/view/homework.types.ts index c8aa72b6..3499ee51 100644 --- a/src/shared/features/homework/view/homework.types.ts +++ b/src/shared/features/homework/view/homework.types.ts @@ -1,11 +1,11 @@ -import { StudentHomeWorkStatus } from "api/graphql/generated/graphql"; +import { TestGroupDto , StudentHomeWorkStatus, Maybe } from "api/graphql/generated/graphql"; export interface IHomework { dataHomeWorkByLectureAndTraining?: { __typename?: "StudentHomeWorkDto"; id?: string | null; - answer?: string | null; - status?: StudentHomeWorkStatus | null; + answer?: Maybe; + status?: Maybe; creationDate?: any | null; startCheckingDate?: any | null; endCheckingDate?: any | null; @@ -35,4 +35,7 @@ export interface IHomework { } | null; } | null; hideMentorAndStudent?: boolean; + testGroup?: TestGroupDto; + trainingId?: string; + lectureId?: string; } diff --git a/src/shared/features/send-homework/container/send-homework-container.tsx b/src/shared/features/send-homework/container/send-homework-container.tsx index 849ca25c..70bd04cd 100644 --- a/src/shared/features/send-homework/container/send-homework-container.tsx +++ b/src/shared/features/send-homework/container/send-homework-container.tsx @@ -2,18 +2,30 @@ import { FC } from "react"; import { useParams } from "react-router-dom"; import { - HomeWorkByLectureAndTrainingDocument, - HomeWorkByLectureAndTrainingQuery, - Maybe, useCreateHomeWorkToCheckMutation, - useSendHomeWorkToCheckMutation, useUpdateHomeworkMutation, + useSendHomeWorkToCheckMutation, + HomeWorkByLectureAndTrainingDocument, + Maybe, + HomeWorkByLectureAndTrainingQuery, } from "api/graphql/generated/graphql"; import SendHomework from "../view"; -const SendHomeworkContainer: FC = () => { - const { lectureId, trainingId } = useParams(); +interface SendHomeworkContainerProps { + testGroup?: any; + trainingId?: string; + lectureId?: string; +} + +const SendHomeworkContainer: FC = ({ + testGroup, + trainingId, + lectureId, +}) => { + const params = useParams(); + const currentLectureId = lectureId || params.lectureId; + const currentTrainingId = trainingId || params.trainingId; const [createHomeWorkToCheck, { loading: loadingCreateHomeWorkToCheck }] = useCreateHomeWorkToCheckMutation({ @@ -23,7 +35,10 @@ const SendHomeworkContainer: FC = () => { const existingHomeWorkByLectureAndTraining: Maybe = cache.readQuery({ query: HomeWorkByLectureAndTrainingDocument, - variables: { lectureId: lectureId!, trainingId: trainingId! }, + variables: { + lectureId: currentLectureId!, + trainingId: currentTrainingId!, + }, }); const updatedHomeWorkByLectureAndTraining = { @@ -35,7 +50,10 @@ const SendHomeworkContainer: FC = () => { cache.writeQuery({ query: HomeWorkByLectureAndTrainingDocument, - variables: { lectureId: lectureId!, trainingId: trainingId! }, + variables: { + lectureId: currentLectureId!, + trainingId: currentTrainingId!, + }, data: updatedHomeWorkByLectureAndTraining, }); }, @@ -49,7 +67,10 @@ const SendHomeworkContainer: FC = () => { const existingHomeWorkByLectureAndTraining: Maybe = cache.readQuery({ query: HomeWorkByLectureAndTrainingDocument, - variables: { lectureId: lectureId!, trainingId: trainingId! }, + variables: { + lectureId: currentLectureId!, + trainingId: currentTrainingId!, + }, }); const updatedHomeWorkByLectureAndTraining = { @@ -61,7 +82,10 @@ const SendHomeworkContainer: FC = () => { cache.writeQuery({ query: HomeWorkByLectureAndTrainingDocument, - variables: { lectureId: lectureId!, trainingId: trainingId! }, + variables: { + lectureId: currentLectureId!, + trainingId: currentTrainingId!, + }, data: updatedHomeWorkByLectureAndTraining, }); }, @@ -78,6 +102,9 @@ const SendHomeworkContainer: FC = () => { createHomeWorkToCheck={createHomeWorkToCheck} sendHomeWorkToCheck={sendHomeWorkToCheck} updateHomework={updateHomework} + testGroup={testGroup} + trainingId={currentTrainingId} + lectureId={currentLectureId} /> ); }; diff --git a/src/shared/features/send-homework/view/send-homework.tsx b/src/shared/features/send-homework/view/send-homework.tsx index 80dbc1d6..aeb7c03c 100644 --- a/src/shared/features/send-homework/view/send-homework.tsx +++ b/src/shared/features/send-homework/view/send-homework.tsx @@ -1,6 +1,7 @@ import { FC, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { HOMEWORK_FILE_GET_URI } from "config"; +import { Alert, Typography } from "@mui/material"; import { createUrlWithParams } from "shared/utils"; import { collectFileIds, type RichTextEditorRef } from "shared/lib/mui-tiptap"; @@ -10,6 +11,7 @@ import { PendingFile } from "shared/components/text-editor/types"; import SendButtons from "shared/components/send-buttons"; import { findNodeByUrl } from "shared/lib/mui-tiptap/utils/find-node-by-url"; import { blobUrlToFile } from "shared/lib/mui-tiptap/utils/blob-url-to-file"; +import { useTestAttemptsByLectureQuery } from "api/graphql/generated/graphql"; import { ISendHomeWork } from "./send-homework.types"; import { StyledBox, StyledFormHelperText } from "./send-homework.styled"; @@ -22,8 +24,13 @@ const SendHomework: FC = (props) => { loadingSendHomeWorkToCheck, loadingUpdateHomework, updateHomework, + testGroup, + trainingId, + lectureId, } = props; - const { lectureId, trainingId } = useParams(); + const params = useParams(); + const currentLectureId = lectureId || params.lectureId; + const currentTrainingId = trainingId || params.trainingId; const rteRef = useRef(null); const [pendingFiles, setPendingFiles] = useState([]); @@ -32,6 +39,21 @@ const SendHomework: FC = (props) => { const { deleteHomeworkFile } = useHomeworkFileDelete(); const [error, setError] = useState(""); + const { data: attemptsData, loading: attemptsLoading } = + useTestAttemptsByLectureQuery({ + variables: { + lectureId: currentLectureId || "", + trainingId: currentTrainingId || "", + }, + skip: !currentLectureId || !currentTrainingId || !testGroup, + }); + + const hasSuccessfulTestAttempt = attemptsData?.testAttempts?.some( + (attempt) => attempt && attempt.result === true && attempt.endTime !== null + ); + + const canSubmitHomework = !testGroup || hasSuccessfulTestAttempt; + const handleSendHomeWork = () => { if (!rteRef.current?.editor) return; @@ -44,8 +66,8 @@ const SendHomework: FC = (props) => { try { createHomeWorkToCheck({ variables: { - lectureId: lectureId!, - trainingId: trainingId!, + lectureId: currentLectureId!, + trainingId: currentTrainingId!, content, }, onCompleted: async (response) => { @@ -140,6 +162,15 @@ const SendHomework: FC = (props) => { return (
+ {testGroup && !hasSuccessfulTestAttempt && !attemptsLoading && ( + + + ⚠️ Вы еще не прошли тест по этой лекции. Для отправки домашнего + задания необходимо успешно пройти тест. + + + )} + = (props) => { loadingUpdateHomework || loadingSendHomeWorkToCheck } + disabled={!canSubmitHomework} />
diff --git a/src/shared/features/send-homework/view/send-homework.types.ts b/src/shared/features/send-homework/view/send-homework.types.ts index bd7dc54d..1b5b23ef 100644 --- a/src/shared/features/send-homework/view/send-homework.types.ts +++ b/src/shared/features/send-homework/view/send-homework.types.ts @@ -3,6 +3,7 @@ import { Maybe, SendHomeWorkToCheckMutationFn, UpdateCommentMutationFn, + TestGroupDto, } from "api/graphql/generated/graphql"; export interface ISendHomeWork { @@ -13,4 +14,7 @@ export interface ISendHomeWork { loadingUpdateHomework: boolean; loadingSendHomeWorkToCheck: boolean; homeWorkId?: Maybe; + testGroup?: TestGroupDto; + trainingId?: string; + lectureId?: string; } From 99ee6d7202efebea4bd3bddf62a8bf8d8bc5f05a Mon Sep 17 00:00:00 2001 From: Nik Elin <67843454+nik1999777@users.noreply.github.com> Date: Sun, 7 Sep 2025 18:04:22 +0300 Subject: [PATCH 09/10] =?UTF-8?q?QAGDEV-723=20-=20[FE]=20=D0=9F=D1=80?= =?UTF-8?q?=D0=B8=D0=BA=D1=80=D0=B5=D0=BF=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=BA=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=B8=D1=8F=D0=BC=20v9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/containers/test-container.tsx | 122 ++++++------------ src/features/test/types.ts | 16 +++ src/features/test/views/test-view.tsx | 122 ++++++++++-------- src/pages/test.tsx | 1 - 4 files changed, 122 insertions(+), 139 deletions(-) create mode 100644 src/features/test/types.ts diff --git a/src/features/test/containers/test-container.tsx b/src/features/test/containers/test-container.tsx index 2195e55a..397b06d6 100644 --- a/src/features/test/containers/test-container.tsx +++ b/src/features/test/containers/test-container.tsx @@ -13,23 +13,7 @@ import { } from "api/graphql/generated/graphql"; import TestView from "../views/test-view"; - -interface TestQuestion { - id: string; - text: string; -} - -interface TestAnswer { - id: string; - text: string; - correct: boolean; - testQuestion: TestQuestion; -} - -interface UserAnswer { - questionId: string; - answerId: string; -} +import { TestAnswer, UserAnswer } from "../types"; interface TestContainerProps { testId: string; @@ -112,10 +96,20 @@ const TestContainer: FC = ({ answerResult.answer === true ) { if (question.id && answerResult.testAnswer.id) { - restoredAnswers.push({ - questionId: question.id, - answerId: answerResult.testAnswer.id, - }); + const existingAnswerIndex = restoredAnswers.findIndex( + (answer) => answer.questionId === question.id + ); + + if (existingAnswerIndex >= 0) { + restoredAnswers[existingAnswerIndex].answerIds.push( + answerResult.testAnswer.id + ); + } else { + restoredAnswers.push({ + questionId: question.id, + answerIds: [answerResult.testAnswer.id], + }); + } } } }); @@ -256,7 +250,7 @@ const TestContainer: FC = ({ fetchCurrentAnswers(); }, [currentQuestion, getTestAnswers]); - const handleAnswerSelect = (answerId: string) => { + const handleAnswerSelect = (answerId: string, isSelected: boolean) => { if (!currentQuestion?.id) return; const existingAnswerIndex = userAnswers.findIndex( @@ -265,15 +259,27 @@ const TestContainer: FC = ({ if (existingAnswerIndex >= 0) { const updatedAnswers = [...userAnswers]; - updatedAnswers[existingAnswerIndex] = { - questionId: currentQuestion.id, - answerId, - }; + const currentAnswerIds = updatedAnswers[existingAnswerIndex].answerIds; + + if (isSelected) { + if (!currentAnswerIds.includes(answerId)) { + updatedAnswers[existingAnswerIndex] = { + questionId: currentQuestion.id, + answerIds: [...currentAnswerIds, answerId], + }; + } + } else { + updatedAnswers[existingAnswerIndex] = { + questionId: currentQuestion.id, + answerIds: currentAnswerIds.filter((id) => id !== answerId), + }; + } + setUserAnswers(updatedAnswers); - } else { + } else if (isSelected) { setUserAnswers([ ...userAnswers, - { questionId: currentQuestion.id, answerId }, + { questionId: currentQuestion.id, answerIds: [answerId] }, ]); } }; @@ -285,77 +291,25 @@ const TestContainer: FC = ({ (answer) => answer.questionId === currentQuestion.id ); - if (currentAnswer) { - await handleSendAnswer(currentQuestion.id, [currentAnswer.answerId]); + if (currentAnswer && currentAnswer.answerIds.length > 0) { + await handleSendAnswer(currentQuestion.id, currentAnswer.answerIds); } if (currentQuestionIndex < testQuestions.length - 1) { setCurrentQuestionIndex(currentQuestionIndex + 1); - } else { - } - }; - - const handleFinishTest = async () => { - if (!testAttemptId) { - const errorMsg = "❌ Нет ID попытки теста"; - console.error(errorMsg); - setErrorMessage(errorMsg); - return; - } - - if (userAnswers.length < testQuestions.length) { - const errorMsg = `❌ Не все вопросы отвечены: ${userAnswers.length} из ${testQuestions.length}`; - console.error(errorMsg); - setErrorMessage(errorMsg); - return; - } - - const successThreshold = - testData?.testTestGroupsById?.successThreshold ?? 0; - if (score < successThreshold) { - const errorMsg = `❌ Порог прохождения не достигнут: ${score} правильных ответов из ${successThreshold} требуемых. Тест не может быть завершен.`; - console.error(errorMsg); - setErrorMessage(errorMsg); - return; - } - - try { - console.log("🏁 Завершаем тест, ID попытки:", testAttemptId); - console.log("📊 Статистика ответов:", { - всего: testQuestions.length, - отвечено: userAnswers.length, - правильных: score, - порог: successThreshold, - достигнут: score >= successThreshold, - }); - - setIsCompleted(true); - setSuccessMessage("✅ Тест успешно завершен!"); - setErrorMessage(null); - } catch (error: any) { - console.error("❌ Ошибка при завершении теста:", error); - - const errorMsg = `❌ Ошибка при завершении теста: ${ - error.message || "Неизвестная ошибка" - }`; - setErrorMessage(errorMsg); } }; const currentAnswer = userAnswers.find( (ua) => ua.questionId === currentQuestion?.id ); - const isCurrentQuestionAnswered = !!currentAnswer; + const isCurrentQuestionAnswered = + !!currentAnswer && currentAnswer.answerIds.length > 0; if (testLoading || answersLoading) return ; if (!testData?.testTestGroupsById || !currentQuestion) return ; - const typedCurrentQuestion: TestQuestion = { - id: currentQuestion.id!, - text: currentQuestion.text!, - }; - return ( <> void; + onAnswerSelect: (answerId: string, isSelected: boolean) => void; onNextQuestion: () => void; errorMessage?: string | null; successMessage?: string | null; @@ -79,7 +62,9 @@ const TestView: FC = ({ }; const getUserAnswerForQuestion = (questionId: string) => { - return userAnswers.find((ua) => ua.questionId === questionId)?.answerId; + return ( + userAnswers.find((ua) => ua.questionId === questionId)?.answerIds || [] + ); }; const successThreshold = testData.successThreshold ?? 0; @@ -107,24 +92,45 @@ const TestView: FC = ({ if (isCompleted) { return ( - + - + Тест завершён! - + {isPassed ? `Поздравляем! Вы прошли тест с результатом ${score} правильных ответов из ${totalQuestions}` : `Тест не пройден. Результат: ${score} правильных ответов из ${totalQuestions}. Требуется: ${successThreshold} правильных ответов`} - - Правильных ответов: {score} из {totalQuestions} - - - Проходной балл: {successThreshold} правильных ответов - + + + Правильных ответов: {score} из {totalQuestions} + + + Проходной балл: {successThreshold} правильных ответов + + {trainingId && lectureId && ( )} - - + + Прогресс: {currentQuestionIndex + 1} из{" "} {totalQuestions} вопросов - + Правильных ответов: {score} из {successThreshold}{" "} необходимых - + Статус:{" "} {isPassed ? "✅ Тест пройден" : "❌ Тест не пройден"} @@ -202,19 +214,20 @@ const TestView: FC = ({ - onAnswerSelect(e.target.value)} - > - {testAnswers.map((answer) => ( - } - label={answer.text} - /> - ))} - + {testAnswers.map((answer) => ( + + onAnswerSelect(answer.id, e.target.checked) + } + /> + } + label={answer.text} + /> + ))} @@ -226,8 +239,9 @@ const TestView: FC = ({ alignItems: "center", }} > - - {userAnswers.length} из {totalQuestions} вопросов отвечено + + {userAnswers.filter((answer) => answer.answerIds.length > 0).length}{" "} + из {totalQuestions} вопросов отвечено