Compare commits

..

No commits in common. "master" and "v3.5.1" have entirely different histories.

75 changed files with 5283 additions and 10517 deletions

24
@types/index.d.ts vendored
View File

@ -1,24 +0,0 @@
declare const IS_PROD: string
declare const KC_URL: string
declare const KC_REALM: string
declare const KC_CLIENT_ID: string
declare module '*.svg' {
const value: string
export default value
}
declare module '*.jpg' {
const value: string
export default value
}
declare module '*.png' {
const value: string
export default value
}
declare const __webpack_public_path__: string

View File

@ -1,5 +1,3 @@
const webpack = require('webpack');
const pkg = require('./package')
module.exports = {
@ -8,13 +6,6 @@ module.exports = {
output: {
publicPath: `/static/${pkg.name}/${process.env.VERSION || pkg.version}/`,
},
plugins: [
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 || '"journal"',
}),
],
},
navigations: {
'journal.main': '/journal.pl',
@ -34,16 +25,6 @@ module.exports = {
value: '',
key: 'group.by.date',
},
'course.statistics': {
on: true,
value: '',
key: 'course.statistics',
},
'courses.statistics': {
on: true,
value: '',
key: 'courses.statistics',
},
},
},
config: {

6
index.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module '*.svg' {
const src: string;
export default src;
}
declare const __webpack_public_path__: string;

View File

@ -1,242 +0,0 @@
{
"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,239 +1,3 @@
{
"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": "Декабрь"
"": ""
}

12
openapi-config.ts Normal file
View File

@ -0,0 +1,12 @@
import type { ConfigFile } from '@rtk-query/codegen-openapi'
const config: ConfigFile = {
schemaFile: 'https://platform.bro-js.ru/jrnl-bh/documentation/json',
apiFile: './src/__data__/api/api.ts',
apiImport: 'api',
outputFile: './src/__data__/api/jrnl.ts',
exportName: 'jrnlApi',
hooks: true,
}
export default config

5774
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "journal.pl",
"version": "3.16.5",
"version": "3.5.1",
"description": "bro-js platform journal ui repo",
"main": "./src/index.tsx",
"scripts": {
@ -19,13 +19,14 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@rtk-query/codegen-openapi": "^1.2.0",
"@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1"
},
"dependencies": {
"@brojs/cli": "^1.8.4",
"@brojs/cli": "^0.0.4-beta.0",
"@chakra-ui/icons": "^2.1.1",
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.11.4",
@ -37,17 +38,15 @@
"dayjs": "^1.11.10",
"express": "^4.19.2",
"js-sha256": "^0.11.0",
"keycloak-js": "^26.0.7",
"keycloak-js": "^23.0.7",
"prettier": "^3.2.5",
"qrcode": "^1.5.3",
"react": "^18.3.1",
"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,11 +68,6 @@ 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 }
@ -122,15 +117,6 @@ 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

@ -1,9 +1,9 @@
import Keycloak from 'keycloak-js'
import Keycloak from 'keycloak-js';
export const keycloak = new Keycloak({
url: KC_URL,
realm: KC_REALM,
clientId: KC_CLIENT_ID,
url: 'https://kc.bro-js.ru',
realm: 'bro-js',
clientId: 'journal'
});
(window as any).kc = keycloak
window.keycloak = keycloak;

View File

@ -49,29 +49,14 @@ 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;
created: string;
}
interface Teacher {
sub: string;
email_verified: boolean;
preferred_username: string;
}
export interface AccessCode {
expires: string;
lesson: Lesson;

View File

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

View File

@ -1,275 +0,0 @@
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

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

View File

@ -1,45 +0,0 @@
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

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

View File

@ -1,18 +1,5 @@
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,
@ -26,7 +13,12 @@ export class ErrorBoundary extends React.Component<
render() {
if (this.state.hasError) {
return <ErrorMessage error={this.state.error} />
return (
<Alert status="error" title="Ошибка">
Что-то пошло не так<br />
{this.state.error && <span>{this.state.error}</span>}
</Alert>
)
}
return this.props.children

View File

@ -1,5 +0,0 @@
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,23 +3,18 @@ import {
Spinner,
Container,
Center,
useColorMode
} from '@chakra-ui/react'
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>
)
}
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>
)

View File

@ -1,96 +1,26 @@
import styled from '@emotion/styled'
import { css, keyframes } from '@emotion/react'
// Правильное определение анимации с помощью keyframes
const fadeIn = keyframes`
from {
opacity: 0;
}
to {
opacity: 1;
}
`
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;
object-fit: cover;
transition: transform 0.3s ease;
width: 96px;
height: 96px;
margin: 0 auto;
border-radius: 6px;
`
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 }>`
export const Wrapper = styled.div<{ warn?: boolean; width?: string | number }>`
list-style: none;
position: relative;
background-color: #ffffff;
padding: 16px;
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);
}
}
box-shadow: 2px 2px 6px #0000005c;
transition: all 0.5;
position: relative;
width: 180px;
min-height: 190px;
max-height: 200px;
margin-right: 12px;
padding-bottom: 22px;
${({ width }) =>
width
? css`
@ -98,19 +28,26 @@ export const Wrapper = styled.div<{ warn?: boolean; width?: string | number; pos
`
: ''}
${({ position }) =>
position
? css`
position: ${position};
`
: ''}
${(props) =>
props.warn
? css`
background-color: #000000;
opacity: 0.7;
filter: grayscale(0.8);
color: #e4e4e4;
`
: ''}
`
export const AddMissedButton = styled.button`
position: absolute;
bottom: 8px;
right: 12px;
border: none;
background-color: #00000000;
opacity: 0.2;
:hover {
cursor: pointer;
opacity: 1;
}
`

View File

@ -1,23 +1,9 @@
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 { Reaction, User } from '../../__data__/model'
import { User } from '../../__data__/model'
import { Avatar, Wrapper, NameOverlay } from './style'
// Map of reaction types to emojis
const REACTION_EMOJIS = {
thumbs_up: '👍',
heart: '❤️',
laugh: '😂',
wow: '😮',
clap: '👏'
}
import { AddMissedButton, Avatar, Wrapper } from './style'
export function getGravatarURL(email, user) {
if (!email) return void 0
@ -30,112 +16,32 @@ export function getGravatarURL(email, user) {
export const UserCard = ({
student,
present,
wrapperAS = 'div',
width,
recentlyPresent = false,
reaction
onAddUser,
wrapperAS,
width
}: {
student: User
present: boolean
width?: string | number
wrapperAS?: React.ElementType<any, keyof React.JSX.IntrinsicElements>
recentlyPresent?: boolean
reaction?: Reaction
onAddUser?: (user: User) => void
wrapperAS?: React.ElementType<any, keyof React.JSX.IntrinsicElements>;
}) => {
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}
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>
{/* Анимация реакции */}
<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 warn={!present} as={wrapperAS} width={width}>
<Avatar src={student.picture || getGravatarURL(student.email, null)} />
<p style={{ marginTop: 6 }}>
{student.name || student.preferred_username}{' '}
</p>
{onAddUser && !present && (
<AddMissedButton onClick={() => onAddUser(student)}>
add
</AddMissedButton>
)}
</Wrapper>
)
}
UserCard.defaultProps = {
wrapperAS: 'div',
onAddUser: void 0,
}

View File

@ -1,165 +0,0 @@
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,27 +3,18 @@ import {
Container,
Center,
Spinner,
useColorMode
} from '@chakra-ui/react'
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>
)
}
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>
)

