From e9aa704bd806844d210e13d7f0ff11a4ed377470 Mon Sep 17 00:00:00 2001
From: Nik Elin <67843454+nik1999777@users.noreply.github.com>
Date: Thu, 9 Oct 2025 16:20:50 +0300
Subject: [PATCH] Add lesson availability date feature
---
.../training-lectures.graphql | 4 +
.../update-training-lecture-settings.graphql | 16 +
src/api/schema.graphql | 7 +
.../containers/lecture-schedule/index.ts | 1 +
.../lecture-schedule-container.tsx | 30 +
.../views/edit-training/edit-training.tsx | 6 +
.../views/lecture-schedule/index.ts | 1 +
.../lecture-schedule/lecture-schedule.tsx | 288 ++++++
.../lecture-detail-container.tsx | 25 +-
.../views/stepper-buttons/stepper-buttons.tsx | 41 +-
.../views/stepper-content/stepper-content.tsx | 85 +-
.../training-lectures/training-lectures.tsx | 109 +-
src/pages/lecture-schedule.tsx | 14 +
src/routes/admin.tsx | 6 +
yarn.lock | 941 ++++++++++++++++--
15 files changed, 1438 insertions(+), 136 deletions(-)
create mode 100644 src/api/graphql/training-lecture/update-training-lecture-settings.graphql
create mode 100644 src/features/edit-training/containers/lecture-schedule/index.ts
create mode 100644 src/features/edit-training/containers/lecture-schedule/lecture-schedule-container.tsx
create mode 100644 src/features/edit-training/views/lecture-schedule/index.ts
create mode 100644 src/features/edit-training/views/lecture-schedule/lecture-schedule.tsx
create mode 100644 src/pages/lecture-schedule.tsx
diff --git a/src/api/graphql/training-lecture/training-lectures.graphql b/src/api/graphql/training-lecture/training-lectures.graphql
index 06570eaf..01e23f42 100644
--- a/src/api/graphql/training-lecture/training-lectures.graphql
+++ b/src/api/graphql/training-lecture/training-lectures.graphql
@@ -1,6 +1,10 @@
query trainingLectures($id: ID!) {
trainingLectures(id: $id) {
id
+ number
+ locking
+ availableFrom
+ isAvailable
lecture {
id
subject
diff --git a/src/api/graphql/training-lecture/update-training-lecture-settings.graphql b/src/api/graphql/training-lecture/update-training-lecture-settings.graphql
new file mode 100644
index 00000000..70be939e
--- /dev/null
+++ b/src/api/graphql/training-lecture/update-training-lecture-settings.graphql
@@ -0,0 +1,16 @@
+mutation updateTrainingLectureSettings(
+ $id: ID!
+ $input: TrainingLectureInput!
+) {
+ updateTrainingLectureSettings(id: $id, input: $input) {
+ id
+ number
+ locking
+ availableFrom
+ isAvailable
+ lecture {
+ id
+ subject
+ }
+ }
+}
diff --git a/src/api/schema.graphql b/src/api/schema.graphql
index 143fbcbb..ca93551e 100644
--- a/src/api/schema.graphql
+++ b/src/api/schema.graphql
@@ -57,6 +57,10 @@ type Mutation {
training lecture
"""
updateTrainingLecture(id: ID!, lectureIds: [ID!]): [TrainingLectureDto]
+ updateTrainingLectureSettings(
+ id: ID!
+ input: TrainingLectureInput!
+ ): TrainingLectureDto
"""
studentHomeWork section
"""
@@ -288,6 +292,7 @@ input TrainingLectureInput {
lecture: ID!
lastLecture: ID
locking: Boolean
+ availableFrom: LocalDateTime
}
type TrainingLectureDto {
@@ -296,6 +301,8 @@ type TrainingLectureDto {
lecture: LectureDto
lastLecture: LectureDto
locking: Boolean
+ availableFrom: LocalDateTime
+ isAvailable: Boolean
}
input LectureInput {
diff --git a/src/features/edit-training/containers/lecture-schedule/index.ts b/src/features/edit-training/containers/lecture-schedule/index.ts
new file mode 100644
index 00000000..fb3d8dc6
--- /dev/null
+++ b/src/features/edit-training/containers/lecture-schedule/index.ts
@@ -0,0 +1 @@
+export { default } from "./lecture-schedule-container";
diff --git a/src/features/edit-training/containers/lecture-schedule/lecture-schedule-container.tsx b/src/features/edit-training/containers/lecture-schedule/lecture-schedule-container.tsx
new file mode 100644
index 00000000..7c1f37cf
--- /dev/null
+++ b/src/features/edit-training/containers/lecture-schedule/lecture-schedule-container.tsx
@@ -0,0 +1,30 @@
+import { FC } from "react";
+import { useParams } from "react-router-dom";
+
+import { AppSpinner } from "shared/components/spinners";
+import NoDataErrorMessage from "shared/components/no-data-error-message";
+import { useTrainingLecturesQuery } from "api/graphql/generated/graphql";
+import { FETCH_POLICY } from "shared/constants";
+
+import LectureSchedule from "../../views/lecture-schedule";
+
+const LectureScheduleContainer: FC = () => {
+ const { trainingId } = useParams();
+
+ const { data, loading, refetch } = useTrainingLecturesQuery({
+ variables: { id: trainingId! },
+ fetchPolicy: FETCH_POLICY.NETWORK_ONLY,
+ });
+
+ if (loading) return ;
+ if (!data?.trainingLectures) return ;
+
+ return (
+
+ );
+};
+
+export default LectureScheduleContainer;
diff --git a/src/features/edit-training/views/edit-training/edit-training.tsx b/src/features/edit-training/views/edit-training/edit-training.tsx
index 69bf9ab9..1e7597e4 100644
--- a/src/features/edit-training/views/edit-training/edit-training.tsx
+++ b/src/features/edit-training/views/edit-training/edit-training.tsx
@@ -116,6 +116,12 @@ const EditTraining: FC = ({ data, updateTraining }) => {
>
Сохранить
+
Продолжить
diff --git a/src/features/edit-training/views/lecture-schedule/index.ts b/src/features/edit-training/views/lecture-schedule/index.ts
new file mode 100644
index 00000000..ebe6ccad
--- /dev/null
+++ b/src/features/edit-training/views/lecture-schedule/index.ts
@@ -0,0 +1 @@
+export { default } from "./lecture-schedule";
diff --git a/src/features/edit-training/views/lecture-schedule/lecture-schedule.tsx b/src/features/edit-training/views/lecture-schedule/lecture-schedule.tsx
new file mode 100644
index 00000000..de61d21e
--- /dev/null
+++ b/src/features/edit-training/views/lecture-schedule/lecture-schedule.tsx
@@ -0,0 +1,288 @@
+import { FC, useState } from "react";
+import {
+ Box,
+ Button,
+ Card,
+ CardContent,
+ Typography,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Paper,
+ Switch,
+ IconButton,
+ Tooltip,
+ TextField,
+} from "@mui/material";
+import {
+ Schedule as ScheduleIcon,
+ Lock as LockIcon,
+ LockOpen as LockOpenIcon,
+ Edit as EditIcon,
+} from "@mui/icons-material";
+import { useSnackbar } from "notistack";
+import dayjs from "dayjs";
+
+import {
+ TrainingLectureDto,
+ useUpdateTrainingLectureSettingsMutation,
+} from "api/graphql/generated/graphql";
+
+interface ILectureSchedule {
+ trainingLectures: (TrainingLectureDto | null)[];
+ refetch: () => void;
+}
+
+const LectureSchedule: FC = ({
+ trainingLectures,
+ refetch,
+}) => {
+ const { enqueueSnackbar } = useSnackbar();
+ const [editingLectureId, setEditingLectureId] = useState(null);
+ const [selectedDate, setSelectedDate] = useState("");
+
+ const [updateSettings] = useUpdateTrainingLectureSettingsMutation();
+
+ const handleToggleLocking = async (lecture: TrainingLectureDto) => {
+ try {
+ await updateSettings({
+ variables: {
+ id: lecture.id!,
+ input: {
+ lecture: lecture.lecture?.id!,
+ locking: !lecture.locking,
+ },
+ },
+ onCompleted: () => {
+ enqueueSnackbar(
+ `Урок "${lecture.lecture?.subject}" ${
+ !lecture.locking ? "заблокирован" : "разблокирован"
+ }`,
+ { variant: "success" }
+ );
+ refetch();
+ },
+ onError: () => {
+ enqueueSnackbar("Не удалось изменить настройки урока", {
+ variant: "error",
+ });
+ },
+ });
+ } catch (error) {
+ console.error("Error toggling locking:", error);
+ }
+ };
+
+ const handleEditDate = (lecture: TrainingLectureDto) => {
+ setEditingLectureId(lecture.id!);
+ if (lecture.availableFrom) {
+ // Backend returns LocalDateTime in format YYYY-MM-DDTHH:mm:ss
+ // We need YYYY-MM-DDTHH:mm for datetime-local input
+ const localDateTime = lecture.availableFrom.slice(0, 16);
+ setSelectedDate(localDateTime);
+ } else {
+ setSelectedDate("");
+ }
+ };
+
+ const handleSaveDate = async (lecture: TrainingLectureDto) => {
+ try {
+ // Convert datetime-local format (YYYY-MM-DDTHH:mm) to LocalDateTime format (YYYY-MM-DDTHH:mm:ss)
+ const formattedDate = selectedDate ? `${selectedDate}:00` : null;
+
+ await updateSettings({
+ variables: {
+ id: lecture.id!,
+ input: {
+ lecture: lecture.lecture?.id!,
+ availableFrom: formattedDate,
+ },
+ },
+ onCompleted: () => {
+ enqueueSnackbar("Дата открытия урока обновлена", {
+ variant: "success",
+ });
+ setEditingLectureId(null);
+ setSelectedDate("");
+ refetch();
+ },
+ onError: () => {
+ enqueueSnackbar("Не удалось обновить дату", { variant: "error" });
+ },
+ });
+ } catch (error) {
+ console.error("Error saving date:", error);
+ }
+ };
+
+ const handleCancelEdit = () => {
+ setEditingLectureId(null);
+ setSelectedDate("");
+ };
+
+ const formatDate = (date: string | null | undefined) => {
+ if (!date) return "Не установлена";
+ return dayjs(date).format("DD.MM.YYYY HH:mm");
+ };
+
+ const getAvailabilityText = (lecture: TrainingLectureDto) => {
+ if (lecture.locking) {
+ return "Заблокирован";
+ }
+ if (!lecture.availableFrom) {
+ return "Доступен сразу";
+ }
+ return lecture.isAvailable
+ ? "Доступен"
+ : `Доступен с ${formatDate(lecture.availableFrom)}`;
+ };
+
+ const getAvailabilityColor = (lecture: TrainingLectureDto) => {
+ if (lecture.locking) return "error";
+ if (lecture.isAvailable) return "success";
+ return "warning";
+ };
+
+ return (
+
+
+
+ Расписание уроков
+
+
+
+
+
+ Здесь вы можете настроить доступность уроков для студентов:
+
+
+
+ Дата начала - урок станет доступен в указанное
+ время
+
+
+ Скрыть урок - урок будет виден, но недоступен для
+ открытия
+
+
+
+
+
+
+
+
+
+ №
+ Урок
+ Дата начала
+ Скрыть урок
+ Статус
+
+
+
+ {trainingLectures.map((lecture) => (
+
+ {lecture?.number}
+
+
+ {lecture?.lecture?.subject}
+
+
+
+ {editingLectureId === lecture?.id ? (
+
+ setSelectedDate(e.target.value)}
+ sx={{ minWidth: 220 }}
+ InputLabelProps={{
+ shrink: true,
+ }}
+ />
+
+
+
+ ) : (
+
+
+ {formatDate(lecture?.availableFrom)}
+
+
+ handleEditDate(lecture!)}
+ >
+
+
+
+
+ )}
+
+
+
+ handleToggleLocking(lecture!)}
+ color="primary"
+ />
+
+
+
+
+ {lecture?.locking ? (
+
+ ) : lecture?.isAvailable ? (
+
+ ) : (
+
+ )}
+
+ {getAvailabilityText(lecture!)}
+
+
+
+
+ ))}
+
+
+
+
+ {trainingLectures.length === 0 && (
+
+
+ Нет уроков в курсе. Добавьте уроки для настройки расписания.
+
+
+ )}
+
+ );
+};
+
+export default LectureSchedule;
diff --git a/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx b/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx
index 16b76eb8..9ad35aba 100644
--- a/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx
+++ b/src/features/lecture-detail/containers/lecture-detail/lecture-detail-container.tsx
@@ -1,5 +1,6 @@
-import { FC } from "react";
-import { useParams } from "react-router-dom";
+import { FC, useEffect } from "react";
+import { useParams, useNavigate } from "react-router-dom";
+import { useSnackbar } from "notistack";
import { AppSpinner } from "shared/components/spinners";
import NoDataErrorMessage from "shared/components/no-data-error-message";
@@ -15,6 +16,8 @@ import useTariff from "../../hooks/use-tariff";
const LectureDetailContainer: FC = () => {
const { lectureId, trainingId } = useParams();
+ const navigate = useNavigate();
+ const { enqueueSnackbar } = useSnackbar();
const { tariffHomework } = useTariff({ trainingId });
@@ -36,6 +39,24 @@ const LectureDetailContainer: FC = () => {
fetchPolicy: FETCH_POLICY.CACHE_AND_NETWORK,
});
+ useEffect(() => {
+ if (dataTrainingLectures?.trainingLectures && lectureId) {
+ const currentLecture = dataTrainingLectures.trainingLectures.find(
+ (tl) => tl?.lecture?.id === lectureId
+ );
+
+ if (currentLecture) {
+ const isLocked = currentLecture.locking;
+ const {isAvailable} = currentLecture;
+
+ if (isLocked || !isAvailable) {
+ enqueueSnackbar("Этот урок пока недоступен", { variant: "warning" });
+ navigate(`/training/${trainingId}`);
+ }
+ }
+ }
+ }, [dataTrainingLectures, lectureId, navigate, trainingId, enqueueSnackbar]);
+
if (loadingLecture || loadingTrainingLectures) {
return ;
}
diff --git a/src/features/lecture-detail/views/stepper-buttons/stepper-buttons.tsx b/src/features/lecture-detail/views/stepper-buttons/stepper-buttons.tsx
index a02c6550..7e776195 100644
--- a/src/features/lecture-detail/views/stepper-buttons/stepper-buttons.tsx
+++ b/src/features/lecture-detail/views/stepper-buttons/stepper-buttons.tsx
@@ -19,9 +19,36 @@ const StepperButtons: FC = ({
(lecture) => lecture?.lecture?.id === lectureId
);
- const isAtFirstLecture = activeStep === 0;
const isAtLastLecture = activeStep === lectures.length - 1;
- const canGoToNextLecture = activeStep < lectures.length - 1;
+
+ const isLectureAccessible = (index: number) => {
+ const lecture = lectures[index];
+ return lecture && !lecture.locking && lecture.isAvailable;
+ };
+
+ const findNextAccessibleLecture = () => {
+ for (let i = activeStep + 1; i < lectures.length; i++) {
+ if (isLectureAccessible(i)) {
+ return i;
+ }
+ }
+ return -1;
+ };
+
+ const findPreviousAccessibleLecture = () => {
+ for (let i = activeStep - 1; i >= 0; i--) {
+ if (isLectureAccessible(i)) {
+ return i;
+ }
+ }
+ return -1;
+ };
+
+ const nextAccessibleIndex = findNextAccessibleLecture();
+ const previousAccessibleIndex = findPreviousAccessibleLecture();
+
+ const canGoToNextLecture = nextAccessibleIndex !== -1;
+ const canGoToPreviousLecture = previousAccessibleIndex !== -1;
const handleNavigation = (step: number) => {
const lecture = lectures[step]?.lecture;
@@ -31,14 +58,14 @@ const StepperButtons: FC = ({
};
const goToPreviousLecture = () => {
- if (activeStep && activeStep > 0) {
- handleNavigation(activeStep - 1);
+ if (previousAccessibleIndex !== -1) {
+ handleNavigation(previousAccessibleIndex);
}
};
const goToNextLecture = () => {
- if (activeStep !== undefined && activeStep < lectures.length - 1) {
- handleNavigation(activeStep + 1);
+ if (nextAccessibleIndex !== -1) {
+ handleNavigation(nextAccessibleIndex);
}
};
@@ -50,7 +77,7 @@ const StepperButtons: FC = ({