Добавлены новые зависимости: "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
View 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"
}

View File

@ -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
View File

@ -31,6 +31,7 @@
"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",
@ -2120,6 +2121,31 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.6.9",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.9.tgz",
"integrity": "sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.6.13",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.13.tgz",
"integrity": "sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.6.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@ -2916,6 +2942,15 @@
"@types/react": "*"
}
},
"node_modules/@types/react-transition-group": {
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@ -4855,6 +4890,16 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -7673,6 +7718,12 @@
"node": ">= 4.0.0"
}
},
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
@ -9018,6 +9069,27 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-select": {
"version": "5.10.1",
"resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.1.tgz",
"integrity": "sha512-roPEZUL4aRZDx6DcsD+ZNreVl+fM8VsKn0Wtex1v4IazH60ILp5xhdlp464IsEAlJdXeD+BhDAFsBVMfvLQueA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.0",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.8.1",
"@floating-ui/dom": "^1.0.1",
"@types/react-transition-group": "^4.4.0",
"memoize-one": "^6.0.0",
"prop-types": "^15.6.0",
"react-transition-group": "^4.3.0",
"use-isomorphic-layout-effect": "^1.2.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-side-effect": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz",
@ -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": {
"version": "3.6.0",
"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": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",

View File

@ -47,6 +47,7 @@
"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

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

View File

