Compare commits

...

73 Commits

Author SHA1 Message Date
Primakov Alexandr Alexandrovich
870ac5348b 3.16.4
All checks were successful
platform/bro-js/journal.pl/pipeline/head This commit looks good
2025-03-27 14:23:32 +03:00
Primakov Alexandr Alexandrovich
d648a181c3 Упрощено управление реакциями студентов в карточке пользователя. Изменено состояние реакций на использование одного объекта вместо массива, улучшена анимация отображения реакций. 2025-03-27 14:14:31 +03:00
Primakov Alexandr Alexandrovich
56a04dbe14 Оптимизация обновления реакций студентов и анимации карточек на странице пользователя. Упрощено добавление новых реакций и улучшено управление состоянием анимации студентов. 2025-03-27 14:01:12 +03:00
Primakov Alexandr Alexandrovich
5a92ff2bee 3.16.3 2025-03-27 13:54:13 +03:00
Primakov Alexandr Alexandrovich
543796740b user page reactions fix 2025-03-27 13:54:09 +03:00
Primakov Alexandr Alexandrovich
452d451224 3.16.2 2025-03-27 13:51:29 +03:00
Primakov Alexandr Alexandrovich
23c943f05d force show new reaction 2025-03-27 13:51:25 +03:00
Primakov Alexandr Alexandrovich
c87413eb2c 3.16.1 2025-03-27 13:45:48 +03:00
Primakov Alexandr Alexandrovich
245d56410d fix read students reactions 2025-03-27 13:45:42 +03:00
Primakov Alexandr Alexandrovich
424013c570 3.16.0 2025-03-27 00:00:26 +03:00
Primakov Alexandr Alexandrovich
8a66b96599 Обновлены локализации для дней недели и месяцев, добавлены новые строки для выбора даты и существующих уроков. В компоненте формы уроков реализован календарь для выбора даты с учетом существующих лекций. 2025-03-26 23:41:05 +03:00
Primakov Alexandr Alexandrovich
32aad802b9 Добавлены новые временные слоты и улучшена форма выбора даты и времени для уроков. Реализованы функции для генерации временных слотов и получения следующего доступного времени. Обновлены локализации для новых строк. 2025-03-26 23:20:25 +03:00
Primakov Alexandr Alexandrovich
03a6172d91 3.15.1
All checks were successful
platform/bro-js/journal.pl/pipeline/head This commit looks good
2025-03-26 19:15:13 +03:00
fbf6347f62 3.15.0
All checks were successful
platform/bro-js/journal.pl/pipeline/pr-master This commit looks good
2025-03-25 19:23:12 +03:00
f4883ee6ea sticky qrcode 2025-03-25 19:23:01 +03:00
b2121cc133 Emojy reactions 2025-03-25 19:12:47 +03:00
c02cf6dfc9 flip animation 2025-03-25 18:05:03 +03:00
ac87a2fc80 Refactor LessonDetail component to enhance student attendance display with 3D flip animation. Removed sorting to prevent reordering animation and added conditional rendering for present and not present states using Flex and Box components. 2025-03-25 09:34:27 +03:00
Primakov Alexandr Alexandrovich
1d95f295cb 3.14.4
All checks were successful
platform/bro-js/journal.pl/pipeline/head This commit looks good
2025-03-24 16:18:29 +03:00
Primakov Alexandr Alexandrovich
0861d667b1 Добавлен компонент Portal для меню редактирования и удаления уроков в LessonList, что улучшает отображение и взаимодействие с меню. 2025-03-24 16:18:20 +03:00
Primakov Alexandr Alexandrovich
b070af3188 3.14.3 2025-03-24 15:46:52 +03:00
Primakov Alexandr Alexandrovich
947599eab2 fix lesson link in course list details 2025-03-24 15:46:47 +03:00
Primakov Alexandr Alexandrovich
f20819696b 3.14.2 2025-03-24 00:01:45 +03:00
Primakov Alexandr Alexandrovich
2f84f4a00a Merge branch 'master' of ssh://85.143.175.152:222/bro-js/journal.pl 2025-03-24 00:01:26 +03:00
Primakov Alexandr Alexandrovich
8906ae6239 Добавлена обработка ошибок загрузки изображения в компоненте UserCard: теперь при ошибке загрузки аватара используется Gravatar. Реализовано состояние для отслеживания ошибок загрузки изображений. 2025-03-24 00:00:31 +03:00
b7133f5889 3.14.1 2025-03-23 23:33:27 +03:00
5f836ea6b4 Обновлен компонент Bar для визуализации данных с нормализацией цветов на основе максимального значения. Добавлены новые стили для tooltip и сетки, улучшены параметры анимации. Обновлен файл mock данных с добавлением новых уроков и студентов, улучшена структура данных. 2025-03-23 23:33:23 +03:00
c92be3d7dd Добавлены хлебные крошки для навигации в компонентах и страницах, включая CourseList, LessonList, UserPage и Attendance. Обновлены локализации для новых элементов навигации. Реализован контекст для управления состоянием хлебных крошек через BreadcrumbsProvider и useBreadcrumbs. Обновлен компонент AppHeader для отображения хлебных крошек в зависимости от текущей страницы. 2025-03-23 23:11:27 +03:00
4e27e3d1c6 3.14.0
All checks were successful
platform/bro-js/journal.pl/pipeline/head This commit looks good
2025-03-23 22:19:54 +03:00
e50fb4fd82 Добавлено состояние для отслеживания пульсации в компоненте LessonDetail при изменении количества студентов. Реализована функция для определения цвета на основе посещаемости. Обновлен компонент LessonList для отображения статистики посещаемости с использованием новых стилей и анимаций. 2025-03-23 22:19:43 +03:00
5885124630 3.13.0 2025-03-23 21:58:24 +03:00
2901f51862 Рефакторинг импорта dayjs и добавление утилиты форматирования дат. Все компоненты, использующие dayjs, теперь используют новую функцию formatDate для форматирования дат с учетом локали. Также добавлена поддержка обновления локали dayjs при изменении языка в i18next. 2025-03-23 21:58:18 +03:00
3d383f2e25 Обновлены стили компонента UserCard для улучшения визуального восприятия и добавлены анимации при наведении. Реализована поддержка отображения недавно присутствующих студентов с помощью анимации. Обновлен компонент LessonDetail для отслеживания новых студентов и их анимации при появлении. Улучшены стили списков студентов для лучшей адаптивности и пользовательского опыта. 2025-03-23 21:45:16 +03:00
570ae4b171 3.12.2 2025-03-23 20:10:45 +03:00
57341c90bb Добавлено новое поле 'courses.statistics' в конфигурацию и обновлен компонент CoursesList для использования этой функции. Теперь статистика курсов отображается только при включенной функции. 2025-03-23 20:10:43 +03:00
32b0e004ca Добавлена новая функция для отображения статистики курса в конфигурации и компоненте LessonList. Теперь статистика курса будет отображаться только при включенной функции. 2025-03-23 20:04:29 +03:00
d76d85dfcf 3.12.1 2025-03-23 18:46:54 +03:00
510d052116 Добавлены новые локализации для статистики прошедших уроков и посещаемости. Обновлены компоненты статистики для отображения подсказок с информацией о посещаемости и прошедших занятиях. Улучшено взаимодействие с пользователем через использование подсказок в интерфейсе. 2025-03-23 18:44:53 +03:00
d61a93e67c 3.12.0 2025-03-23 18:25:51 +03:00
5f952ece7a Добавлены новые компоненты для отображения статистики курсов, включая статистику посещаемости, активности студентов и уроков. Обновлены локализации для поддержки новых данных и улучшено взаимодействие с API для получения информации о курсах и уроках. 2025-03-23 18:24:51 +03:00
b37c96f640 Добавлен компонент CourseStatistics для отображения статистики курса, включая общее количество уроков, посещаемость, количество студентов и информацию о следующем занятии. Обновлены локализации для поддержки новых статистических данных. 2025-03-23 17:57:11 +03:00
Primakov Alexandr Alexandrovich
bc33de2721 3.11.2 2025-03-23 17:24:13 +03:00
Primakov Alexandr Alexandrovich
e66b616ba4 Оптимизирована генерация QR-кода в компоненте LessonDetail: добавлена обработка изменения размера окна и улучшена логика очистки канваса. Обновлены стили для QRCanvas для обеспечения квадратного соотношения сторон и адаптивности на мобильных устройствах. 2025-03-23 17:24:04 +03:00
Primakov Alexandr Alexandrovich
1b337278fe Обновлены компоненты для учета только прошедших лекций в статистике посещаемости. Добавлено мобильное отображение в компонентах LessonItems и Item, улучшена логика фильтрации лекций. Реализовано отображение QR-кода с учетом темы оформления. 2025-03-23 17:14:53 +03:00
d13bff5331 3.11.1 2025-03-23 15:18:04 +03:00
5a71314c82 Добавлены новые сообщения об ошибках и возможность повторной генерации уроков с использованием ИИ в компонентах LessonList и LessonForm. Обновлены локализации для поддержки новых функций. 2025-03-23 15:17:55 +03:00
46107cb3d1 Увеличено количество отображаемых скелетонов в компоненте LessonForm с 5 до 6 для улучшения визуального представления загрузки. 2025-03-23 15:11:31 +03:00
238c852b27 3.11.0 2025-03-23 15:01:52 +03:00
3357c9ddd0 Обновлен компонент LessonList: изменена логика генерации уроков при открытии формы создания, добавлены обработчики для редактирования уроков. Обновлены компоненты Item и LessonItems для поддержки новых функций редактирования. Упрощена логика запуска генерации уроков. 2025-03-23 15:00:08 +03:00
e178ce5cd6 Добавлены новые функции генерации уроков с использованием ИИ в компонент LessonList и соответствующие изменения в форме создания урока. Обновлены локализации для поддержки новых функций. Реализован API для генерации уроков и добавлены тестовые данные для имитации ответов сервера. 2025-03-23 14:57:08 +03:00
b00fd32042 3.10.3 2025-03-23 13:26:09 +03:00
e277308ec2 Добавлены новые переводы для управления списком уроков в файлы локализации (en.json и ru.json). Обновлен компонент CoursesList: реализована форма создания нового курса с использованием нового хука useCreateCourse, а также добавлен компонент YearGroup для отображения курсов по годам. 2025-03-23 13:26:04 +03:00
4416a53bc1 Добавлена группировка курсов по годам в компонент CoursesList с использованием useMemo для оптимизации производительности. Обновлен интерфейс для отображения курсов, сгруппированных по годам, с соответствующими заголовками и разделителями. Обновлены тестовые данные в success.json для поддержки новых курсов. 2025-03-23 13:13:58 +03:00
c7f9e3f2bf 3.10.2 2025-03-23 12:52:45 +03:00
ef8f7356e9 Обновлен компонент CourseCard: добавлены адаптивные размеры и улучшена компоновка для различных экранов. Реализована возможность сворачивания/разворачивания списка уроков. Удален компонент CourseDetails, его функциональность интегрирована в CourseCard. Обновлен компонент CoursesList для поддержки адаптивного дизайна и улучшения пользовательского интерфейса. 2025-03-23 12:51:34 +03:00
142ee6c496 3.10.1 2025-03-23 12:10:01 +03:00
2a5d7efcbb Добавлены новые переводы для полноэкранного режима таблицы посещаемости в файлы локализации (en.json и ru.json). Обновлен компонент AttendanceTable: реализована возможность отображения таблицы в полноэкранном режиме с соответствующими кнопками и модальным окном. 2025-03-23 12:09:56 +03:00
5997723166 3.10.0 2025-03-23 11:54:45 +03:00
d1ae996386 Добавлены новые переводы для статистики курса и посещаемости в файлы локализации (en.json и ru.json). Обновлен компонент CourseCard: реализована логика расчета статистики курса и посещаемости студентов, добавлены визуальные элементы для отображения прогресса и статистики. Улучшено взаимодействие с пользователем через обновленный интерфейс. 2025-03-23 11:54:39 +03:00
d3a7f70d12 Добавлены новые зависимости: "react-select" и "@floating-ui/core". Реализована локализация с использованием i18next, добавлены переводы для английского и русского языков. Обновлены компоненты для поддержки локализации, включая AppHeader, Attendance, Dashboard и другие. Улучшена логика отображения данных и взаимодействия с пользователем. 2025-03-23 11:41:29 +03:00
d5b5838e51 Добавлено новое зависимость "react-icons" версии 5.5.0. Обновлен компонент AttendanceTable: добавлены эмоджи для отображения посещаемости студентов, возможность скрытия/показа таблицы, а также улучшена логика расчета статистики посещаемости. 2025-03-23 09:24:37 +03:00
49a26edabf 3.9.0 2025-03-23 09:10:27 +03:00
f274a62be9 Удален переключатель темы из компонента Attendance. Добавлена кнопка для копирования данных таблицы в компонент AttendanceTable с уведомлениями о результате операции. 2025-03-23 09:09:50 +03:00
5e32e55ac2 Реализованы компоненты для отображения посещаемости: AttendanceTable, StatsCard и ShortText. Добавлены хуки useAttendanceData и useAttendanceStats для обработки данных. Обновлен компонент Attendance с использованием новых компонентов и хуков. 2025-03-23 09:01:00 +03:00
433e3b87bf Добавлено расширение темы Chakra UI, реализован компонент AppHeader с переключением темной/светлой темы, обновлены стили для поддержки темной темы, улучшена загрузка компонентов с учетом цветовой схемы. 2025-03-23 08:48:34 +03:00
Primakov Alexandr Alexandrovich
aef215c6e0 3.8.1
All checks were successful
platform/bro-js/journal.pl/pipeline/head This commit looks good
2025-03-12 17:30:09 +03:00
Primakov Alexandr Alexandrovich
bfd3b98dca ближайший получасовой слот при создании лекции 2025-03-12 17:30:01 +03:00
Primakov Alexandr Alexandrovich
8596d6500a 3.8.0 2025-03-12 17:22:11 +03:00
Primakov Alexandr Alexandrovich
994311c222 fix first lesson problem 2025-03-12 17:22:06 +03:00
Primakov Alexandr Alexandrovich
a4447e978a fix use lib
All checks were successful
platform/bro-js/journal.pl/pipeline/head This commit looks good
2025-03-11 18:30:34 +03:00
Primakov Alexandr Alexandrovich
1f4bb81dee 3.7.0 2025-03-11 18:18:12 +03:00
Primakov Alexandr Alexandrovich
ab55c36ac5 menu 2025-03-11 18:18:05 +03:00
Primakov Alexandr Alexandrovich
4eb8ace12b journal deffault client id
All checks were successful
platform/bro-js/journal.pl/pipeline/head This commit looks good
2025-01-08 22:52:18 +03:00
70 changed files with 8536 additions and 1514 deletions

View File