View File

@ -1,9 +1,8 @@
import React, { useEffect, Suspense, useRef, useState } from 'react'
import React, { useEffect, Suspense } from 'react'
import { Routes, Route, useNavigate } from 'react-router-dom'
import { Provider } from 'react-redux'
import { getNavigationValue } from '@brojs/cli'
import { Box, Container, Spinner, VStack, useColorMode } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { getNavigationsValue } from '@brojs/cli'
import { Box, Container, Spinner, VStack } from '@chakra-ui/react'
import {
CourseListPage,
@ -12,31 +11,7 @@ import {
UserPage,
AttendancePage,
} from './pages'
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;
};
}
}
import { ErrorBoundary } from './components/error-boundary'
const Wrapper = ({ children }: { children: React.ReactElement }) => (
<Suspense
@ -62,106 +37,49 @@ const Wrapper = ({ children }: { children: React.ReactElement }) => (
</Suspense>
)
// Компонент, который соединяет хлебные крошки с 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'),
export const Dashboard = ({ store }) => (
<Provider store={store}>
<Routes>
<Route
path={getNavigationsValue('journal.main')}
element={
<Wrapper>
<CourseListPage />
</Wrapper>
}
});
}
// Очистка при размонтировании
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>
)
}
/>
<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>
)

View File