@ -4,6 +4,7 @@ import { Global } from '@emotion/react'
import { BrowserRouter } from 'react-router-dom';
import ruLocale from 'dayjs/locale/ru';
import dayjs from 'dayjs';
import { useTranslation } from 'react-i18next';
import { ChakraProvider, ColorModeScript, extendTheme } from '@chakra-ui/react'
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}>
<ColorModeScript initialColorMode={theme.config.initialColorMode} />
<BrowserRouter>
<Helmet>
<meta name="viewport" content="width=device-width, user-scalable=no" />
<title>Журнал</title>
<title>{t('journal.pl.title')}</title>
</Helmet>
<Global styles={globalStyles} />
<Dashboard store={store} />
</BrowserRouter>
</ChakraProvider>
)
}
export default App;

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ 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 {
CourseListPage,
@ -70,6 +71,7 @@ export const Dashboard = ({ store }: DashboardProps) => {
const serviceMenuInstanceRef = useRef<any>(null);
const [serviceMenu, setServiceMenu] = useState(false);
const { colorMode } = useColorMode();
const { t } = useTranslation();
useEffect(() => {
loadServiceMenu().then(() => {
@ -92,8 +94,8 @@ export const Dashboard = ({ store }: DashboardProps) => {
textColor: colorMode === 'light' ? '#333' : '#fff',
},
translations: {
menuTitle: 'Сервисы BRO',
menuAriaLabel: 'Сервисы BRO',
menuTitle: t('journal.pl.serviceMenu.title'),
menuAriaLabel: t('journal.pl.serviceMenu.ariaLabel'),
}
});
}
@ -105,7 +107,7 @@ export const Dashboard = ({ store }: DashboardProps) => {
serviceMenuInstanceRef.current = null;
}
};
}, [keycloak.token, serviceMenu, colorMode]);
}, [keycloak.token, serviceMenu, colorMode, t]);
return (
<Provider store={store}>

View File

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

View File

@ -9,6 +9,7 @@ import {
Spacer,
Badge
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { PageLoader } from '../../components/page-loader/page-loader'
import { useAttendanceData, useAttendanceStats } from './hooks'
@ -17,6 +18,7 @@ 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)
@ -30,7 +32,7 @@ export const Attendance = () => {
<Box>
<Heading size="lg" mb={2}>{data.courseInfo?.name}</Heading>
<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>
</Box>
<Spacer />

View File

@ -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;

View File

@ -22,19 +22,11 @@ import {
import { CopyIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons'
import { FaSmile, FaMeh, FaFrown, FaSadTear } from 'react-icons/fa'
import dayjs from 'dayjs'
import { sha256 } from 'js-sha256'
import { useTranslation } from 'react-i18next'
import { getGravatarURL } from '../../../utils/gravatar'
import { ShortText } from './ShortText'
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 {
data: AttendanceData
}
@ -42,6 +34,7 @@ interface AttendanceTableProps {
export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
const { colorMode } = useColorMode()
const toast = useToast()
const { t } = useTranslation()
const [showTable, setShowTable] = useState(false)
const getPresentColor = () => {
@ -60,25 +53,25 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
return {
icon: FaSmile,
color: 'green.500',
label: 'Отличная посещаемость'
label: t('journal.pl.attendance.emojis.excellent')
}
} else if (attendanceRate >= 0.75) {
return {
icon: FaMeh,
color: 'blue.400',
label: 'Хорошая посещаемость'
label: t('journal.pl.attendance.emojis.good')
}
} else if (attendanceRate >= 0.5) {
return {
icon: FaFrown,
color: 'orange.400',
label: 'Низкая посещаемость'
label: t('journal.pl.attendance.emojis.poor')
}
} else {
return {
icon: FaSadTear,
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 => {
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)
.then(() => {
toast({
title: 'Скопировано в буфер обмена',
description: 'Таблица успешно скопирована без сокращений',
title: t('journal.pl.attendance.table.copySuccess'),
description: t('journal.pl.attendance.table.copySuccessDescription'),
status: 'success',
duration: 3000,
isClosable: true,
@ -148,8 +141,8 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
})
.catch(err => {
toast({
title: 'Ошибка копирования',
description: 'Не удалось скопировать таблицу',
title: t('journal.pl.attendance.table.copyError'),
description: t('journal.pl.attendance.table.copyErrorDescription'),
status: 'error',
duration: 3000,
isClosable: true,
@ -171,7 +164,7 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
return {
student,
name: student.name || student.value || 'Имя не определено',
name: student.name || student.value || t('journal.pl.common.name'),
email: student.email,
picture: student.picture || getGravatarURL(student.email),
attendedCount,
@ -182,7 +175,7 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
}
if (!data.attendance?.length || !data.students?.length) {
return <Box>Нет данных для отображения</Box>
return <Box>{t('journal.pl.common.noData')}</Box>
}
return (
@ -199,7 +192,7 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
onClick={copyTableData}
mr={2}
>
Копировать таблицу
{t('journal.pl.attendance.table.copy')}
</Button>
<Button
@ -208,7 +201,7 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
variant="outline"
onClick={() => setShowTable(!showTable)}
>
{showTable ? 'Скрыть таблицу' : 'Показать таблицу'}
{showTable ? t('journal.pl.attendance.table.hide') : t('journal.pl.attendance.table.show')}
</Button>
</Flex>
@ -221,7 +214,7 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
return (
<Tooltip
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
>
<Box
@ -238,13 +231,13 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
name={name}
>
<AvatarBadge boxSize='2em' bg={emoji.color}>
<Icon as={emoji.icon} color="white" boxSize={6} />
<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} из {totalLessons} ({attendance.toFixed(0)}%)
{attendedCount} {t('journal.pl.common.of')} {totalLessons} ({attendance.toFixed(0)}%)
</Text>
</Box>
</HStack>
@ -269,17 +262,17 @@ export const AttendanceTable: React.FC<AttendanceTableProps> = ({ data }) => {
{data.teachers?.map(teacher => (
<Th key={teacher.id}>{teacher.value}</Th>
))}
<Th>Дата</Th>
<Th>Название занятия</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 || 'Имя не определено'}
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>
</Th>
))}

View File

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

View File

@ -1,30 +1,40 @@
import React from 'react'
import { Tooltip, Text, useColorMode } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
interface ShortTextProps {
text: string
maxLength?: number
}
export const ShortText: React.FC<ShortTextProps> = ({ text, maxLength = 20 }) => {
const needShortText = text.length > maxLength
export const ShortText = ({ text, maxLength = 30 }: ShortTextProps) => {
const { t } = useTranslation()
const { colorMode } = useColorMode()
if (needShortText) {
return (
<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>
)
if (!text) {
return <Text>{t('journal.pl.common.noData')}</Text>
}
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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