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/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..04ce1bbc --- /dev/null +++ b/src/api/graphql/test/send-test-answer-to-review.graphql @@ -0,0 +1,28 @@ +mutation sendTestAnswerToReview($attemptId: ID!) { + sendTestAnswerToReview(attemptId: $attemptId) { + id + status + answer + lecture { + subject + } + training { + name + } + student { + firstName + lastName + } + mentor { + firstName + lastName + } + creationDate + testAttempts { + id + startTime + endTime + result + } + } +} 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..83f63c81 --- /dev/null +++ b/src/api/graphql/test/start-test.graphql @@ -0,0 +1,9 @@ +mutation startTest($lectureId: ID!, $trainingId: ID!) { + startTest(lectureId: $lectureId, trainingId: $trainingId) { + id + startTime + 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-detail.graphql b/src/api/graphql/test/test-attempt-detail.graphql new file mode 100644 index 00000000..3736362b --- /dev/null +++ b/src/api/graphql/test/test-attempt-detail.graphql @@ -0,0 +1,29 @@ +query testAttemptDetail($id: ID!) { + testAttemptForAdmin(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-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-all.graphql b/src/api/graphql/test/test-attempts-all.graphql new file mode 100644 index 00000000..a68f8eb4 --- /dev/null +++ b/src/api/graphql/test/test-attempts-all.graphql @@ -0,0 +1,19 @@ +query testAttemptsAll($offset: Int!, $limit: Int!, $sort: TestAttemptSort) { + testAttemptsAll(offset: $offset, limit: $limit, sort: $sort) { + items { + id + startTime + endTime + successfulCount + errorsCount + result + studentName + trainingName + lectureSubject + testGroupName + } + offset + limit + totalElements + } +} 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/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/api/schema.graphql b/src/api/schema.graphql index 0b39548a..143fbcbb 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 @@ -647,6 +653,10 @@ type TestAttemptShortDto { successfulCount: Int errorsCount: Int result: Boolean + studentName: String + trainingName: String + lectureSubject: String + testGroupName: String } type TestAttemptDto { @@ -657,6 +667,30 @@ type TestAttemptDto { errorsCount: Int result: Boolean testAttemptQuestionResults: [TestAttemptQuestionResultDto] + student: UserDto + training: TrainingDto + lecture: LectureInfoDto + testGroup: TestGroupDto +} + +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 { 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..b4251c8b --- /dev/null +++ b/src/features/admin-panel/tests-admin/components/create-test-form.tsx @@ -0,0 +1,484 @@ +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, + useTestAnswerByQuestionLazyQuery, +} 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 + ); + + const [getTestAnswers] = useTestAnswerByQuestionLazyQuery(); + + useEffect(() => { + if (existingTest) { + setTestName(existingTest.testName || ""); + setSuccessThreshold(existingTest.successThreshold || 70); + + 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, getTestAnswers]); + + 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 = () => { + if (!testName.trim()) { + return "Название теста обязательно"; + } + if (successThreshold < 1) { + return "Проходной балл должен быть не менее 1 правильного ответа"; + } + if (successThreshold > questions.length) { + return "Проходной балл не может быть больше количества вопросов"; + } + if (questions.length === 0) { + return "Добавьте хотя бы один вопрос"; + } + 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))} + fullWidth + margin="normal" + helperText="Минимальное количество правильных ответов для прохождения теста" + inputProps={{ min: 1, max: questions.length }} + /> + + + + + + + + + Вопросы теста + + + } + 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..dc95ac32 --- /dev/null +++ b/src/features/admin-panel/tests-admin/containers/create-test-container.tsx @@ -0,0 +1,198 @@ +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}`); + } + + setTimeout(() => { + navigate("/tests"); + }, 2000); + } catch (error: any) { + console.error("Ошибка при сохранении теста:", error); + setError( + error.message || "Ошибка при сохранении теста. Попробуйте еще раз." + ); + } + }; + + if (loadingTest) return ; + if (loadError) return ; + + const existingTest = existingTestData?.testTestGroupsById; + + return ( + + + + + + Управление тестами + + + {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..dc015cda --- /dev/null +++ b/src/features/admin-panel/tests-admin/create-test-form.tsx @@ -0,0 +1,422 @@ +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, + useTestAnswerByQuestionLazyQuery, +} 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(); + const [getTestAnswers] = useTestAnswerByQuestionLazyQuery(); + + useEffect(() => { + if (existingTestData?.testTestGroupsById) { + const test = existingTestData.testTestGroupsById; + setTestName(test.testName || ""); + setSuccessThreshold(test.successThreshold || 70); + + 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: a?.correct || false, + })) || []; + + loadedQuestions.push({ + id: question.id, + text: question.text || "", + answers: answers.length > 0 ? answers : [], + }); + } + + setQuestions(loadedQuestions); + }; + + loadQuestionsWithAnswers(); + } + }, [existingTestData, getTestAnswers]); + + 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: { id: string }[] = []; + + 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/test-attempt-detail-view.tsx b/src/features/admin-panel/tests-admin/test-attempt-detail-view.tsx new file mode 100644 index 00000000..982009da --- /dev/null +++ b/src/features/admin-panel/tests-admin/test-attempt-detail-view.tsx @@ -0,0 +1,328 @@ +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) => { + 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 getScoreDisplay = () => { + if (!attemptData?.testAttemptForAdmin) return "Нет данных"; + const attempt = attemptData.testAttemptForAdmin; + const total = (attempt.successfulCount || 0) + (attempt.errorsCount || 0); + if (total === 0) return "0 правильных ответов"; + return `${attempt.successfulCount} из ${total} правильных ответов`; + }; + + return ( + + + + Результаты тестирования + + Попытка {attempt.id} + + + + + Детали попытки тестирования #{attempt.id} + + + + + + + + + + Общая информация + + + + + Время начала: + + + {formatDate(attempt.startTime)} + + + + + Время завершения: + + + {formatDate(attempt.endTime)} + + + + + Длительность: + + {getDuration()} + + + + Результат: + + + {attempt.result ? ( + <> + + + Пройден + + + ) : attempt.result === false ? ( + <> + + + Не пройден + + + ) : ( + <> + + + В процессе + + + )} + + + + + + + + + + + + Статистика + + + + + Правильных ответов: + + + + + + Ошибок: + + + + + + Результат теста: + + + {getScoreDisplay()} + + + + + + + + + + + + Детали по вопросам + + + {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..fc7590a8 --- /dev/null +++ b/src/features/admin-panel/tests-admin/test-attempt-detail.tsx @@ -0,0 +1,325 @@ +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 } 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 handleGoBack = () => { + navigate(-1); + }; + + if (attemptLoading || !attemptData?.testAttempt) return ; + if (attemptError || !attemptData?.testAttempt) return ; + + const attempt = attemptData.testAttempt; + const questions = attempt.testAttemptQuestionResults || []; + + 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 + .filter((questionResult) => questionResult !== null) + .map((questionResult, index) => { + const question = questionResult!.testQuestion; + if (!question) return null; + + return ( + + + + {index + 1}. {question.text} + + + + + {questionResult!.testAnswerResults?.map( + (answerResult) => ( + + ) + )} + + + + + {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..ed15d32c --- /dev/null +++ b/src/features/admin-panel/tests-admin/test-attempts-list.tsx @@ -0,0 +1,404 @@ +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, + Avatar, + Stack, +} from "@mui/material"; +import { + Visibility as VisibilityIcon, + Refresh as RefreshIcon, + Search as SearchIcon, + Sort as SortIcon, + Person as PersonIcon, + School as SchoolIcon, + Book as BookIcon, +} 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 as any, + order: sortOrder as any, + }, + }, + }); + + 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; + + if (searchTerm) { + const searchLower = searchTerm.toLowerCase(); + return ( + attempt.id?.toLowerCase().includes(searchLower) || + attempt.studentName?.toLowerCase().includes(searchLower) || + attempt.trainingName?.toLowerCase().includes(searchLower) || + attempt.lectureSubject?.toLowerCase().includes(searchLower) || + attempt.testGroupName?.toLowerCase().includes(searchLower) + ); + } + + 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 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 ; + 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} + + + + + + + + + + Студент + Курс + Лекция + Тест + Время начала + Время завершения + Результат + Статус + Действия + + + + {filteredAttempts.map((attempt) => { + if (!attempt) return null; + + return ( + + + + + + + + + {attempt.studentName} + + + Студент + + + + + + + + + + {attempt.trainingName} + + + Курс + + + + + + + + + + {attempt.lectureSubject} + + + Лекция + + + + + + + + {attempt.testGroupName} + + + Тест + + + + + {attempt.startTime ? formatDate(attempt.startTime) : "-"} + + + {attempt.endTime ? formatDate(attempt.endTime) : "-"} + + + + + {getScoreDisplay(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 new file mode 100644 index 00000000..2772034f --- /dev/null +++ b/src/features/admin-panel/tests-admin/tests-admin.tsx @@ -0,0 +1,178 @@ +import { FC } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Box, + Button, + Card, + CardContent, + Typography, + Grid, + IconButton, + Chip, +} from "@mui/material"; +import { + Add as AddIcon, + Edit as EditIcon, + Delete as DeleteIcon, + Assessment as AssessmentIcon, +} 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 ; + + return ( + + + Тесты + + + + + + + {testsData?.testTestGroups && testsData.testTestGroups.length > 0 ? ( + + {testsData.testTestGroups.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/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..e6323850 --- /dev/null +++ b/src/features/edit-training/containers/select-tests.tsx @@ -0,0 +1,99 @@ +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..1a3f5136 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) => { @@ -272,6 +284,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/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 42fa62b1..559a1229 100644 --- a/src/features/lecture-detail/views/lecture-detail/lecture-detail.tsx +++ b/src/features/lecture-detail/views/lecture-detail/lecture-detail.tsx @@ -20,7 +20,8 @@ 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; @@ -38,6 +39,9 @@ const LectureDetail: FC = (props) => { view={view} onKanbanView={handleKanbanView} onListView={handleListView} + testGroup={testGroup || undefined} + trainingId={trainingId} + lectureId={dataLecture.lecture?.id || undefined} /> ); diff --git a/src/features/lecture-detail/views/lecture-test-section/index.ts b/src/features/lecture-detail/views/lecture-test-section/index.ts new file mode 100644 index 00000000..69659f5c --- /dev/null +++ b/src/features/lecture-detail/views/lecture-test-section/index.ts @@ -0,0 +1 @@ +export { default } from "./lecture-test-section"; 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 new file mode 100644 index 00000000..29e89413 --- /dev/null +++ b/src/features/lecture-detail/views/lecture-test-section/lecture-test-section.tsx @@ -0,0 +1,405 @@ +import { FC, useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Box, + Typography, + Card, + CardContent, + Button, + Chip, + Stack, + Alert, +} from "@mui/material"; +import { + Quiz as QuizIcon, + PlayArrow as PlayIcon, + EmojiEvents as TrophyIcon, + CheckCircle as CheckIcon, + Refresh as RefreshIcon, +} from "@mui/icons-material"; + +import { + TestGroupDto, + useTestAttemptsByLectureQuery, +} from "api/graphql/generated/graphql"; + +interface LectureTestSectionProps { + testGroup: TestGroupDto; + trainingId?: string; + lectureId?: string; +} + +const LectureTestSection: FC = ({ + testGroup, + trainingId, + 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 = () => { + if (!testGroup?.id || !trainingId || !lectureId) { + console.error("Missing required parameters for test navigation:", { + testId: testGroup?.id, + trainingId, + lectureId, + }); + return; + } + + if (hasUnfinishedAttempt) { + const unfinishedAttempt = attemptsData?.testAttempts?.find( + (attempt) => + attempt && attempt.result === null && attempt.endTime === null + ); + + if (unfinishedAttempt) { + 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 ( + + + + Тест по лекции + + + + + + + {testGroup.testName} + + + + } + label={`Проходной балл: ${testGroup.successThreshold} правильных ответов`} + color="primary" + variant="outlined" + /> + + + + + {attemptsError && ( + + + Ошибка при загрузке попыток тестирования:{" "} + {attemptsError.message} + + + )} + + {attemptsLoading && ( + + + Загрузка информации о попытках тестирования... + + + )} + + {!attemptsLoading && !attemptsError && ( + + + {hasUnfinishedAttempt + ? "У вас есть незавершенная попытка тестирования." + : "Пройдите тест, чтобы закрепить изученный материал. Для прохождения теста необходимо ответить правильно на " + + testGroup.successThreshold + + " вопросов."} + + + )} + + + + + + + + ); +}; + +export default LectureTestSection; diff --git a/src/features/test/containers/test-container.tsx b/src/features/test/containers/test-container.tsx new file mode 100644 index 00000000..397b06d6 --- /dev/null +++ b/src/features/test/containers/test-container.tsx @@ -0,0 +1,340 @@ +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"; +import { + useTestTestGroupsByIdQuery, + TestAnswerByQuestionDocument, + useStartTestMutation, + useSendTestAnswerMutation, + useTestAttemptQuery, +} from "api/graphql/generated/graphql"; + +import TestView from "../views/test-view"; +import { TestAnswer, UserAnswer } from "../types"; + +interface TestContainerProps { + testId: string; + trainingId: string; + lectureId: string; +} + +const TestContainer: FC = ({ + testId, + trainingId, + lectureId, +}) => { + const [searchParams] = useSearchParams(); + const attemptIdFromUrl = searchParams.get("attemptId"); + + const [userAnswers, setUserAnswers] = useState([]); + const [isCompleted, setIsCompleted] = useState(false); + const [score, setScore] = useState(0); + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [currentQuestionAnswers, setCurrentQuestionAnswers] = useState< + TestAnswer[] + >([]); + 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); + + const [startTest] = useStartTestMutation(); + const [sendTestAnswer] = useSendTestAnswerMutation(); + + const { data: testData, loading: testLoading } = useTestTestGroupsByIdQuery({ + variables: { id: testId }, + }); + + const { data: attemptData, loading: attemptLoading } = useTestAttemptQuery({ + variables: { id: attemptIdFromUrl! }, + skip: !attemptIdFromUrl, + }); + + 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; + + 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) { + 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], + }); + } + } + } + }); + } + }); + 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 { + if (attemptIdFromUrl) { + setTestAttemptId(attemptIdFromUrl); + setTestStarted(true); + return; + } + + const { data } = await startTest({ + variables: { + lectureId, + trainingId, + }, + }); + + if (data?.startTest?.id) { + setTestAttemptId(data.startTest.id); + setTestStarted(true); + } + } catch (error: any) { + console.error("❌ Ошибка при начале теста:", error); + + if (error.message?.includes("unfinished test")) { + setErrorMessage( + "⚠️ У вас есть незавершенная попытка тестирования. " + + "Вернитесь на страницу лекции и нажмите 'Продолжить тест'." + ); + } else { + setErrorMessage(`❌ Ошибка при начале теста: ${error.message}`); + } + } + }; + + const handleSendAnswer = async (questionId: string, answerIds: string[]) => { + if (!testAttemptId) { + console.error("❌ Нет ID попытки теста"); + return; + } + + try { + 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); + + if (attempt.result === true) { + setSuccessMessage("✅ Тест успешно завершен!"); + } else { + setErrorMessage( + "❌ Тест не пройден - недостаточно правильных ответов" + ); + } + } + } + } catch (error) { + console.error("❌ Ошибка при отправке ответа:", error); + } + }; + + useEffect(() => { + if (!currentQuestion?.id) return; + + const existingAnswers = allLoadedAnswers.filter( + (answer) => answer.testQuestion.id === currentQuestion.id + ); + + if (existingAnswers.length > 0) { + setCurrentQuestionAnswers(existingAnswers); + return; + } + + setAnswersLoading(true); + setCurrentQuestionAnswers([]); + + const fetchCurrentAnswers = async () => { + try { + const { data } = await getTestAnswers({ + variables: { questionId: currentQuestion.id }, + }); + + if (data?.testAnswerByQuestion) { + const answers = data.testAnswerByQuestion + .filter((answer: any) => answer != null) + .map((answer: any) => ({ + id: answer.id!, + text: answer.text!, + correct: answer.correct!, + testQuestion: { + id: currentQuestion.id!, + text: currentQuestion.text!, + }, + })); + + setCurrentQuestionAnswers(answers); + setAllLoadedAnswers((prev) => [...prev, ...answers]); + } + } catch (error) { + console.error( + "Error fetching answers for question:", + currentQuestion.id, + error + ); + } finally { + setAnswersLoading(false); + } + }; + + fetchCurrentAnswers(); + }, [currentQuestion, getTestAnswers]); + + const handleAnswerSelect = (answerId: string, isSelected: boolean) => { + if (!currentQuestion?.id) return; + + const existingAnswerIndex = userAnswers.findIndex( + (answer) => answer.questionId === currentQuestion.id + ); + + if (existingAnswerIndex >= 0) { + const updatedAnswers = [...userAnswers]; + 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 if (isSelected) { + setUserAnswers([ + ...userAnswers, + { questionId: currentQuestion.id, answerIds: [answerId] }, + ]); + } + }; + + const handleNextQuestion = async () => { + if (!currentQuestion?.id) return; + + const currentAnswer = userAnswers.find( + (answer) => answer.questionId === currentQuestion.id + ); + + if (currentAnswer && currentAnswer.answerIds.length > 0) { + await handleSendAnswer(currentQuestion.id, currentAnswer.answerIds); + } + + if (currentQuestionIndex < testQuestions.length - 1) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + } + }; + + const currentAnswer = userAnswers.find( + (ua) => ua.questionId === currentQuestion?.id + ); + const isCurrentQuestionAnswered = + !!currentAnswer && currentAnswer.answerIds.length > 0; + + if (testLoading || answersLoading) return ; + if (!testData?.testTestGroupsById || !currentQuestion) + return ; + + return ( + <> + + + ); +}; + +export default TestContainer; diff --git a/src/features/test/types.ts b/src/features/test/types.ts new file mode 100644 index 00000000..055f1d18 --- /dev/null +++ b/src/features/test/types.ts @@ -0,0 +1,16 @@ +export interface TestQuestion { + id: string; + text: string; +} + +export interface TestAnswer { + id: string; + text: string; + correct: boolean; + testQuestion: TestQuestion; +} + +export interface UserAnswer { + questionId: string; + answerIds: string[]; +} diff --git a/src/features/test/views/test-view.tsx b/src/features/test/views/test-view.tsx new file mode 100644 index 00000000..29d921c9 --- /dev/null +++ b/src/features/test/views/test-view.tsx @@ -0,0 +1,261 @@ +import { FC } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Box, + Button, + Card, + CardContent, + FormControl, + FormControlLabel, + Checkbox, + Typography, + Alert, + LinearProgress, +} from "@mui/material"; + +import { TestGroupDto } from "api/graphql/generated/graphql"; + +import { TestQuestion, TestAnswer, UserAnswer } from "../types"; + +interface TestViewProps { + testData: TestGroupDto; + testAnswers: TestAnswer[]; + userAnswers: UserAnswer[]; + isCompleted: boolean; + score: number; + currentQuestion: TestQuestion; + currentQuestionIndex: number; + totalQuestions: number; + isCurrentQuestionAnswered: boolean; + trainingId?: string; + lectureId?: string; + testStarted: boolean; + onAnswerSelect: (answerId: string, isSelected: boolean) => void; + onNextQuestion: () => void; + errorMessage?: string | null; + successMessage?: string | null; +} + +const TestView: FC = ({ + testData, + testAnswers, + userAnswers, + isCompleted, + score, + currentQuestion, + currentQuestionIndex, + totalQuestions, + isCurrentQuestionAnswered, + trainingId, + lectureId, + testStarted, + onAnswerSelect, + onNextQuestion, + errorMessage, + successMessage, +}) => { + const navigate = useNavigate(); + + const handleBackToLecture = () => { + if (trainingId && lectureId) { + navigate(`/training/${trainingId}/${lectureId}`); + } + }; + + const getUserAnswerForQuestion = (questionId: string) => { + return ( + userAnswers.find((ua) => ua.questionId === questionId)?.answerIds || [] + ); + }; + + const successThreshold = testData.successThreshold ?? 0; + + const isPassed = score >= successThreshold; + + if (!testStarted) { + return ( + + + + + Подготовка к тесту... + + + + Инициализация теста... + + + + + ); + } + + if (isCompleted) { + return ( + + + + + Тест завершён! + + + + {isPassed + ? `Поздравляем! Вы прошли тест с результатом ${score} правильных ответов из ${totalQuestions}` + : `Тест не пройден. Результат: ${score} правильных ответов из ${totalQuestions}. Требуется: ${successThreshold} правильных ответов`} + + + + + Правильных ответов: {score} из {totalQuestions} + + + Проходной балл: {successThreshold} правильных ответов + + + + {trainingId && lectureId && ( + + )} + + + + ); + } + + const selectedAnswers = getUserAnswerForQuestion(currentQuestion.id); + const isLastQuestion = currentQuestionIndex === totalQuestions - 1; + + return ( + + + {testData.testName} + + + {errorMessage && ( + + {errorMessage} + + )} + + {successMessage && ( + + {successMessage} + + )} + + {trainingId && lectureId && ( + + + + )} + + + + Прогресс: {currentQuestionIndex + 1} из{" "} + {totalQuestions} вопросов + + + Правильных ответов: {score} из {successThreshold}{" "} + необходимых + + + Статус:{" "} + {isPassed ? "✅ Тест пройден" : "❌ Тест не пройден"} + + + + + + + {currentQuestionIndex + 1}. {currentQuestion.text} + + + + {testAnswers.map((answer) => ( + + onAnswerSelect(answer.id, e.target.checked) + } + /> + } + label={answer.text} + /> + ))} + + + + + + + {userAnswers.filter((answer) => answer.answerIds.length > 0).length}{" "} + из {totalQuestions} вопросов отвечено + + + + + + ); +}; + +export default TestView; 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/pages/test-attempt-detail.tsx b/src/pages/test-attempt-detail.tsx new file mode 100644 index 00000000..37e32ac1 --- /dev/null +++ b/src/pages/test-attempt-detail.tsx @@ -0,0 +1,9 @@ +import { FC } from "react"; + +import TestAttemptDetailView from "features/admin-panel/tests-admin/test-attempt-detail-view"; + +const TestAttemptDetailPage: FC = () => { + 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/pages/test.tsx b/src/pages/test.tsx new file mode 100644 index 00000000..23a9d0fa --- /dev/null +++ b/src/pages/test.tsx @@ -0,0 +1,26 @@ +import { FC } from "react"; +import { useParams } from "react-router-dom"; + +import TestContainer from "features/test/containers/test-container"; + +const TestPage: FC = () => { + const { testId, trainingId, lectureId } = useParams<{ + testId: string; + trainingId: string; + lectureId: string; + }>(); + + if (!testId || !trainingId || !lectureId) { + return
Ошибка: отсутствуют необходимые параметры
; + } + + return ( + + ); +}; + +export default TestPage; diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index fbd11a76..b216d783 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -1,16 +1,12 @@ 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"; +import { KanbanPage } from "pages/kanban"; import { KanbanMentorHomeworkDetailsFullPage, KanbanMentorPage, @@ -20,6 +16,12 @@ import { KanbanStudentPage, } from "pages/kanban-student"; 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 = [ } />, @@ -29,6 +31,23 @@ const AdminRoutes = [ path="/statistics" element={} />, + } />, + } />, + } + />, + } + />, + } + />, } />, - } - />, - } />, - } - />, } />, } />, } />, } />, + } + />, ]; export default AdminRoutes; diff --git a/src/routes/student.tsx b/src/routes/student.tsx index decff53b..350de0c3 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,11 @@ const StudentRoutes = [ path="/training/:trainingId/:lectureId/:modalId?" element={} />, + } + />, = ({ 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; }