@ -30,27 +30,6 @@ 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,16 +1,11 @@
/* 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');
}
@ -19,31 +14,23 @@ export default (props) => <App {...props} />;
let rootElement: ReactDOM.Root
export const mount = async (Component, element = document.getElementById('app')) => {
export const mount = async (Сomponent, element = document.getElementById('app')) => {
let user = null;
try {
await keycloak.init({ onLoad: 'login-required' })
const userInfo = await keycloak.loadUserInfo()
if (userInfo && keycloak.tokenParsed) {
user = { ...userInfo, ...keycloak.tokenParsed }
} else {
console.error('No userInfo or tokenParsed', userInfo, keycloak.tokenParsed)
}
await keycloak.init({ onLoad: "login-required" });
user = { ...(await keycloak.loadUserInfo()), ...keycloak.tokenParsed };
} catch (error) {
console.error('Failed to initialize adapter:', error)
keycloak.login()
console.error("Failed to initialize adapter:", error);
keycloak.login();
}
const store = createStore({ user });
await i18nextPromise
rootElement = ReactDOM.createRoot(element);
rootElement.render(<Component store={store} />);
rootElement.render(<Сomponent store={store} />);
if(module.hot) {
module.hot.accept('./app', ()=> {
rootElement.render(<Component store={store} />);
rootElement.render(<Сomponent store={store} />);
})
}
};

View File

@ -1,70 +1,109 @@
import React from 'react'
import React, { useMemo } from 'react'
import { useParams } from 'react-router-dom'
import {
Box,
Heading,
Container,
useColorMode,
Flex,
Spacer,
Badge
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { Box, Heading, Tooltip, Text } from '@chakra-ui/react'
import dayjs from 'dayjs'
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 { 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
}
])
const { data: attendance, isLoading } = api.useLessonListQuery(courseId, {
selectFromResult: ({ data, isLoading }) => ({
data: data?.body,
isLoading,
}),
})
const { data: courseInfo, isLoading: courseInfoIssLoading } =
api.useGetCourseByIdQuery(courseId)
if (data.isLoading) {
const data = useMemo(() => {
if (!attendance) return null
const studentsMap = new Map()
attendance.forEach((lesson) => {
lesson.students.forEach((student) => {
studentsMap.set(student.sub, {
...student,
value:
student.family_name && student.given_name
? `${student.family_name} ${student.given_name}`
: student.name || student.email,
})
})
})
const compare = Intl.Collator('ru').compare
const students = [...studentsMap.values()]
students.sort(({ family_name: name }, { family_name: nname }) =>
compare(name, nname),
)
return {
students,
}
}, [attendance])
if (!data || isLoading || courseInfoIssLoading) {
return <PageLoader />
}
return (
<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>
<StatsCard stats={stats} />
<Box
bg={colorMode === 'dark' ? 'gray.800' : 'gray.50'}
p={4}
borderRadius="lg"
boxShadow="sm"
>
<AttendanceTable data={data} />
<Box>
<Box mt={12} mb={12}>
<Heading>{courseInfo.name}</Heading>
</Box>
</Container>
<Box>
<table>
<thead>
<tr>
<th>Дата</th>
<th>Название занятия</th>
{data.students.map((student) => (
<th key={student.sub}>{student.name}</th>
))}
</tr>
</thead>
<tbody>
{attendance.map((lesson, index) => (
<tr key={lesson.name}>
<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>
</Box>
</Box>
)
}
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

@ -1,143 +0,0 @@
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

@ -1,373 +0,0 @@
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

@ -1,42 +0,0 @@
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

@ -1,40 +0,0 @@
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

@ -1,103 +0,0 @@
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

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

View File

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

View File

@ -1,72 +0,0 @@
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

@ -1,92 +0,0 @@
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

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

View File

@ -1,76 +0,0 @@
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

@ -1,141 +0,0 @@
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

@ -1,34 +0,0 @@
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

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

View File

@ -1,110 +0,0 @@
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

@ -1,75 +0,0 @@
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

@ -1,135 +0,0 @@
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

@ -1,98 +0,0 @@
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

@ -1,146 +0,0 @@
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

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

View File

@ -1,239 +0,0 @@
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,8 +1,7 @@
import React, { useCallback, useEffect, useState, useMemo } from 'react'
import { formatDate } from '../../utils/dayjs-config'
import React, { useCallback, useEffect, useState } from 'react'
import dayjs from 'dayjs'
import { Link as ConnectedLink, generatePath } from 'react-router-dom'
import { getNavigationValue } from '@brojs/cli'
import { getNavigationsValue } from '@brojs/cli'
import {
Box,
CardHeader,
@ -10,70 +9,22 @@ 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, CalendarIcon, ViewIcon, WarningIcon, StarIcon, TimeIcon } from '@chakra-ui/icons'
import { ArrowUpIcon, LinkIcon } 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)
@ -84,449 +35,85 @@ 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}
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"
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>
<Card key={course._id} align="left">
<CardHeader>
<Heading as="h2" mt="0">
{course.name}
</Heading>
</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>
)}
<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>
)}
<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>
</>
)}
{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 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')}>
<CardFooter>
<ButtonGroup
spacing={[0, 4]}
mt="16px"
flexDirection={['column', 'row']}
>
<Tooltip label="На страницу с лекциями" fontSize="12px" top="16px">
<Button
leftIcon={<ViewIcon />}
leftIcon={<LinkIcon />}
as={ConnectedLink}
colorScheme="blue"
size={buttonSize}
flexGrow={1}
to={`${getNavigationValue('journal.main')}/lessons-list/${course._id}`}
mb={{ base: 2, sm: 0 }}
to={`${getNavigationsValue('journal.main')}/lessons-list/${course._id}`}
>
{t('journal.pl.lesson.list')}
Открыть
</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="Загрузка"
isLoading={populatedCourse.isFetching}
onClick={handleToggleOpene}
>
{isOpened ? 'Закрыть' : 'Просмотреть детали'}
</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

@ -0,0 +1,108 @@
import React from 'react'
import dayjs from 'dayjs'
import { Link as ConnectedLink } from 'react-router-dom'
import { getNavigationsValue, 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 && (
<Tooltip label="Начать экзамен" fontSize="12px" top="16px">
<Button
leftIcon={<LinkIcon />}
as={'a'}
colorScheme="blue"
href={
getNavigationsValue('exam.main') +
getNavigationsValue('link.exam.details')
.replace(':courseId', populatedCourse.id)
.replace(':examId', exam.id)
}
onClick={(event) => {
event.preventDefault()
history.push(
getNavigationsValue('exam.main') +
getNavigationsValue('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)
? `${getNavigationsValue('journal.main')}/lesson/${populatedCourse.id}/${lesson.id}`
: ''
}
>
{lesson.name}
</Link>
))}
</Stack>
</>
)
}

View File

@ -1,143 +1,200 @@
import React, { useState, useMemo, useEffect } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import dayjs from 'dayjs'
import {
Box,
CardHeader,
CardBody,
Button,
Card,
Heading,
Container,
Text,
useColorMode,
useBreakpointValue
VStack,
Input,
CloseButton,
FormControl,
FormLabel,
FormHelperText,
FormErrorMessage,
useToast,
} from '@chakra-ui/react'
import { AddIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
import { getFeatures } from '@brojs/cli'
import { useForm, Controller } from 'react-hook-form'
import { ErrorSpan } from '../style'
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 { useSetBreadcrumbs } from '../../components'
import { useGroupedCourses } from './hooks'
import { CreateCourseForm, YearGroup, CoursesOverview } from './components'
import { Lesson } from '../../__data__/model'
import { CourseCard } from './course-card'
interface NewCourseForm {
startDt: string
name: string
}
/**
* Основной компонент списка курсов
*/
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 { t } = useTranslation()
const { colorMode } = useColorMode()
// Устанавливаем хлебные крошки для главной страницы
useSetBreadcrumbs([
{
title: t('journal.pl.breadcrumbs.home'),
path: '/',
isCurrentPage: true
}
])
// Получаем значения фичей
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 toastRef = useRef(null)
// Используем хук для группировки курсов по годам
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])
const {
control,
handleSubmit,
reset,
formState: { errors },
getValues,
} = useForm<NewCourseForm>({
defaultValues: {
startDt: dayjs().format('YYYY-MM-DD'),
name: 'Название',
},
})
if (isLoading) {
return <PageLoader />
const onSubmit = ({ startDt, name }) => {
toastRef.current = toast({
title: 'Отправляем',
status: 'loading',
duration: 9000,
})
createUpdateCourse({ name, startDt })
}
const handleCloseForm = () => setShowForm(false)
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()
}
}, [crucQuery.isSuccess])
if (isLoading) {
return (
<PageLoader />
)
}
return (
<Container maxW="container.xl" px={containerPadding}>
<Container maxW="container.xl">
{isTeacher(user) && (
<Box mt={{ base: 3, md: 5 }} mb={{ base: 3, md: 5 }}>
<Box mt="15" mb="15">
{showForm ? (
<CreateCourseForm onClose={handleCloseForm} />
<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>
) : (
<Box p={{ base: 1, md: 2 }} m={{ base: 1, md: 2 }}>
<Box p="2" m="2">
<Button
leftIcon={<AddIcon />}
colorScheme="green"
onClick={() => setShowForm(true)}
size={buttonSize}
width={{ base: '100%', sm: 'auto' }}
>
{t('journal.pl.common.add')}
Добавить
</Button>
</Box>
)}
</Box>
)}
{!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>
)}
<VStack as="ul" align="stretch">
{data?.body?.map((c) => (
<CourseCard
key={c.id}
course={c}
/>
))}
</VStack>
</Container>
)
}

