Добавлены новые зависимости: "react-select" и "@floating-ui/core". Реализована локализация с использованием i18next, добавлены переводы для английского и русского языков. Обновлены компоненты для поддержки локализации, включая AppHeader, Attendance, Dashboard и другие. Улучшена логика отображения данных и взаимодействия с пользователем.

This commit is contained in:
Primakov Alexandr Alexandrovich 2025-03-23 11:41:29 +03:00
parent d5b5838e51
commit d3a7f70d12
27 changed files with 995 additions and 191 deletions

131
locales/en.json Normal file

@ -0,0 +1,131 @@
{
"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.common.add": "Add",
"journal.pl.common.edit": "Edit",
"journal.pl.common.delete": "Delete",
"journal.pl.common.save": "Save",
"journal.pl.common.cancel": "Cancel",
"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": "Lesson",
"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.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.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.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.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"
}

@ -1,3 +1,126 @@
{ {
"": "" "journal.pl.add": "Добавить",
"journal.pl.edit": "Редактировать",
"journal.pl.delete": "Удалить",
"journal.pl.save": "Сохранить",
"journal.pl.cancel": "Отменить",
"journal.pl.close": "Закрыть",
"journal.pl.title": "Журнал посещаемости",
"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.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.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.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.none": "Нет посещений",
"journal.pl.attendance.table.copy": "Копировать таблицу",
"journal.pl.attendance.table.show": "Показать таблицу",
"journal.pl.attendance.table.hide": "Скрыть таблицу",
"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": "Название лекции"
} }

102
package-lock.json generated