@ -12,7 +12,7 @@ module.exports = {
new webpack.DefinePlugin({
KC_URL: process.env.KC_URL || '"https://kc.bro-js.ru"',
KC_REALM: process.env.KC_REALM || '"bro-js"',
KC_CLIENT_ID: process.env.KC_CLIENT_ID || '"microfrontend-admin"',
KC_CLIENT_ID: process.env.KC_CLIENT_ID || '"journal"',
}),
],
},
@ -34,6 +34,16 @@ module.exports = {
value: '',
key: 'group.by.date',
},
'course.statistics': {
on: true,
value: '',
key: 'course.statistics',
},
'courses.statistics': {
on: true,
value: '',
key: 'courses.statistics',
},
},
},
config: {

242
locales/en.json Normal file
View File

@ -0,0 +1,242 @@
{
"journal.pl.add": "Add",
"journal.pl.edit": "Edit",
"journal.pl.delete": "Delete",
"journal.pl.save": "Save",
"journal.pl.cancel": "Cancel",
"journal.pl.close": "Close",
"journal.pl.title": "Attendance Journal",
"journal.pl.breadcrumbs.home": "Home",
"journal.pl.breadcrumbs.course": "Course",
"journal.pl.breadcrumbs.lesson": "Lesson",
"journal.pl.breadcrumbs.user": "User",
"journal.pl.breadcrumbs.attendance": "Attendance",
"journal.pl.common.add": "Add",
"journal.pl.common.edit": "Edit",
"journal.pl.common.delete": "Delete",
"journal.pl.common.save": "Save",
"journal.pl.common.students": "students",
"journal.pl.common.teachers": "teachers",
"journal.pl.common.date": "Date",
"journal.pl.common.lessonName": "Lesson Name",
"journal.pl.common.name": "Name",
"journal.pl.common.noData": "No data",
"journal.pl.common.of": "of",
"journal.pl.common.required": "This field is required",
"journal.pl.common.error": "Error",
"journal.pl.common.error.something": "Something went wrong",
"journal.pl.common.create": "Create",
"journal.pl.common.requiredField": "This field is required",
"journal.pl.common.startDate": "Start Date",
"journal.pl.common.selectDateTime": "Select Date and Time",
"journal.pl.common.sending": "Sending",
"journal.pl.common.restored": "Restore",
"journal.pl.common.cancel": "Cancel",
"journal.pl.common.journal": "Journal",
"journal.pl.common.course": "Course",
"journal.pl.common.lesson": "Lessons",
"journal.pl.common.marked": "Marked",
"journal.pl.common.people": "people",
"journal.pl.common.success": "Success",
"journal.pl.common.open": "Open",
"journal.pl.common.loading": "Loading",
"journal.pl.course.defaultName": "Title",
"journal.pl.course.sending": "Sending",
"journal.pl.course.created": "Course created",
"journal.pl.course.successMessage": "Course {{name}} successfully created",
"journal.pl.course.createTitle": "Create Course",
"journal.pl.course.specifyStartDate": "Specify the course start date",
"journal.pl.course.newLectureName": "New lecture name",
"journal.pl.course.namePlaceholder": "KFU-24-2",
"journal.pl.course.startDate": "Course start date",
"journal.pl.course.lessonCount": "Number of lessons",
"journal.pl.course.attendancePage": "Go to lessons page",
"journal.pl.course.attendance": "Attendance",
"journal.pl.course.details": "Details",
"journal.pl.course.viewDetails": "View details",
"journal.pl.course.progress": "Course progress",
"journal.pl.course.completedLessons": "Completed lessons",
"journal.pl.course.upcomingLessons": "Upcoming lessons",
"journal.pl.course.noCourses": "No courses available",
"journal.pl.lesson.created": "Lesson created",
"journal.pl.lesson.successMessage": "Lesson {{name}} successfully created",
"journal.pl.lesson.updated": "Lesson updated",
"journal.pl.lesson.updateMessage": "Lesson {{name}} successfully updated",
"journal.pl.lesson.createTitle": "Create Lesson",
"journal.pl.lesson.editTitle": "Edit Lesson",
"journal.pl.lesson.deleteConfirm": "Delete lesson from {{date}}?",
"journal.pl.lesson.deleteWarning": "All attendance data for this lesson will be deleted",
"journal.pl.lesson.deletedMessage": "Deleted lesson {{name}}",
"journal.pl.lesson.noInternet": "No internet connection",
"journal.pl.lesson.topicTitle": "Lesson Topic:",
"journal.pl.lesson.dateFormat": "MMMM DD, YYYY",
"journal.pl.lesson.attendanceChart": "Lecture attendance chart",
"journal.pl.lesson.list": "Lesson List",
"journal.pl.lesson.link": "Link",
"journal.pl.lesson.time": "Time",
"journal.pl.lesson.action": "Actions",
"journal.pl.lesson.expand": "Expand lesson list",
"journal.pl.lesson.collapse": "Collapse lesson list",
"journal.pl.lesson.aiSuggested": "AI Suggested Lessons",
"journal.pl.lesson.aiSuggestedDescription": "These lessons were automatically generated based on your course schedule",
"journal.pl.lesson.createFromSuggestion": "Create",
"journal.pl.lesson.aiGenerated": "AI generated content",
"journal.pl.lesson.generatingAiSuggestions": "Generating AI lesson suggestions...",
"journal.pl.lesson.aiGenerationError": "Error generating AI suggestions",
"journal.pl.lesson.tryAgainLater": "An error occurred while generating lesson suggestions. Please try again later.",
"journal.pl.lesson.retryGeneration": "Retry Generation",
"journal.pl.lesson.reactions": "Reactions to the lesson:",
"journal.pl.lesson.noStudents": "No Students Yet",
"journal.pl.lesson.waitForStudents": "Students who attend the lesson will appear here",
"journal.pl.lesson.notMarked": "Not yet marked",
"journal.pl.reactions.thumbs_up": "Thumbs up",
"journal.pl.reactions.heart": "Heart",
"journal.pl.reactions.laugh": "Laugh",
"journal.pl.reactions.wow": "Wow",
"journal.pl.reactions.clap": "Clap",
"journal.pl.exam.title": "Exam",
"journal.pl.exam.startExam": "Start exam",
"journal.pl.exam.open": "Open",
"journal.pl.exam.notSpecified": "Not specified",
"journal.pl.exam.createWithJury": "Create exam with jury",
"journal.pl.exam.juryCount": "Number of jury members",
"journal.pl.access.expiredCode": "Failed to activate access code. Please try scanning the code again",
"journal.pl.attendance.stats.title": "Attendance Statistics",
"journal.pl.attendance.stats.totalLessons": "Total Lessons",
"journal.pl.attendance.stats.averageAttendance": "Average Attendance",
"journal.pl.attendance.stats.topStudents": "Top 3 Students by Attendance",
"journal.pl.attendance.stats.lowAttendance": "Students with Low Attendance",
"journal.pl.attendance.stats.noData": "No data",
"journal.pl.attendance.emojis.excellent": "Excellent attendance",
"journal.pl.attendance.emojis.good": "Good attendance",
"journal.pl.attendance.emojis.average": "Average attendance",
"journal.pl.attendance.emojis.poor": "Poor attendance",
"journal.pl.attendance.emojis.critical": "Critical attendance",
"journal.pl.attendance.emojis.none": "No attendance",
"journal.pl.attendance.table.copy": "Copy Table",
"journal.pl.attendance.table.show": "Show Table",
"journal.pl.attendance.table.hide": "Hide Table",
"journal.pl.attendance.table.fullscreen": "Fullscreen",
"journal.pl.attendance.table.exitFullscreen": "Exit Fullscreen",
"journal.pl.attendance.table.attendanceData": "Attendance Data",
"journal.pl.attendance.table.copySuccess": "Table copied",
"journal.pl.attendance.table.copySuccessDescription": "Table data successfully copied to clipboard",
"journal.pl.attendance.table.copyError": "Copy error",
"journal.pl.attendance.table.copyErrorDescription": "Failed to copy table data",
"journal.pl.attendance.addDialog.title": "Add Attendance Data",
"journal.pl.attendance.addDialog.lessonNamePlaceholder": "Enter lesson name",
"journal.pl.attendance.addDialog.selectStudents": "Select students who attended the lesson",
"journal.pl.attendance.addDialog.studentsPlaceholder": "Select students...",
"journal.pl.attendance.addDialog.studentsHelperText": "Select students who attended the lesson",
"journal.pl.attendance.addDialog.selectTeachers": "Select teachers who conducted the lesson",
"journal.pl.attendance.addDialog.teachersPlaceholder": "Select teachers...",
"journal.pl.attendance.addDialog.teachersHelperText": "Select teachers who conducted the lesson",
"journal.pl.attendance.emptyState.title": "No Attendance Data",
"journal.pl.attendance.emptyState.description": "Add lesson information and mark student attendance",
"journal.pl.userSelect.placeholder": "Select users...",
"journal.pl.userSelect.noOptions": "No users found",
"journal.pl.theme.switchDark": "Switch to dark theme",
"journal.pl.theme.switchLight": "Switch to light theme",
"journal.pl.lang.switchToEn": "Switch to English",
"journal.pl.lang.switchToRu": "Switch to Russian",
"journal.pl.serviceMenu.title": "BRO Services",
"journal.pl.serviceMenu.ariaLabel": "BRO Services",
"journal.pl.lesson.form.title": "New lesson title:",
"journal.pl.lesson.form.date": "Date",
"journal.pl.lesson.form.dateTime": "Specify date and time of the lesson",
"journal.pl.lesson.form.datePlaceholder": "Specify lesson date",
"journal.pl.lesson.form.namePlaceholder": "Lesson name",
"journal.pl.statistics.title": "Course Statistics",
"journal.pl.statistics.totalLessons": "Total Lessons",
"journal.pl.statistics.completed": "completed",
"journal.pl.statistics.attendanceRate": "Attendance Rate",
"journal.pl.statistics.totalStudents": "Total Students",
"journal.pl.statistics.perLesson": "per lesson",
"journal.pl.statistics.nextLesson": "Next Lesson",
"journal.pl.statistics.noUpcoming": "Not scheduled",
"journal.pl.statistics.in": "in",
"journal.pl.statistics.days": "days",
"journal.pl.statistics.courseProgress": "Course Progress",
"journal.pl.days.sunday": "Sunday",
"journal.pl.days.monday": "Monday",
"journal.pl.days.tuesday": "Tuesday",
"journal.pl.days.wednesday": "Wednesday",
"journal.pl.days.thursday": "Thursday",
"journal.pl.days.friday": "Friday",
"journal.pl.days.saturday": "Saturday",
"journal.pl.overview.title": "Courses Overview",
"journal.pl.overview.totalCourses": "Total Courses",
"journal.pl.overview.active": "active",
"journal.pl.overview.completed": "completed",
"journal.pl.overview.totalLessons": "Total Lessons",
"journal.pl.overview.upcoming": "upcoming",
"journal.pl.overview.totalStudents": "Total Students",
"journal.pl.overview.totalTeachers": "Total Teachers",
"journal.pl.overview.perCourse": "per course",
"journal.pl.overview.attendance": "attendance",
"journal.pl.overview.noAttendanceData": "no attendance data",
"journal.pl.overview.noActiveData": "no active courses",
"journal.pl.overview.courseTrends": "Course Trends",
"journal.pl.overview.newCourses": "New Courses",
"journal.pl.overview.olderCourses": "Older Courses",
"journal.pl.overview.last3Months": "in last 3 months",
"journal.pl.overview.beyondLast3Months": "created earlier than 3 months",
"journal.pl.overview.mostActiveDay": "Most Active Day",
"journal.pl.overview.activityStats": "Activity Statistics",
"journal.pl.overview.courseCompletion": "Course Completion",
"journal.pl.overview.studentAttendance": "Student Attendance",
"journal.pl.overview.averageRate": "Average Rate",
"journal.pl.overview.lessons": "lessons",
"journal.pl.overview.topStudents": "Top Students by Attendance",
"journal.pl.overview.topAttendanceCourses": "Courses with Best Attendance",
"journal.pl.overview.new": "new",
"journal.pl.overview.pastLessonsStats": "Statistics of past lessons",
"journal.pl.overview.dayOfWeekHelp": "Only statistics for completed lessons are shown",
"journal.pl.overview.attendanceHelp": "Attendance is calculated based on past lessons only",
"journal.pl.today": "Today",
"journal.pl.tomorrow": "Tomorrow",
"journal.pl.dayAfterTomorrow": "Day after tomorrow",
"journal.pl.days.morning": "Morning",
"journal.pl.days.day": "Day",
"journal.pl.days.evening": "Evening",
"journal.pl.lesson.form.selectTime": "Select time",
"journal.pl.lesson.existingLessonHint": "There is already a lesson on this day",
"journal.pl.lesson.form.selectDate": "Select date",
"journal.pl.days.shortMonday": "Mo",
"journal.pl.days.shortTuesday": "Tu",
"journal.pl.days.shortWednesday": "We",
"journal.pl.days.shortThursday": "Th",
"journal.pl.days.shortFriday": "Fr",
"journal.pl.days.shortSaturday": "Sa",
"journal.pl.days.shortSunday": "Su",
"journal.pl.months.january": "January",
"journal.pl.months.february": "February",
"journal.pl.months.march": "March",
"journal.pl.months.april": "April",
"journal.pl.months.may": "May",
"journal.pl.months.june": "June",
"journal.pl.months.july": "July",
"journal.pl.months.august": "August",
"journal.pl.months.september": "September",
"journal.pl.months.october": "October",
"journal.pl.months.november": "November",
"journal.pl.months.december": "December"
}

View File

@ -1,3 +1,239 @@
{
"": ""
"journal.pl.add": "Добавить",
"journal.pl.edit": "Редактировать",
"journal.pl.delete": "Удалить",
"journal.pl.save": "Сохранить",
"journal.pl.cancel": "Отменить",
"journal.pl.close": "Закрыть",
"journal.pl.title": "Журнал посещаемости",
"journal.pl.breadcrumbs.home": "Главная",
"journal.pl.breadcrumbs.course": "Курс",
"journal.pl.breadcrumbs.lesson": "Лекция",
"journal.pl.breadcrumbs.user": "Пользователь",
"journal.pl.breadcrumbs.attendance": "Посещаемость",
"journal.pl.common.students": "студентов",
"journal.pl.common.teachers": "преподавателей",
"journal.pl.common.noData": "Нет данных",
"journal.pl.common.date": "Дата",
"journal.pl.common.name": "Название",
"journal.pl.common.lessonName": "Название занятия",
"journal.pl.common.of": "из",
"journal.pl.common.required": "Обязательное поле",
"journal.pl.common.error": "Ошибка",
"journal.pl.common.error.something": "Что-то пошло не так",
"journal.pl.common.add": "Добавить",
"journal.pl.common.create": "Создать",
"journal.pl.common.requiredField": "Обязательное поле",
"journal.pl.common.startDate": "Дата начала",
"journal.pl.common.selectDateTime": "Выберите дату и время",
"journal.pl.common.sending": "Отправляем",
"journal.pl.common.restored": "Восстановить",
"journal.pl.common.cancel": "Отмена",
"journal.pl.common.journal": "Журнал",
"journal.pl.common.course": "Курс",
"journal.pl.common.lesson": "Лекций",
"journal.pl.common.marked": "Отмечено",
"journal.pl.common.people": "человек",
"journal.pl.common.success": "Успешно",
"journal.pl.common.open": "Открыть",
"journal.pl.common.loading": "Загрузка",
"journal.pl.course.defaultName": "Название",
"journal.pl.course.sending": "Отправляем",
"journal.pl.course.created": "Курс создан",
"journal.pl.course.successMessage": "Курс {{name}} успешно создан",
"journal.pl.course.createTitle": "Создание курса",
"journal.pl.course.specifyStartDate": "Укажите дату начала курса",
"journal.pl.course.newLectureName": "Название новой лекции",
"journal.pl.course.namePlaceholder": "КФУ-24-2",
"journal.pl.course.startDate": "Дата начала курса",
"journal.pl.course.lessonCount": "Количество занятий",
"journal.pl.course.attendancePage": "На страницу с лекциями",
"journal.pl.course.attendance": "Посещаемость",
"journal.pl.course.details": "Детали",
"journal.pl.course.viewDetails": "Просмотреть детали",
"journal.pl.course.progress": "Прогресс курса",
"journal.pl.course.completedLessons": "Завершено занятий",
"journal.pl.course.upcomingLessons": "Предстоящие занятия",
"journal.pl.course.noCourses": "Нет доступных курсов",
"journal.pl.lesson.created": "Лекция создана",
"journal.pl.lesson.successMessage": "Лекция {{name}} успешно создана",
"journal.pl.lesson.updated": "Лекция обновлена",
"journal.pl.lesson.updateMessage": "Лекция {{name}} успешно обновлена",
"journal.pl.lesson.createTitle": "Создание лекции",
"journal.pl.lesson.editTitle": "Редактирование лекции",
"journal.pl.lesson.deleteConfirm": "Удалить занятие от {{date}}?",
"journal.pl.lesson.deleteWarning": "Все данные о посещении данного занятия будут удалены",
"journal.pl.lesson.deletedMessage": "Удалена лекция {{name}}",
"journal.pl.lesson.noInternet": "Отсутствует интернет",
"journal.pl.lesson.topicTitle": "Тема занятия:",
"journal.pl.lesson.dateFormat": "DD MMMM YYYYг.",
"journal.pl.lesson.attendanceChart": "График посещаемости лекций",
"journal.pl.lesson.list": "Список занятий",
"journal.pl.lesson.link": "Ссылка",
"journal.pl.lesson.time": "Время",
"journal.pl.lesson.action": "Действия",
"journal.pl.lesson.expand": "Развернуть список занятий",
"journal.pl.lesson.collapse": "Свернуть список занятий",
"journal.pl.lesson.aiSuggested": "Рекомендации ИИ",
"journal.pl.lesson.aiSuggestedDescription": "Эти занятия были автоматически сгенерированы на основе вашего расписания курса",
"journal.pl.lesson.createFromSuggestion": "Создать",
"journal.pl.lesson.aiGenerated": "Сгенерировано ИИ",
"journal.pl.lesson.generatingAiSuggestions": "Генерация рекомендаций ИИ...",
"journal.pl.lesson.aiGenerationError": "Ошибка генерации рекомендаций ИИ",
"journal.pl.lesson.tryAgainLater": "Произошла ошибка при генерации рекомендаций для занятий. Пожалуйста, попробуйте позже.",
"journal.pl.lesson.retryGeneration": "Повторить генерацию",
"journal.pl.lesson.reactions": "Реакции на занятие:",
"journal.pl.lesson.noStudents": "Пока нет студентов",
"journal.pl.lesson.waitForStudents": "Студенты, посетившие занятие, появятся здесь",
"journal.pl.lesson.notMarked": "Не отмечен",
"journal.pl.reactions.thumbs_up": "Палец вверх",
"journal.pl.reactions.heart": "Сердце",
"journal.pl.reactions.laugh": "Смех",
"journal.pl.reactions.wow": "Вау",
"journal.pl.reactions.clap": "Аплодисменты",
"journal.pl.exam.title": "Экзамен",
"journal.pl.exam.startExam": "Начать экзамен",
"journal.pl.exam.open": "Открыть",
"journal.pl.exam.notSpecified": "Не задан",
"journal.pl.exam.createWithJury": "Создать экзамен с жюри",
"journal.pl.exam.juryCount": "Количество членов жюри",
"journal.pl.access.expiredCode": "Не удалось активировать код доступа. Попробуйте отсканировать код ещё раз",
"journal.pl.attendance.stats.title": "Статистика посещаемости",
"journal.pl.attendance.stats.totalLessons": "Всего занятий",
"journal.pl.attendance.stats.averageAttendance": "Средняя посещаемость",
"journal.pl.attendance.stats.topStudents": "Топ-3 студента по посещаемости",
"journal.pl.attendance.stats.lowAttendance": "Студенты с низкой посещаемостью",
"journal.pl.attendance.stats.noData": "Нет данных",
"journal.pl.attendance.emojis.excellent": "Отличная посещаемость",
"journal.pl.attendance.emojis.good": "Хорошая посещаемость",
"journal.pl.attendance.emojis.average": "Средняя посещаемость",
"journal.pl.attendance.emojis.poor": "Низкая посещаемость",
"journal.pl.attendance.emojis.critical": "Критическая посещаемость",
"journal.pl.attendance.emojis.none": "Нет посещений",
"journal.pl.attendance.table.copy": "Копировать таблицу",
"journal.pl.attendance.table.show": "Показать таблицу",
"journal.pl.attendance.table.hide": "Скрыть таблицу",
"journal.pl.attendance.table.fullscreen": "На весь экран",
"journal.pl.attendance.table.exitFullscreen": "Выйти из полноэкранного режима",
"journal.pl.attendance.table.attendanceData": "Данные о посещаемости",
"journal.pl.attendance.table.copySuccess": "Таблица скопирована",
"journal.pl.attendance.table.copySuccessDescription": "Данные таблицы успешно скопированы в буфер обмена",
"journal.pl.attendance.table.copyError": "Ошибка копирования",
"journal.pl.attendance.table.copyErrorDescription": "Не удалось скопировать данные таблицы",
"journal.pl.attendance.addDialog.title": "Добавить данные о посещаемости",
"journal.pl.attendance.addDialog.lessonNamePlaceholder": "Введите название занятия",
"journal.pl.attendance.addDialog.selectStudents": "Выберите студентов, присутствовавших на занятии",
"journal.pl.attendance.addDialog.studentsPlaceholder": "Выберите студентов...",
"journal.pl.attendance.addDialog.studentsHelperText": "Выберите студентов, которые присутствовали на занятии",
"journal.pl.attendance.addDialog.selectTeachers": "Выберите преподавателей, проводивших занятие",
"journal.pl.attendance.addDialog.teachersPlaceholder": "Выберите преподавателей...",
"journal.pl.attendance.addDialog.teachersHelperText": "Выберите преподавателей, которые проводили занятие",
"journal.pl.attendance.emptyState.title": "Нет данных о посещаемости",
"journal.pl.attendance.emptyState.description": "Добавьте информацию о занятиях и отметьте посещаемость студентов",
"journal.pl.userSelect.placeholder": "Выберите пользователей...",
"journal.pl.userSelect.noOptions": "Пользователи не найдены",
"journal.pl.theme.switchDark": "Переключить на темную тему",
"journal.pl.theme.switchLight": "Переключить на светлую тему",
"journal.pl.lang.switchToEn": "Переключить на английский",
"journal.pl.lang.switchToRu": "Переключить на русский",
"journal.pl.serviceMenu.title": "Сервисы BRO",
"journal.pl.serviceMenu.ariaLabel": "Сервисы BRO",
"journal.pl.lesson.form.title": "Название новой лекции:",
"journal.pl.lesson.form.date": "Дата",
"journal.pl.lesson.form.dateTime": "Укажите дату и время лекции",
"journal.pl.lesson.form.datePlaceholder": "Укажите дату лекции",
"journal.pl.lesson.form.namePlaceholder": "Название лекции",
"journal.pl.statistics.title": "Статистика курса",
"journal.pl.statistics.totalLessons": "Всего занятий",
"journal.pl.statistics.completed": "завершено",
"journal.pl.statistics.attendanceRate": "Посещаемость",
"journal.pl.statistics.totalStudents": "Всего студентов",
"journal.pl.statistics.perLesson": "на занятие",
"journal.pl.statistics.nextLesson": "Следующее занятие",
"journal.pl.statistics.noUpcoming": "Не запланировано",
"journal.pl.statistics.in": "через",
"journal.pl.statistics.days": "дн.",
"journal.pl.statistics.courseProgress": "Прогресс курса",
"journal.pl.days.sunday": "Воскресенье",
"journal.pl.days.monday": "Понедельник",
"journal.pl.days.tuesday": "Вторник",
"journal.pl.days.wednesday": "Среда",
"journal.pl.days.thursday": "Четверг",
"journal.pl.days.friday": "Пятница",
"journal.pl.days.saturday": "Суббота",
"journal.pl.overview.title": "Обзор всех курсов",
"journal.pl.overview.totalCourses": "Всего курсов",
"journal.pl.overview.active": "активных",
"journal.pl.overview.completed": "завершенных",
"journal.pl.overview.totalLessons": "Всего занятий",
"journal.pl.overview.upcoming": "предстоящих",
"journal.pl.overview.totalStudents": "Всего студентов",
"journal.pl.overview.totalTeachers": "Всего преподавателей",
"journal.pl.overview.perCourse": "на курс",
"journal.pl.overview.attendance": "посещаемость",
"journal.pl.overview.noAttendanceData": "нет данных о посещаемости",
"journal.pl.overview.noActiveData": "нет активных курсов",
"journal.pl.overview.courseTrends": "Тренды курсов",
"journal.pl.overview.newCourses": "Новые курсы",
"journal.pl.overview.olderCourses": "Старые курсы",
"journal.pl.overview.last3Months": "за последние 3 месяца",
"journal.pl.overview.beyondLast3Months": "созданы ранее 3 месяцев",
"journal.pl.overview.mostActiveDay": "Самый активный день недели",
"journal.pl.overview.activityStats": "Статистика активности",
"journal.pl.overview.courseCompletion": "Завершенность курсов",
"journal.pl.overview.studentAttendance": "Посещаемость студентов",
"journal.pl.overview.averageRate": "Средний показатель",
"journal.pl.overview.lessons": "занятий",
"journal.pl.overview.topStudents": "Лучшие студенты по посещаемости",
"journal.pl.overview.topAttendanceCourses": "Курсы с лучшей посещаемостью",
"journal.pl.overview.new": "новых",
"journal.pl.overview.pastLessonsStats": "Статистика проведённых занятий",
"journal.pl.overview.dayOfWeekHelp": "Показана статистика только состоявшихся занятий",
"journal.pl.overview.attendanceHelp": "Посещаемость рассчитана только по прошедшим занятиям",
"journal.pl.today": "Сегодня",
"journal.pl.tomorrow": "Завтра",
"journal.pl.dayAfterTomorrow": "Послезавтра",
"journal.pl.days.morning": "Утро",
"journal.pl.days.day": "День",
"journal.pl.days.evening": "Вечер",
"journal.pl.lesson.form.selectTime": "Выберите время",
"journal.pl.lesson.existingLessonHint": "В этот день уже есть лекция",
"journal.pl.lesson.form.selectDate": "Выберите дату",
"journal.pl.days.shortMonday": "Пн",
"journal.pl.days.shortTuesday": "Вт",
"journal.pl.days.shortWednesday": "Ср",
"journal.pl.days.shortThursday": "Чт",
"journal.pl.days.shortFriday": "Пт",
"journal.pl.days.shortSaturday": "Сб",
"journal.pl.days.shortSunday": "Вс",
"journal.pl.months.january": "Январь",
"journal.pl.months.february": "Февраль",
"journal.pl.months.march": "Март",
"journal.pl.months.april": "Апрель",
"journal.pl.months.may": "Май",
"journal.pl.months.june": "Июнь",
"journal.pl.months.july": "Июль",
"journal.pl.months.august": "Август",
"journal.pl.months.september": "Сентябрь",
"journal.pl.months.october": "Октябрь",
"journal.pl.months.november": "Ноябрь",
"journal.pl.months.december": "Декабрь"
}

116
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "journal.pl",
"version": "3.6.8",
"version": "3.16.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "journal.pl",
"version": "3.6.8",
"version": "3.16.4",
"license": "MIT",
"dependencies": {
"@brojs/cli": "^1.8.4",
@ -28,8 +28,10 @@
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
"react-hook-form": "^7.51.2",
"react-icons": "^5.5.0",
"react-redux": "^9.1.0",
"react-router-dom": "^6.22.1",
"react-select": "^5.10.1",
"redux": "^5.0.1",
"ts-node": "^10.9.2",
"typescript": "^5.3.3",
@ -2119,6 +2121,31 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@ -2915,6 +2942,15 @@
"@types/react": "*"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@ -4854,6 +4890,16 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -7672,6 +7718,12 @@
"node": ">= 4.0.0"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
@ -8894,6 +8946,15 @@
}
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@ -9008,6 +9069,27 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-select": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.1.tgz",
"integrity": "sha512-roPEZUL4aRZDx6DcsD+ZNreVl+fM8VsKn0Wtex1v4IazH60ILp5xhdlp464IsEAlJdXeD+BhDAFsBVMfvLQueA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.0",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.8.1",
"@floating-ui/dom": "^1.0.1",
"@types/react-transition-group": "^4.4.0",
"memoize-one": "^6.0.0",
"prop-types": "^15.6.0",
"react-transition-group": "^4.3.0",
"use-isomorphic-layout-effect": "^1.2.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-side-effect": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz",
@ -9039,6 +9121,22 @@
}
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -10539,6 +10637,20 @@
}
}
},
"node_modules/use-isomorphic-layout-effect": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz",
"integrity": "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "journal.pl",
"version": "3.6.8",
"version": "3.16.4",
"description": "bro-js platform journal ui repo",
"main": "./src/index.tsx",
"scripts": {
@ -44,8 +44,10 @@
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
"react-hook-form": "^7.51.2",
"react-icons": "^5.5.0",
"react-redux": "^9.1.0",
"react-router-dom": "^6.22.1",
"react-select": "^5.10.1",
"redux": "^5.0.1",
"ts-node": "^10.9.2",
"typescript": "^5.3.3",

View File

@ -68,6 +68,11 @@ export const api = createApi({
query: (courseId) => `/lesson/list/${courseId}`,
providesTags: ['LessonList'],
}),
generateLessons: builder.mutation<BaseResponse<{ date: string; name: string }[]>, string>({
query: (courseId) => `/lesson/${courseId}/ai/generate-lessons`,
}),
createLesson: builder.mutation<
BaseResponse<Lesson>,
Partial<Lesson> & Pick<Lesson, 'name' | 'date'> & { courseId: string }
@ -117,6 +122,15 @@ export const api = createApi({
method: 'GET',
}),
}),
sendReaction: builder.mutation<void, { lessonId: string; reaction: string }>({
query: ({ lessonId, reaction }) => ({
url: `/lesson/reaction/${lessonId}`,
method: 'POST',
body: { reaction },
}),
}),
getCourseById: builder.query<PopulatedCourse, string>({
query: (courseId) => `/course/${courseId}`,
transformResponse: (response: BaseResponse<PopulatedCourse>) => response.body,

View File

@ -49,9 +49,17 @@ export type BaseResponse<Data> = {
body: Data;
};
export interface Reaction {
_id: string;
sub: string;
reaction: string;
}
export interface Lesson {
id: string;
_id: string;
name: string;
studentReactions: Reaction[];
students: User[];
teachers: Teacher[];
date: string;

View File

@ -2,27 +2,41 @@ import React from 'react';
import { Helmet } from 'react-helmet';
import { Global } from '@emotion/react'
import { BrowserRouter } from 'react-router-dom';
import ruLocale from 'dayjs/locale/ru';
import dayjs from 'dayjs';
import { ChakraProvider } from '@chakra-ui/react'
import dayjs from './utils/dayjs-config';
import { useTranslation } from 'react-i18next';
import { ChakraProvider, ColorModeScript, extendTheme } from '@chakra-ui/react'
import { Dashboard } from './dashboard';
import { globalStyles } from './global.styles';
dayjs.locale('ru', ruLocale);
// Расширяем тему Chakra UI
const theme = extendTheme({
config: {
initialColorMode: 'light',
useSystemColorMode: false,
},
})
const App = ({ store }) => (
<ChakraProvider>
interface AppProps {
store: any; // Тип для store зависит от конкретной реализации хранилища
}
const App: React.FC<AppProps> = ({ store }) => {
const { t } = useTranslation();
return (
<ChakraProvider theme={theme}>
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
<BrowserRouter>
<Helmet>
<meta name="viewport" content="width=device-width, user-scalable=no" />
<title>Журнал</title>
<title>{t('journal.pl.title')}</title>
</Helmet>
<Global styles={globalStyles} />
<Dashboard store={store} />
</BrowserRouter>
</ChakraProvider>
)
}
export default App;

View File

@ -0,0 +1,275 @@
import React from 'react';
import {
Box,
Flex,
IconButton,
useColorMode,
Button,
HStack,
VStack,
Container,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Heading,
useBreakpointValue,
Text,
Tooltip,
useMediaQuery,
} from '@chakra-ui/react';
import { MoonIcon, SunIcon, ChevronRightIcon, InfoIcon, ChevronDownIcon } from '@chakra-ui/icons';
import { useTranslation } from 'react-i18next';
import { Link, useLocation } from 'react-router-dom';
import { getNavigationValue } from '@brojs/cli';
interface AppHeaderProps {
serviceMenuContainerRef?: React.RefObject<HTMLDivElement>;
breadcrumbs?: Array<{
title: string;
path?: string;
isCurrentPage?: boolean;
}>;
}
export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderProps) => {
const { colorMode, toggleColorMode } = useColorMode();
const { t, i18n } = useTranslation();
const location = useLocation();
// Получаем путь к главной странице
const mainPagePath = getNavigationValue('journal.main');
// Функция для формирования правильного пути с учетом mainPagePath
const getFullPath = (path?: string): string => {
if (!path) return '#';
if (path === '/') return mainPagePath;
// Если путь уже начинается с mainPagePath, оставляем как есть
if (path.startsWith(mainPagePath)) return path;
// Если путь начинается со слеша, добавляем mainPagePath
if (path.startsWith('/')) return `${mainPagePath}${path}`;
// Иначе просто объединяем пути
return `${mainPagePath}/${path}`;
};
// Определяем размеры для разных устройств
const fontSize = useBreakpointValue({ base: 'xs', sm: 'xs', md: 'sm' });
// Проверяем, на каком устройстве находимся
const [isLargerThan768] = useMediaQuery("(min-width: 768px)");
const [isLargerThan480] = useMediaQuery("(min-width: 480px)");
// Вертикальное отображение на мобильных устройствах
const isMobile = !isLargerThan480;
// Горизонтальный сепаратор для десктопов
const horizontalSeparator = useBreakpointValue({
sm: <ChevronRightIcon color="gray.400" fontSize="xs" />,
md: <ChevronRightIcon color="gray.400" />
});
const toggleLanguage = () => {
const newLang = i18n.language === 'ru' ? 'en' : 'ru';
i18n.changeLanguage(newLang);
};
return (
<Box
as="header"
width="100%"
py={{ base: 2, md: 3 }}
bg={colorMode === 'light' ? 'white' : 'gray.800'}
boxShadow="sm"
position="sticky"
top={0}
zIndex={10}
>
{/* Рендеринг dots контейнера вне условной логики, всегда присутствует в DOM */}
{serviceMenuContainerRef && (
<Box
id="dots"
ref={serviceMenuContainerRef}
position="absolute"
top="3"
left="0"
height="0"
width="0"
overflow="visible"
/>
)}
<Container maxW="container.xl" px={{ base: 2, sm: 4 }}>
{isMobile ? (
<>
{/* Мобильная версия: верхняя строка с кнопками */}
<Flex justifyContent="space-between" alignItems="center" h={{ base: "40px" }}>
<Box>
{/* Пустой контейнер для поддержания расположения */}
</Box>
<HStack spacing={{ base: 1 }} flexShrink={0}>
<Button
onClick={toggleLanguage}
size="sm"
variant="ghost"
aria-label={i18n.language === 'ru'
? t('journal.pl.lang.switchToEn')
: t('journal.pl.lang.switchToRu')
}
fontSize={fontSize}
px={{ base: 1 }}
minW={{ base: "30px" }}
h={{ base: "30px" }}
>
{i18n.language === 'ru' ? 'EN' : 'RU'}
</Button>
<IconButton
aria-label={colorMode === 'light'
? t('journal.pl.theme.switchDark')
: t('journal.pl.theme.switchLight')
}
icon={colorMode === 'light' ? <MoonIcon boxSize={{ base: "14px" }} /> : <SunIcon boxSize={{ base: "14px" }} />}
onClick={toggleColorMode}
variant="ghost"
size={{ base: "sm" }}
minW={{ base: "30px" }}
h={{ base: "30px" }}
/>
</HStack>
</Flex>
{/* Вертикальные хлебные крошки */}
{breadcrumbs && breadcrumbs.length > 0 && (
<VStack
align="flex-start"
spacing={0}
mt={1}
>
{breadcrumbs.map((crumb, index) => (
<Flex
key={index}
align="center"
w="100%"
pl={index > 0 ? 3 : 1}
py={1}
borderRadius="md"
_hover={!crumb.isCurrentPage && crumb.path ? {
bg: colorMode === 'light' ? 'gray.50' : 'gray.700',
} : {}}
>
{index > 0 && (
<ChevronDownIcon
color="gray.400"
fontSize="10px"
mr={2}
transform="translateY(-2px)"
/>
)}
{crumb.path && !crumb.isCurrentPage ? (
<Link to={getFullPath(crumb.path)}>
<Text
fontWeight="medium"
color={undefined}
fontSize={fontSize}
noOfLines={1}
title={crumb.title}
>
{crumb.title}
</Text>
</Link>
) : (
<Text
fontWeight={crumb.isCurrentPage ? "bold" : "medium"}
color={crumb.isCurrentPage ? (colorMode === 'light' ? 'cyan.600' : 'cyan.300') : undefined}
fontSize={fontSize}
noOfLines={1}
title={crumb.title}
>
{crumb.title}
</Text>
)}
</Flex>
))}
</VStack>
)}
</>
) : (
/* Десктопная версия: всё в одну строку */
<Flex justifyContent="space-between" alignItems="center" h="40px">
<Flex align="center" overflow="hidden" flex={1} minW={0}>
{/* Контейнер для разметки */}
<Box w="24px" mr={{ sm: 3, md: 4 }} flexShrink={0} />
{breadcrumbs && breadcrumbs.length > 0 && (
<Breadcrumb
fontWeight="medium"
fontSize={fontSize}
separator={horizontalSeparator}
spacing={{ sm: "1" }}
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
minW={0}
>
{breadcrumbs.map((crumb, index) => (
<BreadcrumbItem key={index} isCurrentPage={crumb.isCurrentPage}>
<BreadcrumbLink
as={crumb.path ? Link : undefined}
to={getFullPath(crumb.path)}
href={!crumb.path ? "#" : undefined}
maxWidth={{ sm: "120px", md: "200px", lg: "300px" }}
overflow="hidden"
textOverflow="ellipsis"
display="inline-block"
fontWeight={crumb.isCurrentPage ? "bold" : "medium"}
color={crumb.isCurrentPage ? (colorMode === 'light' ? 'cyan.600' : 'cyan.300') : undefined}
title={crumb.title}
>
{crumb.title}
</BreadcrumbLink>
</BreadcrumbItem>
))}
</Breadcrumb>
)}
</Flex>
<HStack spacing={{ sm: 2, md: 4 }} flexShrink={0} ml={{ sm: 2, md: 3 }}>
<Button
onClick={toggleLanguage}
size="sm"
variant="ghost"
aria-label={i18n.language === 'ru'
? t('journal.pl.lang.switchToEn')
: t('journal.pl.lang.switchToRu')
}
fontSize={fontSize}
px={{ sm: 2, md: 3 }}
minW={{ sm: "40px" }}
h={{ sm: "34px" }}
>
{i18n.language === 'ru' ? 'EN' : 'RU'}
</Button>
<IconButton
aria-label={colorMode === 'light'
? t('journal.pl.theme.switchDark')
: t('journal.pl.theme.switchLight')
}
icon={colorMode === 'light' ? <MoonIcon boxSize={{ sm: "14px", md: "16px" }} /> : <SunIcon boxSize={{ sm: "14px", md: "16px" }} />}
onClick={toggleColorMode}
variant="ghost"
size={{ sm: "sm", md: "md" }}
minW={{ sm: "34px" }}
h={{ sm: "34px" }}
/>
</HStack>
</Flex>
)}
</Container>
</Box>
);
};

View File

@ -0,0 +1 @@
export { AppHeader } from './app-header';

View File

@ -0,0 +1,45 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
export type Breadcrumb = {
title: string;
path?: string;
isCurrentPage?: boolean;
};
type BreadcrumbsContextType = {
breadcrumbs: Breadcrumb[];
setBreadcrumbs: (breadcrumbs: Breadcrumb[]) => void;
};
const BreadcrumbsContext = createContext<BreadcrumbsContextType | undefined>(undefined);
export const BreadcrumbsProvider: React.FC<{children: ReactNode}> = ({ children }) => {
const [breadcrumbs, setBreadcrumbs] = useState<Breadcrumb[]>([]);
return (
<BreadcrumbsContext.Provider value={{ breadcrumbs, setBreadcrumbs }}>
{children}
</BreadcrumbsContext.Provider>
);
};
export const useBreadcrumbs = () => {
const context = useContext(BreadcrumbsContext);
if (context === undefined) {
throw new Error('useBreadcrumbs must be used within a BreadcrumbsProvider');
}
return context;
};
export const useSetBreadcrumbs = (newBreadcrumbs: Breadcrumb[]) => {
const { setBreadcrumbs } = useBreadcrumbs();
React.useEffect(() => {
setBreadcrumbs(newBreadcrumbs);
return () => {
// При размонтировании компонента очищаем хлебные крошки
setBreadcrumbs([]);
};
}, [setBreadcrumbs, JSON.stringify(newBreadcrumbs)]);
};

View File

@ -0,0 +1 @@
export * from './breadcrumbs-context';

View File

@ -1,5 +1,18 @@
import { Alert } from '@chakra-ui/react'
import React from 'react'
import { useTranslation } from 'react-i18next'
// Компонент-обертка для использования хука useTranslation внутри классового компонента
const ErrorMessage = ({ error }: { error: string | null }) => {
const { t } = useTranslation()
return (
<Alert status="error" title={t('journal.pl.common.error')}>
{t('journal.pl.common.error.something')}<br />
{error && <span>{error}</span>}
</Alert>
)
}
export class ErrorBoundary extends React.Component<
React.PropsWithChildren,
@ -13,12 +26,7 @@ export class ErrorBoundary extends React.Component<
render() {
if (this.state.hasError) {
return (
<Alert status="error" title="Ошибка">
Что-то пошло не так<br />
{this.state.error && <span>{this.state.error}</span>}
</Alert>
)
return <ErrorMessage error={this.state.error} />
}
return this.props.children

5
src/components/index.ts Normal file
View File

@ -0,0 +1,5 @@
export { PageLoader } from './page-loader/page-loader';
export { XlSpinner } from './xl-spinner/xl-spinner';
export { ErrorBoundary } from './error-boundary';
export { AppHeader } from './app-header';
export { BreadcrumbsProvider, useBreadcrumbs, useSetBreadcrumbs } from './breadcrumbs';

View File

@ -3,18 +3,23 @@ import {
Spinner,
Container,
Center,
useColorMode
} from '@chakra-ui/react'
export const PageLoader = () => (
<Container maxW="container.xl">
<Center h="300px">
<Spinner
thickness="4px"
speed="0.65s"
emptyColor="gray.200"
color="blue.500"
size="xl"
/>
</Center>
</Container>
)
export const PageLoader = () => {
const { colorMode } = useColorMode();
return (
<Container maxW="container.xl">
<Center h="300px">
<Spinner
thickness="4px"
speed="0.65s"
emptyColor={colorMode === 'light' ? 'gray.200' : 'gray.600'}
color={colorMode === 'light' ? 'blue.500' : 'blue.300'}
size="xl"
/>
</Center>
</Container>
)
}

View File

@ -1,26 +1,96 @@
import styled from '@emotion/styled'
import { css, keyframes } from '@emotion/react'
export const Avatar = styled.img`
width: 96px;
height: 96px;
margin: 0 auto;
border-radius: 6px;
// Правильное определение анимации с помощью keyframes
const fadeIn = keyframes`
from {
opacity: 0;
}
to {
opacity: 1;
}
`
export const Wrapper = styled.div<{ warn?: boolean; width?: string | number }>`
list-style: none;
background-color: #ffffff;
padding: 16px;
const pulse = keyframes`
0% {
box-shadow: 0 0 0 0 rgba(72, 187, 120, 0.4);
}
70% {
box-shadow: 0 0 0 10px rgba(72, 187, 120, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(72, 187, 120, 0);
}
`
export const Avatar = styled.img`
width: 100%;
height: 100%;
border-radius: 12px;
box-shadow: 2px 2px 6px #0000005c;
transition: all 0.5;
object-fit: cover;
transition: transform 0.3s ease;
`
export const NameOverlay = styled.div`
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: white;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
font-size: 14px;
font-weight: 500;
text-align: center;
opacity: 0.9;
transition: opacity 0.3s ease;
.chakra-ui-dark & {
background: linear-gradient(transparent, rgba(0, 0, 0, 0.8));
}
`
// Стили без интерполяций компонентов
export const Wrapper = styled.div<{ warn?: boolean; width?: string | number; position?: string }>`
list-style: none;
position: relative;
width: 180px;
min-height: 190px;
max-height: 200px;
margin-right: 12px;
padding-bottom: 22px;
border-radius: 12px;
width: 100%;
aspect-ratio: 1;
overflow: hidden;
cursor: pointer;
animation: ${fadeIn} 0.5s ease;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
&:hover {
transform: translateY(-5px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
}
&:hover img {
transform: scale(1.05);
}
&:hover > div:last-of-type:not(button) {
opacity: 1;
}
&.recent {
animation: ${pulse} 1.5s infinite;
border: 2px solid var(--chakra-colors-green-400);
}
.chakra-ui-dark & {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
&.recent {
border: 2px solid var(--chakra-colors-green-300);
}
}
${({ width }) =>
width
? css`
@ -28,12 +98,18 @@ export const Wrapper = styled.div<{ warn?: boolean; width?: string | number }>`
`
: ''}
${({ position }) =>
position
? css`
position: ${position};
`
: ''}
${(props) =>
props.warn
? css`
background-color: #000000;
opacity: 0.7;
color: #e4e4e4;
filter: grayscale(0.8);
`
: ''}
`
@ -41,13 +117,27 @@ export const Wrapper = styled.div<{ warn?: boolean; width?: string | number }>`
export const AddMissedButton = styled.button`
position: absolute;
bottom: 8px;
right: 12px;
right: 8px;
border: none;
background-color: #00000000;
opacity: 0.2;
background-color: var(--chakra-colors-blue-500);
color: white;
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
opacity: 0.8;
transition: opacity 0.3s ease, transform 0.3s ease;
:hover {
&:hover {
cursor: pointer;
opacity: 1;
transform: scale(1.1);
}
`
.chakra-ui-dark & {
background-color: var(--chakra-colors-blue-400);
}
`

View File

@ -1,9 +1,23 @@
import React from 'react'
import { sha256 } from 'js-sha256'
import { useState, useEffect, useRef } from 'react'
import { Box, useColorMode, Text } from '@chakra-ui/react'
import { CheckCircleIcon, AddIcon } from '@chakra-ui/icons'
import { motion, AnimatePresence } from 'framer-motion'
import { useTranslation } from 'react-i18next'
import { User } from '../../__data__/model'
import { Reaction, User } from '../../__data__/model'
import { AddMissedButton, Avatar, Wrapper } from './style'
import { AddMissedButton, Avatar, Wrapper, NameOverlay } from './style'
// Map of reaction types to emojis
const REACTION_EMOJIS = {
thumbs_up: '👍',
heart: '❤️',
laugh: '😂',
wow: '😮',
clap: '👏'
}
export function getGravatarURL(email, user) {
if (!email) return void 0
@ -16,32 +30,118 @@ export function getGravatarURL(email, user) {
export const UserCard = ({
student,
present,
onAddUser,
wrapperAS,
width
onAddUser = undefined,
wrapperAS = 'div',
width,
recentlyPresent = false,
reaction
}: {
student: User
present: boolean
width?: string | number
onAddUser?: (user: User) => void
wrapperAS?: React.ElementType<any, keyof React.JSX.IntrinsicElements>;
wrapperAS?: React.ElementType<any, keyof React.JSX.IntrinsicElements>
recentlyPresent?: boolean
reaction?: Reaction
}) => {
const { colorMode } = useColorMode();
const { t } = useTranslation();
const [imageError, setImageError] = useState(false);
const [showReaction, setShowReaction] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Обрабатываем изменение реакции
useEffect(() => {
if (reaction) {
setShowReaction(true);
// Очищаем предыдущий таймер если он есть
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Устанавливаем новый таймер
timeoutRef.current = setTimeout(() => {
setShowReaction(false);
timeoutRef.current = null;
}, 3000);
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [reaction]);
return (
<Wrapper warn={!present} as={wrapperAS} width={width}>
<Avatar src={student.picture || getGravatarURL(student.email, null)} />
<p style={{ marginTop: 6 }}>
{student.name || student.preferred_username}{' '}
</p>
<Wrapper
warn={!present}
as={wrapperAS}
width={width}
className={!present ? 'warn' : recentlyPresent ? 'recent' : ''}
position="relative"
>
<Avatar
src={imageError ? getGravatarURL(student.email, null) : (student.picture || getGravatarURL(student.email, null))}
onError={() => {
if (!imageError && student.picture) {
setImageError(true);
}
}}
/>
<NameOverlay>
{student.name || student.preferred_username}
{present && (
<Box as="span" ml={2} display="inline-block" color={recentlyPresent ? "green.100" : "green.300"}>
<CheckCircleIcon boxSize={3} />
</Box>
)}
</NameOverlay>
{onAddUser && !present && (
<AddMissedButton onClick={() => onAddUser(student)}>
add
<AddMissedButton onClick={() => onAddUser(student)} aria-label={t('journal.pl.common.add')}>
<AddIcon boxSize={3} />
</AddMissedButton>
)}
{/* Анимация реакции */}
<AnimatePresence>
{showReaction && reaction && (
<motion.div
key={reaction._id}
initial={{ opacity: 0, scale: 0.5, y: 0 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.5, y: -20 }}
transition={{ duration: 0.5 }}
style={{
position: 'absolute',
top: '10px',
right: '10px',
zIndex: 10,
pointerEvents: 'none',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: colorMode === 'light' ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.6)',
borderRadius: '50%',
padding: '2px',
boxShadow: '0 2px 5px rgba(0,0,0,0.2)'
}}
title={t(`journal.pl.reactions.${reaction.reaction}`)}
>
<Text
fontSize="3xl"
sx={{
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))',
transform: 'scale(1.2)',
display: 'flex'
}}
>
{REACTION_EMOJIS[reaction.reaction] || reaction.reaction}
</Text>
</motion.div>
)}
</AnimatePresence>
</Wrapper>
)
}
UserCard.defaultProps = {
wrapperAS: 'div',
onAddUser: void 0,
}

View File

@ -0,0 +1,165 @@
import React, { useEffect, useState } from 'react';
import Select, { components } from 'react-select';
import { Avatar, HStack, Box, Text, useColorMode } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import { getGravatarURL } from '../../utils/gravatar';
// Кастомный компонент для отображения опций с аватарами
const Option = ({ children, ...props }: any) => {
const { email, picture, value } = props.data;
const avatarUrl = picture || getGravatarURL(email);
return (
<components.Option {...props}>
<HStack spacing={2}>
<Avatar size="xs" src={avatarUrl} name={value} />
<span>{children}</span>
</HStack>
</components.Option>
);
};
// Кастомный компонент для отображения выбранных значений с аватарами
const SingleValue = ({ children, ...props }: any) => {
const { email, picture, value } = props.data;
const avatarUrl = picture || getGravatarURL(email);
return (
<components.SingleValue {...props}>
<HStack spacing={2}>
<Avatar size="xs" src={avatarUrl} name={value} />
<span>{children}</span>
</HStack>
</components.SingleValue>
);
};
// Кастомный компонент для отображения множественных выбранных значений с аватарами
const MultiValue = ({ children, ...props }: any) => {
const { email, picture, value } = props.data;
const avatarUrl = picture || getGravatarURL(email);
return (
<components.MultiValue {...props}>
<HStack spacing={2}>
<Avatar size="xs" src={avatarUrl} name={value} />
<span>{children}</span>
</HStack>
</components.MultiValue>
);
};
interface UserSelectProps {
isMulti?: boolean;
value: any;
onChange: (value: any) => void;
placeholder?: string;
}
interface MockUserData {
value: string;
label: string;
email: string;
sub: string;
}
const UserSelect = ({ isMulti = false, value, onChange, placeholder }: UserSelectProps) => {
const { colorMode } = useColorMode();
const { t, i18n } = useTranslation();
const [options, setOptions] = useState<MockUserData[]>([]);
useEffect(() => {
// В реальном приложении здесь будет запрос к API для получения списка пользователей
const mockUserData: MockUserData[] = [
{ value: 'Иван Иванов', label: 'Иван Иванов', email: 'ivan@example.com', sub: '1' },
{ value: 'Мария Петрова', label: 'Мария Петрова', email: 'maria@example.com', sub: '2' },
{ value: 'Алексей Сидоров', label: 'Алексей Сидоров', email: 'alexey@example.com', sub: '3' },
{ value: 'Екатерина Смирнова', label: 'Екатерина Смирнова', email: 'ekaterina@example.com', sub: '4' },
{ value: 'Дмитрий Козлов', label: 'Дмитрий Козлов', email: 'dmitry@example.com', sub: '5' },
{ value: 'Ольга Новикова', label: 'Ольга Новикова', email: 'olga@example.com', sub: '6' },
{ value: 'Сергей Морозов', label: 'Сергей Морозов', email: 'sergey@example.com', sub: '7' },
{ value: 'Анна Волкова', label: 'Анна Волкова', email: 'anna@example.com', sub: '8' },
{ value: 'Павел Соловьев', label: 'Павел Соловьев', email: 'pavel@example.com', sub: '9' },
{ value: 'Наталья Лебедева', label: 'Наталья Лебедева', email: 'natalia@example.com', sub: '10' },
];
// Mock data на английском языке
const mockEnUserData: MockUserData[] = [
{ value: 'John Smith', label: 'John Smith', email: 'john@example.com', sub: '1' },
{ value: 'Mary Johnson', label: 'Mary Johnson', email: 'mary@example.com', sub: '2' },
{ value: 'Alex Brown', label: 'Alex Brown', email: 'alex@example.com', sub: '3' },
{ value: 'Kate Williams', label: 'Kate Williams', email: 'kate@example.com', sub: '4' },
{ value: 'David Miller', label: 'David Miller', email: 'david@example.com', sub: '5' },
{ value: 'Olivia Jones', label: 'Olivia Jones', email: 'olivia@example.com', sub: '6' },
{ value: 'Steven Davis', label: 'Steven Davis', email: 'steven@example.com', sub: '7' },
{ value: 'Anna Wilson', label: 'Anna Wilson', email: 'anna_w@example.com', sub: '8' },
{ value: 'Paul Taylor', label: 'Paul Taylor', email: 'paul@example.com', sub: '9' },
{ value: 'Natalie Moore', label: 'Natalie Moore', email: 'natalie@example.com', sub: '10' },
];
setOptions(i18n.language === 'ru' ? mockUserData : mockEnUserData);
}, [i18n.language]);
const customStyles = {
control: (provided: any) => ({
...provided,
backgroundColor: colorMode === 'dark' ? '#2D3748' : 'white',
borderColor: colorMode === 'dark' ? '#4A5568' : '#E2E8F0',
}),
menu: (provided: any) => ({
...provided,
backgroundColor: colorMode === 'dark' ? '#2D3748' : 'white',
}),
option: (provided: any, state: any) => ({
...provided,
backgroundColor: state.isFocused
? colorMode === 'dark'
? '#4A5568'
: '#EDF2F7'
: colorMode === 'dark'
? '#2D3748'
: 'white',
color: colorMode === 'dark' ? 'white' : 'black',
}),
multiValue: (provided: any) => ({
...provided,
backgroundColor: colorMode === 'dark' ? '#4A5568' : '#EDF2F7',
}),
multiValueLabel: (provided: any) => ({
...provided,
color: colorMode === 'dark' ? 'white' : 'black',
}),
multiValueRemove: (provided: any) => ({
...provided,
color: colorMode === 'dark' ? 'white' : 'black',
':hover': {
backgroundColor: colorMode === 'dark' ? '#718096' : '#CBD5E0',
color: colorMode === 'dark' ? 'white' : 'black',
},
}),
singleValue: (provided: any) => ({
...provided,
color: colorMode === 'dark' ? 'white' : 'black',
}),
};
return (
<Select
options={options}
value={value}
onChange={onChange}
isMulti={isMulti}
components={{ Option, SingleValue, MultiValue }}
styles={customStyles}
placeholder={placeholder || t('journal.pl.userSelect.placeholder')}
noOptionsMessage={() => t('journal.pl.userSelect.noOptions')}
formatGroupLabel={(data) => (
<Box>
<Text fontWeight="bold">{data.label}</Text>
</Box>
)}
/>
);
};
export default UserSelect;

View File

@ -3,18 +3,27 @@ import {
Container,
Center,
Spinner,
useColorMode
} from '@chakra-ui/react'
export const XlSpinner = () => (
<Container maxW="container.xl">
<Center h="300px">
<Spinner
thickness="4px"
speed="0.65s"
emptyColor="gray.200"
color="blue.500"
size="xl"
/>
</Center>
</Container>
)
interface XlSpinnerProps {
size?: string;
}
export const XlSpinner: React.FC<XlSpinnerProps> = ({ size = 'xl' }) => {
const { colorMode } = useColorMode();
return (
<Container maxW="container.xl">
<Center h={size === 'sm' ? 'auto' : '300px'}>
<Spinner
thickness="4px"
speed="0.65s"
emptyColor={colorMode === 'light' ? 'gray.200' : 'gray.600'}
color={colorMode === 'light' ? 'blue.500' : 'blue.300'}
size={size}
/>
</Center>
</Container>
)
}

View File

@ -1,8 +1,9 @@
import React, { useEffect, Suspense } from 'react'
import React, { useEffect, Suspense, useRef, useState } from 'react'
import { Routes, Route, useNavigate } from 'react-router-dom'
import { Provider } from 'react-redux'
import { getNavigationsValue } from '@brojs/cli'
import { Box, Container, Spinner, VStack } from '@chakra-ui/react'
import { getNavigationValue } from '@brojs/cli'
import { Box, Container, Spinner, VStack, useColorMode } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import {
CourseListPage,
@ -11,7 +12,31 @@ import {
UserPage,
AttendancePage,
} from './pages'
import { ErrorBoundary } from './components/error-boundary'
import { ErrorBoundary, AppHeader, BreadcrumbsProvider, useBreadcrumbs } from './components'
import { keycloak } from './__data__/kc'
const MENU_SCRIPT_URL = 'https://admin.bro-js.ru/remote-assets/lib/serviceMenu/serviceMenu.js'
const loadServiceMenu = () => {
return new Promise((resolve, reject) => {
const script = document.createElement('script')
script.src = MENU_SCRIPT_URL
script.onload = () => setTimeout(() => resolve(true), 1000)
script.onerror = reject
document.body.appendChild(script)
})
}
declare global {
interface Window {
createServiceMenu?: (options: any) => {
show: () => void;
hide: () => void;
update: () => void;
destroy: () => void;
};
}
}
const Wrapper = ({ children }: { children: React.ReactElement }) => (
<Suspense
@ -37,49 +62,106 @@ const Wrapper = ({ children }: { children: React.ReactElement }) => (
</Suspense>
)
export const Dashboard = ({ store }) => (
<Provider store={store}>
<Routes>
<Route
path={getNavigationsValue('journal.main')}
element={
<Wrapper>
<CourseListPage />
</Wrapper>
// Компонент, который соединяет хлебные крошки с AppHeader
const HeaderWithBreadcrumbs = ({ serviceMenuContainerRef }: { serviceMenuContainerRef: React.RefObject<HTMLDivElement> }) => {
const { breadcrumbs } = useBreadcrumbs();
return <AppHeader serviceMenuContainerRef={serviceMenuContainerRef} breadcrumbs={breadcrumbs} />;
};
interface DashboardProps {
store: any; // Используем any, поскольку точный тип store не указан
}
export const Dashboard = ({ store }: DashboardProps) => {
const serviceMenuContainerRef = useRef<HTMLDivElement>(null);
const serviceMenuInstanceRef = useRef<any>(null);
const [serviceMenu, setServiceMenu] = useState(false);
const { colorMode } = useColorMode();
const { t } = useTranslation();
useEffect(() => {
loadServiceMenu().then(() => {
setServiceMenu(true)
}).catch(console.error)
}, [])
useEffect(() => {
// Проверяем, что библиотека загружена и есть контейнер для меню
if (window.createServiceMenu && serviceMenuContainerRef.current && serviceMenu) {
// Создаем меню сервисов
serviceMenuInstanceRef.current = window.createServiceMenu({
accessToken: keycloak.token,
apiUrl: 'https://admin.bro-js.ru',
targetElement: serviceMenuContainerRef.current,
styles: {
dotColor: colorMode === 'light' ? '#333' : '#ccc',
hoverColor: colorMode === 'light' ? '#eee' : '#444',
backgroundColor: colorMode === 'light' ? '#fff' : '#2D3748',
textColor: colorMode === 'light' ? '#333' : '#fff',
},
translations: {
menuTitle: t('journal.pl.serviceMenu.title'),
menuAriaLabel: t('journal.pl.serviceMenu.ariaLabel'),
}
/>
<Route
path={`${getNavigationsValue('journal.main')}/lessons-list/:courseId`}
element={
<Wrapper>
<LessonListPage />
</Wrapper>
}
/>
<Route
path={`${getNavigationsValue('journal.main')}/u/:lessonId/:accessId`}
element={
<Wrapper>
<UserPage />
</Wrapper>
}
/>
<Route
path={`${getNavigationsValue('journal.main')}/lesson/:courseId/:lessonId`}
element={
<Wrapper>
<LessonDetailsPage />
</Wrapper>
}
/>
<Route
path={`${getNavigationsValue('journal.main')}${getNavigationsValue('link.journal.attendance')}`}
element={
<Wrapper>
<AttendancePage />
</Wrapper>
}
/>
</Routes>
</Provider>
)
});
}
// Очистка при размонтировании
return () => {
if (serviceMenuInstanceRef.current) {
serviceMenuInstanceRef.current.destroy();
serviceMenuInstanceRef.current = null;
}
};
}, [keycloak.token, serviceMenu, colorMode, t]);
return (
<Provider store={store}>
<BreadcrumbsProvider>
<HeaderWithBreadcrumbs serviceMenuContainerRef={serviceMenuContainerRef} />
<Routes>
<Route
path={getNavigationValue('journal.main')}
element={
<Wrapper>
<CourseListPage />
</Wrapper>
}
/>
<Route
path={`${getNavigationValue('journal.main')}/lessons-list/:courseId`}
element={
<Wrapper>
<LessonListPage />
</Wrapper>
}
/>
<Route
path={`${getNavigationValue('journal.main')}/u/:lessonId/:accessId`}
element={
<Wrapper>
<UserPage />
</Wrapper>
}
/>
<Route
path={`${getNavigationValue('journal.main')}/lesson/:courseId/:lessonId`}
element={
<Wrapper>
<LessonDetailsPage />
</Wrapper>
}
/>
<Route
path={`${getNavigationValue('journal.main')}${getNavigationValue('link.journal.attendance')}`}
element={
<Wrapper>
<AttendancePage />
</Wrapper>
}
/>
</Routes>
</BreadcrumbsProvider>
</Provider>
)
}

View File

@ -30,6 +30,27 @@ body {
/* font-family: KiyosunaSans, Montserrat, RFKrabuler, sans-serif; */
font-weight: 600;
}
/* Стили для темной темы */
html[data-theme="dark"] body {
color: #fff;
background: radial-gradient(
farthest-side at bottom left,
rgba(23, 138, 3, 0.709),
rgba(0, 0, 0, 0) 65%
),
radial-gradient(
farthest-corner at bottom center,
rgba(155, 155, 7, 0.5),
rgba(0, 0, 0, 0) 40%
),
radial-gradient(
farthest-side at bottom right,
rgba(0, 116, 153, 0.6),
rgb(18, 25, 31) 65%
);
}
#app {
height: 100%;
overflow-y: auto;

View File

@ -1,11 +1,16 @@
/* eslint-disable react/display-name */
import React from 'react';
import ReactDOM from 'react-dom/client';
import i18next from 'i18next'
import { i18nextReactInitConfig } from '@brojs/cli';
import App from './app';
import { keycloak } from "./__data__/kc";
import { createStore } from "./__data__/store";
i18next.t = i18next.t.bind(i18next)
const i18nextPromise = i18nextReactInitConfig(i18next)
if(!module.hot) {
import('./ym');
}
@ -31,6 +36,7 @@ export const mount = async (Component, element = document.getElementById('app'))
keycloak.login()
}
const store = createStore({ user });
await i18nextPromise
rootElement = ReactDOM.createRoot(element);
rootElement.render(<Component store={store} />);

View File

@ -1,140 +1,70 @@
import React, { useMemo } from 'react'
import React from 'react'
import { useParams } from 'react-router-dom'
import { Box, Heading, Tooltip, Text } from '@chakra-ui/react'
import dayjs from 'dayjs'
import {
Box,
Heading,
Container,
useColorMode,
Flex,
Spacer,
Badge
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { api } from '../../__data__/api/api'
import { PageLoader } from '../../components/page-loader/page-loader'
import { useSetBreadcrumbs } from '../../components'
import { useAttendanceData, useAttendanceStats } from './hooks'
import { AttendanceTable, StatsCard } from './components'
export const Attendance = () => {
const { courseId } = useParams()
const { data: attendance, isLoading } = api.useLessonListQuery(courseId, {
selectFromResult: ({ data, isLoading }) => ({
data: data?.body,
isLoading,
}),
})
const { data: courseInfo, isLoading: courseInfoIssLoading } =
api.useGetCourseByIdQuery(courseId)
const data = useMemo(() => {
if (!attendance) return null
const studentsMap = new Map()
const teachersMap = new Map()
attendance.forEach((lesson) => {
lesson.teachers?.map((teacher: any) => {
teachersMap.set(teacher.sub, { id: teacher.sub, ...teacher, value: teacher.value || (teacher.family_name && teacher.given_name
? `${teacher.family_name} ${teacher.given_name}`
: teacher.name || teacher.email || teacher.preferred_username || teacher.family_name || teacher.given_name), })
})
lesson.students.forEach((student) => {
const current = studentsMap.get(student.sub) || {}
studentsMap.set(student.sub, {
...student,
id: student.sub,
value: current.value || (student.family_name && student.given_name
? `${student.family_name} ${student.given_name}`
: student.name || student.email || student.preferred_username || student.family_name || student.given_name),
})
})
})
const compare = Intl.Collator('ru').compare
const students = [...studentsMap.values()]
const taechers = [...teachersMap.values()]
students.sort(({ family_name: name }, { family_name: nname }) =>
compare(name, nname),
)
return {
students,
taechers,
const { colorMode } = useColorMode()
const { t } = useTranslation()
const data = useAttendanceData(courseId)
const stats = useAttendanceStats(data)
// Устанавливаем хлебные крошки
useSetBreadcrumbs([
{
title: t('journal.pl.breadcrumbs.home'),
path: '/'
},
{
title: data.courseInfo?.name || t('journal.pl.breadcrumbs.course'),
path: `/lessons-list/${courseId}`
},
{
title: t('journal.pl.breadcrumbs.attendance'),
isCurrentPage: true
}
}, [attendance])
])
if (!data || isLoading || courseInfoIssLoading) {
if (data.isLoading) {
return <PageLoader />
}
return (
<Box>
<Box mt={12} mb={12}>
<Heading>{courseInfo.name}</Heading>
</Box>
<Box>
<table>
<thead>
<tr>
{data.taechers.map(teacher => (
<th id={teacher.id} key={teacher.id}>{teacher.value}</th>
))}
<th>Дата</th>
<th>Название занятия</th>
{data.students.map((student) => (
<th id={student.id || student.sub} key={student.sub}>{student.name || student.value || 'Имя не определено'}</th>
))}
</tr>
</thead>
<tbody>
{attendance.map((lesson, index) => (
<tr key={lesson.name}>
{data?.taechers?.map((teacher) => {
<Container maxW="container.xl" p={1}>
<Flex alignItems="center" mb={6}>
<Box>
<Heading size="lg" mb={2}>{data.courseInfo?.name}</Heading>
<Badge colorScheme="blue">
{data.students.length} {t('journal.pl.common.students')} {data.teachers.length} {t('journal.pl.common.teachers')}
</Badge>
</Box>
<Spacer />
</Flex>
const wasThere = Boolean(lesson.teachers) &&
lesson?.teachers?.findIndex((u) => u.sub === teacher.sub) !== -1
return (
<td
style={{
textAlign: 'center',
backgroundColor: wasThere ? '#8ef78a' : '#e09797',
}}
key={teacher.sub}
>
{wasThere ? '+' : '-'}
</td>
)
})}
<td>{dayjs(lesson.date).format('DD.MM.YYYY')}</td>
<td>{<ShortText text={lesson.name} />}</td>
{data.students.map((st) => {
const wasThere =
lesson.students.findIndex((u) => u.sub === st.sub) !== -1
return (
<td
style={{
textAlign: 'center',
backgroundColor: wasThere ? '#8ef78a' : '#e09797',
}}
key={st.sub}
>
{wasThere ? '+' : '-'}
</td>
)
})}
</tr>
))}
</tbody>
</table>
<StatsCard stats={stats} />
<Box
bg={colorMode === 'dark' ? 'gray.800' : 'gray.50'}
p={4}
borderRadius="lg"
boxShadow="sm"
>
<AttendanceTable data={data} />
</Box>
</Box>
</Container>
)
}
const ShortText = ({ text }: { text: string }) => {
const needShortText = text.length > 20
if (needShortText) {
return (
<Tooltip label="На страницу с лекциями" fontSize="12px" top="16px">
<Text>{text.slice(0, 20)}...</Text>
</Tooltip>
)
}
return text
}

View File

@ -0,0 +1,143 @@
import React, { useState } from "react";
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
Button,
VStack,
FormControl,
FormLabel,
Input,
FormHelperText,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
useColorMode,
} from "@chakra-ui/react";
import { useTranslation } from 'react-i18next';
import dayjs from "../../../utils/dayjs-config";
import { formatDate } from "../../../utils/dayjs-config";
import UserSelect from "../../../components/user-select";
interface AttendanceEntry {
name: string;
date: string;
students: any[];
teachers: any[];
}
interface AddDataDialogProps {
isOpen: boolean;
onClose: () => void;
onAddData: (data: AttendanceEntry) => void;
}
const AddDataDialog = ({ isOpen, onClose, onAddData }: AddDataDialogProps) => {
const { colorMode } = useColorMode();
const { t } = useTranslation();
const [name, setName] = useState("");
const [date, setDate] = useState(formatDate(dayjs().toDate(), 'YYYY-MM-DD'));
const [selectedStudents, setSelectedStudents] = useState<any[]>([]);
const [selectedTeachers, setSelectedTeachers] = useState<any[]>([]);
const handleSubmit = () => {
const newEntry: AttendanceEntry = {
name,
date,
students: selectedStudents,
teachers: selectedTeachers,
};
onAddData(newEntry);
resetForm();
onClose();
};
const resetForm = () => {
setName("");
setDate(formatDate(dayjs().toDate(), 'YYYY-MM-DD'));
setSelectedStudents([]);
setSelectedTeachers([]);
};
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent bg={colorMode === 'dark' ? 'gray.800' : 'white'}>
<ModalHeader>{t('journal.pl.attendance.addDialog.title')}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4} align="stretch">
<FormControl isRequired>
<FormLabel>{t('journal.pl.common.lessonName')}</FormLabel>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('journal.pl.attendance.addDialog.lessonNamePlaceholder')}
/>
</FormControl>
<FormControl isRequired>
<FormLabel>{t('journal.pl.common.date')}</FormLabel>
<Input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</FormControl>
<Tabs isFitted variant="enclosed" colorScheme="blue" mt={4}>
<TabList>
<Tab>{t('journal.pl.common.students')}</Tab>
<Tab>{t('journal.pl.common.teachers')}</Tab>
</TabList>
<TabPanels>
<TabPanel>
<FormControl>
<FormLabel>{t('journal.pl.attendance.addDialog.selectStudents')}</FormLabel>
<UserSelect
isMulti
value={selectedStudents}
onChange={setSelectedStudents}
placeholder={t('journal.pl.attendance.addDialog.studentsPlaceholder')}
/>
<FormHelperText>{t('journal.pl.attendance.addDialog.studentsHelperText')}</FormHelperText>
</FormControl>
</TabPanel>
<TabPanel>
<FormControl>
<FormLabel>{t('journal.pl.attendance.addDialog.selectTeachers')}</FormLabel>
<UserSelect
isMulti
value={selectedTeachers}
onChange={setSelectedTeachers}
placeholder={t('journal.pl.attendance.addDialog.teachersPlaceholder')}
/>
<FormHelperText>{t('journal.pl.attendance.addDialog.teachersHelperText')}</FormHelperText>
</FormControl>
</TabPanel>
</TabPanels>
</Tabs>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onClose}>
{t('journal.pl.common.cancel')}
</Button>
<Button colorScheme="blue" onClick={handleSubmit} isDisabled={!name || !date}>
{t('journal.pl.common.add')}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};
export default AddDataDialog;

View File

@ -0,0 +1,373 @@
import React, { useState } from 'react'
import {
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Box,
useColorMode,
Button,
useToast,
Flex,
Collapse,
HStack,
Text,
Icon,
Tooltip,
Avatar,
AvatarBadge,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure
} from '@chakra-ui/react'
import { CopyIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'
import { FaSmile, FaMeh, FaFrown, FaSadTear, FaExpand, FaCompress } from 'react-icons/fa'
import dayjs from 'dayjs'
import { useTranslation } from 'react-i18next'
import { getGravatarURL } from '../../../utils/gravatar'
import { ShortText } from './ShortText'
import { AttendanceData } from '../hooks'
import { formatDate } from '../../../utils/dayjs-config'
interface AttendanceTableProps {
data: AttendanceData
}
export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
const { colorMode } = useColorMode()
const toast = useToast()
const { t } = useTranslation()
const [showTable, setShowTable] = useState(false)
const { isOpen, onOpen, onClose } = useDisclosure()
const getPresentColor = () => {
return colorMode === 'dark' ? 'green.600' : 'green.100'
}
const getAbsentColor = () => {
return colorMode === 'dark' ? 'red.800' : 'red.100'
}
// Получаем эмоджи на основе посещаемости
const getAttendanceEmoji = (attendedCount: number, totalLessons: number) => {
const attendanceRate = totalLessons > 0 ? attendedCount / totalLessons : 0
if (attendanceRate >= 0.9) {
return {
icon: FaSmile,
color: 'green.500',
label: t('journal.pl.attendance.emojis.excellent')
}
} else if (attendanceRate >= 0.75) {
return {
icon: FaMeh,
color: 'blue.400',
label: t('journal.pl.attendance.emojis.good')
}
} else if (attendanceRate >= 0.5) {
return {
icon: FaFrown,
color: 'orange.400',
label: t('journal.pl.attendance.emojis.poor')
}
} else {
return {
icon: FaSadTear,
color: 'red.500',
label: t('journal.pl.attendance.emojis.none')
}
}
}
// Функция для копирования данных таблицы без сокращений
const copyTableData = () => {
if (!data.attendance?.length) return
// Строим заголовок таблицы
let tableContent = []
// Добавляем заголовки с именами преподавателей
let headerRow = []
data.teachers?.forEach(teacher => {
headerRow.push(teacher.value)
})
// Добавляем столбцы даты и названия занятия
headerRow.push(t('journal.pl.common.date'), t('journal.pl.common.lessonName'))
// Добавляем студентов
data.students.forEach(student => {
headerRow.push(student.name || student.value || t('journal.pl.common.name'))
})
// Добавляем заголовок в таблицу
tableContent.push(headerRow.join('\t'))
// Формируем данные для каждой строки
data.attendance.forEach(lesson => {
let row = []
// Добавляем данные о присутствии преподавателей
data.teachers?.forEach(teacher => {
const wasThere = Boolean(lesson.teachers) &&
lesson.teachers.findIndex(u => u.sub === teacher.sub) !== -1
row.push(wasThere ? '+' : '-')
})
// Добавляем дату
row.push(formatDate(lesson.date, 'DD.MM.YYYY'))
// Добавляем полное название занятия (без сокращений)
row.push(lesson.name)
// Добавляем данные о присутствии студентов
data.students.forEach(student => {
const wasThere = lesson.students.findIndex(u => u.sub === student.sub) !== -1
row.push(wasThere ? '+' : '-')
})
// Добавляем строку в таблицу
tableContent.push(row.join('\t'))
})
// Копируем в буфер обмена
const finalContent = tableContent.join('\n')
navigator.clipboard.writeText(finalContent)
.then(() => {
toast({
title: t('journal.pl.attendance.table.copySuccess'),
description: t('journal.pl.attendance.table.copySuccessDescription'),
status: 'success',
duration: 3000,
isClosable: true,
})
})
.catch(err => {
toast({
title: t('journal.pl.attendance.table.copyError'),
description: t('journal.pl.attendance.table.copyErrorDescription'),
status: 'error',
duration: 3000,
isClosable: true,
})
console.error('Ошибка копирования', err)
})
}
// Расчет статистики посещаемости для каждого студента
const getStudentAttendance = () => {
const totalLessons = data.attendance.length
return data.students.map(student => {
let attendedCount = 0
data.attendance.forEach(lesson => {
if (lesson.students.findIndex(s => s.sub === student.sub) !== -1) {
attendedCount++
}
})
return {
student,
name: student.name || student.value || t('journal.pl.common.name'),
email: student.email,
picture: student.picture || getGravatarURL(student.email),
attendedCount,
totalLessons,
attendance: totalLessons > 0 ? (attendedCount / totalLessons) * 100 : 0
}
})
}
if (!data.attendance?.length || !data.students?.length) {
return <Box>{t('journal.pl.common.noData')}</Box>
}
// Создаем компонент таблицы для переиспользования
const AttendanceTableContent = () => (
<Table variant="simple" size="sm">
<Thead>
<Tr>
{data.teachers?.map(teacher => (
<Th key={teacher.id}>{teacher.value}</Th>
))}
<Th>{t('journal.pl.common.date')}</Th>
<Th>{t('journal.pl.common.lessonName')}</Th>
{data.students.map((student) => (
<Th key={student.sub}>
<HStack>
<Avatar
size="xs"
src={student.picture || getGravatarURL(student.email)}
name={student.name || student.value || t('journal.pl.common.name')}
/>
<Text>{student.name || student.value || t('journal.pl.common.name')}</Text>
</HStack>
</Th>
))}
</Tr>
</Thead>
<Tbody>
{data.attendance.map((lesson) => (
<Tr key={lesson.name}>
{data.teachers?.map((teacher) => {
const wasThere = Boolean(lesson.teachers) &&
lesson.teachers.findIndex((u) => u.sub === teacher.sub) !== -1
return (
<Td
key={teacher.sub}
textAlign="center"
bg={wasThere ? getPresentColor() : getAbsentColor()}
>
{wasThere ? (
<Icon as={FaSmile} color="green.500" />
) : (
<Icon as={FaFrown} color="red.500" />
)}
</Td>
)
})}
<Td>{formatDate(lesson.date, 'DD.MM.YYYY')}</Td>
<Td><ShortText text={lesson.name} /></Td>
{data.students.map((st) => {
const wasThere =
lesson.students.findIndex((u) => u.sub === st.sub) !== -1
return (
<Td
key={st.sub}
textAlign="center"
bg={wasThere ? getPresentColor() : getAbsentColor()}
>
{wasThere ? (
<Icon as={FaSmile} color="green.500" />
) : (
<Icon as={FaFrown} color="red.500" />
)}
</Td>
)
})}
</Tr>
))}
</Tbody>
</Table>
)
return (
<Box
boxShadow="md"
borderRadius="lg"
bg={colorMode === 'dark' ? 'gray.700' : 'white'}
>
<Flex justifyContent="space-between" p={3} alignItems="center">
<Flex>
<Button
leftIcon={<CopyIcon />}
size="sm"
colorScheme="blue"
onClick={copyTableData}
mr={2}
>
{t('journal.pl.attendance.table.copy')}
</Button>
<Button
leftIcon={<Icon as={FaExpand} />}
size="sm"
colorScheme="teal"
onClick={onOpen}
mr={2}
>
{t('journal.pl.attendance.table.fullscreen')}
</Button>
</Flex>
<Button
rightIcon={showTable ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm"
variant="outline"
onClick={() => setShowTable(!showTable)}
>
{showTable ? t('journal.pl.attendance.table.hide') : t('journal.pl.attendance.table.show')}
</Button>
</Flex>
{/* Краткая статистика по каждому студенту с эмоджи */}
<Box p={4} borderTop="1px" borderColor={colorMode === 'dark' ? 'gray.600' : 'gray.200'}>
<Flex flexWrap="wrap" gap={3}>
{getStudentAttendance().map(({ student, name, attendedCount, totalLessons, attendance, picture }) => {
const emoji = getAttendanceEmoji(attendedCount, totalLessons)
return (
<Tooltip
key={student.sub}
label={`${emoji.label}: ${attendedCount} ${t('journal.pl.common.of')} ${totalLessons} ${t('journal.pl.common.students')} (${attendance.toFixed(0)}%)`}
hasArrow
>
<Box
p={3}
borderRadius="md"
bg={colorMode === 'dark' ? 'gray.800' : 'gray.50'}
boxShadow="sm"
minWidth="180px"
>
<HStack spacing={3}>
<Avatar
size="md"
src={picture}
name={name}
>
<AvatarBadge boxSize='2em' bg={emoji.color}>
<Icon as={emoji.icon} color="white" boxSize={7} />
</AvatarBadge>
</Avatar>
<Box>
<Text fontSize="sm" fontWeight="medium" isTruncated maxW="110px">{name}</Text>
<Text fontSize="xs" mt={1} color={colorMode === 'dark' ? 'gray.400' : 'gray.600'}>
{attendedCount} {t('journal.pl.common.of')} {totalLessons} ({attendance.toFixed(0)}%)
</Text>
</Box>
</HStack>
</Box>
</Tooltip>
)
})}
</Flex>
</Box>
{/* Полная таблица с возможностью скрытия/показа */}
<Collapse in={showTable} animateOpacity>
<Box
overflowX="auto"
p={3}
borderTop="1px"
borderColor={colorMode === 'dark' ? 'gray.600' : 'gray.200'}
>
<AttendanceTableContent />
</Box>
</Collapse>
{/* Модальное окно для отображения таблицы на весь экран */}
<Modal isOpen={isOpen} onClose={onClose} size="full">
<ModalOverlay />
<ModalContent>
<ModalHeader>
<Flex justifyContent="space-between" alignItems="center">
{t('journal.pl.attendance.table.attendanceData')}
</Flex>
</ModalHeader>
<ModalCloseButton size="lg" top="16px" />
<ModalBody pb={6}>
<Box overflowX="auto">
<AttendanceTableContent />
</Box>
</ModalBody>
</ModalContent>
</Modal>
</Box>
)
}

View File

@ -0,0 +1,42 @@
import { Box, Button, Text, VStack, Icon, useColorMode } from "@chakra-ui/react";
import { FaPlus, FaUsers } from "react-icons/fa";
import { useTranslation } from 'react-i18next';
interface EmptyStateProps {
onAddData: () => void;
}
const EmptyState = ({ onAddData }: EmptyStateProps) => {
const { colorMode } = useColorMode();
const { t } = useTranslation();
return (
<Box
p={8}
borderRadius="lg"
boxShadow="md"
bg={colorMode === 'dark' ? 'gray.700' : 'white'}
textAlign="center"
>
<VStack spacing={4}>
<Icon as={FaUsers} boxSize={12} color={colorMode === 'dark' ? 'blue.300' : 'blue.500'} />
<Text fontSize="xl" fontWeight="bold">
{t('journal.pl.attendance.emptyState.title')}
</Text>
<Text color={colorMode === 'dark' ? 'gray.400' : 'gray.600'}>
{t('journal.pl.attendance.emptyState.description')}
</Text>
<Button
leftIcon={<FaPlus />}
colorScheme="blue"
onClick={onAddData}
mt={2}
>
{t('journal.pl.common.add')}
</Button>
</VStack>
</Box>
);
};
export default EmptyState;

View File

@ -0,0 +1,40 @@
import React from 'react'
import { Tooltip, Text, useColorMode } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
interface ShortTextProps {
text: string
maxLength?: number
}
export const ShortText = ({ text, maxLength = 30 }: ShortTextProps) => {
const { t } = useTranslation()
const { colorMode } = useColorMode()
if (!text) {
return <Text>{t('journal.pl.common.noData')}</Text>
}
if (text.length <= maxLength) {
return <Text>{text}</Text>
}
const shortText = `${text.substring(0, maxLength)}...`
return (
<Tooltip
label={text}
fontSize="sm"
bg={colorMode === 'dark' ? 'gray.700' : 'white'}
color={colorMode === 'dark' ? 'white' : 'gray.800'}
boxShadow="md"
borderRadius="md"
p={2}
hasArrow
>
<Text>{shortText}</Text>
</Tooltip>
)
}
export default ShortText

View File

@ -0,0 +1,103 @@
import React from 'react'
import {
Box,
SimpleGrid,
Stat,
StatLabel,
StatNumber,
StatHelpText,
useColorMode,
Heading,
Divider,
Progress,
VStack,
HStack,
Text,
Badge
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { AttendanceStats } from '../hooks'
interface StatsCardProps {
stats: AttendanceStats
}
export const StatsCard: React.FC<StatsCardProps> = ({ stats }) => {
const { colorMode } = useColorMode()
const { t } = useTranslation()
const getBgColor = () => {
return colorMode === 'dark' ? 'gray.700' : 'white'
}
const getProgressColor = (value: number) => {
if (value > 80) return 'green'
if (value > 50) return 'yellow'
return 'red'
}
return (
<Box
p={5}
borderRadius="lg"
boxShadow="md"
bg={getBgColor()}
mb={6}
>
<Heading size="md" mb={4}>{t('journal.pl.attendance.stats.title')}</Heading>
<Divider mb={4} />
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={5}>
<Box>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={5}>
<Stat>
<StatLabel>{t('journal.pl.attendance.stats.totalLessons')}</StatLabel>
<StatNumber>{stats.totalLessons}</StatNumber>
</Stat>
<Stat>
<StatLabel>{t('journal.pl.attendance.stats.averageAttendance')}</StatLabel>
<StatNumber>{stats.averageAttendance.toFixed(1)}%</StatNumber>
<Progress
value={stats.averageAttendance}
colorScheme={getProgressColor(stats.averageAttendance)}
size="sm"
mt={2}
/>
</Stat>
</SimpleGrid>
</Box>
<Box>
<Stat>
<StatLabel mb={3}>{t('journal.pl.attendance.stats.topStudents')}</StatLabel>
<VStack align="stretch" spacing={2}>
{stats.topStudents.map((student, index) => (
<HStack key={index} justify="space-between">
<HStack>
<Badge
colorScheme={index === 0 ? 'green' : index === 1 ? 'blue' : 'yellow'}
fontSize="sm"
borderRadius="full"
px={2}
>
{index + 1}
</Badge>
<Text fontWeight="medium">{student.name}</Text>
</HStack>
<Text>
{student.attendance} {t('journal.pl.common.of')} {stats.totalLessons} ({student.attendancePercent.toFixed(0)}%)
</Text>
</HStack>
))}
{stats.topStudents.length === 0 && (
<Text color="gray.500">{t('journal.pl.attendance.stats.noData')}</Text>
)}
</VStack>
</Stat>
</Box>
</SimpleGrid>
</Box>
)
}

View File

@ -0,0 +1,3 @@
export * from './AttendanceTable'
export * from './ShortText'
export * from './StatsCard'

View File

@ -0,0 +1,2 @@
export * from './useAttendanceData'
export * from './useAttendanceStats'

View File

@ -0,0 +1,72 @@
import { useMemo } from 'react'
import { api } from '../../../__data__/api/api'
export interface AttendanceData {
students: any[]
teachers: any[]
attendance: any[]
isLoading: boolean
courseInfo: any
}
export const useAttendanceData = (courseId: string | undefined): AttendanceData => {
const { data: attendance, isLoading } = api.useLessonListQuery(courseId, {
selectFromResult: ({ data, isLoading }) => ({
data: data?.body,
isLoading,
}),
})
const { data: courseInfo, isLoading: courseInfoIsLoading } =
api.useGetCourseByIdQuery(courseId)
const processedData = useMemo(() => {
if (!attendance) return { students: [], teachers: [], attendance: [] }
const studentsMap = new Map()
const teachersMap = new Map()
attendance.forEach((lesson) => {
lesson.teachers?.forEach((teacher: any) => {
teachersMap.set(teacher.sub, {
id: teacher.sub,
...teacher,
value: teacher.value || (teacher.family_name && teacher.given_name
? `${teacher.family_name} ${teacher.given_name}`
: teacher.name || teacher.email || teacher.preferred_username || teacher.family_name || teacher.given_name),
})
})
lesson.students.forEach((student) => {
const current = studentsMap.get(student.sub) || {}
studentsMap.set(student.sub, {
...student,
id: student.sub,
value: current.value || (student.family_name && student.given_name
? `${student.family_name} ${student.given_name}`
: student.name || student.email || student.preferred_username || student.family_name || student.given_name),
})
})
})
const compare = Intl.Collator('ru').compare
const students = [...studentsMap.values()]
const teachers = [...teachersMap.values()]
students.sort(({ family_name: name }, { family_name: nname }) =>
compare(name, nname),
)
return {
students,
teachers,
attendance,
}
}, [attendance])
return {
...processedData,
isLoading: isLoading || courseInfoIsLoading,
courseInfo
}
}

View File

@ -0,0 +1,92 @@
import { useMemo } from 'react'
import dayjs from 'dayjs'
import { AttendanceData } from './useAttendanceData'
export interface AttendanceStats {
totalLessons: number
averageAttendance: number
topStudents: Array<{
name: string
attendance: number
attendancePercent: number
}>
lessonsAttendance: Array<{
name: string
date: string
attendancePercent: number
}>
}
export const useAttendanceStats = (data: AttendanceData): AttendanceStats => {
return useMemo(() => {
if (!data.attendance || !data.students.length) {
return {
totalLessons: 0,
averageAttendance: 0,
topStudents: [],
lessonsAttendance: []
}
}
const now = dayjs()
// Фильтруем лекции, оставляя только те, которые уже прошли (исключаем будущие)
const pastLessons = data.attendance.filter(lesson => dayjs(lesson.date).isBefore(now))
const totalLessons = pastLessons.length
// Рассчитываем посещаемость для каждого студента
const studentAttendance = data.students.map(student => {
let attended = 0
pastLessons.forEach(lesson => {
if (lesson.students.some(s => s.sub === student.sub)) {
attended++
}
})
return {
student,
name: student.value,
attendance: attended,
attendancePercent: totalLessons > 0 ? (attended / totalLessons) * 100 : 0
}
})
// Рассчитываем статистику посещаемости для каждого урока
const lessonsAttendance = pastLessons.map(lesson => {
const attendedStudents = lesson.students.length
const attendancePercent = data.students.length > 0
? (attendedStudents / data.students.length) * 100
: 0
return {
name: lesson.name,
date: lesson.date,
attendancePercent
}
})
// Выбираем топ-3 студентов по посещаемости
const topStudents = [...studentAttendance]
.sort((a, b) => b.attendance - a.attendance)
.slice(0, 3)
.map(student => ({
name: student.name,
attendance: student.attendance,
attendancePercent: student.attendancePercent
}))
// Считаем среднюю посещаемость
const totalAttendance = studentAttendance.reduce((sum, student) => sum + student.attendance, 0)
const averageAttendance = data.students.length > 0 && totalLessons > 0
? (totalAttendance / (data.students.length * totalLessons)) * 100
: 0
return {
totalLessons,
averageAttendance,
topStudents,
lessonsAttendance
}
}, [data])
}

View File

@ -0,0 +1 @@
export { Attendance as AttendancePage } from './attendance'

View File

@ -0,0 +1,76 @@
import React from 'react'
import {
Box,
Heading,
SimpleGrid,
useColorModeValue,
Card
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { Course, Lesson } from '../../../__data__/model'
import {
useStats,
StatCards,
StudentAttendanceList,
CourseAttendanceList,
ActivityStats
} from './statistics'
interface CoursesOverviewProps {
courses: Course[]
isLoading: boolean
// Детализированные данные с уроками (если есть)
lessonsByCourse?: Record<string, Lesson[]>
}
export const CoursesOverview: React.FC<CoursesOverviewProps> = ({
courses = [],
isLoading = false,
lessonsByCourse = {}
}) => {
const { t } = useTranslation()
const bgColor = useColorModeValue('white', 'gray.700')
// Используем хук для расчета статистики
const stats = useStats(courses, lessonsByCourse)
// Если загрузка или нет данных, возвращаем null
if (isLoading || !courses.length) {
return null
}
return (
<Box mb={6} py={3}>
<Heading size="md" mb={4}>
{t('journal.pl.overview.title')}
</Heading>
{/* Основные показатели */}
<StatCards stats={stats} bgColor={bgColor} />
{/* Дополнительная статистика */}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={5} mb={5}>
{/* Статистика посещаемости и топ-студенты */}
<Card bg={bgColor} p={4} borderRadius="lg" boxShadow="sm">
<StudentAttendanceList
students={stats.topStudents}
title={t('journal.pl.overview.topStudents')}
/>
{stats.topCoursesByAttendance.length > 0 && (
<>
<Box h={3} />
<CourseAttendanceList courses={stats.topCoursesByAttendance} />
</>
)}
</Card>
{/* Статистика деятельности и активности */}
<Card bg={bgColor} p={4} borderRadius="lg" boxShadow="sm">
<ActivityStats stats={stats} />
</Card>
</SimpleGrid>
</Box>
)
}

View File

@ -0,0 +1,141 @@
import React from 'react'
import {
Box,
CardHeader,
CardBody,
Button,
Card,
Heading,
Input,
CloseButton,
FormControl,
FormLabel,
FormHelperText,
FormErrorMessage,
useBreakpointValue,
Flex,
Stack
} from '@chakra-ui/react'
import { Controller } from 'react-hook-form'
import { AddIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
import { ErrorSpan } from '../../style'
import { useCreateCourse } from '../hooks'
interface CreateCourseFormProps {
onClose: () => void
}
/**
* Компонент формы создания нового курса
*/
export const CreateCourseForm = ({ onClose }: CreateCourseFormProps) => {
const { t } = useTranslation()
const { control, errors, handleSubmit, onSubmit, isLoading, error } = useCreateCourse(onClose)
const headingSize = useBreakpointValue({ base: 'md', md: 'lg' })
const formSpacing = useBreakpointValue({ base: 5, md: 10 })
const buttonSize = useBreakpointValue({ base: 'md', md: 'lg' })
return (
<Card align="left">
<CardHeader display="flex" flexWrap="wrap" alignItems="center">
<Heading as="h2" size={headingSize} mt="0" flex="1" mr={2} mb={{ base: 2, md: 0 }}>
{t('journal.pl.course.createTitle')}
</Heading>
<CloseButton ml={{ base: 'auto', md: 0 }} onClick={onClose} />
</CardHeader>
<CardBody>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack spacing={formSpacing} align="left">
<Controller
control={control}
name="startDt"
rules={{ required: t('journal.pl.common.requiredField') }}
render={({ field }) => (
<FormControl
isRequired
isInvalid={Boolean(errors.startDt)}
>
<FormLabel>{t('journal.pl.common.startDate')}</FormLabel>
<Input
{...field}
required={false}
placeholder={t('journal.pl.common.selectDateTime')}
size="md"
type="date"
/>
{errors.startDt ? (
<FormErrorMessage>
{errors.startDt?.message}
</FormErrorMessage>
) : (
<FormHelperText>
{t('journal.pl.course.specifyStartDate')}
</FormHelperText>
)}
</FormControl>
)}
/>
<Controller
control={control}
name="name"
rules={{
required: t('journal.pl.common.requiredField'),
}}
render={({ field }) => (
<FormControl
isRequired
isInvalid={Boolean(errors.name)}
>
<FormLabel>{t('journal.pl.course.newLectureName')}:</FormLabel>
<Input
{...field}
required={false}
placeholder={t('journal.pl.course.namePlaceholder')}
size="md"
/>
{errors.name && (
<FormErrorMessage>
{errors.name.message}
</FormErrorMessage>
)}
</FormControl>
)}
/>
<Flex mt={formSpacing} justifyContent={{ base: 'center', md: 'flex-start' }}>
<Stack direction={{ base: 'column', sm: 'row' }} spacing={2} width={{ base: '100%', sm: 'auto' }}>
<Button
size={buttonSize}
type="submit"
leftIcon={<AddIcon />}
colorScheme="blue"
width={{ base: '100%', sm: 'auto' }}
isLoading={isLoading}
>
{t('journal.pl.common.create')}
</Button>
<Button
size={buttonSize}
variant="outline"
width={{ base: '100%', sm: 'auto' }}
onClick={onClose}
>
{t('journal.pl.common.cancel')}
</Button>
</Stack>
</Flex>
</Stack>
{error && (
<Box mt={4}>
<ErrorSpan>{(error as any).error}</ErrorSpan>
</Box>
)}
</form>
</CardBody>
</Card>
)
}

View File

@ -0,0 +1,34 @@
import React from 'react'
import { Box, Flex, Heading, Divider, VStack } from '@chakra-ui/react'
import { Course } from '../../../__data__/model'
import { CourseCard } from '../course-card'
interface YearGroupProps {
year: string
courses: Course[]
colorMode: string
}
/**
* Компонент для отображения курсов одного года
*/
export const YearGroup = ({ year, courses, colorMode }: YearGroupProps) => {
return (
<Box mb={6}>
<Flex align="center" mb={3}>
<Heading size="md" color={colorMode === 'dark' ? 'blue.300' : 'blue.600'}>
{year}
</Heading>
<Divider ml={4} flex="1" />
</Flex>
<VStack as="ul" align="stretch" spacing={{ base: 3, md: 4 }}>
{courses.map((course) => (
<CourseCard
key={course.id}
course={course}
/>
))}
</VStack>
</Box>
)
}

View File

@ -0,0 +1,3 @@
export * from './CreateCourseForm'
export * from './YearGroup'
export * from './CoursesOverview'

View File

@ -0,0 +1,110 @@
import React from 'react'
import {
Box,
Text,
Progress,
Flex,
Divider,
Tooltip
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { InfoOutlineIcon } from '@chakra-ui/icons'
import { CourseStats } from './useStats'
import { WeekdayActivityChart } from './WeekdayActivityChart'
interface ActivityStatsProps {
stats: CourseStats
}
export const ActivityStats: React.FC<ActivityStatsProps> = ({ stats }) => {
const { t } = useTranslation()
// Определяем цвет для прогресса в зависимости от значения
const getProgressColor = (value: number) => {
if (value > 80) return 'green'
if (value > 50) return 'blue'
if (value > 30) return 'yellow'
return 'red'
}
// Вычисляем процент завершенности курсов
const completionPercentage =
stats.totalLessons > 0
? (stats.completedLessons / stats.totalLessons) * 100
: 0
return (
<Box>
<Flex align="center" mb={3}>
<Text fontWeight="medium" fontSize="md" mr={2}>
{t('journal.pl.overview.activityStats')}
</Text>
<Tooltip label={t('journal.pl.overview.pastLessonsStats')}>
<InfoOutlineIcon color="gray.400" boxSize={3} />
</Tooltip>
</Flex>
<Box mb={3}>
<Flex align="center" mb={1}>
<Text fontSize="sm" fontWeight="medium" mr={1}>
{t('journal.pl.overview.courseCompletion')}:
</Text>
<Tooltip label={`${stats.completedLessons} / ${stats.totalLessons}`}>
<InfoOutlineIcon color="gray.400" boxSize={3} />
</Tooltip>
</Flex>
<Progress
value={completionPercentage}
size="md"
borderRadius="md"
colorScheme={getProgressColor(completionPercentage)}
mb={1}
hasStripe
/>
<Flex justify="space-between" fontSize="sm">
<Text>
{stats.completedLessons} / {stats.totalLessons} {t('journal.pl.overview.lessons')}
</Text>
<Text fontWeight="medium">
{Math.round(completionPercentage)}%
</Text>
</Flex>
</Box>
<Box mb={3}>
<Flex align="center" mb={1}>
<Text fontSize="sm" fontWeight="medium" mr={1}>
{t('journal.pl.overview.studentAttendance')}:
</Text>
<Tooltip label={t('journal.pl.overview.attendanceHelp')}>
<InfoOutlineIcon color="gray.400" boxSize={3} />
</Tooltip>
</Flex>
<Progress
value={stats.averageAttendance}
size="md"
borderRadius="md"
colorScheme={getProgressColor(stats.averageAttendance)}
mb={1}
hasStripe
/>
<Flex justify="space-between" fontSize="sm">
<Text>
{t('journal.pl.overview.averageRate')}
</Text>
<Text fontWeight="medium">
{Math.round(stats.averageAttendance)}%
</Text>
</Flex>
</Box>
<Divider my={3} />
<WeekdayActivityChart
weekdayActivity={stats.weekdayActivity}
mostActiveDayIndex={stats.mostActiveDayIndex}
/>
</Box>
)
}

View File

@ -0,0 +1,75 @@
import React from 'react'
import {
VStack,
HStack,
Box,
Text,
Badge,
Tooltip
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { CheckCircleIcon } from '@chakra-ui/icons'
interface CourseAttendanceProps {
courses: Array<{id: string, name: string, attendanceRate: number}>
}
export const CourseAttendanceList: React.FC<CourseAttendanceProps> = ({ courses }) => {
const { t } = useTranslation()
// Определяем цвет для прогресса в зависимости от значения
const getProgressColor = (value: number) => {
if (value > 80) return 'green'
if (value > 50) return 'blue'
if (value > 30) return 'yellow'
return 'red'
}
if (!courses?.length) {
return (
<Text color="gray.500" fontSize="sm" textAlign="center">
{t('journal.pl.overview.noAttendanceData')}
</Text>
)
}
return (
<Box>
<Text fontWeight="medium" fontSize="sm" mb={2} display="flex" alignItems="center">
<CheckCircleIcon color="green.400" mr={2} />
{t('journal.pl.overview.topAttendanceCourses')}
</Text>
<VStack align="stretch" spacing={2}>
{courses.map((course, index) => (
<HStack key={course.id} spacing={2}>
<Badge
colorScheme={['green', 'blue', 'yellow'][index]}
borderRadius="full"
minW="22px"
textAlign="center"
>
#{index + 1}
</Badge>
<Tooltip label={course.name}>
<Text fontSize="sm" fontWeight="medium" isTruncated flex="1">
{course.name}
</Text>
</Tooltip>
<Badge
colorScheme={getProgressColor(course.attendanceRate)}
variant="solid"
px={2}
>
{Math.round(course.attendanceRate)}%
</Badge>
</HStack>
))}
</VStack>
<Text fontSize="xs" color="gray.500" mt={2} fontStyle="italic">
{t('journal.pl.overview.attendanceHelp')}
</Text>
</Box>
)
}

View File

@ -0,0 +1,135 @@
import React from 'react'
import {
SimpleGrid,
Stat,
StatLabel,
StatNumber,
StatHelpText,
Flex,
HStack,
Text,
Icon,
Badge
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import {
FaGraduationCap,
FaChalkboardTeacher,
FaCalendarAlt,
FaUsers
} from 'react-icons/fa'
import { CourseStats } from './useStats'
interface StatCardsProps {
stats: CourseStats
bgColor: string
}
export const StatCards: React.FC<StatCardsProps> = ({ stats, bgColor }) => {
const { t } = useTranslation()
return (
<SimpleGrid columns={{ base: 1, sm: 2, md: 4 }} spacing={5} mb={5}>
{/* Статистика по курсам */}
<Stat
bg={bgColor}
p={3}
borderRadius="lg"
boxShadow="sm"
borderLeft="4px solid"
borderLeftColor="blue.400"
>
<Flex align="center" mb={2}>
<Icon as={FaGraduationCap} color="blue.400" mr={2} />
<StatLabel>{t('journal.pl.overview.totalCourses')}</StatLabel>
</Flex>
<StatNumber fontSize="2xl">{stats.totalCourses}</StatNumber>
<StatHelpText mb={0}>
<HStack spacing={3} flexWrap="wrap">
<Badge colorScheme="green">
{stats.activeCourses} {t('journal.pl.overview.active')}
</Badge>
{stats.recentCoursesCount > 0 && (
<Badge colorScheme="purple">
+{stats.recentCoursesCount} {t('journal.pl.overview.new')}
</Badge>
)}
</HStack>
</StatHelpText>
</Stat>
{/* Статистика по урокам */}
<Stat
bg={bgColor}
p={3}
borderRadius="lg"
boxShadow="sm"
borderLeft="4px solid"
borderLeftColor="green.400"
>
<Flex align="center" mb={2}>
<Icon as={FaCalendarAlt} color="green.400" mr={2} />
<StatLabel>{t('journal.pl.overview.totalLessons')}</StatLabel>
</Flex>
<StatNumber fontSize="2xl">{stats.totalLessons}</StatNumber>
<StatHelpText mb={0}>
<HStack spacing={3} flexWrap="wrap">
<Badge colorScheme="blue">
{stats.completedLessons} {t('journal.pl.overview.completed')}
</Badge>
<Badge colorScheme="orange">
{stats.upcomingLessons} {t('journal.pl.overview.upcoming')}
</Badge>
</HStack>
</StatHelpText>
</Stat>
{/* Статистика по студентам */}
<Stat
bg={bgColor}
p={3}
borderRadius="lg"
boxShadow="sm"
borderLeft="4px solid"
borderLeftColor="purple.400"
>
<Flex align="center" mb={2}>
<Icon as={FaUsers} color="purple.400" mr={2} />
<StatLabel>{t('journal.pl.overview.totalStudents')}</StatLabel>
</Flex>
<StatNumber fontSize="2xl">{stats.totalStudents.size}</StatNumber>
<StatHelpText mb={0}>
<Text>
{stats.averageAttendance > 0 ?
`~${Math.round(stats.averageAttendance)}% ${t('journal.pl.overview.attendance')}` :
t('journal.pl.overview.noAttendanceData')}
</Text>
</StatHelpText>
</Stat>
{/* Статистика по преподавателям */}
<Stat
bg={bgColor}
p={3}
borderRadius="lg"
boxShadow="sm"
borderLeft="4px solid"
borderLeftColor="orange.400"
>
<Flex align="center" mb={2}>
<Icon as={FaChalkboardTeacher} color="orange.400" mr={2} />
<StatLabel>{t('journal.pl.overview.totalTeachers')}</StatLabel>
</Flex>
<StatNumber fontSize="2xl">{stats.totalTeachers.size}</StatNumber>
<StatHelpText mb={0}>
<Text>
{stats.activeCourses > 0 ?
`~${(stats.totalTeachers.size / Math.max(1, stats.activeCourses)).toFixed(1)} ${t('journal.pl.overview.perCourse')}` :
t('journal.pl.overview.noActiveData')}
</Text>
</StatHelpText>
</Stat>
</SimpleGrid>
)
}

View File

@ -0,0 +1,98 @@
import React from 'react'
import {
VStack,
HStack,
Box,
Text,
Progress,
Badge,
Avatar,
Tooltip,
Flex
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { StarIcon } from '@chakra-ui/icons'
import { StudentAttendance } from './useStats'
interface StudentAttendanceListProps {
students: StudentAttendance[]
title: string
}
export const StudentAttendanceList: React.FC<StudentAttendanceListProps> = ({
students,
title
}) => {
const { t } = useTranslation()
// Определяем цвет для прогресса в зависимости от значения
const getProgressColor = (value: number) => {
if (value > 80) return 'green'
if (value > 50) return 'blue'
if (value > 30) return 'yellow'
return 'red'
}
if (!students?.length) {
return (
<Text color="gray.500" fontSize="sm" textAlign="center">
{t('journal.pl.overview.noAttendanceData')}
</Text>
)
}
return (
<Box>
<Text fontWeight="medium" fontSize="sm" mb={2} display="flex" alignItems="center">
<StarIcon color="yellow.400" mr={2} />
{title}
</Text>
<Text fontSize="xs" color="gray.500" mb={2}>
{t('journal.pl.overview.pastLessonsStats')}
</Text>
<VStack align="stretch" spacing={3}>
{students.map((student, index) => (
<HStack key={student.id} spacing={3}>
<Avatar
size="sm"
name={student.name}
src={student.avatarUrl}
bg={index < 3 ? ['yellow.400', 'gray.400', 'orange.300'][index] : 'blue.300'}
/>
<Box flex="1">
<Flex justify="space-between">
<Tooltip label={student.name}>
<Text fontSize="sm" fontWeight="medium" isTruncated maxW="150px">
{student.name}
</Text>
</Tooltip>
<Tooltip label={`${student.attended} из ${student.total} занятий`}>
<Text fontSize="xs" color="gray.500">
{student.attended}/{student.total}
</Text>
</Tooltip>
</Flex>
<Progress
value={student.percent}
size="xs"
colorScheme={getProgressColor(student.percent)}
borderRadius="full"
mt={1}
/>
</Box>
<Badge colorScheme={getProgressColor(student.percent)}>
{Math.round(student.percent)}%
</Badge>
</HStack>
))}
</VStack>
<Text fontSize="xs" color="gray.500" mt={2} fontStyle="italic">
{t('journal.pl.overview.attendanceHelp')}
</Text>
</Box>
)
}

View File

@ -0,0 +1,146 @@
import React from 'react'
import {
Box,
HStack,
Text,
Badge,
Tooltip,
VStack,
Flex
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
interface WeekdayActivityChartProps {
weekdayActivity: number[]
mostActiveDayIndex: number
}
export const WeekdayActivityChart: React.FC<WeekdayActivityChartProps> = ({
weekdayActivity,
mostActiveDayIndex
}) => {
const { t } = useTranslation()
// Переводим день недели в читаемый формат
const getDayOfWeekName = (dayIndex: number) => {
const days = [
'journal.pl.days.sunday',
'journal.pl.days.monday',
'journal.pl.days.tuesday',
'journal.pl.days.wednesday',
'journal.pl.days.thursday',
'journal.pl.days.friday',
'journal.pl.days.saturday'
]
return t(days[dayIndex])
}
// Получаем короткое название дня недели (первая буква)
const getShortDayName = (dayIndex: number) => {
return t(`journal.pl.days.${['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][dayIndex]}`).charAt(0)
}
// Формируем подсказку для дня недели
const getDayTooltip = (dayIndex: number, count: number) => {
return `${t(`journal.pl.days.${['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][dayIndex]}`)}:
${count} ${t('journal.pl.overview.lessons').toLowerCase()}`
}
// Если нет данных по активности, показываем сообщение
if (!weekdayActivity.some(count => count > 0)) {
return (
<Box textAlign="center" color="gray.500" fontSize="sm">
{t('journal.pl.overview.noAttendanceData')}
</Box>
)
}
// Находим максимальное и суммарное значение для расчета процентов
const maxValue = Math.max(...weekdayActivity)
const totalLessons = weekdayActivity.reduce((sum, count) => sum + count, 0)
return (
<VStack align="start" width="100%">
<Flex justify="space-between" width="100%" align="center">
<Text fontSize="sm" fontWeight="medium">
{t('journal.pl.overview.mostActiveDay')}:
</Text>
<Badge colorScheme="blue" fontSize="md" px={2} py={1}>
{getDayOfWeekName(mostActiveDayIndex)}
</Badge>
</Flex>
<Text fontSize="xs" color="gray.500" mb={2}>
{t('journal.pl.overview.pastLessonsStats')}
</Text>
{/* Визуализация активности по дням недели */}
<Box w="100%" mt={2}>
<HStack spacing={1} w="100%" justify="space-between" alignItems="flex-end">
{weekdayActivity.map((count, index) => (
<Tooltip
key={index}
label={getDayTooltip(index, count)}
>
<Box width="24px" display="flex" flexDirection="column" alignItems="center">
{/* Область для числа */}
<Box height="15px" mb={1}>
{count > 0 && (
<Text
fontSize="10px"
fontWeight="bold"
color={index === mostActiveDayIndex ? 'blue.500' : 'gray.500'}
lineHeight="15px"
textAlign="center"
>
{count}
</Text>
)}
</Box>
{/* Столбец графика */}
<Box
h={`${Math.max((count / Math.max(maxValue, 1)) * 50, 3)}px`}
w="12px"
bg={index === mostActiveDayIndex ? 'blue.400' : 'gray.300'}
minH="3px"
borderRadius="sm"
/>
{/* Буква дня недели */}
<Box height="15px" mt={1}>
<Text
fontSize="xs"
fontWeight={index === mostActiveDayIndex ? "bold" : "normal"}
lineHeight="15px"
textAlign="center"
>
{getShortDayName(index)}
</Text>
</Box>
{/* Процент */}
<Box height="15px">
{count > 0 && totalLessons > 0 && (
<Text
fontSize="9px"
color="gray.500"
lineHeight="15px"
textAlign="center"
>
{Math.round((count / totalLessons) * 100)}%
</Text>
)}
</Box>
</Box>
</Tooltip>
))}
</HStack>
</Box>
<Text fontSize="xs" color="gray.500" mt={2} fontStyle="italic">
{t('journal.pl.overview.dayOfWeekHelp')}
</Text>
</VStack>
)
}

View File

@ -0,0 +1,6 @@
export * from './useStats'
export * from './StatCards'
export * from './StudentAttendanceList'
export * from './CourseAttendanceList'
export * from './ActivityStats'
export * from './WeekdayActivityChart'

View File

@ -0,0 +1,239 @@
import { useMemo } from 'react'
import dayjs from 'dayjs'
import { Course, Lesson } from '../../../../__data__/model'
export interface StudentAttendance {
id: string
name: string
attended: number
total: number
percent: number
avatarUrl?: string
email?: string
}
export interface CourseStats {
totalCourses: number
activeCourses: number
totalLessons: number
completedLessons: number
upcomingLessons: number
averageAttendance: number
totalStudents: Set<string>
totalTeachers: Set<string>
recentCoursesCount: number
oldCoursesCount: number
weekdayActivity: number[]
mostActiveDayIndex: number
topStudents: StudentAttendance[]
topCoursesByAttendance: Array<{id: string, name: string, attendanceRate: number}>
}
export const useStats = (
courses: Course[],
lessonsByCourse: Record<string, Lesson[]> = {}
): CourseStats => {
return useMemo(() => {
if (!courses?.length) {
return {
totalCourses: 0,
activeCourses: 0,
totalLessons: 0,
completedLessons: 0,
upcomingLessons: 0,
averageAttendance: 0,
totalStudents: new Set<string>(),
totalTeachers: new Set<string>(),
recentCoursesCount: 0,
oldCoursesCount: 0,
weekdayActivity: Array(7).fill(0),
mostActiveDayIndex: 0,
topStudents: [] as StudentAttendance[],
topCoursesByAttendance: [] as {id: string, name: string, attendanceRate: number}[]
}
}
const now = dayjs()
const threeMonthsAgo = now.subtract(3, 'month')
const weekdayActivity = Array(7).fill(0)
// Множества для уникальных студентов и учителей
const uniqueStudents = new Set<string>()
const uniqueTeachers = new Set<string>()
// Количество курсов, созданных за последние 3 месяца
const recentCourses = courses.filter(course =>
dayjs(course.created).isAfter(threeMonthsAgo)
)
// Количество активных курсов
const activeCourses = []
let totalLessonsCount = 0
let completedLessonsCount = 0
let upcomingLessonsCount = 0
let totalAttendances = 0
let totalPossibleAttendances = 0
// Для отслеживания посещаемости студентов по всем курсам
const globalStudentsMap = new Map<string, StudentAttendance>()
// Статистика посещаемости по курсам
const courseAttendanceStats: {id: string, name: string, attendanceRate: number}[] = []
// Для каждого курса считаем статистику на основе данных об уроках
courses.forEach(course => {
// Добавляем учителей в множество
course.teachers.forEach(teacher => {
uniqueTeachers.add(teacher.sub)
})
// Добавляем студентов в множество
const courseUniqueStudents = new Set<string>()
// Получаем детализированные данные об уроках курса (если доступны)
const courseLessons = lessonsByCourse[course._id] || []
// Если у нас есть детализированные данные по урокам
if (courseLessons.length > 0) {
// Добавляем количество уроков к общему счетчику
totalLessonsCount += courseLessons.length
// Считаем завершенные и предстоящие уроки
const completed = courseLessons.filter(lesson => dayjs(lesson.date).isBefore(now))
const upcoming = courseLessons.filter(lesson => dayjs(lesson.date).isAfter(now))
completedLessonsCount += completed.length
upcomingLessonsCount += upcoming.length
// Если у курса есть будущие занятия, считаем его активным
if (upcoming.length > 0) {
activeCourses.push(course)
}
// Собираем всех уникальных студентов курса для более точной статистики
courseLessons.forEach(lesson => {
lesson.students?.forEach(student => {
courseUniqueStudents.add(student.sub)
uniqueStudents.add(student.sub)
})
})
// Для статистики посещаемости по курсу
let courseAttendances = 0
let coursePossibleAttendances = 0
// Считаем посещаемость ТОЛЬКО по прошедшим занятиям
completed.forEach(lesson => {
// Добавляем статистику по дням недели
// В dayjs 0 = воскресенье, 1 = понедельник, ... 6 = суббота
// Нужно проверить формат даты урока, что это валидная дата
if (lesson.date && dayjs(lesson.date).isValid()) {
const lessonDay = dayjs(lesson.date).day()
weekdayActivity[lessonDay]++
}
// Добавляем студентов в глобальное множество
const lessonStudentsCount = lesson.students?.length || 0
// Добавляем в статистику посещаемости
courseAttendances += lessonStudentsCount
// Обновляем счетчики общей посещаемости
totalAttendances += lessonStudentsCount
// Собираем статистику по каждому студенту
lesson.students?.forEach(student => {
// Добавляем или обновляем данные студента в глобальной карте
const studentId = student.sub
const currentGlobal = globalStudentsMap.get(studentId) || {
id: studentId,
name: (student.family_name && student.given_name
? `${student.family_name} ${student.given_name}`
: student.name || student.email || student.preferred_username || student.family_name || student.given_name),
attended: 0,
total: 0,
percent: 0,
avatarUrl: student.picture,
email: student.email
}
currentGlobal.attended += 1
globalStudentsMap.set(studentId, currentGlobal)
})
})
// Потенциальные посещения для этого курса рассчитываем только по прошедшим занятиям
// и только для студентов, которые есть хотя бы на одном занятии
// Кол-во прошедших занятий * кол-во уникальных студентов на курсе
coursePossibleAttendances = completed.length * (courseUniqueStudents.size || 1)
totalPossibleAttendances += coursePossibleAttendances
// Добавляем статистику курса, если есть прошедшие занятия
if (completed.length > 0 && coursePossibleAttendances > 0) {
courseAttendanceStats.push({
id: course._id,
name: course.name,
attendanceRate: (courseAttendances / coursePossibleAttendances) * 100
})
}
} else {
// Если у нас нет детализированных данных, считаем на основе общих данных курса
totalLessonsCount += course.lessons.length
// Предполагаем, что курс активен
activeCourses.push(course)
}
})
// Отладочная информация по активности по дням недели
console.log('Weekday activity:', weekdayActivity)
// Обрабатываем глобальную статистику посещаемости студентов
// Устанавливаем общее число занятий для каждого студента
globalStudentsMap.forEach(student => {
// Устанавливаем максимально возможное кол-во занятий как общее число прошедших занятий
// (это завышенная оценка, т.к. студент может быть не на всех курсах)
student.total = completedLessonsCount
student.percent = completedLessonsCount > 0 ? (student.attended / student.total) * 100 : 0
})
// Находим самый активный день недели
const maxValue = Math.max(...weekdayActivity)
// Если максимальное значение = 0, то устанавливаем понедельник как самый активный день по умолчанию
const mostActiveDayIndex = maxValue > 0 ? weekdayActivity.indexOf(maxValue) : 1
// Вычисляем среднюю посещаемость
const averageAttendance = totalPossibleAttendances > 0
? (totalAttendances / totalPossibleAttendances) * 100
: 0
// Топ студенты по посещаемости (по всем курсам)
const topStudents = Array.from(globalStudentsMap.values())
.sort((a, b) => (b.percent - a.percent) || (b.attended - a.attended))
.slice(0, 5)
// Сортируем курсы по посещаемости
const topCoursesByAttendance = courseAttendanceStats
.sort((a, b) => b.attendanceRate - a.attendanceRate)
.slice(0, 3)
return {
totalCourses: courses.length,
activeCourses: activeCourses.length,
totalLessons: totalLessonsCount,
completedLessons: completedLessonsCount,
upcomingLessons: upcomingLessonsCount,
averageAttendance,
totalStudents: uniqueStudents,
totalTeachers: uniqueTeachers,
recentCoursesCount: recentCourses.length,
oldCoursesCount: courses.length - recentCourses.length,
weekdayActivity,
mostActiveDayIndex,
topStudents,
topCoursesByAttendance
}
}, [courses, lessonsByCourse])
}

View File

@ -1,7 +1,8 @@
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useState, useMemo } from 'react'
import { formatDate } from '../../utils/dayjs-config'
import dayjs from 'dayjs'
import { Link as ConnectedLink, generatePath } from 'react-router-dom'
import { getNavigationsValue } from '@brojs/cli'
import { getNavigationValue } from '@brojs/cli'
import {
Box,
CardHeader,
@ -9,22 +10,70 @@ import {
CardFooter,
ButtonGroup,
Stack,
StackDivider,
Button,
Card,
Heading,
Tooltip,
Spinner,
Flex,
IconButton,
Badge,
Progress,
SimpleGrid,
Stat,
StatLabel,
StatNumber,
HStack,
Text,
VStack,
Divider,
useColorMode,
Avatar,
AvatarGroup,
Tag,
TagLabel,
TagLeftIcon,
Wrap,
WrapItem,
useBreakpointValue,
useMediaQuery,
Icon
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { FaExpand, FaCompress } from 'react-icons/fa'
import { api } from '../../__data__/api/api'
import { ArrowUpIcon, LinkIcon } from '@chakra-ui/icons'
import { ArrowUpIcon, LinkIcon, CalendarIcon, ViewIcon, WarningIcon, StarIcon, TimeIcon } from '@chakra-ui/icons'
import { Course } from '../../__data__/model'
import { CourseDetails } from './course-details'
export const CourseCard = ({ course }: { course: Course }) => {
const [getLessonList, populatedCourse] = api.useLazyGetCourseByIdQuery()
const { data: lessonList, isLoading: lessonListLoading } = api.useLessonListQuery(course.id, {
selectFromResult: ({ data, isLoading }) => ({
data: data?.body,
isLoading,
}),
})
const [isOpened, setIsOpened] = useState(false)
const [isLessonsExpanded, setIsLessonsExpanded] = useState(false)
const { t } = useTranslation()
const { colorMode } = useColorMode()
// Адаптивные размеры и компоновка для различных размеров экрана
const headingSize = useBreakpointValue({ base: 'sm', md: 'md' })
const buttonSize = useBreakpointValue({ base: 'xs', md: 'md' })
const tagSize = useBreakpointValue({ base: 'sm', md: 'md' })
const avatarSize = useBreakpointValue({ base: 'xs', md: 'sm' })
const cardPadding = useBreakpointValue({ base: 2, md: 4 })
// Используем медиа-запросы для определения направления бейджей
const [isLargerThanSm] = useMediaQuery("(min-width: 480px)")
const [badgeDirection, setBadgeDirection] = useState<'column' | 'row'>('row')
useEffect(() => {
setBadgeDirection(isLargerThanSm ? 'row' : 'column')
}, [isLargerThanSm])
useEffect(() => {
if (isOpened) {
getLessonList(course.id, true)
@ -35,85 +84,449 @@ export const CourseCard = ({ course }: { course: Course }) => {
setIsOpened((opened) => !opened)
}, [setIsOpened])
const handleToggleExpand = useCallback(() => {
setIsLessonsExpanded((expanded) => !expanded)
}, [setIsLessonsExpanded])
// Рассчитываем статистику курса и посещаемости
const stats = useMemo(() => {
if (!populatedCourse.data) {
return {
totalLessons: course.lessons.length,
upcomingLessons: 0,
completedLessons: 0,
progress: 0,
topStudents: [],
lowAttendanceStudents: []
}
}
const now = dayjs()
const total = populatedCourse.data.lessons.length
const completed = populatedCourse.data.lessons.filter(lesson =>
dayjs(lesson.date).isBefore(now)
).length
return {
totalLessons: total,
upcomingLessons: total - completed,
completedLessons: completed,
progress: total > 0 ? (completed / total) * 100 : 0,
topStudents: [],
lowAttendanceStudents: []
}
}, [populatedCourse.data, course.lessons.length])
// Рассчитываем статистику посещаемости студентов
const attendanceStats = useMemo(() => {
if (!lessonList || lessonList.length === 0) {
return {
topStudents: [],
lowAttendanceStudents: []
}
}
const studentsMap = new Map()
const now = dayjs()
// Фильтруем только прошедшие лекции
const pastLessons = lessonList.filter(lesson => dayjs(lesson.date).isBefore(now))
// Если прошедших лекций нет, возвращаем пустую статистику
if (pastLessons.length === 0) {
return {
topStudents: [],
lowAttendanceStudents: []
}
}
// Собираем данные о всех студентах (только для прошедших лекций)
pastLessons.forEach(lesson => {
lesson.students?.forEach(student => {
const studentId = student.sub
const current = studentsMap.get(studentId) || {
id: studentId,
name: (student.family_name && student.given_name
? `${student.family_name} ${student.given_name}`
: student.name || student.email || student.preferred_username || student.family_name || student.given_name),
attended: 0,
total: 0,
avatarUrl: student.picture,
email: student.email
}
current.attended += 1
studentsMap.set(studentId, current)
})
})
// Для каждого студента установить общее количество лекций (только прошедших)
studentsMap.forEach(student => {
student.total = pastLessons.length
student.percent = (student.attended / student.total) * 100
})
// Преобразуем Map в массив и сортируем
const students = Array.from(studentsMap.values())
// Топ-3 студента по посещаемости
const topStudents = [...students]
.sort((a, b) => b.percent - a.percent)
.slice(0, 3)
// Студенты с низкой посещаемостью (менее 50%)
const lowAttendanceStudents = students
.filter(student => student.percent < 50 && student.total > 0)
.sort((a, b) => a.percent - b.percent)
.slice(0, 3)
return {
topStudents,
lowAttendanceStudents
}
}, [lessonList])
const getProgressColor = (value: number) => {
if (value > 80) return 'green'
if (value > 50) return 'yellow'
return 'blue'
}
const getAttendanceColor = (value: number) => {
if (value > 80) return 'green'
if (value > 50) return 'yellow'
if (value > 30) return 'orange'
return 'red'
}
return (
<Card key={course._id} align="left">
<CardHeader>
<Heading as="h2" mt="0">
{course.name}
</Heading>
</CardHeader>
{isOpened && (
<CardBody mt="16px">
<Stack divider={<StackDivider />} spacing="8px">
<Box as="span" textAlign="left">
{`Дата начала курса - ${dayjs(course.startDt).format('DD MMMM YYYYг.')}`}
</Box>
<Box as="span" textAlign="left">
Количество занятий - {course.lessons.length}
</Box>
{populatedCourse.isFetching && <Spinner />}
{!populatedCourse.isFetching && populatedCourse.isSuccess && (
<CourseDetails populatedCourse={populatedCourse.data} />
)}
{getNavigationsValue('link.journal.attendance') && (
<Tooltip
label="На страницу с лекциями"
fontSize="12px"
top="16px"
>
<Button
leftIcon={<LinkIcon />}
as={ConnectedLink}
variant="outline"
colorScheme="blue"
to={generatePath(
`${getNavigationsValue('journal.main')}${getNavigationsValue('link.journal.attendance')}`,
{ courseId: course.id },
)}
>
<Box mt={3}></Box>
Посещаемость
</Button>
</Tooltip>
)}
</Stack>
</CardBody>
)}
<CardFooter>
<ButtonGroup
spacing={[0, 4]}
mt="16px"
flexDirection={['column', 'row']}
>
<Tooltip label="На страницу с лекциями" fontSize="12px" top="16px">
<Button
leftIcon={<LinkIcon />}
as={ConnectedLink}
<Card
key={course._id}
overflow="hidden"
variant="outline"
borderRadius="lg"
boxShadow="md"
bg={colorMode === 'dark' ? 'gray.700' : 'white'}
borderColor={colorMode === 'dark' ? 'gray.600' : 'gray.200'}
>
<CardHeader pb={2} px={{ base: 3, md: 5 }}>
<Flex justify="space-between" align="center" flexWrap="wrap">
<Heading as="h2" size={headingSize} mb={{ base: 2, md: 0 }}>
{course.name}
</Heading>
<Tooltip label={isOpened ? t('journal.pl.close') : t('journal.pl.course.viewDetails')}>
<IconButton
aria-label={isOpened ? t('journal.pl.close') : t('journal.pl.course.viewDetails')}
icon={<ArrowUpIcon transform={isOpened ? 'rotate(0)' : 'rotate(180deg)'} />}
size="sm"
colorScheme="blue"
to={`${getNavigationsValue('journal.main')}/lessons-list/${course._id}`}
>
Открыть
</Button>
</Tooltip>
<Tooltip label="Детали" fontSize="12px" top="16px">
<Button
colorScheme="blue"
mt={['16px', 0]}
variant="outline"
leftIcon={
<ArrowUpIcon
transform={isOpened ? 'rotate(0)' : 'rotate(180deg)'}
/>
}
loadingText="Загрузка"
variant="ghost"
isLoading={populatedCourse.isFetching}
onClick={handleToggleOpene}
/>
</Tooltip>
</Flex>
<Flex gap={2} mt={2} flexWrap="wrap">
{badgeDirection === 'column' ? (
<VStack align="start" spacing={2} width="100%">
<Badge colorScheme="blue">
<HStack spacing={1}>
<CalendarIcon boxSize="3" />
<Text>{formatDate(course.startDt, 'DD.MM.YYYY')}</Text>
</HStack>
</Badge>
<Badge colorScheme="purple">
{stats.totalLessons} {t('journal.pl.common.lesson').toLowerCase()}
</Badge>
</VStack>
) : (
<HStack spacing={2}>
<Badge colorScheme="blue">
<HStack spacing={1}>
<CalendarIcon boxSize="3" />
<Text>{formatDate(course.startDt, 'DD.MM.YYYY')}</Text>
</HStack>
</Badge>
<Badge colorScheme="purple">
{stats.totalLessons} {t('journal.pl.common.lesson').toLowerCase()}
</Badge>
</HStack>
)}
</Flex>
</CardHeader>
{!isOpened && (
<CardBody pt={2} pb={3} px={{ base: 3, md: 5 }}>
{lessonListLoading ? (
<Flex justify="center" py={3}>
<Spinner size="sm" />
</Flex>
) : attendanceStats.topStudents.length > 0 ? (
<Box>
<Text fontSize="sm" fontWeight="medium" mb={2}>
{t('journal.pl.attendance.stats.topStudents')}:
</Text>
<AvatarGroup size={avatarSize} max={3} mb={1}>
{attendanceStats.topStudents.map(student => (
<Avatar
key={student.id}
name={student.name}
src={student.avatarUrl}
/>
))}
</AvatarGroup>
</Box>
) : (
<Text fontSize="sm" color="gray.500" textAlign="center">
{t('journal.pl.attendance.stats.noData')}
</Text>
)}
</CardBody>
)}
{isOpened && (
<CardBody pt={2} px={{ base: 3, md: 5 }}>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={{ base: 3, md: 4 }} mb={4}>
<Stat>
<StatLabel>{t('journal.pl.course.completedLessons')}</StatLabel>
<HStack align="baseline">
<StatNumber>{stats.completedLessons}</StatNumber>
<Text color="gray.500">/ {stats.totalLessons}</Text>
</HStack>
<Progress
value={stats.progress}
colorScheme={getProgressColor(stats.progress)}
size="sm"
mt={2}
borderRadius="full"
hasStripe
/>
</Stat>
<Stat>
<StatLabel>{t('journal.pl.course.upcomingLessons')}</StatLabel>
<HStack align="baseline" flexWrap="wrap">
<StatNumber>{stats.upcomingLessons}</StatNumber>
<Text color="gray.500" fontSize={{ base: 'xs', md: 'sm' }}>
<TimeIcon ml={1} mr={1} />
{populatedCourse.data?.lessons
.filter(lesson => dayjs(lesson.date).isAfter(dayjs()))
.sort((a, b) => dayjs(a.date).valueOf() - dayjs(b.date).valueOf())[0]?.date
? formatDate(
populatedCourse.data?.lessons
.filter(lesson => dayjs(lesson.date).isAfter(dayjs()))
.sort((a, b) => dayjs(a.date).valueOf() - dayjs(b.date).valueOf())[0]?.date,
'DD.MM.YYYY'
)
: t('journal.pl.common.noData')
}
</Text>
</HStack>
</Stat>
</SimpleGrid>
<Divider my={3} />
{lessonListLoading ? (
<Flex justify="center" py={4}>
<Spinner />
</Flex>
) : (
<Box>
<Heading size="sm" mb={3}>{t('journal.pl.attendance.stats.title')}</Heading>
{attendanceStats.topStudents.length > 0 && (
<Box mb={4}>
<Text fontSize="sm" fontWeight="medium" mb={2}>
<StarIcon color="yellow.400" mr={1} />
{t('journal.pl.attendance.stats.topStudents')}:
</Text>
<VStack align="stretch" spacing={2}>
{attendanceStats.topStudents.map((student, index) => (
<HStack key={student.id} spacing={2}>
<Avatar size={avatarSize} name={student.name} src={student.avatarUrl} />
<Box flex="1">
<Text fontSize="sm" fontWeight="medium" isTruncated maxWidth={{ base: '120px', sm: '100%' }}>{student.name}</Text>
<Progress
value={student.percent}
size="xs"
colorScheme={getAttendanceColor(student.percent)}
borderRadius="full"
/>
</Box>
<Badge colorScheme={getAttendanceColor(student.percent)}>
{student.attended} / {student.total}
</Badge>
</HStack>
))}
</VStack>
</Box>
)}
{attendanceStats.lowAttendanceStudents.length > 0 && (
<Box>
<Text fontSize="sm" fontWeight="medium" mb={2}>
<WarningIcon color="red.400" mr={1} />
{t('journal.pl.attendance.stats.lowAttendance')}:
</Text>
<VStack align="stretch" spacing={2}>
{attendanceStats.lowAttendanceStudents.map((student) => (
<HStack key={student.id} spacing={2}>
<Avatar size={avatarSize} name={student.name} src={student.avatarUrl} />
<Box flex="1">
<Text fontSize="sm" fontWeight="medium" isTruncated maxWidth={{ base: '120px', sm: '100%' }}>{student.name}</Text>
<Progress
value={student.percent}
size="xs"
colorScheme={getAttendanceColor(student.percent)}
borderRadius="full"
/>
</Box>
<Badge colorScheme={getAttendanceColor(student.percent)}>
{student.attended} / {student.total}
</Badge>
</HStack>
))}
</VStack>
</Box>
)}
</Box>
)}
<Divider my={3} />
{populatedCourse.isFetching && (
<Flex justify="center" py={4}>
<Spinner />
</Flex>
)}
{!populatedCourse.isFetching && populatedCourse.isSuccess && populatedCourse.data && (
<>
<Flex justify="space-between" align="center" mb={3}>
<Heading size="sm">{t('journal.pl.lesson.list')}</Heading>
<Tooltip label={isLessonsExpanded ? t('journal.pl.lesson.collapse') : t('journal.pl.lesson.expand')}>
<IconButton
aria-label={isLessonsExpanded ? t('journal.pl.lesson.collapse') : t('journal.pl.lesson.expand')}
icon={isLessonsExpanded ? <Icon as={FaCompress} /> : <Icon as={FaExpand} />}
size="xs"
onClick={handleToggleExpand}
/>
</Tooltip>
</Flex>
<VStack align="stretch" spacing={2} maxH={isLessonsExpanded ? "none" : "300px"} overflowY={isLessonsExpanded ? "visible" : "auto"} pr={2}>
{[...populatedCourse.data.lessons]
.sort((a, b) => dayjs(b.date).valueOf() - dayjs(a.date).valueOf())
.map(lesson => {
const isPast = dayjs(lesson.date).isBefore(dayjs())
const lessonAttendance = lessonList?.find(l => l._id === lesson._id)
const attendanceCount = lessonAttendance?.students?.length || 0
// Безопасный расчёт общего количества студентов
const totalStudentsCount = lessonList && lessonList.length > 0
? new Set(lessonList.flatMap(l => (l.students || []).map(s => s.sub))).size
: 1 // Избегаем деления на ноль
const attendancePercent = totalStudentsCount > 0
? (attendanceCount / totalStudentsCount) * 100
: 0
return (
<Box
key={lesson._id}
p={2}
borderRadius="md"
bg={colorMode === 'dark' ? 'gray.600' : 'gray.50'}
borderLeft="4px solid"
borderLeftColor={isPast ?
(colorMode === 'dark' ? 'green.500' : 'green.400') :
(colorMode === 'dark' ? 'blue.400' : 'blue.500')
}
>
<Flex justify="space-between" align={{ base: 'flex-start', sm: 'center' }} flexDirection={{ base: 'column', sm: 'row' }} gap={{ base: 2, sm: 0 }}>
<Box>
<Text
fontWeight="medium"
fontSize={{ base: 'sm', md: 'md' }}
noOfLines={2}
wordBreak="break-word"
maxWidth={{ base: '100%', sm: '200px', md: '300px' }}
>
{lesson.name}
</Text>
<HStack spacing={2} mt={1} flexWrap="wrap">
<Tag size="sm" colorScheme={isPast ? "green" : "blue"} borderRadius="full">
<TagLeftIcon as={CalendarIcon} boxSize='10px' />
<TagLabel>{formatDate(lesson.date, 'DD.MM.YYYY')}</TagLabel>
</Tag>
{isPast && lessonAttendance && (
<Tag
size="sm"
colorScheme={getAttendanceColor(attendancePercent)}
borderRadius="full"
>
{attendanceCount} {t('journal.pl.common.students')}
</Tag>
)}
</HStack>
</Box>
<Button
as={ConnectedLink}
to={`${getNavigationValue('journal.main')}/lesson/${course._id}/${lesson._id}`}
size="xs"
variant="ghost"
colorScheme="blue"
leftIcon={<ViewIcon />}
ml={{ base: 0, sm: 'auto' }}
alignSelf={{ base: 'flex-end', sm: 'center' }}
>
{t('journal.pl.common.open')}
</Button>
</Flex>
</Box>
)
})}
</VStack>
</>
)}
</CardBody>
)}
<CardFooter pt={2} px={{ base: 3, md: 5 }}>
<ButtonGroup spacing={{ base: 1, md: 2 }} width="100%" flexDirection={{ base: 'column', sm: 'row' }}>
<Tooltip label={t('journal.pl.lesson.list')}>
<Button
leftIcon={<ViewIcon />}
as={ConnectedLink}
colorScheme="blue"
size={buttonSize}
flexGrow={1}
to={`${getNavigationValue('journal.main')}/lessons-list/${course._id}`}
mb={{ base: 2, sm: 0 }}
>
{isOpened ? 'Закрыть' : 'Просмотреть детали'}
{t('journal.pl.lesson.list')}
</Button>
</Tooltip>
{getNavigationValue('link.journal.attendance') && (
<Tooltip label={t('journal.pl.course.attendance')}>
<Button
leftIcon={<LinkIcon />}
as={ConnectedLink}
variant="outline"
colorScheme="blue"
size={buttonSize}
flexGrow={1}
to={generatePath(
`${getNavigationValue('journal.main')}${getNavigationValue('link.journal.attendance')}`,
{ courseId: course.id },
)}
>
{t('journal.pl.course.attendance')}
</Button>
</Tooltip>
)}
</ButtonGroup>
</CardFooter>
</Card>

View File

@ -1,108 +0,0 @@
import React from 'react'
import dayjs from 'dayjs'
import { Link as ConnectedLink } from 'react-router-dom'
import { getNavigationValue, getHistory } from '@brojs/cli'
import { Stack, Heading, Link, Button, Tooltip, Box } from '@chakra-ui/react'
import { useAppSelector } from '../../__data__/store'
import { isTeacher } from '../../utils/user'
import { PopulatedCourse } from '../../__data__/model'
import { api } from '../../__data__/api/api'
import { LinkIcon } from '@chakra-ui/icons'
type CourseDetailsProps = {
populatedCourse: PopulatedCourse
}
const history = getHistory()
export const CourseDetails = ({ populatedCourse }: CourseDetailsProps) => {
const user = useAppSelector((s) => s.user)
const exam = populatedCourse.examWithJury
const [toggleExamWithJury, examWithJuryRequest] =
api.useToggleExamWithJuryMutation()
return (
<>
{isTeacher(user) && (
<Heading as="h3" mt={4} mb={3} size="lg">
Экзамен: {exam?.name}{' '}
{exam && getNavigationValue('exam.main') && getNavigationValue('link.exam.details') && (
<Tooltip label="Начать экзамен" fontSize="12px" top="16px">
<Button
leftIcon={<LinkIcon />}
as={'a'}
colorScheme="blue"
href={
getNavigationValue('exam.main') +
getNavigationValue('link.exam.details')
.replace(':courseId', populatedCourse.id)
.replace(':examId', exam.id)
}
onClick={(event) => {
event.preventDefault()
history.push(
getNavigationValue('exam.main') +
getNavigationValue('link.exam.details')
.replace(':courseId', populatedCourse.id)
.replace(':examId', exam.id),
)
}}
>
Открыть
</Button>
</Tooltip>
)}
</Heading>
)}
{!Boolean(exam) && (
<>
<Heading as="h3" mt={4} mb={3} size="lg">
Не задан
</Heading>
<Box mt={10}>
<Tooltip label="Создать экзамен с жюри" fontSize="12px" top="16px">
<Button
colorScheme="blue"
mt={['16px', 0]}
variant="outline"
isLoading={examWithJuryRequest.isLoading}
onClick={() => toggleExamWithJury(populatedCourse.id)}
>
Создать
</Button>
</Tooltip>
</Box>
</>
)}
{Boolean(exam) && (
<>
<Heading as="h3" mt={4} mb={3} size="lg">
Количество членов жюри:
</Heading>
<Heading as="h3" mt={4} mb={3} size="lg">
{populatedCourse.examWithJury.jury.length}
</Heading>
</>
)}
<Heading as="h3" mt={4} mb={3} size="lg">
Список занятий:
</Heading>
<Stack>
{populatedCourse?.lessons?.map((lesson) => (
<Link
as={ConnectedLink}
key={lesson.id}
to={
isTeacher(user)
? `${getNavigationValue('journal.main')}/lesson/${populatedCourse.id}/${lesson.id}`
: ''
}
>
{lesson.name}
</Link>
))}
</Stack>
</>
)
}

View File

@ -1,200 +1,143 @@
import React, { useEffect, useRef, useState } from 'react'
import dayjs from 'dayjs'
import React, { useState, useMemo, useEffect } from 'react'
import {
Box,
CardHeader,
CardBody,
Button,
Card,
Heading,
Container,
VStack,
Input,
CloseButton,
FormControl,
FormLabel,
FormHelperText,
FormErrorMessage,
useToast,
Text,
useColorMode,
useBreakpointValue
} from '@chakra-ui/react'
import { useForm, Controller } from 'react-hook-form'
import { ErrorSpan } from '../style'
import { AddIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
import { getFeatures } from '@brojs/cli'
import { useAppSelector } from '../../__data__/store'
import { api } from '../../__data__/api/api'
import { isTeacher } from '../../utils/user'
import { AddIcon } from '@chakra-ui/icons'
import { PageLoader } from '../../components/page-loader/page-loader'
import { CourseCard } from './course-card'
interface NewCourseForm {
startDt: string
name: string
}
import { useSetBreadcrumbs } from '../../components'
import { useGroupedCourses } from './hooks'
import { CreateCourseForm, YearGroup, CoursesOverview } from './components'
import { Lesson } from '../../__data__/model'
/**
* Основной компонент списка курсов
*/
export const CoursesList = () => {
const toast = useToast()
const user = useAppSelector((s) => s.user)
const { data, isLoading } = api.useCoursesListQuery()
const [createUpdateCourse, crucQuery] = api.useCreateUpdateCourseMutation()
const [showForm, setShowForm] = useState(false)
const toastRef = useRef(null)
const {
control,
handleSubmit,
reset,
formState: { errors },
getValues,
} = useForm<NewCourseForm>({
defaultValues: {
startDt: dayjs().format('YYYY-MM-DD'),
name: 'Название',
},
})
const onSubmit = ({ startDt, name }) => {
toastRef.current = toast({
title: 'Отправляем',
status: 'loading',
duration: 9000,
})
createUpdateCourse({ name, startDt })
}
useEffect(() => {
if (crucQuery.isSuccess) {
const values = getValues()
if (toastRef.current) {
toast.update(toastRef.current, {
title: 'Курс создан.',
description: `Курс ${values.name} успешно создан`,
status: 'success',
duration: 9000,
isClosable: true,
})
}
reset()
const { t } = useTranslation()
const { colorMode } = useColorMode()
// Устанавливаем хлебные крошки для главной страницы
useSetBreadcrumbs([
{
title: t('journal.pl.breadcrumbs.home'),
path: '/',
isCurrentPage: true
}
}, [crucQuery.isSuccess])
])
// Получаем значения фичей
const features = getFeatures('journal')
const coursesStatistics = features?.['courses.statistics']
// Создаем API запросы для получения уроков
const [getLessons] = api.useLazyLessonListQuery()
const buttonSize = useBreakpointValue({ base: 'md', md: 'lg' })
const containerPadding = useBreakpointValue({ base: '2', md: '4' })
// Используем хук для группировки курсов по годам
const groupedCourses = useGroupedCourses(data?.body)
// Создаем объект с детализированными данными для всех курсов
const [lessonsByCourse, setLessonsByCourse] = useState<Record<string, Lesson[]>>({})
// Используем useMemo для проверки наличия данных
const courses = useMemo(() => data?.body || [], [data])
// Загружаем данные для каждого курса параллельно
useEffect(() => {
if (courses.length > 0 && !showForm) {
// Создаем запросы для получения данных о занятиях каждого курса
const fetchLessonsForCourses = async () => {
const lessonsData: Record<string, Lesson[]> = {}
// Получаем данные курсов параллельно (по 3 курса за раз, чтобы не перегружать сервер)
for (let i = 0; i < courses.length; i += 3) {
const batch = courses.slice(i, i + 3)
const batchPromises = batch.map(async course => {
// Используем существующий API метод с Lazy Query
const response = await getLessons(course.id)
if (response.data?.body) {
lessonsData[course._id] = response.data.body
}
})
await Promise.all(batchPromises)
}
setLessonsByCourse(lessonsData)
}
fetchLessonsForCourses()
}
}, [courses, showForm, getLessons])
if (isLoading) {
return (
<PageLoader />
)
return <PageLoader />
}
const handleCloseForm = () => setShowForm(false)
return (
<Container maxW="container.xl">
<Container maxW="container.xl" px={containerPadding}>
{isTeacher(user) && (
<Box mt="15" mb="15">
<Box mt={{ base: 3, md: 5 }} mb={{ base: 3, md: 5 }}>
{showForm ? (
<Card align="left">
<CardHeader display="flex">
<Heading as="h2" mt="0">
Создание курса
</Heading>
<CloseButton ml="auto" onClick={() => setShowForm(false)} />
</CardHeader>
<CardBody>
<form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={10} align="left">
<Controller
control={control}
name="startDt"
rules={{ required: 'Обязательное поле' }}
render={({ field }) => (
<FormControl
isRequired
isInvalid={Boolean(errors.startDt)}
>
<FormLabel>Дата начала</FormLabel>
<Input
{...field}
required={false}
placeholder="Select Date and Time"
size="md"
type="date"
/>
{errors.startDt ? (
<FormErrorMessage>
{errors.startDt?.message}
</FormErrorMessage>
) : (
<FormHelperText>
Укажите дату начала курса
</FormHelperText>
)}
</FormControl>
)}
/>
<Controller
control={control}
name="name"
rules={{
required: 'Обязательное поле',
}}
render={({ field }) => (
<FormControl
isRequired
isInvalid={Boolean(errors.name)}
>
<FormLabel>Название новой лекции:</FormLabel>
<Input
{...field}
required={false}
placeholder="КФУ-24-2"
size="md"
/>
{errors.name && (
<FormErrorMessage>
{errors.name.message}
</FormErrorMessage>
)}
</FormControl>
)}
/>
<Box mt={10}>
<Button
size="lg"
type="submit"
leftIcon={<AddIcon />}
colorScheme="blue"
>
Создать
</Button>
</Box>
</VStack>
{crucQuery?.error && (
<ErrorSpan>{(crucQuery?.error as any).error}</ErrorSpan>
)}
</form>
</CardBody>
</Card>
<CreateCourseForm onClose={handleCloseForm} />
) : (
<Box p="2" m="2">
<Box p={{ base: 1, md: 2 }} m={{ base: 1, md: 2 }}>
<Button
leftIcon={<AddIcon />}
colorScheme="green"
onClick={() => setShowForm(true)}
size={buttonSize}
width={{ base: '100%', sm: 'auto' }}
>
Добавить
{t('journal.pl.common.add')}
</Button>
</Box>
)}
</Box>
)}
<VStack as="ul" align="stretch">
{data?.body?.map((c) => (
<CourseCard
key={c.id}
course={c}
/>
))}
</VStack>
{!showForm && coursesStatistics && (
<CoursesOverview
courses={courses}
isLoading={isLoading}
lessonsByCourse={lessonsByCourse}
/>
)}
{Object.keys(groupedCourses).length > 0 ? (
Object.entries(groupedCourses)
.sort(([yearA], [yearB]) => Number(yearB) - Number(yearA)) // Сортируем годы по убыванию
.map(([year, courses]) => (
<YearGroup
key={year}
year={year}
courses={courses}
colorMode={colorMode}
/>
))
) : (
<Box textAlign="center" py={10}>
<Text color="gray.500">{t('journal.pl.course.noCourses')}</Text>
</Box>
)}
</Container>
)
}

View File

@ -0,0 +1,2 @@
export * from './useGroupedCourses'
export * from './useCreateCourse'

View File

@ -0,0 +1,73 @@
import { useRef, useEffect } from 'react'
import dayjs from '../../../utils/dayjs-config'
import { useForm } from 'react-hook-form'
import { useToast } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { formatDate } from '../../../utils/dayjs-config'
import { api } from '../../../__data__/api/api'
interface NewCourseForm {
startDt: string
name: string
}
/**
* Хук для управления созданием нового курса
* @param onSuccess Коллбэк, который будет вызван после успешного создания курса
* @returns Объект с формой и функциями для создания курса
*/
export const useCreateCourse = (onSuccess: () => void) => {
const toast = useToast()
const toastRef = useRef(null)
const { t } = useTranslation()
const [createUpdateCourse, crucQuery] = api.useCreateUpdateCourseMutation()
const {
control,
handleSubmit,
reset,
formState: { errors },
getValues,
} = useForm<NewCourseForm>({
defaultValues: {
startDt: formatDate(dayjs().toDate(), 'YYYY-MM-DD'),
name: t('journal.pl.course.defaultName'),
},
})
const onSubmit = ({ startDt, name }: NewCourseForm) => {
toastRef.current = toast({
title: t('journal.pl.course.sending'),
status: 'loading',
duration: 9000,
})
createUpdateCourse({ name, startDt })
}
useEffect(() => {
if (crucQuery.isSuccess) {
const values = getValues()
if (toastRef.current) {
toast.update(toastRef.current, {
title: t('journal.pl.course.created'),
description: t('journal.pl.course.successMessage', { name: values.name }),
status: 'success',
duration: 9000,
isClosable: true,
})
}
reset()
onSuccess()
}
}, [crucQuery.isSuccess, t, onSuccess, reset, getValues, toast])
return {
control,
errors,
handleSubmit,
onSubmit,
isLoading: crucQuery.isLoading,
error: crucQuery.error
}
}

View File

@ -0,0 +1,32 @@
import { useMemo } from 'react'
import dayjs, { formatDate } from '../../../utils/dayjs-config'
import { Course } from '../../../__data__/model'
/**
* Хук для группировки курсов по годам их начала
* @param courses Массив курсов для группировки
* @returns Объект с группировкой курсов по годам, отсортированный от новых к старым
*/
export const useGroupedCourses = (courses?: Course[]) => {
return useMemo(() => {
if (!courses?.length) return {}
const grouped: Record<string, Course[]> = {}
// Сортируем курсы по дате начала (от новых к старым)
const sortedCourses = [...courses].sort((a, b) =>
dayjs(b.startDt).valueOf() - dayjs(a.startDt).valueOf()
)
// Группируем по годам
sortedCourses.forEach(course => {
const year = formatDate(course.startDt, 'YYYY')
if (!grouped[year]) {
grouped[year] = []
}
grouped[year].push(course)
})
return grouped
}, [courses])
}

View File

@ -1,28 +1,29 @@
import React, { useEffect, useState, useRef, useMemo } from 'react'
import React, { useEffect, useRef, useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import dayjs from 'dayjs'
import QRCode from 'qrcode'
import { sha256 } from 'js-sha256'
import { getConfigValue, getNavigationsValue } from '@brojs/cli'
import { getConfigValue, getNavigationValue } from '@brojs/cli'
import { motion, AnimatePresence } from 'framer-motion'
import {
Box,
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Container,
VStack,
Heading,
Stack,
useColorMode,
Flex,
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { api } from '../__data__/api/api'
import { User } from '../__data__/model'
import { User, Reaction } from '../__data__/model'
import { UserCard } from '../components/user-card'
import { formatDate } from '../utils/dayjs-config'
import { useSetBreadcrumbs } from '../components'
import {
QRCanvas,
StudentList,
BreadcrumbsWrapper,
} from './style'
import { useAppSelector } from '../__data__/store'
import { isTeacher } from '../utils/user'
@ -40,6 +41,40 @@ const LessonDetail = () => {
const { lessonId, courseId } = useParams()
const canvRef = useRef(null)
const user = useAppSelector((s) => s.user)
const { t } = useTranslation()
const { colorMode } = useColorMode()
// Получаем данные о курсе и уроке
const { data: courseData } = api.useGetCourseByIdQuery(courseId)
const { data: lessonData } = api.useLessonByIdQuery(lessonId)
// Устанавливаем хлебные крошки
useSetBreadcrumbs([
{
title: t('journal.pl.breadcrumbs.home'),
path: '/'
},
{
title: courseData?.name || t('journal.pl.breadcrumbs.course'),
path: `${getNavigationValue('journal.main')}/lessons-list/${courseId}`
},
{
title: lessonData?.body?.name || t('journal.pl.breadcrumbs.lesson'),
isCurrentPage: true
}
])
// Создаем ref для отслеживания ранее присутствовавших студентов
const prevPresentStudentsRef = useRef(new Set<string>())
// Добавляем состояние для отслеживания пульсации
const [isPulsing, setIsPulsing] = useState(false)
// Отслеживаем предыдущее количество студентов
const prevStudentCountRef = useRef(0)
// Отслеживаем предыдущие реакции для определения новых
const prevReactionsRef = useRef<Record<string, Reaction[]>>({})
// Храним актуальные реакции студентов
const [studentReactions, setStudentReactions] = useState<Record<string, Reaction[]>>({})
const {
isFetching,
@ -62,6 +97,61 @@ const LessonDetail = () => {
[accessCode, lessonId],
)
// Эффект для обнаружения и обновления новых присутствующих студентов
useEffect(() => {
if (accessCode?.body) {
const currentPresent = new Set(accessCode.body.lesson.students.map(s => s.sub))
// Проверяем, изменилось ли количество студентов
const currentCount = accessCode.body.lesson.students.length;
if (prevStudentCountRef.current !== currentCount && prevStudentCountRef.current > 0) {
// Запускаем эффект пульсации
setIsPulsing(true);
// Сбрасываем эффект через 1.5 секунды
setTimeout(() => {
setIsPulsing(false);
}, 1500);
}
// Обновляем предыдущее количество
prevStudentCountRef.current = currentCount;
// Очищаем флаги предыдущего состояния после задержки
const timeoutId = setTimeout(() => {
prevPresentStudentsRef.current = currentPresent
}, 3000)
return () => clearTimeout(timeoutId)
}
}, [accessCode])
// Эффект для обработки новых реакций
useEffect(() => {
if (accessCode?.body?.lesson?.studentReactions) {
const reactions = accessCode.body.lesson.studentReactions;
// Группируем реакции по sub (идентификатору студента)
const groupedReactions: Record<string, Reaction[]> = {};
reactions.forEach(reaction => {
if (!groupedReactions[reaction.sub]) {
groupedReactions[reaction.sub] = [];
}
groupedReactions[reaction.sub].push(reaction);
});
// Обновляем отображаемые реакции
setStudentReactions(groupedReactions);
// Обновляем предыдущие реакции после небольшой задержки
const updatePrevReactionsTimeout = setTimeout(() => {
prevReactionsRef.current = groupedReactions;
}, 1000);
return () => clearTimeout(updatePrevReactionsTimeout);
}
}, [accessCode?.body?.lesson?.studentReactions]);
useEffect(() => {
if (manualAddRqst.isSuccess) {
refetch()
@ -70,95 +160,343 @@ const LessonDetail = () => {
useEffect(() => {
if (!isFetching && isSuccess) {
QRCode.toCanvas(
canvRef.current,
userUrl,
{ width: 600 },
function (error) {
if (error) console.error(error)
console.log('success!')
},
)
const generateQRCode = () => {
if (!canvRef.current) return;
// Получаем текущую ширину канваса, гарантируя квадратный QR-код
const canvas = canvRef.current;
const containerWidth = canvas.clientWidth;
// Очищаем canvas перед новой генерацией
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Устанавливаем одинаковые размеры для ширины и высоты (1:1)
canvas.width = containerWidth;
canvas.height = containerWidth;
QRCode.toCanvas(
canvas,
userUrl,
{
width: containerWidth,
margin: 1 // Небольшой отступ для лучшей читаемости
},
function (error) {
if (error) console.error(error)
console.log('success!')
},
)
}
// Генерируем QR-код
generateQRCode();
// Перегенерируем при изменении размера окна
const handleResize = () => {
generateQRCode();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}
}, [isFetching, isSuccess])
}, [isFetching, isSuccess, userUrl])
const studentsArr = useMemo(() => {
let allStudents: (User & { present?: boolean })[] = [
let allStudents: (User & { present?: boolean; recentlyPresent?: boolean })[] = [
...(AllStudents.data?.body || []),
].map((st) => ({ ...st, present: false }))
].map((st) => ({ ...st, present: false, recentlyPresent: false }))
let presentStudents: (User & { present?: boolean })[] = [
...(accessCode?.body.lesson.students || []),
]
while (allStudents.length && presentStudents.length) {
// Находим новых студентов по сравнению с предыдущим состоянием
const currentPresent = new Set(presentStudents.map(s => s.sub))
const newlyPresent = [...currentPresent].filter(id => !prevPresentStudentsRef.current.has(id))
while (presentStudents.length) {
const student = presentStudents.pop()
const present = allStudents.find((st) => st.sub === student.sub)
if (present) {
present.present = true
present.recentlyPresent = newlyPresent.includes(student.sub)
} else {
allStudents.push({ ...student, present: true })
allStudents.push({
...student,
present: true,
recentlyPresent: newlyPresent.includes(student.sub)
})
}
}
return allStudents.sort((a, b) => (a.present ? -1 : 1))
}, [accessCode?.body, AllStudents.data])
// Removing the sorting to prevent reordering animation
return allStudents
}, [accessCode?.body, AllStudents.data, prevPresentStudentsRef.current])
// Функция для определения цвета на основе посещаемости
const getAttendanceColor = (attendance: number, total: number) => {
const percentage = total > 0 ? (attendance / total) * 100 : 0
if (percentage > 80) return { bg: 'green.100', color: 'green.800', dark: { bg: 'green.800', color: 'green.100' } }
if (percentage > 60) return { bg: 'teal.100', color: 'teal.800', dark: { bg: 'teal.800', color: 'teal.100' } }
if (percentage > 40) return { bg: 'yellow.100', color: 'yellow.800', dark: { bg: 'yellow.800', color: 'yellow.100' } }
if (percentage > 20) return { bg: 'orange.100', color: 'orange.800', dark: { bg: 'orange.800', color: 'orange.100' } }
return { bg: 'red.100', color: 'red.800', dark: { bg: 'red.800', color: 'red.100' } }
}
return (
<>
<BreadcrumbsWrapper>
<Breadcrumb>
<BreadcrumbItem>
<BreadcrumbLink as={Link} to={getNavigationsValue('journal.main')}>
Журнал
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem>
<BreadcrumbLink
as={Link}
to={`${getNavigationsValue('journal.main')}/lessons-list/${courseId}`}
>
Курс
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink href="#">Лекция</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
</BreadcrumbsWrapper>
<Container maxW="2280px">
<VStack align="left">
<Heading as="h3" mt="4" mb="3">
Тема занятия:
{t('journal.pl.lesson.topicTitle')}
</Heading>
<Box as="span">{accessCode?.body?.lesson?.name}</Box>
<Box as="span">
{dayjs(accessCode?.body?.lesson?.date).format('DD MMMM YYYYг.')}{' '}
Отмечено - {accessCode?.body?.lesson?.students?.length}{' '}
{AllStudents.isSuccess
? `/ ${AllStudents?.data?.body?.length}`
: ''}{' '}
человек
</Box>
</VStack>
<Stack spacing="8" sx={{ flexDirection: { sm: 'column', md: 'row' } }}>
<a href={userUrl}>
<QRCanvas ref={canvRef} />
</a>
<StudentList>
{isTeacher(user) && studentsArr.map((student) => (
<UserCard
wrapperAS="li"
key={student.sub}
student={student}
present={student.present}
onAddUser={(user: User) => manualAdd({ lessonId, user })}
/>
))}
</StudentList>
<Stack spacing="8" direction={{ base: "column", md: "row" }} mt={6}>
<Box
flexShrink={0}
alignSelf="flex-start"
p={4}
borderRadius="xl"
bg={colorMode === "light" ? "gray.50" : "gray.700"}
boxShadow="md"
position="sticky"
top="20px"
zIndex="2"
><Box pb={3}>
{formatDate(accessCode?.body?.lesson?.date, t('journal.pl.lesson.dateFormat'))}{' '}
{t('journal.pl.common.marked')} -
{AllStudents.isSuccess && (
<Box
as="span"
px={2}
py={1}
ml={2}
borderRadius="md"
fontWeight="bold"
bg={getAttendanceColor(
accessCode?.body?.lesson?.students?.length || 0,
AllStudents?.data?.body?.length || 1
).bg}
color={getAttendanceColor(
accessCode?.body?.lesson?.students?.length || 0,
AllStudents?.data?.body?.length || 1
).color}
_dark={{
bg: getAttendanceColor(
accessCode?.body?.lesson?.students?.length || 0,
AllStudents?.data?.body?.length || 1
).dark.bg,
color: getAttendanceColor(
accessCode?.body?.lesson?.students?.length || 0,
AllStudents?.data?.body?.length || 1
).dark.color
}}
position="relative"
animation={isPulsing ? "pulse 1.5s ease-in-out" : "none"}
sx={{
'@keyframes pulse': {
'0%': { transform: 'scale(1)' },
'50%': { transform: 'scale(1.15)', boxShadow: '0 0 10px rgba(66, 153, 225, 0.7)' },
'100%': { transform: 'scale(1)' }
}
}}
>
{accessCode?.body?.lesson?.students?.length} / {AllStudents?.data?.body?.length}
</Box>
)}
{!AllStudents.isSuccess && (
<span> {accessCode?.body?.lesson?.students?.length}</span>
)}{' '}
{t('journal.pl.common.people')}
</Box>
<a href={userUrl}>
<QRCanvas ref={canvRef} />
</a>
</Box>
<Box
flex={1}
p={4}
borderRadius="xl"
bg={colorMode === "light" ? "gray.50" : "gray.700"}
boxShadow="md"
>
<StudentList>
{isTeacher(user) && (
<AnimatePresence initial={false}>
{studentsArr.map((student) => (
<motion.li
key={student.sub}
animate={{
rotateY: student.present ? 0 : 180,
boxShadow: student.recentlyPresent
? ['0 0 0 0 rgba(66, 153, 225, 0)', '0 0 20px 5px rgba(66, 153, 225, 0.7)', '0 0 0 0 rgba(66, 153, 225, 0)']
: '0 0 0 0 rgba(0, 0, 0, 0)'
}}
transition={{
rotateY: { type: "spring", stiffness: 300, damping: 20 },
boxShadow: {
repeat: student.recentlyPresent ? 3 : 0,
duration: 1.5
}
}}
style={{
transformStyle: "preserve-3d",
perspective: "1000px",
aspectRatio: "1",
width: "100%",
display: "block"
}}
>
{/* Front side - visible when present */}
<Box
position="relative"
width="100%"
height="100%"
style={{
transformStyle: "preserve-3d"
}}
>
<Box
position="absolute"
top="0"
left="0"
width="100%"
height="100%"
style={{
backfaceVisibility: "hidden",
transform: "rotateY(0deg)",
zIndex: student.present ? 1 : 0
}}
>
<UserCard
wrapperAS="div"
student={student}
present={student.present}
recentlyPresent={student.recentlyPresent}
onAddUser={(user: User) => manualAdd({ lessonId, user })}
reaction={accessCode?.body?.lesson?.studentReactions?.find(r => r.sub === student.sub)}
/>
</Box>
{/* Back side - visible when not present */}
<Flex
position="absolute"
top="0"
left="0"
width="100%"
height="100%"
bg={colorMode === "light" ? "gray.100" : "gray.600"}
borderRadius="12px"
align="center"
justify="center"
p={4}
overflow="hidden"
style={{
backfaceVisibility: "hidden",
transform: "rotateY(180deg)",
zIndex: student.present ? 0 : 1,
aspectRatio: "1"
}}
>
<Box
position="absolute"
top="0"
left="0"
right="0"
bottom="0"
opacity="0.2"
className="animated-bg"
sx={{
background: `linear-gradient(135deg,
${colorMode === "light" ? "#e3f2fd, #bbdefb, #90caf9" : "#1a365d, #2a4365, #2c5282"})`,
backgroundSize: "400% 400%",
animation: "gradientAnimation 8s ease infinite",
"@keyframes gradientAnimation": {
"0%": { backgroundPosition: "0% 50%" },
"50%": { backgroundPosition: "100% 50%" },
"100%": { backgroundPosition: "0% 50%" }
}
}}
/>
<Box
position="relative"
textAlign="center"
zIndex="1"
>
<Box
width="60px"
height="60px"
mx="auto"
mb={2}
sx={{
animation: "float 3s ease-in-out infinite",
"@keyframes float": {
"0%": { transform: "translateY(0px)" },
"50%": { transform: "translateY(-10px)" },
"100%": { transform: "translateY(0px)" }
}
}}
>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Академическая шапочка */}
<path
d="M12 2L2 6.5L12 11L22 6.5L12 2Z"
fill={colorMode === "light" ? "#3182CE" : "#63B3ED"}
/>
<path
d="M19 9V14.5C19 15.163 18.6839 15.7989 18.1213 16.2678C17.0615 17.1301 13.7749 19 12 19C10.2251 19 6.93852 17.1301 5.87868 16.2678C5.31607 15.7989 5 15.163 5 14.5V9L12 12.5L19 9Z"
fill={colorMode === "light" ? "#2C5282" : "#4299E1"}
/>
<path
d="M21 7V14M21 14L19 16M21 14L23 16"
stroke={colorMode === "light" ? "#2C5282" : "#4299E1"}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Лицо студента */}
<circle
cx="12"
cy="15"
r="2.5"
fill={colorMode === "light" ? "#3182CE" : "#63B3ED"}
/>
{/* Тело студента */}
<path
d="M8 18.5C8 17.1193 9.11929 16 10.5 16H13.5C14.8807 16 16 17.1193 16 18.5V21H8V18.5Z"
fill={colorMode === "light" ? "#3182CE" : "#63B3ED"}
/>
</svg>
</Box>
<Box fontSize="sm" fontWeight="medium">
{student.name || student.preferred_username}
</Box>
<Box
fontSize="xs"
opacity={0.8}
color={colorMode === "light" ? "gray.600" : "gray.300"}
>
{t('journal.pl.lesson.notMarked')}
</Box>
</Box>
</Flex>
</Box>
</motion.li>
))}
</AnimatePresence>
)}
</StudentList>
</Box>
</Stack>
</Container>
</>

View File

@ -1,28 +1,66 @@
import React from 'react'
import { ResponsiveBar } from '@nivo/bar'
import { type BarDatum, ResponsiveBar } from '@nivo/bar'
import { useTranslation } from 'react-i18next'
import { useColorMode } from '@chakra-ui/react'
export const Bar = ({ data }) => (
<ResponsiveBar
data={data}
keys={['count']}
indexBy="lessonIndex"
margin={{ top: 50, right: 130, bottom: 50, left: 60 }}
padding={0.3}
valueScale={{ type: 'linear' }}
indexScale={{ type: 'band', round: true }}
colors={{ scheme: 'set3' }}
axisTop={null}
axisRight={null}
labelSkipWidth={12}
labelSkipHeight={12}
labelTextColor={{
from: 'color',
modifiers: [['brighter', 1.4]],
}}
role="application"
ariaLabel="График посещаемости лекций"
barAriaLabel={(e) =>
e.id + ': ' + e.formattedValue + ' on lection: ' + e.indexValue
}
/>
)
export const Bar = ({ data }: { data: BarDatum[] }) => {
const { t } = useTranslation()
const { colorMode } = useColorMode()
// Находим максимальное значение для нормализации цветов
const maxValue = Math.max(...data.map(item => (item.count as number)))
return (
<ResponsiveBar
data={data}
keys={['count']}
indexBy="lessonIndex"
margin={{ top: 50, right: 130, bottom: 50, left: 60 }}
padding={0.4}
valueScale={{ type: 'linear' }}
indexScale={{ type: 'band', round: true }}
colors={(bar) => {
// Нормализованное значение от 0 до 1
const normalized = (bar.data.count as number) / maxValue
// Красный при низких значениях, зеленый при высоких
const r = Math.round(255 * (1 - normalized))
const g = Math.round(255 * normalized)
const b = 100 // Немного синего, чтобы цвета не были слишком резкими
return `rgb(${r}, ${g}, ${b})`
}}
theme={{
tooltip: {
container: {
background: colorMode === 'dark' ? '#2D3748' : '#ffffff',
color: colorMode === 'dark' ? '#ffffff' : '#333333',
fontSize: 14,
borderRadius: 8,
boxShadow: '0 4px 10px rgba(0, 0, 0, 0.1)',
},
},
grid: {
line: {
stroke: colorMode === 'dark' ? '#4A5568' : '#e0e0e0',
strokeWidth: 1,
},
},
}}
borderRadius={4}
borderWidth={1}
borderColor={{ from: 'color', modifiers: [['darker', 0.3]] }}
axisTop={null}
axisRight={null}
labelSkipWidth={12}
labelSkipHeight={12}
labelTextColor={colorMode === 'dark' ? '#ffffff' : '#333333'}
animate={true}
motionConfig="gentle"
enableGridY={false}
role="application"
ariaLabel={t('journal.pl.lesson.attendanceChart')}
barAriaLabel={(e) =>
e.id + ': ' + e.formattedValue + ' on lection: ' + e.indexValue
}
/>
)
}

View File

@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'
import dayjs from 'dayjs'
import { formatDate } from '../../../utils/dayjs-config'
import { Link } from 'react-router-dom'
import { getNavigationsValue, getFeatures } from '@brojs/cli'
import { getNavigationValue, getFeatures } from '@brojs/cli'
import {
Button,
Tr,
@ -11,8 +11,14 @@ import {
MenuItem,
MenuList,
useToast,
Flex,
Text,
useColorMode,
Box,
Image,
} from '@chakra-ui/react'
import { EditIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
import { qrCode } from '../../../assets'
@ -29,7 +35,9 @@ type ItemProps = {
isTeacher: boolean
courseId: string
setlessonToDelete(): void
setEditLesson?: () => void
students: unknown[]
isMobile?: boolean
}
export const Item: React.FC<ItemProps> = ({
@ -39,17 +47,37 @@ export const Item: React.FC<ItemProps> = ({
isTeacher,
courseId,
setlessonToDelete,
setEditLesson,
students,
isMobile = false,
}) => {
const [edit, setEdit] = useState(false)
const toastRef = useRef(null)
const toast = useToast()
const [updateLesson, updateLessonRqst] = api.useUpdateLessonMutation()
const createdLessonRef = useRef(null)
const { t } = useTranslation()
const { colorMode } = useColorMode()
// QR-код с применением фильтра инверсии для тёмной темы
const QRCodeImage = () => (
<Box
display="flex"
justifyContent="center"
filter={colorMode === 'dark' ? 'invert(1)' : 'none'}
>
<img
width={isMobile ? 20 : 24}
src={qrCode}
alt="QR код"
style={{ margin: '0 auto' }}
/>
</Box>
)
const onSubmit = (lessonData) => {
toastRef.current = toast({
title: 'Отправляем',
title: t('journal.pl.common.sending'),
status: 'loading',
duration: 9000,
})
@ -58,7 +86,7 @@ export const Item: React.FC<ItemProps> = ({
updateLesson(lessonData)
} else {
toast.update(toastRef.current, {
title: 'Отсутствует интернет',
title: t('journal.pl.lesson.noInternet'),
status: 'error',
duration: 3000
})
@ -68,8 +96,8 @@ export const Item: React.FC<ItemProps> = ({
useEffect(() => {
if (updateLessonRqst.isSuccess) {
const toastProps = {
title: 'Лекция Обновлена',
description: `Лекция ${createdLessonRef.current?.name} успешно обновлена`,
title: t('journal.pl.lesson.updated'),
description: t('journal.pl.lesson.updateMessage', { name: createdLessonRef.current?.name }),
status: 'success' as const,
duration: 9000,
isClosable: true,
@ -92,28 +120,80 @@ export const Item: React.FC<ItemProps> = ({
setEdit(false)
}}
lesson={{ _id: id, id, name, date }}
title={'Редактирование лекции'}
nameButton={'Сохранить'}
title={t('journal.pl.lesson.editTitle')}
nameButton={t('journal.pl.save')}
/>
</Td>
</Tr>
)
}
if (isMobile) {
return (
<>
<Flex justify="space-between" align="center" mb={2}>
<Text fontWeight="medium">{name}</Text>
<Text fontSize="sm">{formatDate(date, groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')}</Text>
</Flex>
<Flex justify="space-between" align="center">
{isTeacher && (
<Link
to={`${getNavigationValue('journal.main')}/lesson/${courseId}/${id}`}
style={{ display: 'flex' }}
>
<QRCodeImage />
</Link>
)}
<Flex align="center">
<Text fontSize="sm" mr={2}>
{t('journal.pl.common.marked')}: {students.length}
</Text>
{isTeacher && !edit && (
<Menu>
<MenuButton as={Button} size="sm">
<EditIcon />
</MenuButton>
<MenuList>
<MenuItem
onClick={() => {
if (setEditLesson) {
setEditLesson();
} else {
setEdit(true);
}
}}
>
{t('journal.pl.edit')}
</MenuItem>
<MenuItem onClick={setlessonToDelete}>{t('journal.pl.delete')}</MenuItem>
</MenuList>
</Menu>
)}
{edit && <Button size="sm" onClick={setlessonToDelete}>{t('journal.pl.save')}</Button>}
</Flex>
</Flex>
</>
)
}
// Стандартное отображение
return (
<Tr>
{isTeacher && (
<Td>
<Link
to={`${getNavigationsValue('journal.main')}/lesson/${courseId}/${id}`}
to={`${getNavigationValue('journal.main')}/lesson/${courseId}/${id}`}
style={{ display: 'flex' }}
>
<img width={24} src={qrCode} style={{ margin: '0 auto' }} />
<QRCodeImage />
</Link>
</Td>
)}
<Td textAlign="center">
{dayjs(date).format(groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')}
{formatDate(date, groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')}
</Td>
<Td>{name}</Td>
{isTeacher && (
@ -126,16 +206,20 @@ export const Item: React.FC<ItemProps> = ({
<MenuList>
<MenuItem
onClick={() => {
setEdit(true)
if (setEditLesson) {
setEditLesson();
} else {
setEdit(true);
}
}}
>
Edit
{t('journal.pl.edit')}
</MenuItem>
<MenuItem onClick={setlessonToDelete}>Delete</MenuItem>
<MenuItem onClick={setlessonToDelete}>{t('journal.pl.delete')}</MenuItem>
</MenuList>
</Menu>
)}
{edit && <Button onClick={setlessonToDelete}>Сохранить</Button>}
{edit && <Button onClick={setlessonToDelete}>{t('journal.pl.save')}</Button>}
</Td>
)}
<Td isNumeric>{students.length}</Td>

View File

@ -1,8 +1,12 @@
import React from 'react'
import dayjs from 'dayjs'
import { formatDate } from '../../../utils/dayjs-config'
import {
Tr,
Td,
Box,
Flex,
Text,
useBreakpointValue,
} from '@chakra-ui/react'
import { Lesson } from '../../../__data__/model'
@ -15,6 +19,7 @@ type LessonItemProps = {
isTeacher: boolean
courseId: string
setlessonToDelete(lesson: Lesson): void
setEditLesson?(lesson: Lesson): void
}
export const LessonItems: React.FC<LessonItemProps> = ({
@ -23,23 +28,71 @@ export const LessonItems: React.FC<LessonItemProps> = ({
isTeacher,
courseId,
setlessonToDelete,
}) => (
<>
{date && (
<Tr>
<Td colSpan={isTeacher ? 5 : 3}>
{dayjs(date).format('DD MMMM YYYY')}
</Td>
</Tr>
)}
{lessons.map((lesson) => (
<Item
key={lesson.id}
{...lesson}
setlessonToDelete={() => setlessonToDelete(lesson)}
courseId={courseId}
isTeacher={isTeacher}
/>
))}
</>
)
setEditLesson,
}) => {
// Использование useBreakpointValue для определения мобильного отображения
const isMobile = useBreakpointValue({ base: true, md: false })
// Мобильное отображение
if (isMobile) {
return (
<>
{date && (
<Box
p={3}
mb={2}
bg="gray.100"
borderRadius="md"
_dark={{ bg: "gray.700" }}
>
<Text fontWeight="bold">{formatDate(date, 'DD MMMM YYYY')}</Text>
</Box>
)}
{lessons.map((lesson) => (
<Box
key={lesson.id}
p={3}
mb={2}
borderRadius="md"
boxShadow="sm"
borderLeft="4px solid"
borderLeftColor="cyan.500"
>
<Item
{...lesson}
setlessonToDelete={() => setlessonToDelete(lesson)}
setEditLesson={setEditLesson ? () => setEditLesson(lesson) : undefined}
courseId={courseId}
isTeacher={isTeacher}
isMobile={true}
/>
</Box>
))}
</>
)
}
// Стандартное отображение для планшетов и больших экранов
return (
<>
{date && (
<Tr>
<Td colSpan={isTeacher ? 5 : 3}>
{formatDate(date, 'DD MMMM YYYY')}
</Td>
</Tr>
)}
{lessons.map((lesson) => (
<Item
key={lesson.id}
{...lesson}
setlessonToDelete={() => setlessonToDelete(lesson)}
setEditLesson={setEditLesson ? () => setEditLesson(lesson) : undefined}
courseId={courseId}
isTeacher={isTeacher}
isMobile={false}
/>
))}
</>
)
}

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import { useForm, Controller } from 'react-hook-form'
import {
Box,
@ -14,8 +14,28 @@ import {
FormHelperText,
FormErrorMessage,
Input,
Flex,
Icon,
Text,
Badge,
useColorModeValue,
HStack,
Divider,
SimpleGrid,
Skeleton,
SkeletonText,
useStyleConfig,
Select,
Wrap,
WrapItem,
IconButton,
Center
} from '@chakra-ui/react'
import { AddIcon } from '@chakra-ui/icons'
import { AddIcon, CheckIcon, WarningIcon, RepeatIcon, ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
import { FaRobot } from 'react-icons/fa'
import dayjs from 'dayjs'
import { formatDate } from '../../../utils/dayjs-config'
import { dateToCalendarFormat } from '../../../utils/time'
import { Lesson } from '../../../__data__/model'
@ -24,16 +44,23 @@ import { ErrorSpan } from '../style'
interface NewLessonForm {
name: string
date: string
time: string
}
interface LessonFormProps {
lesson?: Partial<Lesson>
lesson?: Partial<Lesson> | any // Разрешаем передавать как Lesson, так и AI-сгенерированный урок
isLoading: boolean
onCancel: () => void
onSubmit: (lesson: Lesson) => void
error?: string
title: string
nameButton: string
aiSuggestions?: any[] // Список предложений от ИИ
isLoadingAiSuggestions?: boolean // Индикатор загрузки предложений
onSelectAiSuggestion?: (suggestion: any) => void // Обработчик выбора предложения
selectedAiSuggestion?: any // Выбранное предложение
onRetryAiGeneration?: () => void // Функция для повторного запуска генерации
existingLessons?: Array<{ date: string; name: string }> // Добавляем новый проп
}
export const LessonForm = ({
@ -44,11 +71,42 @@ export const LessonForm = ({
error,
title,
nameButton,
aiSuggestions = [],
isLoadingAiSuggestions = false,
onSelectAiSuggestion = () => {},
selectedAiSuggestion,
onRetryAiGeneration = () => {},
existingLessons
}: LessonFormProps) => {
const { t } = useTranslation()
const isAiSuggested = lesson && !lesson._id && !lesson.id
const aiHighlightColor = useColorModeValue('blue.100', 'blue.800')
const suggestionBgColor = useColorModeValue('blue.50', 'blue.900')
const suggestionHoverBgColor = useColorModeValue('blue.100', 'blue.800')
const borderColor = useColorModeValue('blue.200', 'blue.700')
const textSecondaryColor = useColorModeValue('gray.600', 'gray.400')
const getNearestTimeSlot = () => {
const now = new Date();
const minutes = now.getMinutes();
if (minutes < 30) {
// Округляем до начала текущего часа
now.setMinutes(0, 0, 0);
} else {
// Округляем до начала следующего часа
now.setHours(now.getHours() + 1);
now.setMinutes(0, 0, 0);
}
return dateToCalendarFormat(now.toISOString());
};
const {
control,
handleSubmit,
reset,
setValue,
formState: { errors },
} = useForm<NewLessonForm>({
defaultValues: (lesson && {
@ -56,16 +114,296 @@ export const LessonForm = ({
date: dateToCalendarFormat(lesson.date),
}) || {
name: '',
date: dateToCalendarFormat(),
date: getNearestTimeSlot(),
},
})
// Рендерим скелетон для предложений ИИ
const renderSkeletons = () => {
return (
<VStack spacing={3} align="stretch" mt={4}>
<Skeleton height="20px" width="70%" />
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3}>
{[1, 2, 3, 4, 5, 6].map(idx => (
<Skeleton key={idx} height="60px" borderRadius="md" />
))}
</SimpleGrid>
</VStack>
)
}
// Применяем выбранное предложение к форме
const handleSelectSuggestion = (suggestion) => {
setValue('name', suggestion.name)
setValue('date', dateToCalendarFormat(suggestion.date))
onSelectAiSuggestion(suggestion)
}
// Добавляем новые вспомогательные функции
const generateTimeSlots = () => {
const slots = [];
for (let hour = 8; hour <= 21; hour++) {
slots.push(`${hour.toString().padStart(2, '0')}:00`);
slots.push(`${hour.toString().padStart(2, '0')}:30`);
}
return slots;
};
const getNextTimeSlots = (date: string, count: number = 3) => {
const currentDate = new Date();
const selectedDate = new Date(date);
const isToday = selectedDate.toDateString() === currentDate.toDateString();
if (!isToday) return [];
const currentMinutes = currentDate.getHours() * 60 + currentDate.getMinutes();
const slots = generateTimeSlots();
return slots
.map(slot => {
const [hours, minutes] = slot.split(':').map(Number);
const slotMinutes = hours * 60 + minutes;
return { slot, minutes: slotMinutes };
})
.filter(({ minutes }) => minutes > currentMinutes)
.slice(0, count)
.map(({ slot }) => slot);
};
const timeGroups = {
[`${t('journal.pl.days.morning')} (8-12)`]: generateTimeSlots().filter(slot => {
const hour = parseInt(slot.split(':')[0]);
return hour >= 8 && hour < 12;
}),
[`${t('journal.pl.days.day')} (12-17)`]: generateTimeSlots().filter(slot => {
const hour = parseInt(slot.split(':')[0]);
return hour >= 12 && hour < 17;
}),
[`${t('journal.pl.days.evening')} (17-21)`]: generateTimeSlots().filter(slot => {
const hour = parseInt(slot.split(':')[0]);
return hour >= 17 && hour <= 21;
})
};
// Добавляем функцию для получения дня недели
const getDayOfWeek = (date: Date) => {
const days = [
t('journal.pl.days.sunday'),
t('journal.pl.days.monday'),
t('journal.pl.days.tuesday'),
t('journal.pl.days.wednesday'),
t('journal.pl.days.thursday'),
t('journal.pl.days.friday'),
t('journal.pl.days.saturday')
];
return days[date.getDay()];
};
// Добавляем вспомогательные функции для календаря
const getDaysInMonth = (year: number, month: number) => {
return new Date(year, month + 1, 0).getDate();
};
const getFirstDayOfMonth = (year: number, month: number) => {
return new Date(year, month, 1).getDay();
};
const isWeekend = (dayOfWeek: number) => {
return dayOfWeek === 0 || dayOfWeek === 6; // Воскресенье или суббота
};
const isSameDay = (date1: Date, date2: Date) => {
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();
};
// Компонент календаря
interface CalendarProps {
selectedDate: Date;
onSelectDate: (date: Date) => void;
existingLessons?: string[];
}
const Calendar: React.FC<CalendarProps> = ({ selectedDate, onSelectDate, existingLessons = [] }) => {
const { t } = useTranslation();
const [viewDate, setViewDate] = useState(new Date());
// Используем короткие названия дней недели из локализации
const weekDays = [
t('journal.pl.days.shortMonday'),
t('journal.pl.days.shortTuesday'),
t('journal.pl.days.shortWednesday'),
t('journal.pl.days.shortThursday'),
t('journal.pl.days.shortFriday'),
t('journal.pl.days.shortSaturday'),
t('journal.pl.days.shortSunday'),
];
// Используем локализованные названия месяцев
const monthNames = [
t('journal.pl.months.january'),
t('journal.pl.months.february'),
t('journal.pl.months.march'),
t('journal.pl.months.april'),
t('journal.pl.months.may'),
t('journal.pl.months.june'),
t('journal.pl.months.july'),
t('journal.pl.months.august'),
t('journal.pl.months.september'),
t('journal.pl.months.october'),
t('journal.pl.months.november'),
t('journal.pl.months.december'),
];
const daysInMonth = getDaysInMonth(viewDate.getFullYear(), viewDate.getMonth());
let firstDay = getFirstDayOfMonth(viewDate.getFullYear(), viewDate.getMonth());
firstDay = firstDay === 0 ? 6 : firstDay - 1; // Корректируем для начала недели с понедельника
const days = Array.from({ length: 42 }, (_, i) => {
const dayNumber = i - firstDay + 1;
if (dayNumber > 0 && dayNumber <= daysInMonth) {
const date = new Date(viewDate.getFullYear(), viewDate.getMonth(), dayNumber);
return {
date,
dayOfMonth: dayNumber,
isCurrentMonth: true,
isWeekend: isWeekend(date.getDay()),
isToday: isSameDay(date, new Date()),
isSelected: isSameDay(date, selectedDate)
};
}
return null;
});
// Добавим функцию проверки наличия лекции в определенный день
const hasLessonOnDate = (date: Date) => {
return existingLessons.some(lessonDate =>
isSameDay(new Date(lessonDate), date)
);
};
return (
<Box>
<Text fontSize="sm" mb={2}>{t('journal.pl.lesson.form.selectDate')}</Text>
<HStack justify="space-between" mb={2}>
<IconButton
aria-label="Previous month"
icon={<ChevronLeftIcon />}
size="sm"
onClick={() => {
const newDate = new Date(viewDate);
newDate.setMonth(newDate.getMonth() - 1);
setViewDate(newDate);
}}
/>
<HStack>
<Select
size="sm"
value={viewDate.getMonth()}
onChange={(e) => {
const newDate = new Date(viewDate);
newDate.setMonth(parseInt(e.target.value));
setViewDate(newDate);
}}
>
{monthNames.map((month, i) => (
<option key={i} value={i}>{month}</option>
))}
</Select>
<Select
size="sm"
value={viewDate.getFullYear()}
onChange={(e) => {
const newDate = new Date(viewDate);
newDate.setFullYear(parseInt(e.target.value));
setViewDate(newDate);
}}
>
{Array.from({ length: 5 }, (_, i) => {
const year = new Date().getFullYear() + i;
return <option key={year} value={year}>{year}</option>;
})}
</Select>
</HStack>
<IconButton
aria-label="Next month"
icon={<ChevronRightIcon />}
size="sm"
onClick={() => {
const newDate = new Date(viewDate);
newDate.setMonth(newDate.getMonth() + 1);
setViewDate(newDate);
}}
/>
</HStack>
<SimpleGrid columns={7} spacing={1}>
{weekDays.map(day => (
<Center key={day} py={1}>
<Text fontSize="xs" color="gray.500">
{day}
</Text>
</Center>
))}
{days.map((day, i) => {
const hasLesson = day?.isCurrentMonth && hasLessonOnDate(day.date);
return (
<Button
key={i}
size="sm"
variant={day?.isSelected ? "solid" : "ghost"}
colorScheme={day?.isSelected ? "blue" : day?.isWeekend ? "red" : "gray"}
opacity={day?.isCurrentMonth ? 1 : 0}
onClick={() => day?.date && onSelectDate(day.date)}
h="32px"
disabled={!day?.isCurrentMonth}
position="relative"
_after={hasLesson ? {
content: '""',
position: "absolute",
bottom: "2px",
left: "50%",
transform: "translateX(-50%)",
width: "4px",
height: "4px",
borderRadius: "full",
bg: day?.isSelected ? "white" : "blue.500",
_dark: {
bg: day?.isSelected ? "white" : "blue.300"
}
} : undefined}
title={hasLesson ? t('journal.pl.lesson.existingLessonHint') : undefined}
>
<Text
fontSize="xs"
fontWeight={day?.isToday ? "bold" : "normal"}
textDecoration={day?.isToday ? "underline" : "none"}
>
{day?.dayOfMonth}
</Text>
</Button>
);
})}
</SimpleGrid>
</Box>
);
};
return (
<Card align="left">
<Card align="left" bg={isAiSuggested ? aiHighlightColor : undefined}>
<CardHeader display="flex">
<Heading as="h2" mt="0">
{title}
</Heading>
<Flex align="center">
<Heading as="h2" mt="0">
{title}
</Heading>
{isAiSuggested && (
<Badge colorScheme="blue" ml={2} display="flex" alignItems="center">
<Icon as={FaRobot} mr={1} />
<Text>{t('journal.pl.lesson.aiGenerated')}</Text>
</Badge>
)}
</Flex>
<CloseButton
ml="auto"
onClick={() => {
@ -80,37 +418,82 @@ export const LessonForm = ({
<Controller
control={control}
name="date"
rules={{ required: 'Обязательное поле' }}
render={({ field }) => (
<FormControl>
<FormLabel>Дата</FormLabel>
<Input
{...field}
required={false}
placeholder="Укажите дату лекции"
size="md"
type="datetime-local"
/>
{errors.date ? (
<FormErrorMessage>{errors.date?.message}</FormErrorMessage>
) : (
<FormHelperText>Укажите дату и время лекции</FormHelperText>
)}
</FormControl>
)}
rules={{ required: t('journal.pl.common.required') }}
render={({ field }) => {
const [currentDate = '', currentTime = '00:00:00'] = field.value.split('T');
const currentTimeShort = currentTime.split(':').slice(0, 2).join(':');
const selectedDate = new Date(currentDate);
// Получаем существующие лекции из пропсов компонента
const existingLessons2 = existingLessons?.map(lesson => lesson.date) || [];
return (
<FormControl>
<FormLabel>{t('journal.pl.lesson.form.date')}</FormLabel>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{/* Календарь */}
<Box>
<Calendar
selectedDate={selectedDate}
existingLessons={existingLessons2}
onSelectDate={(date) => {
const formattedDate = dateToCalendarFormat(date.toISOString()).split('T')[0];
field.onChange(`${formattedDate}T${currentTimeShort}:00`);
}}
/>
</Box>
{/* Временные слоты */}
<Box>
<Text fontSize="sm" mb={2}>{t('journal.pl.lesson.form.selectTime')}:</Text>
<SimpleGrid columns={1} spacing={4}>
{Object.entries(timeGroups).map(([groupName, slots]) => (
<Box key={groupName}>
<Text fontSize="xs" color="gray.500" mb={1}>
{groupName}
</Text>
<Wrap spacing={1}>
{slots.map(slot => {
const isSelected = currentTimeShort === slot;
return (
<WrapItem key={slot}>
<Button
size="xs"
variant={isSelected ? "solid" : "outline"}
colorScheme="blue"
onClick={() => {
field.onChange(`${currentDate}T${slot}:00`);
}}
h="24px"
minW="54px"
>
{slot}
</Button>
</WrapItem>
);
})}
</Wrap>
</Box>
))}
</SimpleGrid>
</Box>
</SimpleGrid>
</FormControl>
);
}}
/>
<Controller
control={control}
name="name"
rules={{ required: 'Обязательное поле' }}
rules={{ required: t('journal.pl.common.required') }}
render={({ field }) => (
<FormControl isRequired isInvalid={Boolean(errors.name)}>
<FormLabel>Название новой лекции:</FormLabel>
<FormLabel>{t('journal.pl.lesson.form.title')}</FormLabel>
<Input
{...field}
required={false}
placeholder="Название лекции"
placeholder={t('journal.pl.lesson.form.namePlaceholder')}
size="md"
/>
{errors.name && (
@ -123,8 +506,8 @@ export const LessonForm = ({
<Button
size="lg"
type="submit"
leftIcon={<AddIcon />}
colorScheme="blue"
leftIcon={isAiSuggested ? <Icon as={FaRobot} /> : <AddIcon />}
colorScheme={isAiSuggested ? "blue" : "blue"}
isLoading={isLoading}
>
{nameButton}
@ -134,6 +517,98 @@ export const LessonForm = ({
{error && <ErrorSpan>{error}</ErrorSpan>}
</form>
{/* Блок с предложениями ИИ */}
{((Array.isArray(aiSuggestions) && aiSuggestions.length > 0) || isLoadingAiSuggestions || typeof aiSuggestions === 'string') && (
<>
<Divider my={6} />
<Flex align="center" mb={4}>
<Icon as={FaRobot} color="blue.500" mr={2} />
<Heading size="sm" color="blue.500">
{t('journal.pl.lesson.aiSuggested')}
</Heading>
{!isLoadingAiSuggestions && Array.isArray(aiSuggestions) && (
<Badge colorScheme="blue" ml={2}>
{aiSuggestions.length} {t('journal.pl.common.lesson').toLowerCase()}
</Badge>
)}
</Flex>
{isLoadingAiSuggestions ? (
renderSkeletons()
) : typeof aiSuggestions === 'string' ? (
<Box
p={4}
bg={useColorModeValue('red.50', 'red.900')}
color={useColorModeValue('red.600', 'red.200')}
borderRadius="md"
borderLeft="3px solid"
borderLeftColor="red.500"
>
<Flex align="center" mb={2}>
<Icon as={WarningIcon} color="red.500" mr={2} />
<Text fontWeight="bold">{t('journal.pl.lesson.aiGenerationError')}</Text>
</Flex>
<Text fontSize="sm" mb={3}>{t('journal.pl.lesson.tryAgainLater')}</Text>
<Button
size="sm"
colorScheme="red"
variant="outline"
leftIcon={<RepeatIcon />}
onClick={onRetryAiGeneration}
isLoading={isLoadingAiSuggestions}
>
{t('journal.pl.lesson.retryGeneration')}
</Button>
</Box>
) : (
<>
<Text fontSize="sm" color={textSecondaryColor} mb={4}>
{t('journal.pl.lesson.aiSuggestedDescription')}
</Text>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{aiSuggestions.map((suggestion, index) => {
const isSelected = selectedAiSuggestion &&
selectedAiSuggestion.name === suggestion.name &&
selectedAiSuggestion.date === suggestion.date;
return (
<Box
key={`ai-suggestion-${index}`}
bg={isSelected ? suggestionHoverBgColor : suggestionBgColor}
p={3}
borderRadius="md"
borderLeft="3px solid"
borderLeftColor={isSelected ? "green.400" : borderColor}
cursor="pointer"
onClick={() => handleSelectSuggestion(suggestion)}
transition="all 0.2s"
_hover={{
bg: suggestionHoverBgColor,
transform: 'translateY(-2px)',
boxShadow: 'sm'
}}
>
<Flex justify="space-between" align="center" mb={1}>
<HStack>
<Icon as={FaRobot} color="blue.500" boxSize="3" />
<Text fontWeight="bold">{suggestion.name}</Text>
</HStack>
{isSelected && <CheckIcon color="green.400" />}
</Flex>
<Text fontSize="sm" color={textSecondaryColor}>
{formatDate(suggestion.date, 'DD.MM.YYYY HH:mm')}
</Text>
</Box>
);
})}
</SimpleGrid>
</>
)}
</>
)}
</CardBody>
</Card>
)

View File

@ -0,0 +1,309 @@
import React, { useMemo } from 'react'
import dayjs from 'dayjs'
import { formatDate } from '../../../utils/dayjs-config'
import {
Box,
Heading,
Text,
SimpleGrid,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
StatGroup,
Flex,
Icon,
Progress,
Divider,
Badge,
VStack,
HStack,
useColorModeValue
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import {
FaChalkboardTeacher,
FaUserGraduate,
FaClock,
FaCalendarCheck,
FaCalendarAlt,
FaPercentage
} from 'react-icons/fa'
import { CalendarIcon, StarIcon, TimeIcon } from '@chakra-ui/icons'
import { Lesson } from '../../../__data__/model'
interface CourseStatisticsProps {
lessons: Lesson[]
isLoading: boolean
}
export const CourseStatistics: React.FC<CourseStatisticsProps> = ({ lessons = [], isLoading }) => {
const { t } = useTranslation()
const statBgColor = useColorModeValue('white', 'gray.700')
const borderColor = useColorModeValue('gray.200', 'gray.600')
// Вычисляем статистику курса
const stats = useMemo(() => {
if (!lessons || lessons.length === 0) {
return {
totalLessons: 0,
completedLessons: 0,
upcomingLessons: 0,
attendanceRate: 0,
averageStudentsPerLesson: 0,
nextLessonDate: null,
mostAttendedLesson: null,
attendanceTrend: 0,
totalStudents: 0,
daysUntilNextLesson: 0,
percentageCompleted: 0
}
}
const now = dayjs()
const completed = lessons.filter(lesson => dayjs(lesson.date).isBefore(now))
const upcoming = lessons.filter(lesson => dayjs(lesson.date).isAfter(now))
// Сортируем предстоящие занятия по дате (ближайшие вперед)
const sortedUpcoming = [...upcoming].sort((a, b) =>
dayjs(a.date).valueOf() - dayjs(b.date).valueOf()
)
// Находим ближайшее занятие
const nextLesson = sortedUpcoming.length > 0 ? sortedUpcoming[0] : null
// Вычисляем среднее количество студентов на занятии
const totalStudentsCount = completed.reduce(
(sum, lesson) => sum + (lesson.students?.length || 0),
0
)
const averageStudents = completed.length
? totalStudentsCount / completed.length
: 0
// Находим занятие с наибольшей посещаемостью
let mostAttended = null
let maxAttendance = 0
completed.forEach(lesson => {
const attendance = lesson.students?.length || 0
if (attendance > maxAttendance) {
maxAttendance = attendance
mostAttended = lesson
}
})
// Вычисляем тренд посещаемости (положительный или отрицательный)
let attendanceTrend = 0
if (completed.length >= 2) {
// Берем последние 5 занятий или меньше, если их меньше 5
const recentLessons = [...completed]
.sort((a, b) => dayjs(b.date).valueOf() - dayjs(a.date).valueOf())
.slice(0, 5)
if (recentLessons.length >= 2) {
const lastLesson = recentLessons[0]
const previousLessons = recentLessons.slice(1)
const lastAttendance = lastLesson.students?.length || 0
const avgPreviousAttendance = previousLessons.reduce(
(sum, lesson) => sum + (lesson.students?.length || 0),
0
) / previousLessons.length
// Вычисляем процентное изменение
attendanceTrend = avgPreviousAttendance
? ((lastAttendance - avgPreviousAttendance) / avgPreviousAttendance) * 100
: 0
}
}
// Вычисляем количество дней до следующего занятия
const daysUntilNext = nextLesson
? dayjs(nextLesson.date).diff(now, 'day')
: 0
// Собираем все уникальные ID студентов
const uniqueStudents = new Set()
lessons.forEach(lesson => {
lesson.students?.forEach(student => {
uniqueStudents.add(student.sub)
})
})
// Вычисляем процент завершенного курса
const percentComplete = lessons.length
? (completed.length / lessons.length) * 100
: 0
return {
totalLessons: lessons.length,
completedLessons: completed.length,
upcomingLessons: upcoming.length,
attendanceRate: completed.length ? (totalStudentsCount / (completed.length * uniqueStudents.size || 1)) * 100 : 0,
averageStudentsPerLesson: Math.round(averageStudents * 10) / 10,
nextLessonDate: nextLesson?.date || null,
mostAttendedLesson: mostAttended,
attendanceTrend,
totalStudents: uniqueStudents.size,
daysUntilNextLesson: daysUntilNext,
percentageCompleted: percentComplete
}
}, [lessons])
// Определяем цвет для показателей статистики
const getProgressColor = (value) => {
if (value > 80) return 'green'
if (value > 50) return 'blue'
if (value > 30) return 'yellow'
return 'red'
}
if (isLoading || !lessons.length) {
return null
}
return (
<Box mb={6} py={3}>
<Heading size="md" mb={4}>
{t('journal.pl.statistics.title')}
</Heading>
<SimpleGrid columns={{ base: 1, sm: 2, md: 4 }} spacing={5} mb={5}>
{/* Статистика по занятиям */}
<Stat
bg={statBgColor}
p={3}
borderRadius="lg"
boxShadow="sm"
borderLeft="4px solid"
borderLeftColor="blue.400"
>
<Flex align="center" mb={2}>
<Icon as={FaCalendarAlt} color="blue.400" mr={2} />
<StatLabel>{t('journal.pl.statistics.totalLessons')}</StatLabel>
</Flex>
<StatNumber fontSize="2xl">{stats.totalLessons}</StatNumber>
<StatHelpText mb={0}>
<HStack>
<Icon as={FaCalendarCheck} color="green.400" boxSize="0.9em" />
<Text>{stats.completedLessons} {t('journal.pl.statistics.completed')}</Text>
</HStack>
</StatHelpText>
</Stat>
{/* Статистика по посещаемости */}
<Stat
bg={statBgColor}
p={3}
borderRadius="lg"
boxShadow="sm"
borderLeft="4px solid"
borderLeftColor="green.400"
>
<Flex align="center" mb={2}>
<Icon as={FaPercentage} color="green.400" mr={2} />
<StatLabel>{t('journal.pl.statistics.attendanceRate')}</StatLabel>
</Flex>
<StatNumber fontSize="2xl">
{Math.round(stats.attendanceRate)}%
</StatNumber>
<StatHelpText>
{stats.attendanceTrend !== 0 && (
<Flex align="center">
<StatArrow
type={Number(stats.attendanceTrend) > 0 ? 'increase' : 'decrease'}
/>
<Text>
{Math.abs(Math.round(Number(stats.attendanceTrend)))}%
</Text>
</Flex>
)}
</StatHelpText>
</Stat>
{/* Статистика по студентам */}
<Stat
bg={statBgColor}
p={3}
borderRadius="lg"
boxShadow="sm"
borderLeft="4px solid"
borderLeftColor="purple.400"
>
<Flex align="center" mb={2}>
<Icon as={FaUserGraduate} color="purple.400" mr={2} />
<StatLabel>{t('journal.pl.statistics.totalStudents')}</StatLabel>
</Flex>
<StatNumber fontSize="2xl">{stats.totalStudents}</StatNumber>
<StatHelpText mb={0}>
<Text>
~ {stats.averageStudentsPerLesson} {t('journal.pl.statistics.perLesson')}
</Text>
</StatHelpText>
</Stat>
{/* Следующее занятие */}
<Stat
bg={statBgColor}
p={3}
borderRadius="lg"
boxShadow="sm"
borderLeft="4px solid"
borderLeftColor="orange.400"
>
<Flex align="center" mb={2}>
<Icon as={FaClock} color="orange.400" mr={2} />
<StatLabel>{t('journal.pl.statistics.nextLesson')}</StatLabel>
</Flex>
<StatNumber fontSize="xl">
{stats.nextLessonDate
? formatDate(stats.nextLessonDate, 'DD.MM.YYYY')
: t('journal.pl.statistics.noUpcoming')
}
</StatNumber>
<StatHelpText mb={0}>
{stats.nextLessonDate && (
<Text>
{t('journal.pl.statistics.in')} {stats.daysUntilNextLesson} {t('journal.pl.statistics.days')}
</Text>
)}
</StatHelpText>
</Stat>
</SimpleGrid>
<Box
bg={statBgColor}
p={4}
borderRadius="lg"
boxShadow="sm"
borderTop="1px solid"
borderColor={borderColor}
>
<Text fontWeight="bold" mb={2}>
{t('journal.pl.statistics.courseProgress')}
</Text>
<Progress
value={stats.percentageCompleted}
size="lg"
borderRadius="md"
colorScheme={getProgressColor(stats.percentageCompleted)}
mb={1}
hasStripe
/>
<Flex justify="space-between" fontSize="sm">
<Text>
{t('journal.pl.statistics.completed')}: {stats.completedLessons} / {stats.totalLessons}
</Text>
<Text fontWeight="medium">
{Math.round(stats.percentageCompleted)}%
</Text>
</Flex>
</Box>
</Box>
)
}

View File

@ -1,11 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import dayjs from 'dayjs'
import dayjs, { formatDate } from '../../utils/dayjs-config'
import { generatePath, Link, useParams } from 'react-router-dom'
import { getNavigationsValue, getFeatures } from '@brojs/cli'
import { getNavigationValue, getFeatures } from '@brojs/cli'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Container,
Box,
Button,
@ -17,6 +14,7 @@ import {
Tr,
Th,
Tbody,
Td,
Text,
AlertDialog,
AlertDialogBody,
@ -24,29 +22,49 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
useBreakpointValue,
Flex,
Menu,
MenuButton,
MenuList,
MenuItem,
useColorMode,
Portal,
} from '@chakra-ui/react'
import { AddIcon } from '@chakra-ui/icons'
import { AddIcon, EditIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
import { useAppSelector } from '../../__data__/store'
import { api } from '../../__data__/api/api'
import { isTeacher } from '../../utils/user'
import { Lesson } from '../../__data__/model'
import { XlSpinner } from '../../components/xl-spinner'
import { XlSpinner, useSetBreadcrumbs } from '../../components'
import { qrCode } from '../../assets'
import { LessonForm } from './components/lessons-form'
import { Bar } from './components/bar'
import { LessonItems } from './components/lesson-items'
import { BreadcrumbsWrapper } from './style'
import { CourseStatistics } from './components/statistics'
const features = getFeatures('journal')
const barFeature = features?.['lesson.bar']
const groupByDate = features?.['group.by.date']
const courseStatistics = features?.['course.statistics']
const LessonList = () => {
const { courseId } = useParams()
const user = useAppSelector((s) => s.user)
const { data, isLoading, error, isSuccess } = api.useLessonListQuery(courseId)
const { data: courseData } = api.useGetCourseByIdQuery(courseId)
const [generateLessonsMutation, {
data: generateLessons,
isLoading: isLoadingGenerateLessons,
error: errorGenerateLessons,
isSuccess: isSuccessGenerateLessons
}, ] = api.useGenerateLessonsMutation()
const { colorMode } = useColorMode()
const [createLesson, crLQuery] = api.useCreateLessonMutation()
const [deleteLesson, deletingRqst] = api.useDeleteLessonMutation()
const [updateLesson, updateLessonRqst] = api.useUpdateLessonMutation()
@ -57,11 +75,43 @@ const LessonList = () => {
const toastRef = useRef(null)
const createdLessonRef = useRef(null)
const [editLesson, setEditLesson] = useState<Lesson>(null)
const [suggestedLessonToCreate, setSuggestedLessonToCreate] = useState(null)
const { t } = useTranslation()
// Устанавливаем хлебные крошки для страницы списка уроков
useSetBreadcrumbs([
{
title: t('journal.pl.breadcrumbs.home'),
path: '/'
},
{
title: courseData?.name || t('journal.pl.breadcrumbs.course'),
isCurrentPage: true
}
])
const sorted = useMemo(
() => [...(data?.body || [])]?.sort((a, b) => (a.date > b.date ? 1 : -1)),
[data, data?.body],
)
// Найдем максимальное количество студентов среди всех уроков
const maxStudents = useMemo(() => {
if (!sorted || sorted.length === 0) return 1
const max = Math.max(...sorted.map(lesson => lesson.students?.length || 0))
return max > 0 ? max : 1 // Избегаем деления на ноль
}, [sorted])
// Функция для определения цвета на основе посещаемости
const getAttendanceColor = (attendance: number) => {
const percentage = (attendance / maxStudents) * 100
if (percentage > 80) return { bg: 'green.100', color: 'green.800', dark: { bg: 'green.800', color: 'green.100' } }
if (percentage > 60) return { bg: 'teal.100', color: 'teal.800', dark: { bg: 'teal.800', color: 'teal.100' } }
if (percentage > 40) return { bg: 'yellow.100', color: 'yellow.800', dark: { bg: 'yellow.800', color: 'yellow.100' } }
if (percentage > 20) return { bg: 'orange.100', color: 'orange.800', dark: { bg: 'orange.800', color: 'orange.100' } }
return { bg: 'red.100', color: 'red.800', dark: { bg: 'red.800', color: 'red.100' } }
}
const lessonCalc = useMemo(() => {
if (!isSuccess) {
return []
@ -91,9 +141,38 @@ const LessonList = () => {
return lessonsData.sort((a, b) => (a.date < b.date ? 1 : -1))
}, [groupByDate, isSuccess, sorted])
useEffect(() => {
if (isSuccessGenerateLessons) {
console.log(generateLessons)
// Проверяем корректность ответа API
if (typeof generateLessons?.body === 'string') {
toast({
title: t('journal.pl.lesson.aiGenerationError'),
description: t('journal.pl.lesson.tryAgainLater'),
status: 'error',
duration: 5000,
isClosable: true,
});
}
}
}, [isSuccessGenerateLessons, generateLessons])
useEffect(() => {
if (errorGenerateLessons) {
toast({
title: t('journal.pl.lesson.aiGenerationError'),
description: t('journal.pl.lesson.tryAgainLater'),
status: 'error',
duration: 5000,
isClosable: true,
});
}
}, [errorGenerateLessons])
const onSubmit = (lessonData) => {
toastRef.current = toast({
title: 'Отправляем',
title: t('journal.pl.common.sending'),
status: 'loading',
duration: 9000,
})
@ -123,7 +202,7 @@ const LessonList = () => {
title={
<>
<Box pb={3}>
<Text fontSize="xl">{`Удалена лекция ${lesson.name}`}</Text>
<Text fontSize="xl">{t('journal.pl.lesson.deletedMessage', { name: lesson.name })}</Text>
</Box>
<Button
onClick={() => {
@ -131,7 +210,7 @@ const LessonList = () => {
toast.close(id)
}}
>
Восстановить
{t('journal.pl.common.restored')}
</Button>
</>
}
@ -145,8 +224,8 @@ const LessonList = () => {
useEffect(() => {
if (crLQuery.isSuccess) {
const toastProps = {
title: 'Лекция создана',
description: `Лекция ${createdLessonRef.current?.name} успешно создана`,
title: t('journal.pl.lesson.created'),
description: t('journal.pl.lesson.successMessage', { name: createdLessonRef.current?.name }),
status: 'success' as const,
duration: 9000,
isClosable: true,
@ -160,8 +239,8 @@ const LessonList = () => {
useEffect(() => {
if (updateLessonRqst.isSuccess) {
const toastProps = {
title: 'Лекция Обновлена',
description: `Лекция ${createdLessonRef.current?.name} успешно обновлена`,
title: t('journal.pl.lesson.updated'),
description: t('journal.pl.lesson.updateMessage', { name: createdLessonRef.current?.name }),
status: 'success' as const,
duration: 9000,
isClosable: true,
@ -172,6 +251,54 @@ const LessonList = () => {
}
}, [updateLessonRqst.isSuccess])
// Обработчик выбора предложения ИИ в форме
const handleSelectAiSuggestion = (suggestion) => {
setSuggestedLessonToCreate(suggestion)
}
// Очищаем выбранную сгенерированную лекцию при закрытии формы
const handleCancelForm = () => {
setShowForm(false)
setEditLesson(null)
setSuggestedLessonToCreate(null)
// Сбрасываем флаги генерации, чтобы при повторном открытии формы
// генерация запускалась снова при необходимости
// (особенно если была ошибка в предыдущей генерации)
}
// Обработчик открытия формы создания новой лекции
const handleOpenForm = () => {
setShowForm(true)
// Запускаем генерацию лекций только при открытии формы создания новой лекции
// и если генерация ещё не была запущена или предыдущая попытка завершилась с ошибкой
const shouldGenerateAgain = !generateLessons ||
typeof generateLessons?.body === 'string' ||
errorGenerateLessons;
if (isTeacher(user) && !editLesson && (!isLoadingGenerateLessons && shouldGenerateAgain)) {
generateLessonsMutation(courseId)
}
}
// Обработчик редактирования существующей лекции
const handleEditLesson = (lesson) => {
setEditLesson(lesson)
setShowForm(true)
// Не запускаем генерацию при редактировании
}
// Обработчик повторной генерации предложений ИИ
const handleRetryAiGeneration = () => {
if (isTeacher(user) && !isLoadingGenerateLessons) {
generateLessonsMutation(courseId)
}
}
// Добавляем определение размера экрана
const isMobile = useBreakpointValue({ base: true, md: false })
if (isLoading) {
return <XlSpinner />
}
@ -186,12 +313,11 @@ const LessonList = () => {
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Удалить занятие от{' '}
{dayjs(lessonToDelete?.date).format('DD.MM.YY')}?
{t('journal.pl.lesson.deleteConfirm', { date: formatDate(lessonToDelete?.date, 'DD.MM.YY') })}
</AlertDialogHeader>
<AlertDialogBody>
Все данные о посещении данного занятия будут удалены
{t('journal.pl.lesson.deleteWarning')}
</AlertDialogBody>
<AlertDialogFooter>
@ -200,7 +326,7 @@ const LessonList = () => {
ref={cancelRef}
onClick={() => setlessonToDelete(null)}
>
Cancel
{t('journal.pl.cancel')}
</Button>
<Button
colorScheme="red"
@ -209,53 +335,52 @@ const LessonList = () => {
onClick={() => deleteLesson(lessonToDelete.id)}
ml={3}
>
Delete
{t('journal.pl.delete')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
<BreadcrumbsWrapper>
<Breadcrumb>
<BreadcrumbItem>
<BreadcrumbLink as={Link} to={getNavigationsValue('journal.main')}>
Журнал
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbItem isCurrentPage>
<BreadcrumbLink href="#">Курс</BreadcrumbLink>
</BreadcrumbItem>
</Breadcrumb>
</BreadcrumbsWrapper>
<Container maxW="container.xl" position="relative">
{isTeacher(user) && (
<Box mt="15" mb="15">
{showForm ? (
<LessonForm
key={editLesson?.id}
key={editLesson?.id || 'new-lesson'}
isLoading={crLQuery.isLoading}
onSubmit={onSubmit}
onCancel={() => {
setShowForm(false)
setEditLesson(null)
}}
onCancel={handleCancelForm}
error={(crLQuery.error as any)?.error}
lesson={editLesson}
title={editLesson ? 'Редактирование лекции' : 'Создание лекции'}
nameButton={editLesson ? 'Редактировать' : 'Создать'}
lesson={editLesson || suggestedLessonToCreate || undefined}
title={editLesson ? t('journal.pl.lesson.editTitle') : t('journal.pl.lesson.createTitle')}
nameButton={editLesson ? t('journal.pl.edit') : t('journal.pl.common.create')}
aiSuggestions={generateLessons?.body}
isLoadingAiSuggestions={isLoadingGenerateLessons}
onSelectAiSuggestion={handleSelectAiSuggestion}
selectedAiSuggestion={suggestedLessonToCreate}
onRetryAiGeneration={handleRetryAiGeneration}
existingLessons={data?.body?.map(lesson => ({
date: lesson.date,
name: lesson.name
}))}
/>
) : (
<Button
leftIcon={<AddIcon />}
colorScheme="green"
onClick={() => setShowForm(true)}
onClick={handleOpenForm}
>
Добавить
{t('journal.pl.common.create')}
</Button>
)}
</Box>
)}
{/* Статистика курса */}
{!showForm && courseStatistics && (
<CourseStatistics lessons={sorted} isLoading={isLoading} />
)}
{barFeature && sorted?.length > 1 && (
<Box height="300">
<Bar
@ -266,37 +391,175 @@ const LessonList = () => {
/>
</Box>
)}
<TableContainer whiteSpace="wrap" pb={13}>
<Table variant="striped" colorScheme="cyan">
<Thead>
<Tr>
{isTeacher(user) && (
<Th align="center" width={1}>
ссылка
</Th>
{isMobile ? (
<Box pb={13}>
{lessonCalc?.map(({ data: lessons, date }) => (
<LessonItems
courseId={courseId}
date={date}
isTeacher={isTeacher(user)}
lessons={lessons}
setlessonToDelete={setlessonToDelete}
setEditLesson={handleEditLesson}
key={date}
/>
))}
</Box>
) : (
<Box pb={13}>
{lessonCalc?.map(({ data: lessons, date }) => (
<Box key={date} mb={6}>
{date && (
<Box
p={3}
mb={4}
bg="cyan.50"
borderRadius="md"
_dark={{ bg: "cyan.900" }}
boxShadow="sm"
>
<Text fontWeight="bold" fontSize="lg">
{formatDate(date, 'DD MMMM YYYY')}
</Text>
</Box>
)}
<Th textAlign="center" width={1}>
{groupByDate ? 'Время' : 'Дата'}
</Th>
<Th width="100%">Название</Th>
{isTeacher(user) && <Th>action</Th>}
<Th isNumeric>Отмечено</Th>
</Tr>
</Thead>
<Tbody>
{lessonCalc?.map(({ data: lessons, date }) => (
<LessonItems
courseId={courseId}
date={date}
isTeacher={isTeacher(user)}
lessons={lessons}
setlessonToDelete={setlessonToDelete}
key={date}
/>
))}
</Tbody>
</Table>
</TableContainer>
<Box>
{lessons.map((lesson, index) => (
<Box
key={lesson.id}
borderRadius="lg"
boxShadow="md"
bg="white"
_dark={{ bg: "gray.700" }}
transition="all 0.3s"
_hover={{
transform: "translateX(5px)",
boxShadow: "lg"
}}
overflow="hidden"
position="relative"
mb={4}
animation={`slideIn 0.6s ease-out ${index * 0.15}s both`}
sx={{
'@keyframes slideIn': {
'0%': {
opacity: 0,
transform: 'translateX(-30px)'
},
'100%': {
opacity: 1,
transform: 'translateX(0)'
}
}
}}
>
<Flex direction={{ base: "column", sm: "row" }}>
{/* QR код и ссылка - левая часть карточки */}
{isTeacher(user) && (
<Link
to={`${getNavigationValue('journal.main')}/lesson/${courseId}/${lesson.id}`}
>
<Box
p={4}
bg="cyan.500"
_dark={{ bg: "cyan.600" }}
color="white"
display="flex"
alignItems="center"
justifyContent="center"
transition="all 0.2s"
_hover={{ bg: "cyan.600", _dark: { bg: "cyan.700" } }}
height="100%"
minW="150px"
>
<Box
mr={0}
bg="white"
borderRadius="md"
p={2}
display="flex"
>
<img width={32} src={qrCode} alt="QR код" />
</Box>
</Box>
</Link>
)}
{/* Содержимое карточки */}
<Box p={5} w="100%" display="flex" flexDirection="column" justifyContent="space-between">
<Flex mb={3} justify="space-between" align="center">
{/* Название урока */}
<Text fontWeight="bold" fontSize="xl" lineHeight="1.4" flex="1">
{lesson.name}
</Text>
<Text fontSize="sm" color="gray.500" _dark={{ color: "gray.300" }} ml={3} whiteSpace="nowrap">
{formatDate(lesson.date, groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')}
</Text>
</Flex>
{/* Нижняя часть с метками и действиями */}
<Flex justifyContent="space-between" alignItems="center" mt={1}>
<Flex align="center">
<Text fontSize="sm" mr={2}>
{t('journal.pl.common.marked')}:
</Text>
<Text
px={2}
py={1}
bg={getAttendanceColor(lesson.students.length).bg}
color={getAttendanceColor(lesson.students.length).color}
_dark={{
bg: getAttendanceColor(lesson.students.length).dark.bg,
color: getAttendanceColor(lesson.students.length).dark.color
}}
borderRadius="md"
fontWeight="bold"
fontSize="sm"
>
{lesson.students.length}
</Text>
</Flex>
{isTeacher(user) && (
<Menu>
<MenuButton
as={Button}
size="sm"
colorScheme="cyan"
variant="ghost"
rightIcon={<EditIcon />}
>
{t('journal.pl.edit')}
</MenuButton>
<Portal>
<MenuList zIndex={1000}>
<MenuItem
onClick={() => handleEditLesson(lesson)}
icon={<EditIcon />}
>
{t('journal.pl.edit')}
</MenuItem>
<MenuItem
onClick={() => setlessonToDelete(lesson)}
color="red.500"
>
{t('journal.pl.delete')}
</MenuItem>
</MenuList>
</Portal>
</Menu>
)}
</Flex>
</Box>
</Flex>
</Box>
))}
</Box>
</Box>
))}
</Box>
)}
</Container>
</>
)

View File

@ -16,24 +16,120 @@ const reveal = keyframes`
`
export const StudentList = styled.ul`
padding-left: 0px;
height: 600px;
justify-content: space-evenly;
padding-right: 20px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
padding: 0;
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
width: 100%;
@media (max-width: 768px) {
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
/* Стили для motion.li элементов */
li {
list-style: none;
height: 100%;
}
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
}
.chakra-ui-dark &::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
.chakra-ui-dark &::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
}
`
export const StudentListView = styled.ul`
padding: 0;
list-style: none;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px;
width: 100%;
/* Адаптивные отступы на разных экранах */
@media (max-width: 768px) {
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
}
/* Стили для контейнеров карточек */
li {
list-style: none;
height: 100%;
transform-origin: center bottom;
}
/* Добавляем плавные переходы между состояниями */
li:hover {
z-index: 10;
}
/* Стилизация скроллбара */
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.03);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.chakra-ui-dark &::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.03);
}
.chakra-ui-dark &::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
}
`
export const QRCanvas = styled.canvas`
display: block;
aspect-ratio: 1 / 1;
width: 450px;
min-width: 450px;
max-width: 100%;
height: auto;
@media (max-width: 768px) {
width: 300px;
min-width: auto;
}
`
export const ErrorSpan = styled.span`
color: #f9e2e2;
color: var(--chakra-colors-red-100);
display: block;
padding: 16px;
background-color: #d32f0b;
background-color: var(--chakra-colors-red-600);
border-radius: 11px;
.chakra-ui-dark & {
color: var(--chakra-colors-red-200);
background-color: var(--chakra-colors-red-800);
}
`

View File

@ -1,8 +1,10 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { motion, AnimatePresence } from 'framer-motion'
import { api } from '../__data__/api/api'
import dayjs from 'dayjs'
import { formatDate } from '../utils/dayjs-config'
import {
Alert,
AlertIcon,
@ -11,17 +13,104 @@ import {
Container,
Spinner,
Text,
Heading,
Badge,
Flex,
useColorMode,
IconButton,
Tooltip,
HStack,
} from '@chakra-ui/react'
import { UserCard } from '../components/user-card'
import { StudentListView } from './style'
import { useSetBreadcrumbs } from '../components'
// Reaction emojis with their string values
const REACTIONS = [
{ emoji: '👍', value: 'thumbs_up' },
{ emoji: '❤️', value: 'heart' },
{ emoji: '😂', value: 'laugh' },
{ emoji: '😮', value: 'wow' },
{ emoji: '👏', value: 'clap' },
]
const UserPage = () => {
const { lessonId, accessId } = useParams()
const { t } = useTranslation()
const { colorMode } = useColorMode()
const acc = api.useGetAccessQuery({ accessCode: accessId })
const [animatedStudents, setAnimatedStudents] = useState([])
const [sendReaction] = api.useSendReactionMutation()
const [activeReaction, setActiveReaction] = useState(null)
const ls = api.useLessonByIdQuery(lessonId, {
pollingInterval: 1000,
skipPollingIfUnfocused: true,
})
// Устанавливаем хлебные крошки
useSetBreadcrumbs([
{
title: t('journal.pl.breadcrumbs.home'),
path: '/'
},
{
title: t('journal.pl.breadcrumbs.user'),
isCurrentPage: true
}
])
// Эффект для поэтапного появления карточек студентов
useEffect(() => {
if (ls.data?.body?.students?.length) {
// Обновляем существующих студентов с сохранением их анимации
setAnimatedStudents(prevStudents => {
const newStudents = ls.data.body.students.map(student => {
// Находим существующего студента
const existingStudent = prevStudents.find(p => p.sub === student.sub);
// Сохраняем флаг isNew если студент уже существует
return {
...student,
isNew: existingStudent ? existingStudent.isNew : true
};
});
// Если количество студентов не изменилось, сохраняем текущий массив
if (prevStudents.length === newStudents.length &&
prevStudents.every(student => newStudents.find(n => n.sub === student.sub))) {
return prevStudents;
}
return newStudents;
});
}
}, [ls.data?.body?.students, ls.data?.body?.studentReactions])
// Эффект для сброса флага "новизны" студентов
useEffect(() => {
if (animatedStudents.length > 0) {
const timeoutId = setTimeout(() => {
setAnimatedStudents(students =>
students.map(student => ({...student, isNew: false}))
)
}, 2000)
return () => clearTimeout(timeoutId)
}
}, [animatedStudents])
// Обработчик отправки реакции
const handleReaction = (reaction) => {
if (lessonId) {
sendReaction({ lessonId, reaction })
setActiveReaction(reaction)
// Сбрасываем активную реакцию через 1 секунду
setTimeout(() => {
setActiveReaction(null)
}, 1000)
}
}
if (acc.isLoading) {
return (
@ -40,17 +129,34 @@ const UserPage = () => {
}
return (
<Container>
{acc.isLoading && <h1>Отправляем запрос</h1>}
{acc.isSuccess && <h1>Успешно</h1>}
<Container maxW="container.lg" pt={4}>
{acc.isLoading && (
<Center py={4}>
<Spinner mr={2} />
<Text>{t('journal.pl.common.sending')}</Text>
</Center>
)}
{acc.isSuccess && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Alert status="success" mb={4} borderRadius="lg">
<AlertIcon />
{t('journal.pl.common.success')}
</Alert>
</motion.div>
)}
{acc.error && (
<Box mb="6" mt="2">
<Alert status="warning">
<Alert status="warning" borderRadius="lg">
<AlertIcon />
{(acc as any).error?.data?.body?.errorMessage ===
'Code is expired' ? (
'Не удалось активировать код доступа. Попробуйте отсканировать код ещё раз'
t('journal.pl.access.expiredCode')
) : (
<pre>{JSON.stringify(acc.error, null, 4)}</pre>
)}
@ -58,31 +164,150 @@ const UserPage = () => {
</Box>
)}
<Box mb={6}>
<Text fontSize={18} fontWeight={600} as="h1" mt="4" mb="3">
Тема занятия: {ls.data?.body?.name}
</Text>
<span>{dayjs(ls.data?.body?.date).format('DD MMMM YYYYг.')}</span>
</Box>
<Box
as="ul"
display="flex"
flexWrap="wrap"
justifyContent="center"
gap={3}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
{ls.data?.body?.students?.map((student) => (
<UserCard
width="40%"
wrapperAS="li"
key={student.sub}
student={student}
present
/>
))}
</Box>
<Box
mb={6}
p={5}
borderRadius="xl"
bg={colorMode === "light" ? "gray.50" : "gray.700"}
boxShadow="md"
>
<Heading fontSize="xl" fontWeight={600} mb={2}>
{t('journal.pl.lesson.topicTitle')}
<Box as="span" ml={2} color={colorMode === "light" ? "blue.500" : "blue.300"}>
{ls.data?.body?.name}
</Box>
</Heading>
<Flex align="center" justify="space-between" mt={3}>
<Text color={colorMode === "light" ? "gray.600" : "gray.300"}>
{formatDate(ls.data?.body?.date, t('journal.pl.lesson.dateFormat'))}
</Text>
<Badge colorScheme="green" fontSize="md" borderRadius="full" px={3} py={1}>
{t('journal.pl.common.people')}: {animatedStudents.length}
</Badge>
</Flex>
</Box>
</motion.div>
{/* Реакции на занятие */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
>
<Box
mb={6}
p={5}
borderRadius="xl"
bg={colorMode === "light" ? "gray.50" : "gray.700"}
boxShadow="md"
>
<Text mb={3} fontWeight="medium">{t('journal.pl.lesson.reactions')}</Text>
<HStack spacing={3} justify="center">
{REACTIONS.map((reaction) => (
<Tooltip key={reaction.value} label={t(`journal.pl.reactions.${reaction.value}`)} placement="top">
<IconButton
aria-label={t(`journal.pl.reactions.${reaction.value}`)}
icon={<Text fontSize="24px">{reaction.emoji}</Text>}
size="lg"
variant={activeReaction === reaction.value ? "solid" : "outline"}
colorScheme={activeReaction === reaction.value ? "blue" : "gray"}
onClick={() => handleReaction(reaction.value)}
transition="all 0.2s"
_hover={{ transform: "scale(1.1)" }}
sx={{
animation: activeReaction === reaction.value
? "pulse 0.5s ease-in-out" : "none",
"@keyframes pulse": {
"0%": { transform: "scale(1)" },
"50%": { transform: "scale(1.2)" },
"100%": { transform: "scale(1)" }
}
}}
/>
</Tooltip>
))}
</HStack>
</Box>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
{animatedStudents.length > 0 ? (
<StudentListView>
<AnimatePresence initial={true}>
{animatedStudents.map((student) => (
<motion.li
key={student.sub}
layout
initial={{ opacity: 0, scale: 0.6, y: 20 }}
animate={{
opacity: 1,
scale: 1,
y: 0,
boxShadow: student.isNew
? ['0 0 0 0 rgba(66, 153, 225, 0)', '0 0 20px 5px rgba(66, 153, 225, 0.7)', '0 0 0 0 rgba(66, 153, 225, 0)']
: '0 0 0 0 rgba(0, 0, 0, 0)'
}}
exit={{ opacity: 0, scale: 0.6, y: 20 }}
transition={{
type: "spring",
stiffness: 300,
damping: 25,
delay: 0.03 * animatedStudents.indexOf(student), // Уменьшенная задержка для более плавного появления
boxShadow: {
repeat: student.isNew ? 3 : 0,
duration: 1.5
}
}}
>
<UserCard
width="100%"
wrapperAS="div"
student={student}
present={true}
recentlyPresent={student.isNew}
reaction={ls.data?.body?.studentReactions?.find(r => r.sub === student.sub)}
/>
</motion.li>
))}
</AnimatePresence>
</StudentListView>
) : (
ls.data && (
<Center py={10} px={5}>
<Box
textAlign="center"
p={6}
borderRadius="xl"
bg={colorMode === "light" ? "gray.50" : "gray.700"}
boxShadow="md"
width="100%"
maxWidth="500px"
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<Heading size="md" mb={4}>{t('journal.pl.lesson.noStudents')}</Heading>
<Text>{t('journal.pl.lesson.waitForStudents')}</Text>
</motion.div>
</Box>
</Center>
)
)}
</motion.div>
</Container>
)
}

9
src/types/serviceMenu.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
declare module 'https://admin.bro-js.ru/remote-assets/lib/serviceMenu/serviceMenu.js' {
const createServiceMenu: (options: any) => {
show: () => void;
hide: () => void;
update: () => void;
destroy: () => void;
};
export default createServiceMenu;
}

30
src/utils/dayjs-config.ts Normal file
View File

@ -0,0 +1,30 @@
import dayjs from 'dayjs';
import 'dayjs/locale/ru';
import 'dayjs/locale/en';
import i18next from 'i18next';
// Функция для обновления локали dayjs при изменении языка в i18next
export const updateDayjsLocale = () => {
const currentLocale = i18next.language;
// Убедимся, что локаль поддерживается, иначе используем 'en'
const locale = ['ru', 'en'].includes(currentLocale) ? currentLocale : 'en';
// Установим локаль для dayjs
dayjs.locale(locale);
};
// Слушаем изменения языка и обновляем локаль dayjs
i18next.on('languageChanged', () => {
updateDayjsLocale();
});
// Вызываем функцию инициализации при импорте
updateDayjsLocale();
// Хелпер для форматирования даты с учетом текущей локали
export const formatDate = (date: string | Date | number, format = 'DD.MM.YYYY') => {
return dayjs(date).format(format);
};
export default dayjs;

15
src/utils/gravatar.ts Normal file
View File

@ -0,0 +1,15 @@
import { sha256 } from 'js-sha256';
/**
* Получает URL аватарки через Gravatar по email
* @param email - Email пользователя
* @returns URL аватарки
*/
export function getGravatarURL(email: string | undefined): string | undefined {
if (!email) return undefined;
const address = String(email).trim().toLowerCase();
const hash = sha256(address);
return `https://www.gravatar.com/avatar/${hash}?d=robohash`;
}

View File

@ -1,3 +1,3 @@
import dayjs from "dayjs";
import dayjs, { formatDate } from "./dayjs-config";
export const dateToCalendarFormat = (date?: string) => dayjs(date).format('YYYY-MM-DDTHH:mm')
export const dateToCalendarFormat = (date?: string) => formatDate(date, 'YYYY-MM-DDTHH:mm')

View File

@ -2,19 +2,87 @@ const router = require('express').Router()
const fs = require('node:fs')
const path = require('node:path')
// Функция для чтения JSON файла и случайной модификации содержимого
function readAndModifyJson(filePath) {
try {
// Используем fs.readFileSync вместо require для избежания кэширования
const fullPath = path.resolve(__dirname, filePath);
const fileContent = fs.readFileSync(fullPath, 'utf8');
const jsonContent = JSON.parse(fileContent);
// Если это список учеников, немного перемешаем их
if (jsonContent.body && Array.isArray(jsonContent.body.students)) {
jsonContent.body.students.sort(() => 0.5 - Math.random());
}
// Если это список реакций, обновим время создания и слегка перемешаем
if (jsonContent.body && Array.isArray(jsonContent.body.reactions)) {
const now = Date.now();
jsonContent.body.reactions.forEach((reaction, index) => {
// Интервал от 10 секунд до 2 минут назад
const randomTime = now - Math.floor(Math.random() * (120000 - 10000) + 10000);
reaction.created = new Date(randomTime).toISOString();
});
// Сортируем реакции по времени создания (новые сверху)
jsonContent.body.reactions.sort((a, b) =>
new Date(b.created) - new Date(a.created)
);
}
// Если это список уроков, обновим даты
if (jsonContent.body && Array.isArray(jsonContent.body) && jsonContent.body[0] && jsonContent.body[0].name) {
jsonContent.body.forEach((lesson) => {
// Случайная дата в пределах последних 3 месяцев
const randomDate = new Date();
randomDate.setDate(randomDate.getDate() - Math.random() * 30);
lesson.date = randomDate.toISOString();
lesson.created = new Date(randomDate.getTime() - 86400000).toISOString(); // Создан за день до даты
});
}
// Если это список курсов, добавим случайные данные
if (jsonContent.body && Array.isArray(jsonContent.body) && jsonContent.body[0] && jsonContent.body[0].id) {
jsonContent.body.forEach((course) => {
course.startDt = new Date(new Date().getTime() - Math.random() * 31536000000).toISOString(); // В пределах года
course.created = new Date(new Date(course.startDt).getTime() - 604800000).toISOString(); // Создан за неделю до начала
});
}
return jsonContent;
} catch (error) {
console.error(`Error reading/modifying file ${filePath}:`, error);
return { success: false, error: "Failed to read file" };
}
}
// Функция для чтения JSON без модификации
function readJsonFile(filePath) {
try {
const fullPath = path.resolve(__dirname, filePath);
const fileContent = fs.readFileSync(fullPath, 'utf8');
return JSON.parse(fileContent);
} catch (error) {
console.error(`Error reading file ${filePath}:`, error);
return { success: false, error: "Failed to read file" };
}
}
const timer =
(time = 1000) =>
(_req, _res, next) =>
setTimeout(next, time)
router.use(timer())
// Небольшая задержка для имитации реальной сети
router.use(timer(100));
const config = {
examCreated: false
}
router.get('/course/list', (req, res) => {
res.send(require('../mocks/courses/list/success.json'))
const modifiedData = readAndModifyJson('../mocks/courses/list/success.json');
res.send(modifiedData);
})
router.get('/course/:id', (req, res) => {
@ -22,18 +90,30 @@ router.get('/course/:id', (req, res) => {
return res.status(400).send({ success: false, error: 'Invalid course id' })
if (config.examCreated) {
config.examCreated = false
return res.send(require('../mocks/courses/by-id/with-exam.json'))
config.examCreated = false;
const modifiedData = readAndModifyJson('../mocks/courses/by-id/with-exam.json');
return res.send(modifiedData);
}
res.send(require('../mocks/courses/by-id/success.json'))
const modifiedData = readAndModifyJson('../mocks/courses/by-id/success.json');
res.send(modifiedData);
})
router.get('/course/students/:courseId', (req, res) => {
res.send(require('../mocks/courses/all-students/success.json'))
const modifiedData = readAndModifyJson('../mocks/courses/all-students/success.json');
res.send(modifiedData);
})
router.post('/course', (req, res) => {
res.send(require('../mocks/courses/create/success.json'))
const baseData = readJsonFile('../mocks/courses/create/success.json');
// Добавляем данные из запроса
if (baseData.body) {
baseData.body.name = req.body.name || baseData.body.name;
baseData.body.created = new Date().toISOString();
}
res.send(baseData);
})
router.post('/course/toggle-exam-with-jury/:id', (req, res) => {
@ -42,27 +122,62 @@ router.post('/course/toggle-exam-with-jury/:id', (req, res) => {
})
router.get('/lesson/list/:courseId', (req, res) => {
res.send(require('../mocks/lessons/list/success.json'))
const modifiedData = readAndModifyJson('../mocks/lessons/list/success.json');
res.send(modifiedData);
})
router.get('/lesson/:courseId/ai/generate-lessons', timer(3000), (req, res) => {
const modifiedData = readAndModifyJson('../mocks/lessons/generate/success.json');
res.send(modifiedData);
})
router.post('/lesson', (req, res) => {
res.send(require('../mocks/lessons/create/success.json'))
const baseData = readJsonFile('../mocks/lessons/create/success.json');
// Добавляем данные из запроса
if (baseData.body) {
baseData.body.name = req.body.name || baseData.body.name;
baseData.body.date = req.body.date || new Date().toISOString();
baseData.body.created = new Date().toISOString();
}
res.send(baseData);
})
router.post('/lesson/access-code', (req, res) => {
const answer = fs.readFileSync(
path.resolve(__dirname, '../mocks/lessons/access-code/create/success.json'),
)
// res.send(require('../mocks/lessons/access-code/create/success.json'))
res.send(answer)
const modifiedData = readAndModifyJson('../mocks/lessons/access-code/create/success.json');
// Обновляем дату истечения через час от текущего времени
if (modifiedData.body) {
modifiedData.body.expires = new Date(Date.now() + 60 * 60 * 1000).toISOString();
modifiedData.body.created = new Date().toISOString();
}
res.send(modifiedData);
})
router.get('/lesson/access-code/:accessCode', (req, res) => {
res.status(400).send(require('../mocks/lessons/access-code/get/error.json'))
const modifiedData = readAndModifyJson('../mocks/lessons/access-code/get/success.json');
// Обновляем дату истечения через час от текущего времени
if (modifiedData.body && modifiedData.body.accessCode) {
modifiedData.body.accessCode.expires = new Date(Date.now() + 60 * 60 * 1000).toISOString();
modifiedData.body.accessCode.created = new Date().toISOString();
}
res.send(modifiedData);
})
router.get('/lesson/:lessonId', (req, res) => {
res.send(require('../mocks/lessons/byid/success.json'))
const modifiedData = readAndModifyJson('../mocks/lessons/byid/success.json');
// Обновляем даты
if (modifiedData.body) {
modifiedData.body.date = new Date().toISOString();
modifiedData.body.created = new Date(Date.now() - 86400000).toISOString(); // Создан день назад
}
res.send(modifiedData);
})
router.delete('/lesson/:lessonId', (req, res) => {
@ -73,4 +188,24 @@ router.put('/lesson', (req, res) => {
res.send({ success: true, body: req.body })
})
router.post('/lesson/reaction/:lessonId', (req, res) => {
// Simulate processing a new reaction
const { reaction } = req.body;
const lessonId = req.params.lessonId;
// Log the reaction for debugging
console.log(`Received reaction "${reaction}" for lesson ${lessonId}`);
// Return success response
res.send({
success: true,
body: {
_id: `r-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
reaction,
lessonId,
created: new Date().toISOString()
}
});
});
module.exports = router

View File

@ -590,8 +590,8 @@
"sub": "developer",
"email": "email@email.ml"
},
"startDt": "2024-08-25T17:40:17.814Z",
"created": "2024-08-25T17:40:17.814Z",
"startDt": "2024-08-25T17:30:00.000Z",
"created": "2024-08-25T17:40:17.000Z",
"examWithJury2": {
"_id": "66cf3d3f4637d420d6271451",
"name": "Хакатон",

View File

@ -19,7 +19,7 @@
"email": "primakovpro@gmail.com"
}
],
"date": "2024-04-16T13:38:00.000Z",
"date": "2024-04-16T13:30:00.000Z",
"created": "2024-04-16T13:38:23.381Z",
"id": "661e7f4f69f40b0ebebcd5e4"
},
@ -37,7 +37,7 @@
"email": "primakovpro@gmail.com"
}
],
"date": "2024-08-04T07:00:00.000Z",
"date": "2024-08-04T08:00:00.000Z",
"created": "2024-08-04T06:23:28.491Z",
"id": "66af1e60a0eef5a89f99aa94"
},

View File

@ -46,6 +46,181 @@
"startDt": "2024-03-02T15:37:05.907Z",
"created": "2024-03-02T15:37:05.908Z",
"__v": 2
},
{
"id": "66f84d32def890e3g789239a",
"name": "МГУ-24-1",
"teachers": [
{
"sub": "a72905c2-f334-50db-920f-d9e85c7248d2",
"name": "Елена Иванова",
"preferred_username": "ivanova",
"email": "ivanova@example.com"
}
],
"lessons": [
"66e3f6gcec37fec650f28490",
"66e312d5ec37fec650f2abff",
"66e79cfcced789d2f6791416"
],
"creator": {
"sub": "a72905c2-f334-50db-920f-d9e85c7248d2",
"email_verified": true,
"name": "Елена Иванова",
"preferred_username": "ivanova",
"given_name": "Елена",
"family_name": "Иванова",
"email": "ivanova@example.com"
},
"startDt": "2024-04-15T10:30:00.000Z",
"endDt": "2024-06-30T18:00:00.000Z",
"created": "2024-04-10T09:25:33.112Z",
"__v": 1
},
{
"id": "66g95e43fef901f4h890340b",
"name": "СПБГУ-24-3",
"teachers": [
{
"sub": "b83016d3-g445-61ec-931g-e0f96d8359e3",
"name": "Михаил Петров",
"preferred_username": "petrov",
"email": "petrov@example.com"
},
{
"sub": "c94127e4-h556-72fd-042h-f1g07e9460f4",
"name": "Анна Сидорова",
"preferred_username": "sidorova",
"email": "sidorova@example.com"
}
],
"lessons": [
"67f4g7hdec37fec650f28501",
"67f423e6ec37fec650f2acgg",
"67f80dgdced789d2f6791527",
"67f80e1gced789d2f679152d"
],
"creator": {
"sub": "b83016d3-g445-61ec-931g-e0f96d8359e3",
"email_verified": true,
"name": "Михаил Петров",
"preferred_username": "petrov",
"given_name": "Михаил",
"family_name": "Петров",
"email": "petrov@example.com"
},
"startDt": "2024-05-20T09:00:00.000Z",
"examWithJury": "67dg4e4g5748e531e7382562",
"created": "2024-05-15T14:22:45.500Z",
"capacity": 30,
"enrolled": 28,
"__v": 3
},
{
"id": "67h06f54gfg012g5i901451c",
"name": "HSE-24-2",
"teachers": [
{
"sub": "d05238f5-i667-83ge-153i-g2h18f0571g5",
"name": "Сергей Кузнецов",
"preferred_username": "kuznetsov",
"email": "kuznetsov@example.com"
}
],
"lessons": [
"68g5h8iec37fec650f28612",
"68g534f7ec37fec650f2adhi",
"68g91eigced789d2f6791638"
],
"creator": {
"sub": "d05238f5-i667-83ge-153i-g2h18f0571g5",
"email_verified": true,
"name": "Сергей Кузнецов",
"preferred_username": "kuznetsov",
"given_name": "Сергей",
"family_name": "Кузнецов",
"email": "kuznetsov@example.com"
},
"startDt": "2024-06-10T13:15:00.000Z",
"endDt": "2024-08-25T17:45:00.000Z",
"created": "2024-06-01T11:08:30.750Z",
"location": "Онлайн",
"tags": ["программирование", "алгоритмы", "анализ данных"],
"__v": 0
},
{
"id": "54c62b10bdc678a1d5680127e",
"name": "МФТИ-23-1",
"teachers": [
{
"sub": "e16349g6-j778-94hf-264j-h3i29g1682h6",
"name": "Дмитрий Соколов",
"preferred_username": "sokolov",
"email": "sokolov@example.com"
}
],
"lessons": [
"54b2d4eadb26edb541e17378",
"54b2e2c3db26edb541e18489",
"54b3d5efdb26edb541e19590"
],
"creator": {
"sub": "e16349g6-j778-94hf-264j-h3i29g1682h6",
"email_verified": true,
"name": "Дмитрий Соколов",
"preferred_username": "sokolov",
"given_name": "Дмитрий",
"family_name": "Соколов",
"email": "sokolov@example.com"
},
"startDt": "2023-09-01T08:00:00.000Z",
"endDt": "2023-12-25T16:00:00.000Z",
"created": "2023-08-15T10:42:18.320Z",
"status": "completed",
"completionRate": 92,
"__v": 5
},
{
"id": "78i17g65hgh123h6j012562d",
"name": "УрФУ-25-4",
"teachers": [
{
"sub": "f27450h7-k889-05ig-375k-i4j30h2793i7",
"name": "Ольга Морозова",
"preferred_username": "morozova",
"email": "morozova@example.com"
},
{
"sub": "g38561i8-l990-16jh-486l-j5k41i3804j8",
"name": "Константин Волков",
"preferred_username": "volkov",
"email": "volkov@example.com"
}
],
"lessons": [
"79h6i9jfdb26edb541e20601",
"79h7j0kgdb26edb541e21712",
"79h8k1lhdb26edb541e22823",
"79h9l2midb26edb541e23934"
],
"creator": {
"sub": "f27450h7-k889-05ig-375k-i4j30h2793i7",
"email_verified": true,
"name": "Ольга Морозова",
"preferred_username": "morozova",
"given_name": "Ольга",
"family_name": "Морозова",
"email": "morozova@example.com"
},
"startDt": "2025-02-10T09:30:00.000Z",
"endDt": "2025-07-05T15:30:00.000Z",
"created": "2024-11-20T14:55:40.670Z",
"status": "scheduled",
"maxCapacity": 50,
"registrationDeadline": "2025-01-30T23:59:59.999Z",
"location": "Гибрид",
"tags": ["машинное обучение", "компьютерное зрение", "нейронные сети"],
"__v": 0
}
]
}

View File

@ -7,19 +7,87 @@
"name": "ВВОДНАЯ ПО JS.ПРИМЕНЕНИЕ И СПОСОБЫ ПОДКЛЮЧЕНИЯ НА СТРАНИЦЕ. LET, CONST. БАЗОВЫЕ ТИПЫ ДАННЫХ, ПРИВЕДЕНИЕ ТИПОВ. ПЕРЕМЕННЫЕ, ОБЛАСТЬ ВИДИМОСТИ ПЕРЕМЕННЫХ",
"students": [
{
"sub": "f62905b1-e223-40ca-910f-c8d84c6137c1",
"email_verified": true,
"gravatar": "true",
"name": "Александр Примаков",
"groups": [
"/inno-staff",
"/microfrontend-admin-user"
],
"preferred_username": "primakov",
"given_name": "Александр",
"family_name": "Примаков",
"email": "primakovpro@gmail.com"
}
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"email_verified": true,
"name": "Мария Капитанова",
"preferred_username": "maryaKapitan@gmail.com",
"given_name": "Мария",
"family_name": "Капитанова",
"email": "maryaKapitan@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJgIjjOFD2YUSyRF5kH4jaysE6X5p-kq0Cg0CFncfMi=s96-c"
},
{
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"email_verified": true,
"name": "Евгения Жужова",
"preferred_username": "zhuzhova@gmail.com",
"given_name": "Евгения",
"family_name": "Жужова",
"email": "zhuzhova@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJUtJBAVBm642AxoGpMDDMV8CPu3MEoLjU3hmO7oisG=s96-c"
}
],
"studentReactions": [
{
"_id": "r1d73f22-c9ba-422a-b572-c59e515a2901",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "thumbs_up"
},
{
"_id": "r2d73f22-c9ba-422a-b572-c59e515a2902",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "heart"
},
{
"_id": "r3d73f22-c9ba-422a-b572-c59e515a2903",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "clap"
},
{
"_id": "r4d73f22-c9ba-422a-b572-c59e515a2904",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "laugh"
},
{
"_id": "r5d73f22-c9ba-422a-b572-c59e515a2905",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "wow"
},
{
"_id": "r6d73f22-c9ba-422a-b572-c59e515a2906",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "thumbs_up"
},
{
"_id": "r7d73f22-c9ba-422a-b572-c59e515a2907",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "heart"
},
{
"_id": "r8d73f22-c9ba-422a-b572-c59e515a2908",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "clap"
},
{
"_id": "r9d73f22-c9ba-422a-b572-c59e515a2909",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "laugh"
},
{
"_id": "r10d73f22-c9ba-422a-b572-c59e515a2910",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "wow"
},
{
"_id": "r11d73f22-c9ba-422a-b572-c59e515a2911",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "laugh"
},
{
"_id": "r12d73f22-c9ba-422a-b572-c59e515a2912",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "heart"
}
],
"date": "2024-02-28T20:37:00.057Z",
"created": "2024-02-28T20:37:00.057Z",

View File

@ -0,0 +1,25 @@
{
"success": true,
"body": [
{
"date": "2025-03-26",
"name": "Создание первого агента"
},
{
"date": "2025-03-31",
"name": "Работа с памятью агентов"
},
{
"date": "2025-04-02",
"name": "Интеграция инструментов в агентов"
},
{
"date": "2025-04-07",
"name": "Управление цепочками рассуждений"
},
{
"date": "2025-04-09",
"name": "Оптимизация производительности агентов"
}
]
}

File diff suppressed because it is too large Load Diff