View File

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

View File

@ -1,73 +0,0 @@
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

@ -1,32 +0,0 @@
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,34 +1,29 @@
import React, { useEffect, useRef, useMemo, useState } from 'react'
import React, { useEffect, useState, useRef, useMemo } from 'react'
import { useParams, Link } from 'react-router-dom'
import dayjs from 'dayjs'
import QRCode from 'qrcode'
import { sha256 } from 'js-sha256'
import { getConfigValue, getNavigationValue } from '@brojs/cli'
import { motion, AnimatePresence } from 'framer-motion'
import { AddIcon } from '@chakra-ui/icons'
import { getConfigValue, getNavigationsValue } from '@brojs/cli'
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, Reaction } from '../__data__/model'
import { User } from '../__data__/model'
import { UserCard } from '../components/user-card'
import { formatDate } from '../utils/dayjs-config'
import { useSetBreadcrumbs } from '../components'
import {
AddMissedButton,
QRCanvas,
StudentList,
BreadcrumbsWrapper,
} from './style'
import { useAppSelector } from '../__data__/store'
import { isTeacher } from '../utils/user'
export function getGravatarURL(email, user) {
if (!email) return void 0
@ -42,42 +37,6 @@ export function getGravatarURL(email, user) {
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,
data: accessCode,
@ -86,7 +45,6 @@ const LessonDetail = () => {
} = api.useCreateAccessCodeQuery(
{ lessonId },
{
skip: !isTeacher(user),
pollingInterval:
Number(getConfigValue('journal.polling-interval')) || 3000,
skipPollingIfUnfocused: true,
@ -99,61 +57,6 @@ 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()
@ -162,348 +65,95 @@ const LessonDetail = () => {
useEffect(() => {
if (!isFetching && isSuccess) {
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);
};
QRCode.toCanvas(
canvRef.current,
userUrl,
{ width: 600 },
function (error) {
if (error) console.error(error)
console.log('success!')
},
)
}
}, [isFetching, isSuccess, userUrl])
}, [isFetching, isSuccess])
const studentsArr = useMemo(() => {
let allStudents: (User & { present?: boolean; recentlyPresent?: boolean })[] = [
let allStudents: (User & { present?: boolean })[] = [
...(AllStudents.data?.body || []),
].map((st) => ({ ...st, present: false, recentlyPresent: false }))
].map((st) => ({ ...st, present: false }))
let presentStudents: (User & { present?: boolean })[] = [
...(accessCode?.body.lesson.students || []),
]
// Находим новых студентов по сравнению с предыдущим состоянием
const currentPresent = new Set(presentStudents.map(s => s.sub))
const newlyPresent = [...currentPresent].filter(id => !prevPresentStudentsRef.current.has(id))
while (presentStudents.length) {
while (allStudents.length && 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,
recentlyPresent: newlyPresent.includes(student.sub)
})
allStudents.push({ ...student, present: true })
}
}
// 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 allStudents.sort((a, b) => (a.present ? -1 : 1))
}, [accessCode?.body, AllStudents.data])
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" 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}
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"
}}
>
<AddMissedButton
onClick={() => manualAdd({ lessonId, user: student })}
aria-label={t('journal.pl.common.add')}
>
<AddIcon boxSize={3} />
</AddMissedButton>
<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 spacing="8" sx={{ flexDirection: { sm: 'column', md: 'row' } }}>
<a href={userUrl}>
<QRCanvas ref={canvRef} />
</a>
<StudentList>
{studentsArr.map((student) => (
<UserCard
wrapperAS="li"
key={student.sub}
student={student}
present={student.present}
onAddUser={(user: User) => manualAdd({ lessonId, user })}
/>
))}
</StudentList>
</Stack>
</Container>
</>