@ -31,6 +31,7 @@
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-redux": "^9.1.0", "react-redux": "^9.1.0",
"react-router-dom": "^6.22.1", "react-router-dom": "^6.22.1",
"react-select": "^5.10.1",
"redux": "^5.0.1", "redux": "^5.0.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
@ -2120,6 +2121,31 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@ -2916,6 +2942,15 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"node_modules/@types/react-transition-group": {
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/use-sync-external-store": { "node_modules/@types/use-sync-external-store": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@ -4855,6 +4890,16 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -7673,6 +7718,12 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/merge-descriptors": { "node_modules/merge-descriptors": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
@ -9018,6 +9069,27 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/react-select": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.1.tgz",
"integrity": "sha512-roPEZUL4aRZDx6DcsD+ZNreVl+fM8VsKn0Wtex1v4IazH60ILp5xhdlp464IsEAlJdXeD+BhDAFsBVMfvLQueA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.0",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.8.1",
"@floating-ui/dom": "^1.0.1",
"@types/react-transition-group": "^4.4.0",
"memoize-one": "^6.0.0",
"prop-types": "^15.6.0",
"react-transition-group": "^4.3.0",
"use-isomorphic-layout-effect": "^1.2.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-side-effect": { "node_modules/react-side-effect": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz", "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz",
@ -9049,6 +9121,22 @@
} }
} }
}, },
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -10549,6 +10637,20 @@
} }
} }
}, },
"node_modules/use-isomorphic-layout-effect": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz",
"integrity": "sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": { "node_modules/use-sidecar": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",

@ -47,6 +47,7 @@
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-redux": "^9.1.0", "react-redux": "^9.1.0",
"react-router-dom": "^6.22.1", "react-router-dom": "^6.22.1",
"react-select": "^5.10.1",
"redux": "^5.0.1", "redux": "^5.0.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",

@ -51,6 +51,7 @@ export type BaseResponse<Data> = {
export interface Lesson { export interface Lesson {
id: string; id: string;
_id: string;
name: string; name: string;
students: User[]; students: User[];
teachers: Teacher[]; teachers: Teacher[];

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

@ -1,10 +1,14 @@
import React from 'react'; import React from 'react';
import { import {
Box,
Flex, Flex,
IconButton, IconButton,
useColorMode, useColorMode,
Button,
HStack
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { MoonIcon, SunIcon } from '@chakra-ui/icons'; import { MoonIcon, SunIcon } from '@chakra-ui/icons';
import { useTranslation } from 'react-i18next';
interface AppHeaderProps { interface AppHeaderProps {
serviceMenuContainerRef?: React.RefObject<HTMLDivElement>; serviceMenuContainerRef?: React.RefObject<HTMLDivElement>;
@ -12,7 +16,13 @@ interface AppHeaderProps {
export const AppHeader = ({ serviceMenuContainerRef }: AppHeaderProps) => { export const AppHeader = ({ serviceMenuContainerRef }: AppHeaderProps) => {
const { colorMode, toggleColorMode } = useColorMode(); const { colorMode, toggleColorMode } = useColorMode();
const { t, i18n } = useTranslation();
const toggleLanguage = () => {
const newLang = i18n.language === 'ru' ? 'en' : 'ru';
i18n.changeLanguage(newLang);
};
return ( return (
<Flex <Flex
as="header" as="header"
@ -28,14 +38,33 @@ export const AppHeader = ({ serviceMenuContainerRef }: AppHeaderProps) => {
boxShadow="sm" boxShadow="sm"
> >
{serviceMenuContainerRef && <div id="dots" ref={serviceMenuContainerRef}></div>} {serviceMenuContainerRef && <div id="dots" ref={serviceMenuContainerRef}></div>}
<Box>
<IconButton
aria-label={colorMode === 'light' ? 'Включить темную тему' : 'Включить светлую тему'} </Box>
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />} <HStack spacing={4}>
onClick={toggleColorMode} <Button
variant="ghost" onClick={toggleLanguage}
size="md" size="sm"
/> variant="ghost"
aria-label={i18n.language === 'ru'
? t('journal.pl.lang.switchToEn')
: t('journal.pl.lang.switchToRu')
}
>
{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 /> : <SunIcon />}
onClick={toggleColorMode}
variant="ghost"
size="md"
/>
</HStack>
</Flex> </Flex>
); );
}; };

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

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

@ -3,6 +3,7 @@ import { Routes, Route, useNavigate } from 'react-router-dom'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import { getNavigationValue } from '@brojs/cli' import { getNavigationValue } from '@brojs/cli'
import { Box, Container, Spinner, VStack, useColorMode } from '@chakra-ui/react' import { Box, Container, Spinner, VStack, useColorMode } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { import {
CourseListPage, CourseListPage,
@ -70,6 +71,7 @@ export const Dashboard = ({ store }: DashboardProps) => {
const serviceMenuInstanceRef = useRef<any>(null); const serviceMenuInstanceRef = useRef<any>(null);
const [serviceMenu, setServiceMenu] = useState(false); const [serviceMenu, setServiceMenu] = useState(false);
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
loadServiceMenu().then(() => { loadServiceMenu().then(() => {
@ -92,8 +94,8 @@ export const Dashboard = ({ store }: DashboardProps) => {
textColor: colorMode === 'light' ? '#333' : '#fff', textColor: colorMode === 'light' ? '#333' : '#fff',
}, },
translations: { translations: {
menuTitle: 'Сервисы BRO', menuTitle: t('journal.pl.serviceMenu.title'),
menuAriaLabel: 'Сервисы BRO', menuAriaLabel: t('journal.pl.serviceMenu.ariaLabel'),
} }
}); });
} }
@ -105,7 +107,7 @@ export const Dashboard = ({ store }: DashboardProps) => {
serviceMenuInstanceRef.current = null; serviceMenuInstanceRef.current = null;
} }
}; };
}, [keycloak.token, serviceMenu, colorMode]); }, [keycloak.token, serviceMenu, colorMode, t]);
return ( return (
<Provider store={store}> <Provider store={store}>

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

@ -9,6 +9,7 @@ import {
Spacer, Spacer,
Badge Badge
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { PageLoader } from '../../components/page-loader/page-loader' import { PageLoader } from '../../components/page-loader/page-loader'
import { useAttendanceData, useAttendanceStats } from './hooks' import { useAttendanceData, useAttendanceStats } from './hooks'
@ -17,6 +18,7 @@ import { AttendanceTable, StatsCard } from './components'
export const Attendance = () => { export const Attendance = () => {
const { courseId } = useParams() const { courseId } = useParams()
const { colorMode } = useColorMode() const { colorMode } = useColorMode()
const { t } = useTranslation()
const data = useAttendanceData(courseId) const data = useAttendanceData(courseId)
const stats = useAttendanceStats(data) const stats = useAttendanceStats(data)
@ -30,7 +32,7 @@ export const Attendance = () => {
<Box> <Box>
<Heading size="lg" mb={2}>{data.courseInfo?.name}</Heading> <Heading size="lg" mb={2}>{data.courseInfo?.name}</Heading>
<Badge colorScheme="blue"> <Badge colorScheme="blue">
{data.students.length} студентов {data.teachers.length} преподавателей {data.students.length} {t('journal.pl.common.students')} {data.teachers.length} {t('journal.pl.common.teachers')}
</Badge> </Badge>
</Box> </Box>
<Spacer /> <Spacer />

@ -0,0 +1,142 @@
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 'dayjs';
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(dayjs().format('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(dayjs().format('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;

@ -22,19 +22,11 @@ import {
import { CopyIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons' import { CopyIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'
import { FaSmile, FaMeh, FaFrown, FaSadTear } from 'react-icons/fa' import { FaSmile, FaMeh, FaFrown, FaSadTear } from 'react-icons/fa'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { sha256 } from 'js-sha256' import { useTranslation } from 'react-i18next'
import { getGravatarURL } from '../../../utils/gravatar'
import { ShortText } from './ShortText' import { ShortText } from './ShortText'
import { AttendanceData } from '../hooks' import { AttendanceData } from '../hooks'
// Функция для получения URL аватарки через Gravatar
function getGravatarURL(email) {
if (!email) return undefined
const address = String(email).trim().toLowerCase()
const hash = sha256(address)
return `https://www.gravatar.com/avatar/${hash}?d=robohash`
}
interface AttendanceTableProps { interface AttendanceTableProps {
data: AttendanceData data: AttendanceData
} }
@ -42,6 +34,7 @@ interface AttendanceTableProps {
export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => { export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
const { colorMode } = useColorMode() const { colorMode } = useColorMode()
const toast = useToast() const toast = useToast()
const { t } = useTranslation()
const [showTable, setShowTable] = useState(false) const [showTable, setShowTable] = useState(false)
const getPresentColor = () => { const getPresentColor = () => {
@ -60,25 +53,25 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
return { return {
icon: FaSmile, icon: FaSmile,
color: 'green.500', color: 'green.500',
label: 'Отличная посещаемость' label: t('journal.pl.attendance.emojis.excellent')
} }
} else if (attendanceRate >= 0.75) { } else if (attendanceRate >= 0.75) {
return { return {
icon: FaMeh, icon: FaMeh,
color: 'blue.400', color: 'blue.400',
label: 'Хорошая посещаемость' label: t('journal.pl.attendance.emojis.good')
} }
} else if (attendanceRate >= 0.5) { } else if (attendanceRate >= 0.5) {
return { return {
icon: FaFrown, icon: FaFrown,
color: 'orange.400', color: 'orange.400',
label: 'Низкая посещаемость' label: t('journal.pl.attendance.emojis.poor')
} }
} else { } else {
return { return {
icon: FaSadTear, icon: FaSadTear,
color: 'red.500', color: 'red.500',
label: 'Критически низкая посещаемость' label: t('journal.pl.attendance.emojis.none')
} }
} }
} }
@ -97,11 +90,11 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
}) })
// Добавляем столбцы даты и названия занятия // Добавляем столбцы даты и названия занятия
headerRow.push('Дата', 'Название занятия') headerRow.push(t('journal.pl.common.date'), t('journal.pl.common.lessonName'))
// Добавляем студентов // Добавляем студентов
data.students.forEach(student => { data.students.forEach(student => {
headerRow.push(student.name || student.value || 'Имя не определено') headerRow.push(student.name || student.value || t('journal.pl.common.name'))
}) })
// Добавляем заголовок в таблицу // Добавляем заголовок в таблицу
@ -139,8 +132,8 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
navigator.clipboard.writeText(finalContent) navigator.clipboard.writeText(finalContent)
.then(() => { .then(() => {
toast({ toast({
title: 'Скопировано в буфер обмена', title: t('journal.pl.attendance.table.copySuccess'),
description: 'Таблица успешно скопирована без сокращений', description: t('journal.pl.attendance.table.copySuccessDescription'),
status: 'success', status: 'success',
duration: 3000, duration: 3000,
isClosable: true, isClosable: true,
@ -148,8 +141,8 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
}) })
.catch(err => { .catch(err => {
toast({ toast({
title: 'Ошибка копирования', title: t('journal.pl.attendance.table.copyError'),
description: 'Не удалось скопировать таблицу', description: t('journal.pl.attendance.table.copyErrorDescription'),
status: 'error', status: 'error',
duration: 3000, duration: 3000,
isClosable: true, isClosable: true,
@ -171,7 +164,7 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
return { return {
student, student,
name: student.name || student.value || 'Имя не определено', name: student.name || student.value || t('journal.pl.common.name'),
email: student.email, email: student.email,
picture: student.picture || getGravatarURL(student.email), picture: student.picture || getGravatarURL(student.email),
attendedCount, attendedCount,
@ -182,7 +175,7 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
} }
if (!data.attendance?.length || !data.students?.length) { if (!data.attendance?.length || !data.students?.length) {
return <Box>Нет данных для отображения</Box> return <Box>{t('journal.pl.common.noData')}</Box>
} }
return ( return (
@ -199,7 +192,7 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
onClick={copyTableData} onClick={copyTableData}
mr={2} mr={2}
> >
Копировать таблицу {t('journal.pl.attendance.table.copy')}
</Button> </Button>
<Button <Button
@ -208,7 +201,7 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
variant="outline" variant="outline"
onClick={() => setShowTable(!showTable)} onClick={() => setShowTable(!showTable)}
> >
{showTable ? 'Скрыть таблицу' : 'Показать таблицу'} {showTable ? t('journal.pl.attendance.table.hide') : t('journal.pl.attendance.table.show')}
</Button> </Button>
</Flex> </Flex>
@ -221,7 +214,7 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
return ( return (
<Tooltip <Tooltip
key={student.sub} key={student.sub}
label={`${emoji.label}: ${attendedCount} из ${totalLessons} занятий (${attendance.toFixed(0)}%)`} label={`${emoji.label}: ${attendedCount} ${t('journal.pl.common.of')} ${totalLessons} ${t('journal.pl.common.students')} (${attendance.toFixed(0)}%)`}
hasArrow hasArrow
> >
<Box <Box
@ -238,13 +231,13 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
name={name} name={name}
> >
<AvatarBadge boxSize='2em' bg={emoji.color}> <AvatarBadge boxSize='2em' bg={emoji.color}>
<Icon as={emoji.icon} color="white" boxSize={6} /> <Icon as={emoji.icon} color="white" boxSize={7} />
</AvatarBadge> </AvatarBadge>
</Avatar> </Avatar>
<Box> <Box>
<Text fontSize="sm" fontWeight="medium" isTruncated maxW="110px">{name}</Text> <Text fontSize="sm" fontWeight="medium" isTruncated maxW="110px">{name}</Text>
<Text fontSize="xs" mt={1} color={colorMode === 'dark' ? 'gray.400' : 'gray.600'}> <Text fontSize="xs" mt={1} color={colorMode === 'dark' ? 'gray.400' : 'gray.600'}>
{attendedCount} из {totalLessons} ({attendance.toFixed(0)}%) {attendedCount} {t('journal.pl.common.of')} {totalLessons} ({attendance.toFixed(0)}%)
</Text> </Text>
</Box> </Box>
</HStack> </HStack>
@ -269,17 +262,17 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
{data.teachers?.map(teacher => ( {data.teachers?.map(teacher => (
<Th key={teacher.id}>{teacher.value}</Th> <Th key={teacher.id}>{teacher.value}</Th>
))} ))}
<Th>Дата</Th> <Th>{t('journal.pl.common.date')}</Th>
<Th>Название занятия</Th> <Th>{t('journal.pl.common.lessonName')}</Th>
{data.students.map((student) => ( {data.students.map((student) => (
<Th key={student.sub}> <Th key={student.sub}>
<HStack> <HStack>
<Avatar <Avatar
size="xs" size="xs"
src={student.picture || getGravatarURL(student.email)} src={student.picture || getGravatarURL(student.email)}
name={student.name || student.value || 'Имя не определено'} name={student.name || student.value || t('journal.pl.common.name')}
/> />
<Text>{student.name || student.value || 'Имя не определено'}</Text> <Text>{student.name || student.value || t('journal.pl.common.name')}</Text>
</HStack> </HStack>
</Th> </Th>
))} ))}

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

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

@ -15,6 +15,7 @@ import {
Text, Text,
Badge Badge
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { AttendanceStats } from '../hooks' import { AttendanceStats } from '../hooks'
interface StatsCardProps { interface StatsCardProps {
@ -23,6 +24,7 @@ interface StatsCardProps {
export const StatsCard: React.FC<StatsCardProps> = ({ stats }) => { export const StatsCard: React.FC<StatsCardProps> = ({ stats }) => {
const { colorMode } = useColorMode() const { colorMode } = useColorMode()
const { t } = useTranslation()
const getBgColor = () => { const getBgColor = () => {
return colorMode === 'dark' ? 'gray.700' : 'white' return colorMode === 'dark' ? 'gray.700' : 'white'
@ -42,19 +44,19 @@ export const StatsCard: React.FC<StatsCardProps> = ({ stats }) => {
bg={getBgColor()} bg={getBgColor()}
mb={6} mb={6}
> >
<Heading size="md" mb={4}>Статистика посещаемости</Heading> <Heading size="md" mb={4}>{t('journal.pl.attendance.stats.title')}</Heading>
<Divider mb={4} /> <Divider mb={4} />
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={5}> <SimpleGrid columns={{ base: 1, md: 2 }} spacing={5}>
<Box> <Box>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={5}> <SimpleGrid columns={{ base: 1, md: 2 }} spacing={5}>
<Stat> <Stat>
<StatLabel>Всего занятий</StatLabel> <StatLabel>{t('journal.pl.attendance.stats.totalLessons')}</StatLabel>
<StatNumber>{stats.totalLessons}</StatNumber> <StatNumber>{stats.totalLessons}</StatNumber>
</Stat> </Stat>
<Stat> <Stat>
<StatLabel>Средняя посещаемость</StatLabel> <StatLabel>{t('journal.pl.attendance.stats.averageAttendance')}</StatLabel>
<StatNumber>{stats.averageAttendance.toFixed(1)}%</StatNumber> <StatNumber>{stats.averageAttendance.toFixed(1)}%</StatNumber>
<Progress <Progress
value={stats.averageAttendance} value={stats.averageAttendance}
@ -68,7 +70,7 @@ export const StatsCard: React.FC<StatsCardProps> = ({ stats }) => {
<Box> <Box>
<Stat> <Stat>
<StatLabel mb={3}>Топ-3 студента по посещаемости</StatLabel> <StatLabel mb={3}>{t('journal.pl.attendance.stats.topStudents')}</StatLabel>
<VStack align="stretch" spacing={2}> <VStack align="stretch" spacing={2}>
{stats.topStudents.map((student, index) => ( {stats.topStudents.map((student, index) => (
<HStack key={index} justify="space-between"> <HStack key={index} justify="space-between">
@ -84,12 +86,12 @@ export const StatsCard: React.FC<StatsCardProps> = ({ stats }) => {
<Text fontWeight="medium">{student.name}</Text> <Text fontWeight="medium">{student.name}</Text>
</HStack> </HStack>
<Text> <Text>
{student.attendance} из {stats.totalLessons} ({student.attendancePercent.toFixed(0)}%) {student.attendance} {t('journal.pl.common.of')} {stats.totalLessons} ({student.attendancePercent.toFixed(0)}%)
</Text> </Text>
</HStack> </HStack>
))} ))}
{stats.topStudents.length === 0 && ( {stats.topStudents.length === 0 && (
<Text color="gray.500">Нет данных</Text> <Text color="gray.500">{t('journal.pl.attendance.stats.noData')}</Text>
)} )}
</VStack> </VStack>
</Stat> </Stat>

@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Link as ConnectedLink, generatePath } from 'react-router-dom' import { Link as ConnectedLink, generatePath } from 'react-router-dom'
import { getNavigationsValue } from '@brojs/cli' import { getNavigationValue } from '@brojs/cli'
import { import {
Box, Box,
CardHeader, CardHeader,
@ -16,6 +16,7 @@ import {
Tooltip, Tooltip,
Spinner, Spinner,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { api } from '../../__data__/api/api' import { api } from '../../__data__/api/api'
import { ArrowUpIcon, LinkIcon } from '@chakra-ui/icons' import { ArrowUpIcon, LinkIcon } from '@chakra-ui/icons'
@ -25,6 +26,8 @@ import { CourseDetails } from './course-details'
export const CourseCard = ({ course }: { course: Course }) => { export const CourseCard = ({ course }: { course: Course }) => {
const [getLessonList, populatedCourse] = api.useLazyGetCourseByIdQuery() const [getLessonList, populatedCourse] = api.useLazyGetCourseByIdQuery()
const [isOpened, setIsOpened] = useState(false) const [isOpened, setIsOpened] = useState(false)
const { t } = useTranslation()
useEffect(() => { useEffect(() => {
if (isOpened) { if (isOpened) {
getLessonList(course.id, true) getLessonList(course.id, true)
@ -46,10 +49,10 @@ export const CourseCard = ({ course }: { course: Course }) => {
<CardBody mt="16px"> <CardBody mt="16px">
<Stack divider={<StackDivider />} spacing="8px"> <Stack divider={<StackDivider />} spacing="8px">
<Box as="span" textAlign="left"> <Box as="span" textAlign="left">
{`Дата начала курса - ${dayjs(course.startDt).format('DD MMMM YYYYг.')}`} {`${t('journal.pl.course.startDate')} - ${dayjs(course.startDt).format(t('journal.pl.lesson.dateFormat'))}`}
</Box> </Box>
<Box as="span" textAlign="left"> <Box as="span" textAlign="left">
Количество занятий - {course.lessons.length} {t('journal.pl.course.lessonCount')} - {course.lessons.length}
</Box> </Box>
{populatedCourse.isFetching && <Spinner />} {populatedCourse.isFetching && <Spinner />}
@ -57,9 +60,9 @@ export const CourseCard = ({ course }: { course: Course }) => {
<CourseDetails populatedCourse={populatedCourse.data} /> <CourseDetails populatedCourse={populatedCourse.data} />
)} )}
{getNavigationsValue('link.journal.attendance') && ( {getNavigationValue('link.journal.attendance') && (
<Tooltip <Tooltip
label="На страницу с лекциями" label={t('journal.pl.course.attendancePage')}
fontSize="12px" fontSize="12px"
top="16px" top="16px"
> >
@ -69,12 +72,12 @@ export const CourseCard = ({ course }: { course: Course }) => {
variant="outline" variant="outline"
colorScheme="blue" colorScheme="blue"
to={generatePath( to={generatePath(
`${getNavigationsValue('journal.main')}${getNavigationsValue('link.journal.attendance')}`, `${getNavigationValue('journal.main')}${getNavigationValue('link.journal.attendance')}`,
{ courseId: course.id }, { courseId: course.id },
)} )}
> >
<Box mt={3}></Box> <Box mt={3}></Box>
Посещаемость {t('journal.pl.course.attendance')}
</Button> </Button>
</Tooltip> </Tooltip>
)} )}
@ -87,17 +90,17 @@ export const CourseCard = ({ course }: { course: Course }) => {
mt="16px" mt="16px"
flexDirection={['column', 'row']} flexDirection={['column', 'row']}
> >
<Tooltip label="На страницу с лекциями" fontSize="12px" top="16px"> <Tooltip label={t('journal.pl.course.attendancePage')} fontSize="12px" top="16px">
<Button <Button
leftIcon={<LinkIcon />} leftIcon={<LinkIcon />}
as={ConnectedLink} as={ConnectedLink}
colorScheme="blue" colorScheme="blue"
to={`${getNavigationsValue('journal.main')}/lessons-list/${course._id}`} to={`${getNavigationValue('journal.main')}/lessons-list/${course._id}`}
> >
Открыть {t('journal.pl.common.open')}
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip label="Детали" fontSize="12px" top="16px"> <Tooltip label={t('journal.pl.course.details')} fontSize="12px" top="16px">
<Button <Button
colorScheme="blue" colorScheme="blue"
mt={['16px', 0]} mt={['16px', 0]}
@ -107,11 +110,11 @@ export const CourseCard = ({ course }: { course: Course }) => {
transform={isOpened ? 'rotate(0)' : 'rotate(180deg)'} transform={isOpened ? 'rotate(0)' : 'rotate(180deg)'}
/> />
} }
loadingText="Загрузка" loadingText={t('journal.pl.common.loading')}
isLoading={populatedCourse.isFetching} isLoading={populatedCourse.isFetching}
onClick={handleToggleOpene} onClick={handleToggleOpene}
> >
{isOpened ? 'Закрыть' : 'Просмотреть детали'} {isOpened ? t('journal.pl.close') : t('journal.pl.course.viewDetails')}
</Button> </Button>
</Tooltip> </Tooltip>
</ButtonGroup> </ButtonGroup>

@ -3,12 +3,13 @@ import dayjs from 'dayjs'
import { Link as ConnectedLink } from 'react-router-dom' import { Link as ConnectedLink } from 'react-router-dom'
import { getNavigationValue, getHistory } from '@brojs/cli' import { getNavigationValue, getHistory } from '@brojs/cli'
import { Stack, Heading, Link, Button, Tooltip, Box } from '@chakra-ui/react' import { Stack, Heading, Link, Button, Tooltip, Box } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { LinkIcon } from '@chakra-ui/icons'
import { useAppSelector } from '../../__data__/store' import { useAppSelector } from '../../__data__/store'
import { isTeacher } from '../../utils/user' import { isTeacher } from '../../utils/user'
import { PopulatedCourse } from '../../__data__/model' import { PopulatedCourse } from '../../__data__/model'
import { api } from '../../__data__/api/api' import { api } from '../../__data__/api/api'
import { LinkIcon } from '@chakra-ui/icons'
type CourseDetailsProps = { type CourseDetailsProps = {
populatedCourse: PopulatedCourse populatedCourse: PopulatedCourse
@ -21,14 +22,15 @@ export const CourseDetails = ({ populatedCourse }: CourseDetailsProps) => {
const exam = populatedCourse.examWithJury const exam = populatedCourse.examWithJury
const [toggleExamWithJury, examWithJuryRequest] = const [toggleExamWithJury, examWithJuryRequest] =
api.useToggleExamWithJuryMutation() api.useToggleExamWithJuryMutation()
const { t } = useTranslation()
return ( return (
<> <>
{isTeacher(user) && ( {isTeacher(user) && (
<Heading as="h3" mt={4} mb={3} size="lg"> <Heading as="h3" mt={4} mb={3} size="lg">
Экзамен: {exam?.name}{' '} {t('journal.pl.exam.title')}: {exam?.name}{' '}
{exam && getNavigationValue('exam.main') && getNavigationValue('link.exam.details') && ( {exam && getNavigationValue('exam.main') && getNavigationValue('link.exam.details') && (
<Tooltip label="Начать экзамен" fontSize="12px" top="16px"> <Tooltip label={t('journal.pl.exam.startExam')} fontSize="12px" top="16px">
<Button <Button
leftIcon={<LinkIcon />} leftIcon={<LinkIcon />}
as={'a'} as={'a'}
@ -49,7 +51,7 @@ export const CourseDetails = ({ populatedCourse }: CourseDetailsProps) => {
) )
}} }}
> >
Открыть {t('journal.pl.exam.open')}
</Button> </Button>
</Tooltip> </Tooltip>
)} )}
@ -58,10 +60,10 @@ export const CourseDetails = ({ populatedCourse }: CourseDetailsProps) => {
{!Boolean(exam) && ( {!Boolean(exam) && (
<> <>
<Heading as="h3" mt={4} mb={3} size="lg"> <Heading as="h3" mt={4} mb={3} size="lg">
Не задан {t('journal.pl.exam.notSpecified')}
</Heading> </Heading>
<Box mt={10}> <Box mt={10}>
<Tooltip label="Создать экзамен с жюри" fontSize="12px" top="16px"> <Tooltip label={t('journal.pl.exam.createWithJury')} fontSize="12px" top="16px">
<Button <Button
colorScheme="blue" colorScheme="blue"
mt={['16px', 0]} mt={['16px', 0]}
@ -69,7 +71,7 @@ export const CourseDetails = ({ populatedCourse }: CourseDetailsProps) => {
isLoading={examWithJuryRequest.isLoading} isLoading={examWithJuryRequest.isLoading}
onClick={() => toggleExamWithJury(populatedCourse.id)} onClick={() => toggleExamWithJury(populatedCourse.id)}
> >
Создать {t('journal.pl.common.create')}
</Button> </Button>
</Tooltip> </Tooltip>
</Box> </Box>
@ -78,7 +80,7 @@ export const CourseDetails = ({ populatedCourse }: CourseDetailsProps) => {
{Boolean(exam) && ( {Boolean(exam) && (
<> <>
<Heading as="h3" mt={4} mb={3} size="lg"> <Heading as="h3" mt={4} mb={3} size="lg">
Количество членов жюри: {t('journal.pl.exam.juryCount')}:
</Heading> </Heading>
<Heading as="h3" mt={4} mb={3} size="lg"> <Heading as="h3" mt={4} mb={3} size="lg">
{populatedCourse.examWithJury.jury.length} {populatedCourse.examWithJury.jury.length}
@ -86,7 +88,7 @@ export const CourseDetails = ({ populatedCourse }: CourseDetailsProps) => {
</> </>
)} )}
<Heading as="h3" mt={4} mb={3} size="lg"> <Heading as="h3" mt={4} mb={3} size="lg">
Список занятий: {t('journal.pl.lesson.list')}:
</Heading> </Heading>
<Stack> <Stack>
{populatedCourse?.lessons?.map((lesson) => ( {populatedCourse?.lessons?.map((lesson) => (

@ -20,6 +20,7 @@ import {
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useForm, Controller } from 'react-hook-form' import { useForm, Controller } from 'react-hook-form'
import { AddIcon } from '@chakra-ui/icons' import { AddIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
import { ErrorSpan } from '../style' import { ErrorSpan } from '../style'
import { useAppSelector } from '../../__data__/store' import { useAppSelector } from '../../__data__/store'
@ -40,6 +41,7 @@ export const CoursesList = () => {
const [createUpdateCourse, crucQuery] = api.useCreateUpdateCourseMutation() const [createUpdateCourse, crucQuery] = api.useCreateUpdateCourseMutation()
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
const toastRef = useRef(null) const toastRef = useRef(null)
const { t } = useTranslation()
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
@ -52,13 +54,13 @@ export const CoursesList = () => {
} = useForm<NewCourseForm>({ } = useForm<NewCourseForm>({
defaultValues: { defaultValues: {
startDt: dayjs().format('YYYY-MM-DD'), startDt: dayjs().format('YYYY-MM-DD'),
name: 'Название', name: t('journal.pl.course.defaultName'),
}, },
}) })
const onSubmit = ({ startDt, name }) => { const onSubmit = ({ startDt, name }) => {
toastRef.current = toast({ toastRef.current = toast({
title: 'Отправляем', title: t('journal.pl.course.sending'),
status: 'loading', status: 'loading',
duration: 9000, duration: 9000,
}) })
@ -70,8 +72,8 @@ export const CoursesList = () => {
const values = getValues() const values = getValues()
if (toastRef.current) { if (toastRef.current) {
toast.update(toastRef.current, { toast.update(toastRef.current, {
title: 'Курс создан.', title: t('journal.pl.course.created'),
description: `Курс ${values.name} успешно создан`, description: t('journal.pl.course.successMessage', { name: values.name }),
status: 'success', status: 'success',
duration: 9000, duration: 9000,
isClosable: true, isClosable: true,
@ -79,7 +81,7 @@ export const CoursesList = () => {
} }
reset() reset()
} }
}, [crucQuery.isSuccess]) }, [crucQuery.isSuccess, t])
if (isLoading) { if (isLoading) {
return ( return (
@ -95,7 +97,7 @@ export const CoursesList = () => {
<Card align="left"> <Card align="left">
<CardHeader display="flex"> <CardHeader display="flex">
<Heading as="h2" mt="0"> <Heading as="h2" mt="0">
Создание курса {t('journal.pl.course.createTitle')}
</Heading> </Heading>
<CloseButton ml="auto" onClick={() => setShowForm(false)} /> <CloseButton ml="auto" onClick={() => setShowForm(false)} />
</CardHeader> </CardHeader>
@ -105,17 +107,17 @@ export const CoursesList = () => {
<Controller <Controller
control={control} control={control}
name="startDt" name="startDt"
rules={{ required: 'Обязательное поле' }} rules={{ required: t('journal.pl.common.requiredField') }}
render={({ field }) => ( render={({ field }) => (
<FormControl <FormControl
isRequired isRequired
isInvalid={Boolean(errors.startDt)} isInvalid={Boolean(errors.startDt)}
> >
<FormLabel>Дата начала</FormLabel> <FormLabel>{t('journal.pl.common.startDate')}</FormLabel>
<Input <Input
{...field} {...field}
required={false} required={false}
placeholder="Select Date and Time" placeholder={t('journal.pl.common.selectDateTime')}
size="md" size="md"
type="date" type="date"
/> />
@ -125,7 +127,7 @@ export const CoursesList = () => {
</FormErrorMessage> </FormErrorMessage>
) : ( ) : (
<FormHelperText> <FormHelperText>
Укажите дату начала курса {t('journal.pl.course.specifyStartDate')}
</FormHelperText> </FormHelperText>
)} )}
</FormControl> </FormControl>
@ -135,18 +137,18 @@ export const CoursesList = () => {
control={control} control={control}
name="name" name="name"
rules={{ rules={{
required: 'Обязательное поле', required: t('journal.pl.common.requiredField'),
}} }}
render={({ field }) => ( render={({ field }) => (
<FormControl <FormControl
isRequired isRequired
isInvalid={Boolean(errors.name)} isInvalid={Boolean(errors.name)}
> >
<FormLabel>Название новой лекции:</FormLabel> <FormLabel>{t('journal.pl.course.newLectureName')}:</FormLabel>
<Input <Input
{...field} {...field}
required={false} required={false}
placeholder="КФУ-24-2" placeholder={t('journal.pl.course.namePlaceholder')}
size="md" size="md"
/> />
{errors.name && ( {errors.name && (
@ -165,7 +167,7 @@ export const CoursesList = () => {
leftIcon={<AddIcon />} leftIcon={<AddIcon />}
colorScheme="blue" colorScheme="blue"
> >
Создать {t('journal.pl.common.create')}
</Button> </Button>
</Box> </Box>
</VStack> </VStack>
@ -183,7 +185,7 @@ export const CoursesList = () => {
colorScheme="green" colorScheme="green"
onClick={() => setShowForm(true)} onClick={() => setShowForm(true)}
> >
Добавить {t('journal.pl.common.add')}
</Button> </Button>
</Box> </Box>
)} )}

@ -14,6 +14,7 @@ import {
Heading, Heading,
Stack, Stack,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { api } from '../__data__/api/api' import { api } from '../__data__/api/api'
import { User } from '../__data__/model' import { User } from '../__data__/model'
@ -40,6 +41,7 @@ const LessonDetail = () => {
const { lessonId, courseId } = useParams() const { lessonId, courseId } = useParams()
const canvRef = useRef(null) const canvRef = useRef(null)
const user = useAppSelector((s) => s.user) const user = useAppSelector((s) => s.user)
const { t } = useTranslation()
const { const {
isFetching, isFetching,
@ -111,7 +113,7 @@ const LessonDetail = () => {
<Breadcrumb> <Breadcrumb>
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbLink as={Link} to={getNavigationValue('journal.main')}> <BreadcrumbLink as={Link} to={getNavigationValue('journal.main')}>
Журнал {t('journal.pl.common.journal')}
</BreadcrumbLink> </BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
@ -120,28 +122,28 @@ const LessonDetail = () => {
as={Link} as={Link}
to={`${getNavigationValue('journal.main')}/lessons-list/${courseId}`} to={`${getNavigationValue('journal.main')}/lessons-list/${courseId}`}
> >
Курс {t('journal.pl.common.course')}
</BreadcrumbLink> </BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbItem isCurrentPage> <BreadcrumbItem isCurrentPage>
<BreadcrumbLink href="#">Лекция</BreadcrumbLink> <BreadcrumbLink href="#">{t('journal.pl.common.lesson')}</BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
</Breadcrumb> </Breadcrumb>
</BreadcrumbsWrapper> </BreadcrumbsWrapper>
<Container maxW="2280px"> <Container maxW="2280px">
<VStack align="left"> <VStack align="left">
<Heading as="h3" mt="4" mb="3"> <Heading as="h3" mt="4" mb="3">
Тема занятия: {t('journal.pl.lesson.topicTitle')}
</Heading> </Heading>
<Box as="span">{accessCode?.body?.lesson?.name}</Box> <Box as="span">{accessCode?.body?.lesson?.name}</Box>
<Box as="span"> <Box as="span">
{dayjs(accessCode?.body?.lesson?.date).format('DD MMMM YYYYг.')}{' '} {dayjs(accessCode?.body?.lesson?.date).format(t('journal.pl.lesson.dateFormat'))}{' '}
Отмечено - {accessCode?.body?.lesson?.students?.length}{' '} {t('journal.pl.common.marked')} - {accessCode?.body?.lesson?.students?.length}{' '}
{AllStudents.isSuccess {AllStudents.isSuccess
? `/ ${AllStudents?.data?.body?.length}` ? `/ ${AllStudents?.data?.body?.length}`
: ''}{' '} : ''}{' '}
человек {t('journal.pl.common.people')}
</Box> </Box>
</VStack> </VStack>
<Stack spacing="8" sx={{ flexDirection: { sm: 'column', md: 'row' } }}> <Stack spacing="8" sx={{ flexDirection: { sm: 'column', md: 'row' } }}>

@ -1,28 +1,33 @@
import React from 'react' import React from 'react'
import { ResponsiveBar } from '@nivo/bar' import { type BarDatum, ResponsiveBar } from '@nivo/bar'
import { useTranslation } from 'react-i18next'
export const Bar = ({ data }) => ( export const Bar = ({ data }: { data: BarDatum[] }) => {
<ResponsiveBar const { t } = useTranslation()
data={data}
keys={['count']} return (
indexBy="lessonIndex" <ResponsiveBar
margin={{ top: 50, right: 130, bottom: 50, left: 60 }} data={data}
padding={0.3} keys={['count']}
valueScale={{ type: 'linear' }} indexBy="lessonIndex"
indexScale={{ type: 'band', round: true }} margin={{ top: 50, right: 130, bottom: 50, left: 60 }}
colors={{ scheme: 'set3' }} padding={0.3}
axisTop={null} valueScale={{ type: 'linear' }}
axisRight={null} indexScale={{ type: 'band', round: true }}
labelSkipWidth={12} colors={{ scheme: 'set3' }}
labelSkipHeight={12} axisTop={null}
labelTextColor={{ axisRight={null}
from: 'color', labelSkipWidth={12}
modifiers: [['brighter', 1.4]], labelSkipHeight={12}
}} labelTextColor={{
role="application" from: 'color',
ariaLabel="График посещаемости лекций" modifiers: [['brighter', 1.4]],
barAriaLabel={(e) => }}
e.id + ': ' + e.formattedValue + ' on lection: ' + e.indexValue role="application"
} ariaLabel={t('journal.pl.lesson.attendanceChart')}
/> barAriaLabel={(e) =>
) e.id + ': ' + e.formattedValue + ' on lection: ' + e.indexValue
}
/>
)
}

@ -13,6 +13,7 @@ import {
useToast, useToast,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { EditIcon } from '@chakra-ui/icons' import { EditIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
import { qrCode } from '../../../assets' import { qrCode } from '../../../assets'
@ -46,10 +47,11 @@ export const Item: React.FC<ItemProps> = ({
const toast = useToast() const toast = useToast()
const [updateLesson, updateLessonRqst] = api.useUpdateLessonMutation() const [updateLesson, updateLessonRqst] = api.useUpdateLessonMutation()
const createdLessonRef = useRef(null) const createdLessonRef = useRef(null)
const { t } = useTranslation()
const onSubmit = (lessonData) => { const onSubmit = (lessonData) => {
toastRef.current = toast({ toastRef.current = toast({
title: 'Отправляем', title: t('journal.pl.common.sending'),
status: 'loading', status: 'loading',
duration: 9000, duration: 9000,
}) })
@ -58,7 +60,7 @@ export const Item: React.FC<ItemProps> = ({
updateLesson(lessonData) updateLesson(lessonData)
} else { } else {
toast.update(toastRef.current, { toast.update(toastRef.current, {
title: 'Отсутствует интернет', title: t('journal.pl.lesson.noInternet'),
status: 'error', status: 'error',
duration: 3000 duration: 3000
}) })
@ -68,8 +70,8 @@ export const Item: React.FC<ItemProps> = ({
useEffect(() => { useEffect(() => {
if (updateLessonRqst.isSuccess) { if (updateLessonRqst.isSuccess) {
const toastProps = { const toastProps = {
title: 'Лекция Обновлена', title: t('journal.pl.lesson.updated'),
description: `Лекция ${createdLessonRef.current?.name} успешно обновлена`, description: t('journal.pl.lesson.updateMessage', { name: createdLessonRef.current?.name }),
status: 'success' as const, status: 'success' as const,
duration: 9000, duration: 9000,
isClosable: true, isClosable: true,
@ -92,8 +94,8 @@ export const Item: React.FC<ItemProps> = ({
setEdit(false) setEdit(false)
}} }}
lesson={{ _id: id, id, name, date }} lesson={{ _id: id, id, name, date }}
title={'Редактирование лекции'} title={t('journal.pl.lesson.editTitle')}
nameButton={'Сохранить'} nameButton={t('journal.pl.save')}
/> />
</Td> </Td>
</Tr> </Tr>
@ -129,13 +131,13 @@ export const Item: React.FC<ItemProps> = ({
setEdit(true) setEdit(true)
}} }}
> >
Edit {t('journal.pl.edit')}
</MenuItem> </MenuItem>
<MenuItem onClick={setlessonToDelete}>Delete</MenuItem> <MenuItem onClick={setlessonToDelete}>{t('journal.pl.delete')}</MenuItem>
</MenuList> </MenuList>
</Menu> </Menu>
)} )}
{edit && <Button onClick={setlessonToDelete}>Сохранить</Button>} {edit && <Button onClick={setlessonToDelete}>{t('journal.pl.save')}</Button>}
</Td> </Td>
)} )}
<Td isNumeric>{students.length}</Td> <Td isNumeric>{students.length}</Td>

@ -16,6 +16,7 @@ import {
Input, Input,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { AddIcon } from '@chakra-ui/icons' import { AddIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
import { dateToCalendarFormat } from '../../../utils/time' import { dateToCalendarFormat } from '../../../utils/time'
import { Lesson } from '../../../__data__/model' import { Lesson } from '../../../__data__/model'
@ -45,6 +46,8 @@ export const LessonForm = ({
title, title,
nameButton, nameButton,
}: LessonFormProps) => { }: LessonFormProps) => {
const { t } = useTranslation()
const getNearestTimeSlot = () => { const getNearestTimeSlot = () => {
const now = new Date(); const now = new Date();
const minutes = now.getMinutes(); const minutes = now.getMinutes();
@ -96,21 +99,21 @@ export const LessonForm = ({
<Controller <Controller
control={control} control={control}
name="date" name="date"
rules={{ required: 'Обязательное поле' }} rules={{ required: t('journal.pl.common.required') }}
render={({ field }) => ( render={({ field }) => (
<FormControl> <FormControl>
<FormLabel>Дата</FormLabel> <FormLabel>{t('journal.pl.lesson.form.date')}</FormLabel>
<Input <Input
{...field} {...field}
required={false} required={false}
placeholder="Укажите дату лекции" placeholder={t('journal.pl.lesson.form.datePlaceholder')}
size="md" size="md"
type="datetime-local" type="datetime-local"
/> />
{errors.date ? ( {errors.date ? (
<FormErrorMessage>{errors.date?.message}</FormErrorMessage> <FormErrorMessage>{errors.date?.message}</FormErrorMessage>
) : ( ) : (
<FormHelperText>Укажите дату и время лекции</FormHelperText> <FormHelperText>{t('journal.pl.lesson.form.dateTime')}</FormHelperText>
)} )}
</FormControl> </FormControl>
)} )}
@ -119,14 +122,14 @@ export const LessonForm = ({
<Controller <Controller
control={control} control={control}
name="name" name="name"
rules={{ required: 'Обязательное поле' }} rules={{ required: t('journal.pl.common.required') }}
render={({ field }) => ( render={({ field }) => (
<FormControl isRequired isInvalid={Boolean(errors.name)}> <FormControl isRequired isInvalid={Boolean(errors.name)}>
<FormLabel>Название новой лекции:</FormLabel> <FormLabel>{t('journal.pl.lesson.form.title')}</FormLabel>
<Input <Input
{...field} {...field}
required={false} required={false}
placeholder="Название лекции" placeholder={t('journal.pl.lesson.form.namePlaceholder')}
size="md" size="md"
/> />
{errors.name && ( {errors.name && (

@ -26,6 +26,7 @@ import {
AlertDialogOverlay, AlertDialogOverlay,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { AddIcon } from '@chakra-ui/icons' import { AddIcon } from '@chakra-ui/icons'
import { useTranslation } from 'react-i18next'
import { useAppSelector } from '../../__data__/store' import { useAppSelector } from '../../__data__/store'
import { api } from '../../__data__/api/api' import { api } from '../../__data__/api/api'
@ -57,6 +58,7 @@ const LessonList = () => {
const toastRef = useRef(null) const toastRef = useRef(null)
const createdLessonRef = useRef(null) const createdLessonRef = useRef(null)
const [editLesson, setEditLesson] = useState<Lesson>(null) const [editLesson, setEditLesson] = useState<Lesson>(null)
const { t } = useTranslation()
const sorted = useMemo( const sorted = useMemo(
() => [...(data?.body || [])]?.sort((a, b) => (a.date > b.date ? 1 : -1)), () => [...(data?.body || [])]?.sort((a, b) => (a.date > b.date ? 1 : -1)),
[data, data?.body], [data, data?.body],
@ -93,7 +95,7 @@ const LessonList = () => {
const onSubmit = (lessonData) => { const onSubmit = (lessonData) => {
toastRef.current = toast({ toastRef.current = toast({
title: 'Отправляем', title: t('journal.pl.common.sending'),
status: 'loading', status: 'loading',
duration: 9000, duration: 9000,
}) })
@ -123,7 +125,7 @@ const LessonList = () => {
title={ title={
<> <>
<Box pb={3}> <Box pb={3}>
<Text fontSize="xl">{`Удалена лекция ${lesson.name}`}</Text> <Text fontSize="xl">{t('journal.pl.lesson.deletedMessage', { name: lesson.name })}</Text>
</Box> </Box>
<Button <Button
onClick={() => { onClick={() => {
@ -131,7 +133,7 @@ const LessonList = () => {
toast.close(id) toast.close(id)
}} }}
> >
Восстановить {t('journal.pl.common.restored')}
</Button> </Button>
</> </>
} }
@ -145,8 +147,8 @@ const LessonList = () => {
useEffect(() => { useEffect(() => {
if (crLQuery.isSuccess) { if (crLQuery.isSuccess) {
const toastProps = { const toastProps = {
title: 'Лекция создана', title: t('journal.pl.lesson.created'),
description: `Лекция ${createdLessonRef.current?.name} успешно создана`, description: t('journal.pl.lesson.successMessage', { name: createdLessonRef.current?.name }),
status: 'success' as const, status: 'success' as const,
duration: 9000, duration: 9000,
isClosable: true, isClosable: true,
@ -160,8 +162,8 @@ const LessonList = () => {
useEffect(() => { useEffect(() => {
if (updateLessonRqst.isSuccess) { if (updateLessonRqst.isSuccess) {
const toastProps = { const toastProps = {
title: 'Лекция Обновлена', title: t('journal.pl.lesson.updated'),
description: `Лекция ${createdLessonRef.current?.name} успешно обновлена`, description: t('journal.pl.lesson.updateMessage', { name: createdLessonRef.current?.name }),
status: 'success' as const, status: 'success' as const,
duration: 9000, duration: 9000,
isClosable: true, isClosable: true,
@ -186,12 +188,11 @@ const LessonList = () => {
<AlertDialogOverlay> <AlertDialogOverlay>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold"> <AlertDialogHeader fontSize="lg" fontWeight="bold">
Удалить занятие от{' '} {t('journal.pl.lesson.deleteConfirm', { date: dayjs(lessonToDelete?.date).format('DD.MM.YY') })}
{dayjs(lessonToDelete?.date).format('DD.MM.YY')}?
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogBody> <AlertDialogBody>
Все данные о посещении данного занятия будут удалены {t('journal.pl.lesson.deleteWarning')}
</AlertDialogBody> </AlertDialogBody>
<AlertDialogFooter> <AlertDialogFooter>
@ -200,7 +201,7 @@ const LessonList = () => {
ref={cancelRef} ref={cancelRef}
onClick={() => setlessonToDelete(null)} onClick={() => setlessonToDelete(null)}
> >
Cancel {t('journal.pl.cancel')}
</Button> </Button>
<Button <Button
colorScheme="red" colorScheme="red"
@ -209,7 +210,7 @@ const LessonList = () => {
onClick={() => deleteLesson(lessonToDelete.id)} onClick={() => deleteLesson(lessonToDelete.id)}
ml={3} ml={3}
> >
Delete {t('journal.pl.delete')}
</Button> </Button>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
@ -219,12 +220,12 @@ const LessonList = () => {
<Breadcrumb> <Breadcrumb>
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbLink as={Link} to={getNavigationsValue('journal.main')}> <BreadcrumbLink as={Link} to={getNavigationsValue('journal.main')}>
Журнал {t('journal.pl.common.journal')}
</BreadcrumbLink> </BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
<BreadcrumbItem isCurrentPage> <BreadcrumbItem isCurrentPage>
<BreadcrumbLink href="#">Курс</BreadcrumbLink> <BreadcrumbLink href="#">{t('journal.pl.common.course')}</BreadcrumbLink>
</BreadcrumbItem> </BreadcrumbItem>
</Breadcrumb> </Breadcrumb>
</BreadcrumbsWrapper> </BreadcrumbsWrapper>
@ -242,8 +243,8 @@ const LessonList = () => {
}} }}
error={(crLQuery.error as any)?.error} error={(crLQuery.error as any)?.error}
lesson={editLesson} lesson={editLesson}
title={editLesson ? 'Редактирование лекции' : 'Создание лекции'} title={editLesson ? t('journal.pl.lesson.editTitle') : t('journal.pl.lesson.createTitle')}
nameButton={editLesson ? 'Редактировать' : 'Создать'} nameButton={editLesson ? t('journal.pl.edit') : t('journal.pl.common.create')}
/> />
) : ( ) : (
<Button <Button
@ -251,7 +252,7 @@ const LessonList = () => {
colorScheme="green" colorScheme="green"
onClick={() => setShowForm(true)} onClick={() => setShowForm(true)}
> >
Добавить {t('journal.pl.common.create')}
</Button> </Button>
)} )}
</Box> </Box>
@ -272,15 +273,15 @@ const LessonList = () => {
<Tr> <Tr>
{isTeacher(user) && ( {isTeacher(user) && (
<Th align="center" width={1}> <Th align="center" width={1}>
ссылка {t('journal.pl.lesson.link')}
</Th> </Th>
)} )}
<Th textAlign="center" width={1}> <Th textAlign="center" width={1}>
{groupByDate ? 'Время' : 'Дата'} {groupByDate ? t('journal.pl.lesson.time') : t('journal.pl.common.date')}
</Th> </Th>
<Th width="100%">Название</Th> <Th width="100%">{t('journal.pl.common.name')}</Th>
{isTeacher(user) && <Th>action</Th>} {isTeacher(user) && <Th>{t('journal.pl.lesson.action')}</Th>}
<Th isNumeric>Отмечено</Th> <Th isNumeric>{t('journal.pl.common.marked')}</Th>
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>

@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { api } from '../__data__/api/api' import { api } from '../__data__/api/api'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -16,6 +17,7 @@ import { UserCard } from '../components/user-card'
const UserPage = () => { const UserPage = () => {
const { lessonId, accessId } = useParams() const { lessonId, accessId } = useParams()
const { t } = useTranslation()
const acc = api.useGetAccessQuery({ accessCode: accessId }) const acc = api.useGetAccessQuery({ accessCode: accessId })
const ls = api.useLessonByIdQuery(lessonId, { const ls = api.useLessonByIdQuery(lessonId, {
@ -41,8 +43,8 @@ const UserPage = () => {
return ( return (
<Container> <Container>
{acc.isLoading && <h1>Отправляем запрос</h1>} {acc.isLoading && <h1>{t('journal.pl.common.sending')}</h1>}
{acc.isSuccess && <h1>Успешно</h1>} {acc.isSuccess && <h1>{t('journal.pl.common.success')}</h1>}
{acc.error && ( {acc.error && (
<Box mb="6" mt="2"> <Box mb="6" mt="2">
@ -50,7 +52,7 @@ const UserPage = () => {
<AlertIcon /> <AlertIcon />
{(acc as any).error?.data?.body?.errorMessage === {(acc as any).error?.data?.body?.errorMessage ===
'Code is expired' ? ( 'Code is expired' ? (
'Не удалось активировать код доступа. Попробуйте отсканировать код ещё раз' t('journal.pl.access.expiredCode')
) : ( ) : (
<pre>{JSON.stringify(acc.error, null, 4)}</pre> <pre>{JSON.stringify(acc.error, null, 4)}</pre>
)} )}
@ -60,10 +62,10 @@ const UserPage = () => {
<Box mb={6}> <Box mb={6}>
<Text fontSize={18} fontWeight={600} as="h1" mt="4" mb="3"> <Text fontSize={18} fontWeight={600} as="h1" mt="4" mb="3">
Тема занятия: {ls.data?.body?.name} {t('journal.pl.lesson.topicTitle')} {ls.data?.body?.name}
</Text> </Text>
<span>{dayjs(ls.data?.body?.date).format('DD MMMM YYYYг.')}</span> <span>{dayjs(ls.data?.body?.date).format(t('journal.pl.lesson.dateFormat'))}</span>
</Box> </Box>
<Box <Box

15
src/utils/gravatar.ts Normal file

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