View File

@ -1,66 +1,28 @@
import React from 'react'
import { type BarDatum, ResponsiveBar } from '@nivo/bar'
import { useTranslation } from 'react-i18next'
import { useColorMode } from '@chakra-ui/react'
import { ResponsiveBar } from '@nivo/bar'
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
}
/>
)
}
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
}
/>
)

View File

@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'
import { formatDate } from '../../../utils/dayjs-config'
import dayjs from 'dayjs'
import { Link } from 'react-router-dom'
import { getNavigationValue, getFeatures } from '@brojs/cli'
import { getNavigationsValue, getFeatures } from '@brojs/cli'
import {
Button,
Tr,
@ -11,14 +11,8 @@ 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'
@ -35,9 +29,7 @@ type ItemProps = {
isTeacher: boolean
courseId: string
setlessonToDelete(): void
setEditLesson?: () => void
students: unknown[]
isMobile?: boolean
}
export const Item: React.FC<ItemProps> = ({
@ -47,37 +39,17 @@ 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: t('journal.pl.common.sending'),
title: 'Отправляем',
status: 'loading',
duration: 9000,
})
@ -86,7 +58,7 @@ export const Item: React.FC<ItemProps> = ({
updateLesson(lessonData)
} else {
toast.update(toastRef.current, {
title: t('journal.pl.lesson.noInternet'),
title: 'Отсутствует интернет',
status: 'error',
duration: 3000
})
@ -96,8 +68,8 @@ export const Item: React.FC<ItemProps> = ({
useEffect(() => {
if (updateLessonRqst.isSuccess) {
const toastProps = {
title: t('journal.pl.lesson.updated'),
description: t('journal.pl.lesson.updateMessage', { name: createdLessonRef.current?.name }),
title: 'Лекция Обновлена',
description: `Лекция ${createdLessonRef.current?.name} успешно обновлена`,
status: 'success' as const,
duration: 9000,
isClosable: true,
@ -120,80 +92,28 @@ export const Item: React.FC<ItemProps> = ({
setEdit(false)
}}
lesson={{ _id: id, id, name, date }}
title={t('journal.pl.lesson.editTitle')}
nameButton={t('journal.pl.save')}
title={'Редактирование лекции'}
nameButton={'Сохранить'}
/>
</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={`${getNavigationValue('journal.main')}/lesson/${courseId}/${id}`}
to={`${getNavigationsValue('journal.main')}/lesson/${courseId}/${id}`}
style={{ display: 'flex' }}
>
<QRCodeImage />
<img width={24} src={qrCode} style={{ margin: '0 auto' }} />
</Link>
</Td>
)}
<Td textAlign="center">
{formatDate(date, groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')}
{dayjs(date).format(groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')}
</Td>
<Td>{name}</Td>
{isTeacher && (
@ -206,20 +126,16 @@ export const Item: React.FC<ItemProps> = ({
<MenuList>
<MenuItem
onClick={() => {
if (setEditLesson) {
setEditLesson();
} else {
setEdit(true);
}
setEdit(true)
}}
>
{t('journal.pl.edit')}
Edit
</MenuItem>
<MenuItem onClick={setlessonToDelete}>{t('journal.pl.delete')}</MenuItem>
<MenuItem onClick={setlessonToDelete}>Delete</MenuItem>
</MenuList>
</Menu>
)}
{edit && <Button onClick={setlessonToDelete}>{t('journal.pl.save')}</Button>}
{edit && <Button onClick={setlessonToDelete}>Сохранить</Button>}
</Td>
)}
<Td isNumeric>{students.length}</Td>

View File

@ -1,12 +1,8 @@
import React from 'react'
import { formatDate } from '../../../utils/dayjs-config'
import dayjs from 'dayjs'
import {
Tr,
Td,
Box,
Flex,
Text,
useBreakpointValue,
} from '@chakra-ui/react'
import { Lesson } from '../../../__data__/model'
@ -19,7 +15,6 @@ type LessonItemProps = {
isTeacher: boolean
courseId: string
setlessonToDelete(lesson: Lesson): void
setEditLesson?(lesson: Lesson): void
}
export const LessonItems: React.FC<LessonItemProps> = ({
@ -28,71 +23,23 @@ export const LessonItems: React.FC<LessonItemProps> = ({
isTeacher,
courseId,
setlessonToDelete,
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}
/>
))}
</>
)
}
}) => (
<>
{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}
/>
))}
</>
)

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'
import React from 'react'
import { useForm, Controller } from 'react-hook-form'
import {
Box,
@ -14,28 +14,8 @@ 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, 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 { AddIcon } from '@chakra-ui/icons'
import { dateToCalendarFormat } from '../../../utils/time'
import { Lesson } from '../../../__data__/model'
@ -44,23 +24,16 @@ import { ErrorSpan } from '../style'
interface NewLessonForm {
name: string
date: string
time: string
}
interface LessonFormProps {
lesson?: Partial<Lesson> | any // Разрешаем передавать как Lesson, так и AI-сгенерированный урок
lesson?: Partial<Lesson>
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 = ({
@ -71,42 +44,11 @@ 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 && {
@ -114,296 +56,16 @@ export const LessonForm = ({
date: dateToCalendarFormat(lesson.date),
}) || {
name: '',
date: getNearestTimeSlot(),
date: dateToCalendarFormat(),
},
})
// Рендерим скелетон для предложений ИИ
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" bg={isAiSuggested ? aiHighlightColor : undefined}>
<Card align="left">
<CardHeader display="flex">
<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>
<Heading as="h2" mt="0">
{title}
</Heading>
<CloseButton
ml="auto"
onClick={() => {
@ -418,82 +80,37 @@ export const LessonForm = ({
<Controller
control={control}
name="date"
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>
);
}}
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>
)}
/>
<Controller
control={control}
name="name"
rules={{ required: t('journal.pl.common.required') }}
rules={{ required: 'Обязательное поле' }}
render={({ field }) => (
<FormControl isRequired isInvalid={Boolean(errors.name)}>
<FormLabel>{t('journal.pl.lesson.form.title')}</FormLabel>
<FormLabel>Название новой лекции:</FormLabel>
<Input
{...field}
required={false}
placeholder={t('journal.pl.lesson.form.namePlaceholder')}
placeholder="Название лекции"
size="md"
/>
{errors.name && (
@ -506,8 +123,8 @@ export const LessonForm = ({
<Button
size="lg"
type="submit"
leftIcon={isAiSuggested ? <Icon as={FaRobot} /> : <AddIcon />}
colorScheme={isAiSuggested ? "blue" : "blue"}
leftIcon={<AddIcon />}
colorScheme="blue"
isLoading={isLoading}
>
{nameButton}
@ -517,98 +134,6 @@ 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

@ -1,309 +0,0 @@
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,8 +1,11 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import dayjs, { formatDate } from '../../utils/dayjs-config'
import dayjs from 'dayjs'
import { generatePath, Link, useParams } from 'react-router-dom'
import { getNavigationValue, getFeatures } from '@brojs/cli'
import { getNavigationsValue, getFeatures } from '@brojs/cli'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
Container,
Box,
Button,
@ -14,7 +17,6 @@ import {
Tr,
Th,
Tbody,
Td,
Text,
AlertDialog,
AlertDialogBody,
@ -22,49 +24,29 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
useBreakpointValue,
Flex,
Menu,
MenuButton,
MenuList,
MenuItem,
useColorMode,
Portal,
} from '@chakra-ui/react'
import { AddIcon, EditIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
import { AddIcon } from '@chakra-ui/icons'
import { useAppSelector } from '../../__data__/store'
import { api } from '../../__data__/api/api'
import { isTeacher } from '../../utils/user'
import { Lesson } from '../../__data__/model'
import { XlSpinner, useSetBreadcrumbs } from '../../components'
import { qrCode } from '../../assets'
import { XlSpinner } from '../../components/xl-spinner'
import { LessonForm } from './components/lessons-form'
import { Bar } from './components/bar'
import { LessonItems } from './components/lesson-items'
import { CourseStatistics } from './components/statistics'
import { BreadcrumbsWrapper } from './style'
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()
@ -75,43 +57,11 @@ 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 []
@ -141,38 +91,9 @@ 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: t('journal.pl.common.sending'),
title: 'Отправляем',
status: 'loading',
duration: 9000,
})
@ -202,7 +123,7 @@ const LessonList = () => {
title={
<>
<Box pb={3}>
<Text fontSize="xl">{t('journal.pl.lesson.deletedMessage', { name: lesson.name })}</Text>
<Text fontSize="xl">{`Удалена лекция ${lesson.name}`}</Text>
</Box>
<Button
onClick={() => {
@ -210,7 +131,7 @@ const LessonList = () => {
toast.close(id)
}}
>
{t('journal.pl.common.restored')}
Восстановить
</Button>
</>
}
@ -224,8 +145,8 @@ const LessonList = () => {
useEffect(() => {
if (crLQuery.isSuccess) {
const toastProps = {
title: t('journal.pl.lesson.created'),
description: t('journal.pl.lesson.successMessage', { name: createdLessonRef.current?.name }),
title: 'Лекция создана',
description: `Лекция ${createdLessonRef.current?.name} успешно создана`,
status: 'success' as const,
duration: 9000,
isClosable: true,
@ -239,8 +160,8 @@ const LessonList = () => {
useEffect(() => {
if (updateLessonRqst.isSuccess) {
const toastProps = {
title: t('journal.pl.lesson.updated'),
description: t('journal.pl.lesson.updateMessage', { name: createdLessonRef.current?.name }),
title: 'Лекция Обновлена',
description: `Лекция ${createdLessonRef.current?.name} успешно обновлена`,
status: 'success' as const,
duration: 9000,
isClosable: true,
@ -251,54 +172,6 @@ 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 />
}
@ -313,11 +186,12 @@ const LessonList = () => {
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{t('journal.pl.lesson.deleteConfirm', { date: formatDate(lessonToDelete?.date, 'DD.MM.YY') })}
Удалить занятие от{' '}
{dayjs(lessonToDelete?.date).format('DD.MM.YY')}?
</AlertDialogHeader>
<AlertDialogBody>
{t('journal.pl.lesson.deleteWarning')}
Все данные о посещении данного занятия будут удалены
</AlertDialogBody>
<AlertDialogFooter>
@ -326,7 +200,7 @@ const LessonList = () => {
ref={cancelRef}
onClick={() => setlessonToDelete(null)}
>
{t('journal.pl.cancel')}
Cancel
</Button>
<Button
colorScheme="red"
@ -335,52 +209,53 @@ const LessonList = () => {
onClick={() => deleteLesson(lessonToDelete.id)}
ml={3}
>
{t('journal.pl.delete')}
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 || 'new-lesson'}
key={editLesson?.id}
isLoading={crLQuery.isLoading}
onSubmit={onSubmit}
onCancel={handleCancelForm}
onCancel={() => {
setShowForm(false)
setEditLesson(null)
}}
error={(crLQuery.error as any)?.error}
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
}))}
lesson={editLesson}
title={editLesson ? 'Редактирование лекции' : 'Создание лекции'}
nameButton={editLesson ? 'Редактировать' : 'Создать'}
/>
) : (
<Button
leftIcon={<AddIcon />}
colorScheme="green"
onClick={handleOpenForm}
onClick={() => setShowForm(true)}
>
{t('journal.pl.common.create')}
Добавить
</Button>
)}
</Box>
)}
{/* Статистика курса */}
{!showForm && courseStatistics && (
<CourseStatistics lessons={sorted} isLoading={isLoading} />
)}
{barFeature && sorted?.length > 1 && (
<Box height="300">
<Bar
@ -391,175 +266,37 @@ const LessonList = () => {
/>
</Box>
)}
{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>
<TableContainer whiteSpace="wrap" pb={13}>
<Table variant="striped" colorScheme="cyan">
<Thead>
<Tr>
{isTeacher(user) && (
<Th align="center" width={1}>
ссылка
</Th>
)}
<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>
)}
<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>
</Container>
</>
)

View File

@ -15,149 +15,25 @@ const reveal = keyframes`
}
`
export const AddMissedButton = styled.button`
position: absolute;
bottom: 8px;
right: 8px;
border: none;
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 {
cursor: pointer;
opacity: 1;
transform: scale(1.1);
}
.chakra-ui-dark & {
background-color: var(--chakra-colors-blue-400);
}
`
export const StudentList = styled.ul`
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);
}
padding-left: 0px;
height: 600px;
justify-content: space-evenly;
padding-right: 20px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
`
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: var(--chakra-colors-red-100);
color: #f9e2e2;
display: block;
padding: 16px;
background-color: var(--chakra-colors-red-600);
background-color: #d32f0b;
border-radius: 11px;
.chakra-ui-dark & {
color: var(--chakra-colors-red-200);
background-color: var(--chakra-colors-red-800);
}
`

View File

@ -1,10 +1,8 @@
import React, { useEffect, useState } from 'react'
import React 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 { formatDate } from '../utils/dayjs-config'
import dayjs from 'dayjs'
import {
Alert,
AlertIcon,
@ -13,104 +11,17 @@ 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 (
@ -129,34 +40,17 @@ const UserPage = () => {
}
return (
<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>
)}
<Container>
{acc.isLoading && <h1>Отправляем запрос</h1>}
{acc.isSuccess && <h1>Успешно</h1>}
{acc.error && (
<Box mb="6" mt="2">
<Alert status="warning" borderRadius="lg">
<Alert status="warning">
<AlertIcon />
{(acc as any).error?.data?.body?.errorMessage ===
'Code is expired' ? (
t('journal.pl.access.expiredCode')
'Не удалось активировать код доступа. Попробуйте отсканировать код ещё раз'
) : (
<pre>{JSON.stringify(acc.error, null, 4)}</pre>
)}
@ -164,150 +58,31 @@ const UserPage = () => {
</Box>
)}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
<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>
<Box mb={6}>
<Text fontSize={18} fontWeight={600} as="h1" mt="4" mb="3">
Тема занятия: {ls.data?.body?.name}
</Text>
<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>
<span>{dayjs(ls.data?.body?.date).format('DD MMMM YYYYг.')}</span>
</Box>
{/* Реакции на занятие */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
<Box
as="ul"
display="flex"
flexWrap="wrap"
justifyContent="center"
gap={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>
{ls.data?.body?.students?.map((student) => (
<UserCard
width="40%"
wrapperAS="li"
key={student.sub}
student={student}
present
/>
))}
</Box>
</Container>
)
}

View File

@ -1,9 +0,0 @@
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;
}

View File

@ -1,30 +0,0 @@
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;

View File

@ -1,15 +0,0 @@
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, { formatDate } from "./dayjs-config";
import dayjs from "dayjs";
export const dateToCalendarFormat = (date?: string) => formatDate(date, 'YYYY-MM-DDTHH:mm')
export const dateToCalendarFormat = (date?: string) => dayjs(date).format('YYYY-MM-DDTHH:mm')

View File

@ -2,87 +2,19 @@ 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(100));
router.use(timer())
const config = {
examCreated: false
}
router.get('/course/list', (req, res) => {
const modifiedData = readAndModifyJson('../mocks/courses/list/success.json');
res.send(modifiedData);
res.send(require('../mocks/courses/list/success.json'))
})
router.get('/course/:id', (req, res) => {
@ -90,30 +22,18 @@ router.get('/course/:id', (req, res) => {
return res.status(400).send({ success: false, error: 'Invalid course id' })
if (config.examCreated) {
config.examCreated = false;
const modifiedData = readAndModifyJson('../mocks/courses/by-id/with-exam.json');
return res.send(modifiedData);
config.examCreated = false
return res.send(require('../mocks/courses/by-id/with-exam.json'))
}
const modifiedData = readAndModifyJson('../mocks/courses/by-id/success.json');
res.send(modifiedData);
res.send(require('../mocks/courses/by-id/success.json'))
})
router.get('/course/students/:courseId', (req, res) => {
const modifiedData = readAndModifyJson('../mocks/courses/all-students/success.json');
res.send(modifiedData);
res.send(require('../mocks/courses/all-students/success.json'))
})
router.post('/course', (req, res) => {
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);
res.send(require('../mocks/courses/create/success.json'))
})
router.post('/course/toggle-exam-with-jury/:id', (req, res) => {
@ -122,62 +42,27 @@ router.post('/course/toggle-exam-with-jury/:id', (req, res) => {
})
router.get('/lesson/list/:courseId', (req, res) => {
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);
res.send(require('../mocks/lessons/list/success.json'))
})
router.post('/lesson', (req, res) => {
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);
res.send(require('../mocks/lessons/create/success.json'))
})
router.post('/lesson/access-code', (req, res) => {
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);
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)
})
router.get('/lesson/access-code/:accessCode', (req, res) => {
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);
res.status(400).send(require('../mocks/lessons/access-code/get/error.json'))
})
router.get('/lesson/:lessonId', (req, res) => {
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);
res.send(require('../mocks/lessons/byid/success.json'))
})
router.delete('/lesson/:lessonId', (req, res) => {
@ -188,24 +73,4 @@ 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:30:00.000Z",
"created": "2024-08-25T17:40:17.000Z",
"startDt": "2024-08-25T17:40:17.814Z",
"created": "2024-08-25T17:40:17.814Z",
"examWithJury2": {
"_id": "66cf3d3f4637d420d6271451",
"name": "Хакатон",

View File

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

View File

@ -46,181 +46,6 @@
"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,87 +7,19 @@
"name": "ВВОДНАЯ ПО JS.ПРИМЕНЕНИЕ И СПОСОБЫ ПОДКЛЮЧЕНИЯ НА СТРАНИЦЕ. LET, CONST. БАЗОВЫЕ ТИПЫ ДАННЫХ, ПРИВЕДЕНИЕ ТИПОВ. ПЕРЕМЕННЫЕ, ОБЛАСТЬ ВИДИМОСТИ ПЕРЕМЕННЫХ",
"students": [
{
"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"
}
"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"
}
],
"date": "2024-02-28T20:37:00.057Z",
"created": "2024-02-28T20:37:00.057Z",

View File

@ -1,25 +0,0 @@
{
"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

View File

@ -1,26 +1,25 @@
{
"compilerOptions": {
"lib": ["dom", "es2017"],
"lib": [
"dom",
"es2017"
],
"outDir": "./dist/",
"sourceMap": true,
"esModuleInterop": true,
"noImplicitAny": false,
"module": "esnext",
"moduleResolution": "node",
"target": "es6",
"jsx": "react",
"typeRoots": ["node_modules/@types", "./@types"],
"types": ["webpack-env", "node"],
"resolveJsonModule": true,
"moduleResolution": "Bundler",
"skipLibCheck": true,
"typeRoots": ["node_modules/@types", "src/typings"],
"types" : ["webpack-env", "node"],
"resolveJsonModule": true
},
"types": [
"@types/*"
],
"exclude": [
"node_modules",
"**/*.test.ts",
"**/*.test.tsx",
"node_modules/@types/jest"
]
}
]
}