diff --git a/bro.config.js b/bro.config.js index f95d148..c376278 100644 --- a/bro.config.js +++ b/bro.config.js @@ -34,6 +34,16 @@ module.exports = { value: '', key: 'group.by.date', }, + 'course.statistics': { + on: true, + value: '', + key: 'course.statistics', + }, + 'courses.statistics': { + on: true, + value: '', + key: 'courses.statistics', + }, }, }, config: { diff --git a/locales/en.json b/locales/en.json index 9948000..ec25650 100644 --- a/locales/en.json +++ b/locales/en.json @@ -7,6 +7,12 @@ "journal.pl.close": "Close", "journal.pl.title": "Attendance Journal", + "journal.pl.breadcrumbs.home": "Home", + "journal.pl.breadcrumbs.course": "Course", + "journal.pl.breadcrumbs.lesson": "Lesson", + "journal.pl.breadcrumbs.user": "User", + "journal.pl.breadcrumbs.attendance": "Attendance", + "journal.pl.common.add": "Add", "journal.pl.common.edit": "Edit", "journal.pl.common.delete": "Delete", @@ -54,6 +60,7 @@ "journal.pl.course.progress": "Course progress", "journal.pl.course.completedLessons": "Completed lessons", "journal.pl.course.upcomingLessons": "Upcoming lessons", + "journal.pl.course.noCourses": "No courses available", "journal.pl.lesson.created": "Lesson created", "journal.pl.lesson.successMessage": "Lesson {{name}} successfully created", @@ -143,5 +150,55 @@ "journal.pl.lesson.form.date": "Date", "journal.pl.lesson.form.dateTime": "Specify date and time of the lesson", "journal.pl.lesson.form.datePlaceholder": "Specify lesson date", - "journal.pl.lesson.form.namePlaceholder": "Lesson name" + "journal.pl.lesson.form.namePlaceholder": "Lesson name", + + "journal.pl.statistics.title": "Course Statistics", + "journal.pl.statistics.totalLessons": "Total Lessons", + "journal.pl.statistics.completed": "completed", + "journal.pl.statistics.attendanceRate": "Attendance Rate", + "journal.pl.statistics.totalStudents": "Total Students", + "journal.pl.statistics.perLesson": "per lesson", + "journal.pl.statistics.nextLesson": "Next Lesson", + "journal.pl.statistics.noUpcoming": "Not scheduled", + "journal.pl.statistics.in": "in", + "journal.pl.statistics.days": "days", + "journal.pl.statistics.courseProgress": "Course Progress", + + "journal.pl.days.sunday": "Sunday", + "journal.pl.days.monday": "Monday", + "journal.pl.days.tuesday": "Tuesday", + "journal.pl.days.wednesday": "Wednesday", + "journal.pl.days.thursday": "Thursday", + "journal.pl.days.friday": "Friday", + "journal.pl.days.saturday": "Saturday", + + "journal.pl.overview.title": "Courses Overview", + "journal.pl.overview.totalCourses": "Total Courses", + "journal.pl.overview.active": "active", + "journal.pl.overview.completed": "completed", + "journal.pl.overview.totalLessons": "Total Lessons", + "journal.pl.overview.upcoming": "upcoming", + "journal.pl.overview.totalStudents": "Total Students", + "journal.pl.overview.totalTeachers": "Total Teachers", + "journal.pl.overview.perCourse": "per course", + "journal.pl.overview.attendance": "attendance", + "journal.pl.overview.noAttendanceData": "no attendance data", + "journal.pl.overview.noActiveData": "no active courses", + "journal.pl.overview.courseTrends": "Course Trends", + "journal.pl.overview.newCourses": "New Courses", + "journal.pl.overview.olderCourses": "Older Courses", + "journal.pl.overview.last3Months": "in last 3 months", + "journal.pl.overview.beyondLast3Months": "created earlier than 3 months", + "journal.pl.overview.mostActiveDay": "Most Active Day", + "journal.pl.overview.activityStats": "Activity Statistics", + "journal.pl.overview.courseCompletion": "Course Completion", + "journal.pl.overview.studentAttendance": "Student Attendance", + "journal.pl.overview.averageRate": "Average Rate", + "journal.pl.overview.lessons": "lessons", + "journal.pl.overview.topStudents": "Top Students by Attendance", + "journal.pl.overview.topAttendanceCourses": "Courses with Best Attendance", + "journal.pl.overview.new": "new", + "journal.pl.overview.pastLessonsStats": "Statistics of past lessons", + "journal.pl.overview.dayOfWeekHelp": "Only statistics for completed lessons are shown", + "journal.pl.overview.attendanceHelp": "Attendance is calculated based on past lessons only" } \ No newline at end of file diff --git a/locales/ru.json b/locales/ru.json index 9e0fdcd..7bfb09d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -7,6 +7,12 @@ "journal.pl.close": "Закрыть", "journal.pl.title": "Журнал посещаемости", + "journal.pl.breadcrumbs.home": "Главная", + "journal.pl.breadcrumbs.course": "Курс", + "journal.pl.breadcrumbs.lesson": "Лекция", + "journal.pl.breadcrumbs.user": "Пользователь", + "journal.pl.breadcrumbs.attendance": "Посещаемость", + "journal.pl.common.students": "студентов", "journal.pl.common.teachers": "преподавателей", "journal.pl.common.noData": "Нет данных", @@ -51,6 +57,7 @@ "journal.pl.course.progress": "Прогресс курса", "journal.pl.course.completedLessons": "Завершено занятий", "journal.pl.course.upcomingLessons": "Предстоящие занятия", + "journal.pl.course.noCourses": "Нет доступных курсов", "journal.pl.lesson.created": "Лекция создана", "journal.pl.lesson.successMessage": "Лекция {{name}} успешно создана", @@ -140,5 +147,55 @@ "journal.pl.lesson.form.date": "Дата", "journal.pl.lesson.form.dateTime": "Укажите дату и время лекции", "journal.pl.lesson.form.datePlaceholder": "Укажите дату лекции", - "journal.pl.lesson.form.namePlaceholder": "Название лекции" + "journal.pl.lesson.form.namePlaceholder": "Название лекции", + + "journal.pl.statistics.title": "Статистика курса", + "journal.pl.statistics.totalLessons": "Всего занятий", + "journal.pl.statistics.completed": "завершено", + "journal.pl.statistics.attendanceRate": "Посещаемость", + "journal.pl.statistics.totalStudents": "Всего студентов", + "journal.pl.statistics.perLesson": "на занятие", + "journal.pl.statistics.nextLesson": "Следующее занятие", + "journal.pl.statistics.noUpcoming": "Не запланировано", + "journal.pl.statistics.in": "через", + "journal.pl.statistics.days": "дн.", + "journal.pl.statistics.courseProgress": "Прогресс курса", + + "journal.pl.days.sunday": "Воскресенье", + "journal.pl.days.monday": "Понедельник", + "journal.pl.days.tuesday": "Вторник", + "journal.pl.days.wednesday": "Среда", + "journal.pl.days.thursday": "Четверг", + "journal.pl.days.friday": "Пятница", + "journal.pl.days.saturday": "Суббота", + + "journal.pl.overview.title": "Обзор всех курсов", + "journal.pl.overview.totalCourses": "Всего курсов", + "journal.pl.overview.active": "активных", + "journal.pl.overview.completed": "завершенных", + "journal.pl.overview.totalLessons": "Всего занятий", + "journal.pl.overview.upcoming": "предстоящих", + "journal.pl.overview.totalStudents": "Всего студентов", + "journal.pl.overview.totalTeachers": "Всего преподавателей", + "journal.pl.overview.perCourse": "на курс", + "journal.pl.overview.attendance": "посещаемость", + "journal.pl.overview.noAttendanceData": "нет данных о посещаемости", + "journal.pl.overview.noActiveData": "нет активных курсов", + "journal.pl.overview.courseTrends": "Тренды курсов", + "journal.pl.overview.newCourses": "Новые курсы", + "journal.pl.overview.olderCourses": "Старые курсы", + "journal.pl.overview.last3Months": "за последние 3 месяца", + "journal.pl.overview.beyondLast3Months": "созданы ранее 3 месяцев", + "journal.pl.overview.mostActiveDay": "Самый активный день недели", + "journal.pl.overview.activityStats": "Статистика активности", + "journal.pl.overview.courseCompletion": "Завершенность курсов", + "journal.pl.overview.studentAttendance": "Посещаемость студентов", + "journal.pl.overview.averageRate": "Средний показатель", + "journal.pl.overview.lessons": "занятий", + "journal.pl.overview.topStudents": "Лучшие студенты по посещаемости", + "journal.pl.overview.topAttendanceCourses": "Курсы с лучшей посещаемостью", + "journal.pl.overview.new": "новых", + "journal.pl.overview.pastLessonsStats": "Статистика проведённых занятий", + "journal.pl.overview.dayOfWeekHelp": "Показана статистика только состоявшихся занятий", + "journal.pl.overview.attendanceHelp": "Посещаемость рассчитана только по прошедшим занятиям" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8e7b2e1..add76b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "journal.pl", - "version": "3.11.2", + "version": "3.14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "journal.pl", - "version": "3.11.2", + "version": "3.14.1", "license": "MIT", "dependencies": { "@brojs/cli": "^1.8.4", diff --git a/package.json b/package.json index c2f4c3e..4bd93cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "journal.pl", - "version": "3.11.2", + "version": "3.14.1", "description": "bro-js platform journal ui repo", "main": "./src/index.tsx", "scripts": { diff --git a/src/app.tsx b/src/app.tsx index 6087672..82cb40d 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -2,16 +2,13 @@ import React from 'react'; import { Helmet } from 'react-helmet'; import { Global } from '@emotion/react' import { BrowserRouter } from 'react-router-dom'; -import ruLocale from 'dayjs/locale/ru'; -import dayjs from 'dayjs'; +import dayjs from './utils/dayjs-config'; import { useTranslation } from 'react-i18next'; import { ChakraProvider, ColorModeScript, extendTheme } from '@chakra-ui/react' import { Dashboard } from './dashboard'; import { globalStyles } from './global.styles'; -dayjs.locale('ru', ruLocale); - // Расширяем тему Chakra UI const theme = extendTheme({ config: { diff --git a/src/components/app-header/app-header.tsx b/src/components/app-header/app-header.tsx index 98a4124..4e39a76 100644 --- a/src/components/app-header/app-header.tsx +++ b/src/components/app-header/app-header.tsx @@ -1,22 +1,74 @@ import React from 'react'; import { - Box, + Box, Flex, IconButton, useColorMode, Button, - HStack + HStack, + VStack, + Container, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + Heading, + useBreakpointValue, + Text, + Tooltip, + useMediaQuery, } from '@chakra-ui/react'; -import { MoonIcon, SunIcon } from '@chakra-ui/icons'; +import { MoonIcon, SunIcon, ChevronRightIcon, InfoIcon, ChevronDownIcon } from '@chakra-ui/icons'; import { useTranslation } from 'react-i18next'; +import { Link, useLocation } from 'react-router-dom'; +import { getNavigationValue } from '@brojs/cli'; interface AppHeaderProps { serviceMenuContainerRef?: React.RefObject; + breadcrumbs?: Array<{ + title: string; + path?: string; + isCurrentPage?: boolean; + }>; } -export const AppHeader = ({ serviceMenuContainerRef }: AppHeaderProps) => { +export const AppHeader = ({ serviceMenuContainerRef, breadcrumbs }: AppHeaderProps) => { const { colorMode, toggleColorMode } = useColorMode(); const { t, i18n } = useTranslation(); + const location = useLocation(); + + // Получаем путь к главной странице + const mainPagePath = getNavigationValue('journal.main'); + + // Функция для формирования правильного пути с учетом mainPagePath + const getFullPath = (path?: string): string => { + if (!path) return '#'; + if (path === '/') return mainPagePath; + + // Если путь уже начинается с mainPagePath, оставляем как есть + if (path.startsWith(mainPagePath)) return path; + + // Если путь начинается со слеша, добавляем mainPagePath + if (path.startsWith('/')) return `${mainPagePath}${path}`; + + // Иначе просто объединяем пути + return `${mainPagePath}/${path}`; + }; + + // Определяем размеры для разных устройств + const fontSize = useBreakpointValue({ base: 'xs', sm: 'xs', md: 'sm' }); + + // Проверяем, на каком устройстве находимся + const [isLargerThan768] = useMediaQuery("(min-width: 768px)"); + const [isLargerThan480] = useMediaQuery("(min-width: 480px)"); + + // Вертикальное отображение на мобильных устройствах + const isMobile = !isLargerThan480; + + // Горизонтальный сепаратор для десктопов + const horizontalSeparator = useBreakpointValue({ + sm: , + md: + }); const toggleLanguage = () => { const newLang = i18n.language === 'ru' ? 'en' : 'ru'; @@ -24,47 +76,200 @@ export const AppHeader = ({ serviceMenuContainerRef }: AppHeaderProps) => { }; return ( - - {serviceMenuContainerRef &&
} - - - - - - - : } - onClick={toggleColorMode} - variant="ghost" - size="md" + {/* Рендеринг dots контейнера вне условной логики, всегда присутствует в DOM */} + {serviceMenuContainerRef && ( + - -
+ )} + + + {isMobile ? ( + <> + {/* Мобильная версия: верхняя строка с кнопками */} + + + {/* Пустой контейнер для поддержания расположения */} + + + + + + : } + onClick={toggleColorMode} + variant="ghost" + size={{ base: "sm" }} + minW={{ base: "30px" }} + h={{ base: "30px" }} + /> + + + + {/* Вертикальные хлебные крошки */} + {breadcrumbs && breadcrumbs.length > 0 && ( + + {breadcrumbs.map((crumb, index) => ( + 0 ? 3 : 1} + py={1} + borderRadius="md" + _hover={!crumb.isCurrentPage && crumb.path ? { + bg: colorMode === 'light' ? 'gray.50' : 'gray.700', + } : {}} + > + {index > 0 && ( + + )} + + {crumb.path && !crumb.isCurrentPage ? ( + + + {crumb.title} + + + ) : ( + + {crumb.title} + + )} + + ))} + + )} + + ) : ( + /* Десктопная версия: всё в одну строку */ + + + {/* Контейнер для разметки */} + + + {breadcrumbs && breadcrumbs.length > 0 && ( + + {breadcrumbs.map((crumb, index) => ( + + + {crumb.title} + + + ))} + + )} + + + + + + : } + onClick={toggleColorMode} + variant="ghost" + size={{ sm: "sm", md: "md" }} + minW={{ sm: "34px" }} + h={{ sm: "34px" }} + /> + + + )} + + ); }; \ No newline at end of file diff --git a/src/components/breadcrumbs/breadcrumbs-context.tsx b/src/components/breadcrumbs/breadcrumbs-context.tsx new file mode 100644 index 0000000..1641a73 --- /dev/null +++ b/src/components/breadcrumbs/breadcrumbs-context.tsx @@ -0,0 +1,45 @@ +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +export type Breadcrumb = { + title: string; + path?: string; + isCurrentPage?: boolean; +}; + +type BreadcrumbsContextType = { + breadcrumbs: Breadcrumb[]; + setBreadcrumbs: (breadcrumbs: Breadcrumb[]) => void; +}; + +const BreadcrumbsContext = createContext(undefined); + +export const BreadcrumbsProvider: React.FC<{children: ReactNode}> = ({ children }) => { + const [breadcrumbs, setBreadcrumbs] = useState([]); + + return ( + + {children} + + ); +}; + +export const useBreadcrumbs = () => { + const context = useContext(BreadcrumbsContext); + if (context === undefined) { + throw new Error('useBreadcrumbs must be used within a BreadcrumbsProvider'); + } + return context; +}; + +export const useSetBreadcrumbs = (newBreadcrumbs: Breadcrumb[]) => { + const { setBreadcrumbs } = useBreadcrumbs(); + + React.useEffect(() => { + setBreadcrumbs(newBreadcrumbs); + + return () => { + // При размонтировании компонента очищаем хлебные крошки + setBreadcrumbs([]); + }; + }, [setBreadcrumbs, JSON.stringify(newBreadcrumbs)]); +}; \ No newline at end of file diff --git a/src/components/breadcrumbs/index.ts b/src/components/breadcrumbs/index.ts new file mode 100644 index 0000000..959a0f4 --- /dev/null +++ b/src/components/breadcrumbs/index.ts @@ -0,0 +1 @@ +export * from './breadcrumbs-context'; \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts index f1f6840..f14925b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,4 +1,5 @@ export { PageLoader } from './page-loader/page-loader'; export { XlSpinner } from './xl-spinner/xl-spinner'; export { ErrorBoundary } from './error-boundary'; -export { AppHeader } from './app-header'; \ No newline at end of file +export { AppHeader } from './app-header'; +export { BreadcrumbsProvider, useBreadcrumbs, useSetBreadcrumbs } from './breadcrumbs'; \ No newline at end of file diff --git a/src/components/user-card/style.ts b/src/components/user-card/style.ts index 1e90f6e..1691921 100644 --- a/src/components/user-card/style.ts +++ b/src/components/user-card/style.ts @@ -1,26 +1,96 @@ import styled from '@emotion/styled' import { css, keyframes } from '@emotion/react' -export const Avatar = styled.img` - width: 96px; - height: 96px; - margin: 0 auto; - border-radius: 6px; +// Правильное определение анимации с помощью keyframes +const fadeIn = keyframes` + from { + opacity: 0; + } + to { + opacity: 1; + } ` +const pulse = keyframes` + 0% { + box-shadow: 0 0 0 0 rgba(72, 187, 120, 0.4); + } + 70% { + box-shadow: 0 0 0 10px rgba(72, 187, 120, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(72, 187, 120, 0); + } +` + +export const Avatar = styled.img` + width: 100%; + height: 100%; + border-radius: 12px; + object-fit: cover; + transition: transform 0.3s ease; +` + +export const NameOverlay = styled.div` + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 8px; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); + color: white; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; + font-size: 14px; + font-weight: 500; + text-align: center; + opacity: 0.9; + transition: opacity 0.3s ease; + + .chakra-ui-dark & { + background: linear-gradient(transparent, rgba(0, 0, 0, 0.8)); + } +` + +// Стили без интерполяций компонентов export const Wrapper = styled.div<{ warn?: boolean; width?: string | number }>` list-style: none; - background-color: var(--chakra-colors-white); - padding: 16px; - border-radius: 12px; - box-shadow: 2px 2px 6px var(--chakra-colors-blackAlpha-400); - transition: all 0.5; position: relative; - width: 180px; - min-height: 190px; - max-height: 200px; - margin-right: 12px; - padding-bottom: 22px; + border-radius: 12px; + width: 100%; + aspect-ratio: 1; + overflow: hidden; + cursor: pointer; + animation: ${fadeIn} 0.5s ease; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + transition: transform 0.3s ease, box-shadow 0.3s ease; + + &:hover { + transform: translateY(-5px); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); + } + + &:hover img { + transform: scale(1.05); + } + + &:hover > div:last-of-type:not(button) { + opacity: 1; + } + + &.recent { + animation: ${pulse} 1.5s infinite; + border: 2px solid var(--chakra-colors-green-400); + } + + .chakra-ui-dark & { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + + &.recent { + border: 2px solid var(--chakra-colors-green-300); + } + } + ${({ width }) => width ? css` @@ -31,35 +101,36 @@ export const Wrapper = styled.div<{ warn?: boolean; width?: string | number }>` ${(props) => props.warn ? css` - background-color: var(--chakra-colors-blackAlpha-800); opacity: 0.7; - color: var(--chakra-colors-gray-200); + filter: grayscale(0.8); ` : ''} - - .chakra-ui-dark & { - background-color: var(--chakra-colors-gray-700); - color: var(--chakra-colors-white); - box-shadow: 2px 2px 6px var(--chakra-colors-blackAlpha-600); - } - - .chakra-ui-dark &.warn { - background-color: var(--chakra-colors-blackAlpha-900); - color: var(--chakra-colors-gray-300); - } ` export const AddMissedButton = styled.button` position: absolute; bottom: 8px; - right: 12px; + right: 8px; border: none; - background-color: transparent; - opacity: 0.2; - color: inherit; + background-color: var(--chakra-colors-blue-500); + color: white; + width: 26px; + height: 26px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + opacity: 0.8; + transition: opacity 0.3s ease, transform 0.3s ease; - :hover { + &:hover { cursor: pointer; opacity: 1; + transform: scale(1.1); + } + + .chakra-ui-dark & { + background-color: var(--chakra-colors-blue-400); } ` \ No newline at end of file diff --git a/src/components/user-card/user-card.tsx b/src/components/user-card/user-card.tsx index bc28fe6..179c171 100644 --- a/src/components/user-card/user-card.tsx +++ b/src/components/user-card/user-card.tsx @@ -1,11 +1,12 @@ import React from 'react' import { sha256 } from 'js-sha256' -import { useColorMode } from '@chakra-ui/react' import { useState } from 'react' +import { Box, useColorMode } from '@chakra-ui/react' +import { CheckCircleIcon, AddIcon } from '@chakra-ui/icons' import { User } from '../../__data__/model' -import { AddMissedButton, Avatar, Wrapper } from './style' +import { AddMissedButton, Avatar, Wrapper, NameOverlay } from './style' export function getGravatarURL(email, user) { if (!email) return void 0 @@ -18,15 +19,17 @@ export function getGravatarURL(email, user) { export const UserCard = ({ student, present, - onAddUser, - wrapperAS, - width + onAddUser = undefined, + wrapperAS = 'div', + width, + recentlyPresent = false }: { student: User present: boolean width?: string | number onAddUser?: (user: User) => void wrapperAS?: React.ElementType; + recentlyPresent?: boolean }) => { const { colorMode } = useColorMode(); const [imageError, setImageError] = useState(false); @@ -36,7 +39,7 @@ export const UserCard = ({ warn={!present} as={wrapperAS} width={width} - className={!present ? 'warn' : ''} + className={!present ? 'warn' : recentlyPresent ? 'recent' : ''} > -

- {student.name || student.preferred_username}{' '} -

+ + {student.name || student.preferred_username} + {present && ( + + + + )} + {onAddUser && !present && ( - onAddUser(student)}> - add + onAddUser(student)} aria-label="Отметить присутствие"> + )} ) } - -UserCard.defaultProps = { - wrapperAS: 'div', - onAddUser: void 0, -} diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 64d3958..f353abc 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -12,7 +12,7 @@ import { UserPage, AttendancePage, } from './pages' -import { ErrorBoundary, AppHeader } from './components' +import { ErrorBoundary, AppHeader, BreadcrumbsProvider, useBreadcrumbs } from './components' import { keycloak } from './__data__/kc' const MENU_SCRIPT_URL = 'https://admin.bro-js.ru/remote-assets/lib/serviceMenu/serviceMenu.js' @@ -62,6 +62,12 @@ const Wrapper = ({ children }: { children: React.ReactElement }) => ( ) +// Компонент, который соединяет хлебные крошки с AppHeader +const HeaderWithBreadcrumbs = ({ serviceMenuContainerRef }: { serviceMenuContainerRef: React.RefObject }) => { + const { breadcrumbs } = useBreadcrumbs(); + return ; +}; + interface DashboardProps { store: any; // Используем any, поскольку точный тип store не указан } @@ -111,49 +117,51 @@ export const Dashboard = ({ store }: DashboardProps) => { return ( - - - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - + + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + ) } diff --git a/src/pages/attendance/attendance.tsx b/src/pages/attendance/attendance.tsx index 6fa0e83..7e2f83b 100644 --- a/src/pages/attendance/attendance.tsx +++ b/src/pages/attendance/attendance.tsx @@ -12,6 +12,7 @@ import { import { useTranslation } from 'react-i18next' import { PageLoader } from '../../components/page-loader/page-loader' +import { useSetBreadcrumbs } from '../../components' import { useAttendanceData, useAttendanceStats } from './hooks' import { AttendanceTable, StatsCard } from './components' @@ -21,6 +22,22 @@ export const Attendance = () => { const { t } = useTranslation() const data = useAttendanceData(courseId) const stats = useAttendanceStats(data) + + // Устанавливаем хлебные крошки + useSetBreadcrumbs([ + { + title: t('journal.pl.breadcrumbs.home'), + path: '/' + }, + { + title: data.courseInfo?.name || t('journal.pl.breadcrumbs.course'), + path: `/lessons-list/${courseId}` + }, + { + title: t('journal.pl.breadcrumbs.attendance'), + isCurrentPage: true + } + ]) if (data.isLoading) { return diff --git a/src/pages/attendance/components/AddDataDialog.tsx b/src/pages/attendance/components/AddDataDialog.tsx index 6bc23b0..1a4152a 100644 --- a/src/pages/attendance/components/AddDataDialog.tsx +++ b/src/pages/attendance/components/AddDataDialog.tsx @@ -21,7 +21,8 @@ import { useColorMode, } from "@chakra-ui/react"; import { useTranslation } from 'react-i18next'; -import dayjs from 'dayjs'; +import dayjs from "../../../utils/dayjs-config"; +import { formatDate } from "../../../utils/dayjs-config"; import UserSelect from "../../../components/user-select"; interface AttendanceEntry { @@ -41,7 +42,7 @@ 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 [date, setDate] = useState(formatDate(dayjs().toDate(), 'YYYY-MM-DD')); const [selectedStudents, setSelectedStudents] = useState([]); const [selectedTeachers, setSelectedTeachers] = useState([]); @@ -60,7 +61,7 @@ const AddDataDialog = ({ isOpen, onClose, onAddData }: AddDataDialogProps) => { const resetForm = () => { setName(""); - setDate(dayjs().format('YYYY-MM-DD')); + setDate(formatDate(dayjs().toDate(), 'YYYY-MM-DD')); setSelectedStudents([]); setSelectedTeachers([]); }; diff --git a/src/pages/attendance/components/AttendanceTable.tsx b/src/pages/attendance/components/AttendanceTable.tsx index a3206d0..eb690ec 100644 --- a/src/pages/attendance/components/AttendanceTable.tsx +++ b/src/pages/attendance/components/AttendanceTable.tsx @@ -33,6 +33,7 @@ import { useTranslation } from 'react-i18next' import { getGravatarURL } from '../../../utils/gravatar' import { ShortText } from './ShortText' import { AttendanceData } from '../hooks' +import { formatDate } from '../../../utils/dayjs-config' interface AttendanceTableProps { data: AttendanceData @@ -120,7 +121,7 @@ export const AttendanceTable: React.FC = ({ data }) => { }) // Добавляем дату - row.push(dayjs(lesson.date).format('DD.MM.YYYY')) + row.push(formatDate(lesson.date, 'DD.MM.YYYY')) // Добавляем полное название занятия (без сокращений) row.push(lesson.name) @@ -230,7 +231,7 @@ export const AttendanceTable: React.FC = ({ data }) => { ) })} - {dayjs(lesson.date).format('DD.MM.YYYY')} + {formatDate(lesson.date, 'DD.MM.YYYY')} {data.students.map((st) => { diff --git a/src/pages/course-list/components/CoursesOverview.tsx b/src/pages/course-list/components/CoursesOverview.tsx new file mode 100644 index 0000000..ba51261 --- /dev/null +++ b/src/pages/course-list/components/CoursesOverview.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import { + Box, + Heading, + SimpleGrid, + useColorModeValue, + Card +} from '@chakra-ui/react' +import { useTranslation } from 'react-i18next' + +import { Course, Lesson } from '../../../__data__/model' +import { + useStats, + StatCards, + StudentAttendanceList, + CourseAttendanceList, + ActivityStats +} from './statistics' + +interface CoursesOverviewProps { + courses: Course[] + isLoading: boolean + // Детализированные данные с уроками (если есть) + lessonsByCourse?: Record +} + +export const CoursesOverview: React.FC = ({ + courses = [], + isLoading = false, + lessonsByCourse = {} +}) => { + const { t } = useTranslation() + const bgColor = useColorModeValue('white', 'gray.700') + + // Используем хук для расчета статистики + const stats = useStats(courses, lessonsByCourse) + + // Если загрузка или нет данных, возвращаем null + if (isLoading || !courses.length) { + return null + } + + return ( + + + {t('journal.pl.overview.title')} + + + {/* Основные показатели */} + + + {/* Дополнительная статистика */} + + {/* Статистика посещаемости и топ-студенты */} + + + + {stats.topCoursesByAttendance.length > 0 && ( + <> + + + + )} + + + {/* Статистика деятельности и активности */} + + + + + + ) +} \ No newline at end of file diff --git a/src/pages/course-list/components/index.ts b/src/pages/course-list/components/index.ts index 7461583..db1826f 100644 --- a/src/pages/course-list/components/index.ts +++ b/src/pages/course-list/components/index.ts @@ -1,2 +1,3 @@ +export * from './CreateCourseForm' export * from './YearGroup' -export * from './CreateCourseForm' \ No newline at end of file +export * from './CoursesOverview' \ No newline at end of file diff --git a/src/pages/course-list/components/statistics/ActivityStats.tsx b/src/pages/course-list/components/statistics/ActivityStats.tsx new file mode 100644 index 0000000..43fa195 --- /dev/null +++ b/src/pages/course-list/components/statistics/ActivityStats.tsx @@ -0,0 +1,110 @@ +import React from 'react' +import { + Box, + Text, + Progress, + Flex, + Divider, + Tooltip +} from '@chakra-ui/react' +import { useTranslation } from 'react-i18next' +import { InfoOutlineIcon } from '@chakra-ui/icons' + +import { CourseStats } from './useStats' +import { WeekdayActivityChart } from './WeekdayActivityChart' + +interface ActivityStatsProps { + stats: CourseStats +} + +export const ActivityStats: React.FC = ({ stats }) => { + const { t } = useTranslation() + + // Определяем цвет для прогресса в зависимости от значения + const getProgressColor = (value: number) => { + if (value > 80) return 'green' + if (value > 50) return 'blue' + if (value > 30) return 'yellow' + return 'red' + } + + // Вычисляем процент завершенности курсов + const completionPercentage = + stats.totalLessons > 0 + ? (stats.completedLessons / stats.totalLessons) * 100 + : 0 + + return ( + + + + {t('journal.pl.overview.activityStats')} + + + + + + + + + + {t('journal.pl.overview.courseCompletion')}: + + + + + + + + + {stats.completedLessons} / {stats.totalLessons} {t('journal.pl.overview.lessons')} + + + {Math.round(completionPercentage)}% + + + + + + + + {t('journal.pl.overview.studentAttendance')}: + + + + + + + + + {t('journal.pl.overview.averageRate')} + + + {Math.round(stats.averageAttendance)}% + + + + + + + + + ) +} \ No newline at end of file diff --git a/src/pages/course-list/components/statistics/CourseAttendanceList.tsx b/src/pages/course-list/components/statistics/CourseAttendanceList.tsx new file mode 100644 index 0000000..1de9923 --- /dev/null +++ b/src/pages/course-list/components/statistics/CourseAttendanceList.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { + VStack, + HStack, + Box, + Text, + Badge, + Tooltip +} from '@chakra-ui/react' +import { useTranslation } from 'react-i18next' +import { CheckCircleIcon } from '@chakra-ui/icons' + +interface CourseAttendanceProps { + courses: Array<{id: string, name: string, attendanceRate: number}> +} + +export const CourseAttendanceList: React.FC = ({ courses }) => { + const { t } = useTranslation() + + // Определяем цвет для прогресса в зависимости от значения + const getProgressColor = (value: number) => { + if (value > 80) return 'green' + if (value > 50) return 'blue' + if (value > 30) return 'yellow' + return 'red' + } + + if (!courses?.length) { + return ( + + {t('journal.pl.overview.noAttendanceData')} + + ) + } + + return ( + + + + {t('journal.pl.overview.topAttendanceCourses')} + + + + {courses.map((course, index) => ( + + + #{index + 1} + + + + {course.name} + + + + {Math.round(course.attendanceRate)}% + + + ))} + + + + {t('journal.pl.overview.attendanceHelp')} + + + ) +} \ No newline at end of file diff --git a/src/pages/course-list/components/statistics/StatCards.tsx b/src/pages/course-list/components/statistics/StatCards.tsx new file mode 100644 index 0000000..96968e6 --- /dev/null +++ b/src/pages/course-list/components/statistics/StatCards.tsx @@ -0,0 +1,135 @@ +import React from 'react' +import { + SimpleGrid, + Stat, + StatLabel, + StatNumber, + StatHelpText, + Flex, + HStack, + Text, + Icon, + Badge +} from '@chakra-ui/react' +import { useTranslation } from 'react-i18next' +import { + FaGraduationCap, + FaChalkboardTeacher, + FaCalendarAlt, + FaUsers +} from 'react-icons/fa' + +import { CourseStats } from './useStats' + +interface StatCardsProps { + stats: CourseStats + bgColor: string +} + +export const StatCards: React.FC = ({ stats, bgColor }) => { + const { t } = useTranslation() + + return ( + + {/* Статистика по курсам */} + + + + {t('journal.pl.overview.totalCourses')} + + {stats.totalCourses} + + + + {stats.activeCourses} {t('journal.pl.overview.active')} + + {stats.recentCoursesCount > 0 && ( + + +{stats.recentCoursesCount} {t('journal.pl.overview.new')} + + )} + + + + + {/* Статистика по урокам */} + + + + {t('journal.pl.overview.totalLessons')} + + {stats.totalLessons} + + + + {stats.completedLessons} {t('journal.pl.overview.completed')} + + + {stats.upcomingLessons} {t('journal.pl.overview.upcoming')} + + + + + + {/* Статистика по студентам */} + + + + {t('journal.pl.overview.totalStudents')} + + {stats.totalStudents.size} + + + {stats.averageAttendance > 0 ? + `~${Math.round(stats.averageAttendance)}% ${t('journal.pl.overview.attendance')}` : + t('journal.pl.overview.noAttendanceData')} + + + + + {/* Статистика по преподавателям */} + + + + {t('journal.pl.overview.totalTeachers')} + + {stats.totalTeachers.size} + + + {stats.activeCourses > 0 ? + `~${(stats.totalTeachers.size / Math.max(1, stats.activeCourses)).toFixed(1)} ${t('journal.pl.overview.perCourse')}` : + t('journal.pl.overview.noActiveData')} + + + + + ) +} \ No newline at end of file diff --git a/src/pages/course-list/components/statistics/StudentAttendanceList.tsx b/src/pages/course-list/components/statistics/StudentAttendanceList.tsx new file mode 100644 index 0000000..1afa7fe --- /dev/null +++ b/src/pages/course-list/components/statistics/StudentAttendanceList.tsx @@ -0,0 +1,98 @@ +import React from 'react' +import { + VStack, + HStack, + Box, + Text, + Progress, + Badge, + Avatar, + Tooltip, + Flex +} from '@chakra-ui/react' +import { useTranslation } from 'react-i18next' +import { StarIcon } from '@chakra-ui/icons' + +import { StudentAttendance } from './useStats' + +interface StudentAttendanceListProps { + students: StudentAttendance[] + title: string +} + +export const StudentAttendanceList: React.FC = ({ + students, + title +}) => { + const { t } = useTranslation() + + // Определяем цвет для прогресса в зависимости от значения + const getProgressColor = (value: number) => { + if (value > 80) return 'green' + if (value > 50) return 'blue' + if (value > 30) return 'yellow' + return 'red' + } + + if (!students?.length) { + return ( + + {t('journal.pl.overview.noAttendanceData')} + + ) + } + + return ( + + + + {title} + + + + {t('journal.pl.overview.pastLessonsStats')} + + + + {students.map((student, index) => ( + + + + + + + {student.name} + + + + + {student.attended}/{student.total} + + + + + + + {Math.round(student.percent)}% + + + ))} + + + + {t('journal.pl.overview.attendanceHelp')} + + + ) +} \ No newline at end of file diff --git a/src/pages/course-list/components/statistics/WeekdayActivityChart.tsx b/src/pages/course-list/components/statistics/WeekdayActivityChart.tsx new file mode 100644 index 0000000..f10bdae --- /dev/null +++ b/src/pages/course-list/components/statistics/WeekdayActivityChart.tsx @@ -0,0 +1,146 @@ +import React from 'react' +import { + Box, + HStack, + Text, + Badge, + Tooltip, + VStack, + Flex +} from '@chakra-ui/react' +import { useTranslation } from 'react-i18next' + +interface WeekdayActivityChartProps { + weekdayActivity: number[] + mostActiveDayIndex: number +} + +export const WeekdayActivityChart: React.FC = ({ + weekdayActivity, + mostActiveDayIndex +}) => { + const { t } = useTranslation() + + // Переводим день недели в читаемый формат + const getDayOfWeekName = (dayIndex: number) => { + const days = [ + 'journal.pl.days.sunday', + 'journal.pl.days.monday', + 'journal.pl.days.tuesday', + 'journal.pl.days.wednesday', + 'journal.pl.days.thursday', + 'journal.pl.days.friday', + 'journal.pl.days.saturday' + ] + return t(days[dayIndex]) + } + + // Получаем короткое название дня недели (первая буква) + const getShortDayName = (dayIndex: number) => { + return t(`journal.pl.days.${['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][dayIndex]}`).charAt(0) + } + + // Формируем подсказку для дня недели + const getDayTooltip = (dayIndex: number, count: number) => { + return `${t(`journal.pl.days.${['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][dayIndex]}`)}: +${count} ${t('journal.pl.overview.lessons').toLowerCase()}` + } + + // Если нет данных по активности, показываем сообщение + if (!weekdayActivity.some(count => count > 0)) { + return ( + + {t('journal.pl.overview.noAttendanceData')} + + ) + } + + // Находим максимальное и суммарное значение для расчета процентов + const maxValue = Math.max(...weekdayActivity) + const totalLessons = weekdayActivity.reduce((sum, count) => sum + count, 0) + + return ( + + + + {t('journal.pl.overview.mostActiveDay')}: + + + {getDayOfWeekName(mostActiveDayIndex)} + + + + + {t('journal.pl.overview.pastLessonsStats')} + + + {/* Визуализация активности по дням недели */} + + + {weekdayActivity.map((count, index) => ( + + + {/* Область для числа */} + + {count > 0 && ( + + {count} + + )} + + + {/* Столбец графика */} + + + {/* Буква дня недели */} + + + {getShortDayName(index)} + + + + {/* Процент */} + + {count > 0 && totalLessons > 0 && ( + + {Math.round((count / totalLessons) * 100)}% + + )} + + + + ))} + + + + + {t('journal.pl.overview.dayOfWeekHelp')} + + + ) +} \ No newline at end of file diff --git a/src/pages/course-list/components/statistics/index.ts b/src/pages/course-list/components/statistics/index.ts new file mode 100644 index 0000000..6c32c6e --- /dev/null +++ b/src/pages/course-list/components/statistics/index.ts @@ -0,0 +1,6 @@ +export * from './useStats' +export * from './StatCards' +export * from './StudentAttendanceList' +export * from './CourseAttendanceList' +export * from './ActivityStats' +export * from './WeekdayActivityChart' \ No newline at end of file diff --git a/src/pages/course-list/components/statistics/useStats.ts b/src/pages/course-list/components/statistics/useStats.ts new file mode 100644 index 0000000..0712a51 --- /dev/null +++ b/src/pages/course-list/components/statistics/useStats.ts @@ -0,0 +1,239 @@ +import { useMemo } from 'react' +import dayjs from 'dayjs' +import { Course, Lesson } from '../../../../__data__/model' + +export interface StudentAttendance { + id: string + name: string + attended: number + total: number + percent: number + avatarUrl?: string + email?: string +} + +export interface CourseStats { + totalCourses: number + activeCourses: number + totalLessons: number + completedLessons: number + upcomingLessons: number + averageAttendance: number + totalStudents: Set + totalTeachers: Set + recentCoursesCount: number + oldCoursesCount: number + weekdayActivity: number[] + mostActiveDayIndex: number + topStudents: StudentAttendance[] + topCoursesByAttendance: Array<{id: string, name: string, attendanceRate: number}> +} + +export const useStats = ( + courses: Course[], + lessonsByCourse: Record = {} +): CourseStats => { + + return useMemo(() => { + if (!courses?.length) { + return { + totalCourses: 0, + activeCourses: 0, + totalLessons: 0, + completedLessons: 0, + upcomingLessons: 0, + averageAttendance: 0, + totalStudents: new Set(), + totalTeachers: new Set(), + recentCoursesCount: 0, + oldCoursesCount: 0, + weekdayActivity: Array(7).fill(0), + mostActiveDayIndex: 0, + topStudents: [] as StudentAttendance[], + topCoursesByAttendance: [] as {id: string, name: string, attendanceRate: number}[] + } + } + + const now = dayjs() + const threeMonthsAgo = now.subtract(3, 'month') + const weekdayActivity = Array(7).fill(0) + + // Множества для уникальных студентов и учителей + const uniqueStudents = new Set() + const uniqueTeachers = new Set() + + // Количество курсов, созданных за последние 3 месяца + const recentCourses = courses.filter(course => + dayjs(course.created).isAfter(threeMonthsAgo) + ) + + // Количество активных курсов + const activeCourses = [] + + let totalLessonsCount = 0 + let completedLessonsCount = 0 + let upcomingLessonsCount = 0 + let totalAttendances = 0 + let totalPossibleAttendances = 0 + + // Для отслеживания посещаемости студентов по всем курсам + const globalStudentsMap = new Map() + + // Статистика посещаемости по курсам + const courseAttendanceStats: {id: string, name: string, attendanceRate: number}[] = [] + + // Для каждого курса считаем статистику на основе данных об уроках + courses.forEach(course => { + // Добавляем учителей в множество + course.teachers.forEach(teacher => { + uniqueTeachers.add(teacher.sub) + }) + + // Добавляем студентов в множество + const courseUniqueStudents = new Set() + + // Получаем детализированные данные об уроках курса (если доступны) + const courseLessons = lessonsByCourse[course._id] || [] + + // Если у нас есть детализированные данные по урокам + if (courseLessons.length > 0) { + // Добавляем количество уроков к общему счетчику + totalLessonsCount += courseLessons.length + + // Считаем завершенные и предстоящие уроки + const completed = courseLessons.filter(lesson => dayjs(lesson.date).isBefore(now)) + const upcoming = courseLessons.filter(lesson => dayjs(lesson.date).isAfter(now)) + + completedLessonsCount += completed.length + upcomingLessonsCount += upcoming.length + + // Если у курса есть будущие занятия, считаем его активным + if (upcoming.length > 0) { + activeCourses.push(course) + } + + // Собираем всех уникальных студентов курса для более точной статистики + courseLessons.forEach(lesson => { + lesson.students?.forEach(student => { + courseUniqueStudents.add(student.sub) + uniqueStudents.add(student.sub) + }) + }) + + // Для статистики посещаемости по курсу + let courseAttendances = 0 + let coursePossibleAttendances = 0 + + // Считаем посещаемость ТОЛЬКО по прошедшим занятиям + completed.forEach(lesson => { + // Добавляем статистику по дням недели + // В dayjs 0 = воскресенье, 1 = понедельник, ... 6 = суббота + // Нужно проверить формат даты урока, что это валидная дата + if (lesson.date && dayjs(lesson.date).isValid()) { + const lessonDay = dayjs(lesson.date).day() + weekdayActivity[lessonDay]++ + } + + // Добавляем студентов в глобальное множество + const lessonStudentsCount = lesson.students?.length || 0 + + // Добавляем в статистику посещаемости + courseAttendances += lessonStudentsCount + + // Обновляем счетчики общей посещаемости + totalAttendances += lessonStudentsCount + + // Собираем статистику по каждому студенту + lesson.students?.forEach(student => { + // Добавляем или обновляем данные студента в глобальной карте + const studentId = student.sub + const currentGlobal = globalStudentsMap.get(studentId) || { + id: studentId, + name: (student.family_name && student.given_name + ? `${student.family_name} ${student.given_name}` + : student.name || student.email || student.preferred_username || student.family_name || student.given_name), + attended: 0, + total: 0, + percent: 0, + avatarUrl: student.picture, + email: student.email + } + + currentGlobal.attended += 1 + globalStudentsMap.set(studentId, currentGlobal) + }) + }) + + // Потенциальные посещения для этого курса рассчитываем только по прошедшим занятиям + // и только для студентов, которые есть хотя бы на одном занятии + // Кол-во прошедших занятий * кол-во уникальных студентов на курсе + coursePossibleAttendances = completed.length * (courseUniqueStudents.size || 1) + totalPossibleAttendances += coursePossibleAttendances + + // Добавляем статистику курса, если есть прошедшие занятия + if (completed.length > 0 && coursePossibleAttendances > 0) { + courseAttendanceStats.push({ + id: course._id, + name: course.name, + attendanceRate: (courseAttendances / coursePossibleAttendances) * 100 + }) + } + } else { + // Если у нас нет детализированных данных, считаем на основе общих данных курса + totalLessonsCount += course.lessons.length + + // Предполагаем, что курс активен + activeCourses.push(course) + } + }) + + // Отладочная информация по активности по дням недели + console.log('Weekday activity:', weekdayActivity) + + // Обрабатываем глобальную статистику посещаемости студентов + // Устанавливаем общее число занятий для каждого студента + globalStudentsMap.forEach(student => { + // Устанавливаем максимально возможное кол-во занятий как общее число прошедших занятий + // (это завышенная оценка, т.к. студент может быть не на всех курсах) + student.total = completedLessonsCount + student.percent = completedLessonsCount > 0 ? (student.attended / student.total) * 100 : 0 + }) + + // Находим самый активный день недели + const maxValue = Math.max(...weekdayActivity) + // Если максимальное значение = 0, то устанавливаем понедельник как самый активный день по умолчанию + const mostActiveDayIndex = maxValue > 0 ? weekdayActivity.indexOf(maxValue) : 1 + + // Вычисляем среднюю посещаемость + const averageAttendance = totalPossibleAttendances > 0 + ? (totalAttendances / totalPossibleAttendances) * 100 + : 0 + + // Топ студенты по посещаемости (по всем курсам) + const topStudents = Array.from(globalStudentsMap.values()) + .sort((a, b) => (b.percent - a.percent) || (b.attended - a.attended)) + .slice(0, 5) + + // Сортируем курсы по посещаемости + const topCoursesByAttendance = courseAttendanceStats + .sort((a, b) => b.attendanceRate - a.attendanceRate) + .slice(0, 3) + + return { + totalCourses: courses.length, + activeCourses: activeCourses.length, + totalLessons: totalLessonsCount, + completedLessons: completedLessonsCount, + upcomingLessons: upcomingLessonsCount, + averageAttendance, + totalStudents: uniqueStudents, + totalTeachers: uniqueTeachers, + recentCoursesCount: recentCourses.length, + oldCoursesCount: courses.length - recentCourses.length, + weekdayActivity, + mostActiveDayIndex, + topStudents, + topCoursesByAttendance + } + }, [courses, lessonsByCourse]) +} \ No newline at end of file diff --git a/src/pages/course-list/course-card.tsx b/src/pages/course-list/course-card.tsx index dc5f952..14a9519 100644 --- a/src/pages/course-list/course-card.tsx +++ b/src/pages/course-list/course-card.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useState, useMemo } from 'react' +import { formatDate } from '../../utils/dayjs-config' import dayjs from 'dayjs' import { Link as ConnectedLink, generatePath } from 'react-router-dom' import { getNavigationValue } from '@brojs/cli' @@ -231,7 +232,7 @@ export const CourseCard = ({ course }: { course: Course }) => { - {dayjs(course.startDt).format('DD.MM.YYYY')} + {formatDate(course.startDt, 'DD.MM.YYYY')} @@ -243,7 +244,7 @@ export const CourseCard = ({ course }: { course: Course }) => { - {dayjs(course.startDt).format('DD.MM.YYYY')} + {formatDate(course.startDt, 'DD.MM.YYYY')} @@ -311,10 +312,12 @@ export const CourseCard = ({ course }: { course: Course }) => { {populatedCourse.data?.lessons .filter(lesson => dayjs(lesson.date).isAfter(dayjs())) .sort((a, b) => dayjs(a.date).valueOf() - dayjs(b.date).valueOf())[0]?.date - ? dayjs(populatedCourse.data?.lessons - .filter(lesson => dayjs(lesson.date).isAfter(dayjs())) - .sort((a, b) => dayjs(a.date).valueOf() - dayjs(b.date).valueOf())[0]?.date) - .format('DD.MM.YYYY') + ? formatDate( + populatedCourse.data?.lessons + .filter(lesson => dayjs(lesson.date).isAfter(dayjs())) + .sort((a, b) => dayjs(a.date).valueOf() - dayjs(b.date).valueOf())[0]?.date, + 'DD.MM.YYYY' + ) : t('journal.pl.common.noData') } @@ -455,7 +458,7 @@ export const CourseCard = ({ course }: { course: Course }) => { - {dayjs(lesson.date).format('DD.MM.YYYY')} + {formatDate(lesson.date, 'DD.MM.YYYY')} {isPast && lessonAttendance && ( { const { t } = useTranslation() const { colorMode } = useColorMode() + // Устанавливаем хлебные крошки для главной страницы + useSetBreadcrumbs([ + { + title: t('journal.pl.breadcrumbs.home'), + path: '/', + isCurrentPage: true + } + ]) + + // Получаем значения фичей + const features = getFeatures('journal') + const coursesStatistics = features?.['courses.statistics'] + + // Создаем API запросы для получения уроков + const [getLessons] = api.useLazyLessonListQuery() + const buttonSize = useBreakpointValue({ base: 'md', md: 'lg' }) const containerPadding = useBreakpointValue({ base: '2', md: '4' }) // Используем хук для группировки курсов по годам const groupedCourses = useGroupedCourses(data?.body) + + // Создаем объект с детализированными данными для всех курсов + const [lessonsByCourse, setLessonsByCourse] = useState>({}) + + // Используем useMemo для проверки наличия данных + const courses = useMemo(() => data?.body || [], [data]) + + // Загружаем данные для каждого курса параллельно + useEffect(() => { + if (courses.length > 0 && !showForm) { + // Создаем запросы для получения данных о занятиях каждого курса + const fetchLessonsForCourses = async () => { + const lessonsData: Record = {} + + // Получаем данные курсов параллельно (по 3 курса за раз, чтобы не перегружать сервер) + for (let i = 0; i < courses.length; i += 3) { + const batch = courses.slice(i, i + 3) + const batchPromises = batch.map(async course => { + // Используем существующий API метод с Lazy Query + const response = await getLessons(course.id) + if (response.data?.body) { + lessonsData[course._id] = response.data.body + } + }) + + await Promise.all(batchPromises) + } + + setLessonsByCourse(lessonsData) + } + + fetchLessonsForCourses() + } + }, [courses, showForm, getLessons]) if (isLoading) { return @@ -61,6 +114,14 @@ export const CoursesList = () => { )} + {!showForm && coursesStatistics && ( + + )} + {Object.keys(groupedCourses).length > 0 ? ( Object.entries(groupedCourses) .sort(([yearA], [yearB]) => Number(yearB) - Number(yearA)) // Сортируем годы по убыванию diff --git a/src/pages/course-list/hooks/useCreateCourse.ts b/src/pages/course-list/hooks/useCreateCourse.ts index 7ffc7cf..bd8ac9b 100644 --- a/src/pages/course-list/hooks/useCreateCourse.ts +++ b/src/pages/course-list/hooks/useCreateCourse.ts @@ -1,8 +1,9 @@ import { useRef, useEffect } from 'react' -import dayjs from 'dayjs' +import dayjs from '../../../utils/dayjs-config' import { useForm } from 'react-hook-form' import { useToast } from '@chakra-ui/react' import { useTranslation } from 'react-i18next' +import { formatDate } from '../../../utils/dayjs-config' import { api } from '../../../__data__/api/api' @@ -30,7 +31,7 @@ export const useCreateCourse = (onSuccess: () => void) => { getValues, } = useForm({ defaultValues: { - startDt: dayjs().format('YYYY-MM-DD'), + startDt: formatDate(dayjs().toDate(), 'YYYY-MM-DD'), name: t('journal.pl.course.defaultName'), }, }) diff --git a/src/pages/course-list/hooks/useGroupedCourses.ts b/src/pages/course-list/hooks/useGroupedCourses.ts index c5098ab..6329b72 100644 --- a/src/pages/course-list/hooks/useGroupedCourses.ts +++ b/src/pages/course-list/hooks/useGroupedCourses.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react' -import dayjs from 'dayjs' +import dayjs, { formatDate } from '../../../utils/dayjs-config' import { Course } from '../../../__data__/model' /** @@ -20,7 +20,7 @@ export const useGroupedCourses = (courses?: Course[]) => { // Группируем по годам sortedCourses.forEach(course => { - const year = dayjs(course.startDt).format('YYYY') + const year = formatDate(course.startDt, 'YYYY') if (!grouped[year]) { grouped[year] = [] } diff --git a/src/pages/lesson-details.tsx b/src/pages/lesson-details.tsx index c405320..703c896 100644 --- a/src/pages/lesson-details.tsx +++ b/src/pages/lesson-details.tsx @@ -1,29 +1,28 @@ -import React, { useEffect, useRef, useMemo } from 'react' +import React, { useEffect, useRef, useMemo, useState } from 'react' import { useParams, Link } from 'react-router-dom' -import dayjs from 'dayjs' import QRCode from 'qrcode' import { sha256 } from 'js-sha256' import { getConfigValue, getNavigationValue } from '@brojs/cli' +import { motion, AnimatePresence } from 'framer-motion' import { Box, - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, Container, VStack, Heading, Stack, + useColorMode, } from '@chakra-ui/react' import { useTranslation } from 'react-i18next' import { api } from '../__data__/api/api' import { User } from '../__data__/model' import { UserCard } from '../components/user-card' +import { formatDate } from '../utils/dayjs-config' +import { useSetBreadcrumbs } from '../components' import { QRCanvas, StudentList, - BreadcrumbsWrapper, } from './style' import { useAppSelector } from '../__data__/store' import { isTeacher } from '../utils/user' @@ -42,6 +41,35 @@ const LessonDetail = () => { const canvRef = useRef(null) const user = useAppSelector((s) => s.user) const { t } = useTranslation() + const { colorMode } = useColorMode() + + // Получаем данные о курсе и уроке + const { data: courseData } = api.useGetCourseByIdQuery(courseId) + const { data: lessonData } = api.useLessonByIdQuery(lessonId) + + // Устанавливаем хлебные крошки + useSetBreadcrumbs([ + { + title: t('journal.pl.breadcrumbs.home'), + path: '/' + }, + { + title: courseData?.name || t('journal.pl.breadcrumbs.course'), + path: `${getNavigationValue('journal.main')}/lessons-list/${courseId}` + }, + { + title: lessonData?.body?.name || t('journal.pl.breadcrumbs.lesson'), + isCurrentPage: true + } + ]) + + // Создаем ref для отслеживания ранее присутствовавших студентов + const prevPresentStudentsRef = useRef(new Set()) + + // Добавляем состояние для отслеживания пульсации + const [isPulsing, setIsPulsing] = useState(false) + // Отслеживаем предыдущее количество студентов + const prevStudentCountRef = useRef(0) const { isFetching, @@ -64,6 +92,34 @@ const LessonDetail = () => { [accessCode, lessonId], ) + // Эффект для обнаружения и обновления новых присутствующих студентов + useEffect(() => { + if (accessCode?.body) { + const currentPresent = new Set(accessCode.body.lesson.students.map(s => s.sub)) + + // Проверяем, изменилось ли количество студентов + const currentCount = accessCode.body.lesson.students.length; + if (prevStudentCountRef.current !== currentCount && prevStudentCountRef.current > 0) { + // Запускаем эффект пульсации + setIsPulsing(true); + // Сбрасываем эффект через 1.5 секунды + setTimeout(() => { + setIsPulsing(false); + }, 1500); + } + + // Обновляем предыдущее количество + prevStudentCountRef.current = currentCount; + + // Очищаем флаги предыдущего состояния после задержки + const timeoutId = setTimeout(() => { + prevPresentStudentsRef.current = currentPresent + }, 3000) + + return () => clearTimeout(timeoutId) + } + }, [accessCode]) + useEffect(() => { if (manualAddRqst.isSuccess) { refetch() @@ -118,13 +174,17 @@ const LessonDetail = () => { }, [isFetching, isSuccess, userUrl]) const studentsArr = useMemo(() => { - let allStudents: (User & { present?: boolean })[] = [ + let allStudents: (User & { present?: boolean; recentlyPresent?: boolean })[] = [ ...(AllStudents.data?.body || []), - ].map((st) => ({ ...st, present: false })) + ].map((st) => ({ ...st, present: false, recentlyPresent: false })) let presentStudents: (User & { present?: boolean })[] = [ ...(accessCode?.body.lesson.students || []), ] + // Находим новых студентов по сравнению с предыдущим состоянием + const currentPresent = new Set(presentStudents.map(s => s.sub)) + const newlyPresent = [...currentPresent].filter(id => !prevPresentStudentsRef.current.has(id)) + while (presentStudents.length) { const student = presentStudents.pop() @@ -132,70 +192,147 @@ const LessonDetail = () => { if (present) { present.present = true + present.recentlyPresent = newlyPresent.includes(student.sub) } else { - allStudents.push({ ...student, present: true }) + allStudents.push({ + ...student, + present: true, + recentlyPresent: newlyPresent.includes(student.sub) + }) } } return allStudents.sort((a, b) => (a.present ? -1 : 1)) - }, [accessCode?.body, AllStudents.data]) + }, [accessCode?.body, AllStudents.data, prevPresentStudentsRef.current]) + + // Функция для определения цвета на основе посещаемости + const getAttendanceColor = (attendance: number, total: number) => { + const percentage = total > 0 ? (attendance / total) * 100 : 0 + + if (percentage > 80) return { bg: 'green.100', color: 'green.800', dark: { bg: 'green.800', color: 'green.100' } } + if (percentage > 60) return { bg: 'teal.100', color: 'teal.800', dark: { bg: 'teal.800', color: 'teal.100' } } + if (percentage > 40) return { bg: 'yellow.100', color: 'yellow.800', dark: { bg: 'yellow.800', color: 'yellow.100' } } + if (percentage > 20) return { bg: 'orange.100', color: 'orange.800', dark: { bg: 'orange.800', color: 'orange.100' } } + return { bg: 'red.100', color: 'red.800', dark: { bg: 'red.800', color: 'red.100' } } + } return ( <> - - - - - {t('journal.pl.common.journal')} - - - - - - {t('journal.pl.common.course')} - - - - - {t('journal.pl.common.lesson')} - - - {t('journal.pl.lesson.topicTitle')} {accessCode?.body?.lesson?.name} - - {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')} - + - - + + + {formatDate(accessCode?.body?.lesson?.date, t('journal.pl.lesson.dateFormat'))}{' '} + {t('journal.pl.common.marked')} - + {AllStudents.isSuccess && ( + + {accessCode?.body?.lesson?.students?.length} / {AllStudents?.data?.body?.length} + + )} + {!AllStudents.isSuccess && ( + {accessCode?.body?.lesson?.students?.length} + )}{' '} + {t('journal.pl.common.people')} + - - {isTeacher(user) && studentsArr.map((student) => ( - manualAdd({ lessonId, user })} - /> - ))} - + + + {isTeacher(user) && ( + + {studentsArr.map((student) => ( + + manualAdd({ lessonId, user })} + /> + + ))} + + )} + + diff --git a/src/pages/lesson-list/components/bar.tsx b/src/pages/lesson-list/components/bar.tsx index 53d7cad..f8b3355 100644 --- a/src/pages/lesson-list/components/bar.tsx +++ b/src/pages/lesson-list/components/bar.tsx @@ -1,9 +1,14 @@ import React from 'react' import { type BarDatum, ResponsiveBar } from '@nivo/bar' import { useTranslation } from 'react-i18next' +import { useColorMode } from '@chakra-ui/react' export const Bar = ({ data }: { data: BarDatum[] }) => { const { t } = useTranslation() + const { colorMode } = useColorMode() + + // Находим максимальное значение для нормализации цветов + const maxValue = Math.max(...data.map(item => (item.count as number))) return ( { keys={['count']} indexBy="lessonIndex" margin={{ top: 50, right: 130, bottom: 50, left: 60 }} - padding={0.3} + padding={0.4} valueScale={{ type: 'linear' }} indexScale={{ type: 'band', round: true }} - colors={{ scheme: 'set3' }} + colors={(bar) => { + // Нормализованное значение от 0 до 1 + const normalized = (bar.data.count as number) / maxValue + // Красный при низких значениях, зеленый при высоких + const r = Math.round(255 * (1 - normalized)) + const g = Math.round(255 * normalized) + const b = 100 // Немного синего, чтобы цвета не были слишком резкими + return `rgb(${r}, ${g}, ${b})` + }} + theme={{ + tooltip: { + container: { + background: colorMode === 'dark' ? '#2D3748' : '#ffffff', + color: colorMode === 'dark' ? '#ffffff' : '#333333', + fontSize: 14, + borderRadius: 8, + boxShadow: '0 4px 10px rgba(0, 0, 0, 0.1)', + }, + }, + grid: { + line: { + stroke: colorMode === 'dark' ? '#4A5568' : '#e0e0e0', + strokeWidth: 1, + }, + }, + }} + borderRadius={4} + borderWidth={1} + borderColor={{ from: 'color', modifiers: [['darker', 0.3]] }} axisTop={null} axisRight={null} labelSkipWidth={12} labelSkipHeight={12} - labelTextColor={{ - from: 'color', - modifiers: [['brighter', 1.4]], - }} + labelTextColor={colorMode === 'dark' ? '#ffffff' : '#333333'} + animate={true} + motionConfig="gentle" + enableGridY={false} role="application" ariaLabel={t('journal.pl.lesson.attendanceChart')} barAriaLabel={(e) => diff --git a/src/pages/lesson-list/components/item.tsx b/src/pages/lesson-list/components/item.tsx index 58fa5ee..df92be0 100644 --- a/src/pages/lesson-list/components/item.tsx +++ b/src/pages/lesson-list/components/item.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react' -import dayjs from 'dayjs' +import { formatDate } from '../../../utils/dayjs-config' import { Link } from 'react-router-dom' import { getNavigationValue, getFeatures } from '@brojs/cli' import { @@ -133,7 +133,7 @@ export const Item: React.FC = ({ <> {name} - {dayjs(date).format(groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')} + {formatDate(date, groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')} @@ -193,7 +193,7 @@ export const Item: React.FC = ({ )} - {dayjs(date).format(groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')} + {formatDate(date, groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')} {name} {isTeacher && ( diff --git a/src/pages/lesson-list/components/lesson-items.tsx b/src/pages/lesson-list/components/lesson-items.tsx index 109918f..ee9bca0 100644 --- a/src/pages/lesson-list/components/lesson-items.tsx +++ b/src/pages/lesson-list/components/lesson-items.tsx @@ -1,5 +1,5 @@ import React from 'react' -import dayjs from 'dayjs' +import { formatDate } from '../../../utils/dayjs-config' import { Tr, Td, @@ -45,7 +45,7 @@ export const LessonItems: React.FC = ({ borderRadius="md" _dark={{ bg: "gray.700" }} > - {dayjs(date).format('DD MMMM YYYY')} + {formatDate(date, 'DD MMMM YYYY')} )} {lessons.map((lesson) => ( @@ -78,7 +78,7 @@ export const LessonItems: React.FC = ({ {date && ( - {dayjs(date).format('DD MMMM YYYY')} + {formatDate(date, 'DD MMMM YYYY')} )} diff --git a/src/pages/lesson-list/components/lessons-form.tsx b/src/pages/lesson-list/components/lessons-form.tsx index c5a877c..acdf034 100644 --- a/src/pages/lesson-list/components/lessons-form.tsx +++ b/src/pages/lesson-list/components/lessons-form.tsx @@ -30,6 +30,7 @@ import { AddIcon, CheckIcon, WarningIcon, RepeatIcon } from '@chakra-ui/icons' import { useTranslation } from 'react-i18next' import { FaRobot } from 'react-icons/fa' import dayjs from 'dayjs' +import { formatDate } from '../../../utils/dayjs-config' import { dateToCalendarFormat } from '../../../utils/time' import { Lesson } from '../../../__data__/model' @@ -294,7 +295,7 @@ export const LessonForm = ({ {isSelected && } - {dayjs(suggestion.date).format('DD.MM.YYYY HH:mm')} + {formatDate(suggestion.date, 'DD.MM.YYYY HH:mm')} ); diff --git a/src/pages/lesson-list/components/statistics.tsx b/src/pages/lesson-list/components/statistics.tsx new file mode 100644 index 0000000..d606f09 --- /dev/null +++ b/src/pages/lesson-list/components/statistics.tsx @@ -0,0 +1,309 @@ +import React, { useMemo } from 'react' +import dayjs from 'dayjs' +import { formatDate } from '../../../utils/dayjs-config' +import { + Box, + Heading, + Text, + SimpleGrid, + Stat, + StatLabel, + StatNumber, + StatHelpText, + StatArrow, + StatGroup, + Flex, + Icon, + Progress, + Divider, + Badge, + VStack, + HStack, + useColorModeValue +} from '@chakra-ui/react' +import { useTranslation } from 'react-i18next' +import { + FaChalkboardTeacher, + FaUserGraduate, + FaClock, + FaCalendarCheck, + FaCalendarAlt, + FaPercentage +} from 'react-icons/fa' +import { CalendarIcon, StarIcon, TimeIcon } from '@chakra-ui/icons' + +import { Lesson } from '../../../__data__/model' + +interface CourseStatisticsProps { + lessons: Lesson[] + isLoading: boolean +} + +export const CourseStatistics: React.FC = ({ lessons = [], isLoading }) => { + const { t } = useTranslation() + const statBgColor = useColorModeValue('white', 'gray.700') + const borderColor = useColorModeValue('gray.200', 'gray.600') + + // Вычисляем статистику курса + const stats = useMemo(() => { + if (!lessons || lessons.length === 0) { + return { + totalLessons: 0, + completedLessons: 0, + upcomingLessons: 0, + attendanceRate: 0, + averageStudentsPerLesson: 0, + nextLessonDate: null, + mostAttendedLesson: null, + attendanceTrend: 0, + totalStudents: 0, + daysUntilNextLesson: 0, + percentageCompleted: 0 + } + } + + const now = dayjs() + const completed = lessons.filter(lesson => dayjs(lesson.date).isBefore(now)) + const upcoming = lessons.filter(lesson => dayjs(lesson.date).isAfter(now)) + + // Сортируем предстоящие занятия по дате (ближайшие вперед) + const sortedUpcoming = [...upcoming].sort((a, b) => + dayjs(a.date).valueOf() - dayjs(b.date).valueOf() + ) + + // Находим ближайшее занятие + const nextLesson = sortedUpcoming.length > 0 ? sortedUpcoming[0] : null + + // Вычисляем среднее количество студентов на занятии + const totalStudentsCount = completed.reduce( + (sum, lesson) => sum + (lesson.students?.length || 0), + 0 + ) + + const averageStudents = completed.length + ? totalStudentsCount / completed.length + : 0 + + // Находим занятие с наибольшей посещаемостью + let mostAttended = null + let maxAttendance = 0 + + completed.forEach(lesson => { + const attendance = lesson.students?.length || 0 + if (attendance > maxAttendance) { + maxAttendance = attendance + mostAttended = lesson + } + }) + + // Вычисляем тренд посещаемости (положительный или отрицательный) + let attendanceTrend = 0 + + if (completed.length >= 2) { + // Берем последние 5 занятий или меньше, если их меньше 5 + const recentLessons = [...completed] + .sort((a, b) => dayjs(b.date).valueOf() - dayjs(a.date).valueOf()) + .slice(0, 5) + + if (recentLessons.length >= 2) { + const lastLesson = recentLessons[0] + const previousLessons = recentLessons.slice(1) + + const lastAttendance = lastLesson.students?.length || 0 + const avgPreviousAttendance = previousLessons.reduce( + (sum, lesson) => sum + (lesson.students?.length || 0), + 0 + ) / previousLessons.length + + // Вычисляем процентное изменение + attendanceTrend = avgPreviousAttendance + ? ((lastAttendance - avgPreviousAttendance) / avgPreviousAttendance) * 100 + : 0 + } + } + + // Вычисляем количество дней до следующего занятия + const daysUntilNext = nextLesson + ? dayjs(nextLesson.date).diff(now, 'day') + : 0 + + // Собираем все уникальные ID студентов + const uniqueStudents = new Set() + lessons.forEach(lesson => { + lesson.students?.forEach(student => { + uniqueStudents.add(student.sub) + }) + }) + + // Вычисляем процент завершенного курса + const percentComplete = lessons.length + ? (completed.length / lessons.length) * 100 + : 0 + + return { + totalLessons: lessons.length, + completedLessons: completed.length, + upcomingLessons: upcoming.length, + attendanceRate: completed.length ? (totalStudentsCount / (completed.length * uniqueStudents.size || 1)) * 100 : 0, + averageStudentsPerLesson: Math.round(averageStudents * 10) / 10, + nextLessonDate: nextLesson?.date || null, + mostAttendedLesson: mostAttended, + attendanceTrend, + totalStudents: uniqueStudents.size, + daysUntilNextLesson: daysUntilNext, + percentageCompleted: percentComplete + } + }, [lessons]) + + // Определяем цвет для показателей статистики + const getProgressColor = (value) => { + if (value > 80) return 'green' + if (value > 50) return 'blue' + if (value > 30) return 'yellow' + return 'red' + } + + if (isLoading || !lessons.length) { + return null + } + + return ( + + + {t('journal.pl.statistics.title')} + + + + {/* Статистика по занятиям */} + + + + {t('journal.pl.statistics.totalLessons')} + + {stats.totalLessons} + + + + {stats.completedLessons} {t('journal.pl.statistics.completed')} + + + + + {/* Статистика по посещаемости */} + + + + {t('journal.pl.statistics.attendanceRate')} + + + {Math.round(stats.attendanceRate)}% + + + {stats.attendanceTrend !== 0 && ( + + 0 ? 'increase' : 'decrease'} + /> + + {Math.abs(Math.round(Number(stats.attendanceTrend)))}% + + + )} + + + + {/* Статистика по студентам */} + + + + {t('journal.pl.statistics.totalStudents')} + + {stats.totalStudents} + + + ~ {stats.averageStudentsPerLesson} {t('journal.pl.statistics.perLesson')} + + + + + {/* Следующее занятие */} + + + + {t('journal.pl.statistics.nextLesson')} + + + {stats.nextLessonDate + ? formatDate(stats.nextLessonDate, 'DD.MM.YYYY') + : t('journal.pl.statistics.noUpcoming') + } + + + {stats.nextLessonDate && ( + + {t('journal.pl.statistics.in')} {stats.daysUntilNextLesson} {t('journal.pl.statistics.days')} + + )} + + + + + + + {t('journal.pl.statistics.courseProgress')} + + + + + {t('journal.pl.statistics.completed')}: {stats.completedLessons} / {stats.totalLessons} + + + {Math.round(stats.percentageCompleted)}% + + + + + ) +} \ No newline at end of file diff --git a/src/pages/lesson-list/lesson-list.tsx b/src/pages/lesson-list/lesson-list.tsx index fd333c6..7bf9b47 100644 --- a/src/pages/lesson-list/lesson-list.tsx +++ b/src/pages/lesson-list/lesson-list.tsx @@ -1,11 +1,8 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' -import dayjs from 'dayjs' +import dayjs, { formatDate } from '../../utils/dayjs-config' import { generatePath, Link, useParams } from 'react-router-dom' import { getNavigationValue, getFeatures } from '@brojs/cli' import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, Container, Box, Button, @@ -26,36 +23,46 @@ import { AlertDialogHeader, AlertDialogOverlay, useBreakpointValue, + Flex, + Menu, + MenuButton, + MenuList, + MenuItem, + useColorMode, } from '@chakra-ui/react' -import { AddIcon } from '@chakra-ui/icons' +import { AddIcon, EditIcon } from '@chakra-ui/icons' import { useTranslation } from 'react-i18next' import { useAppSelector } from '../../__data__/store' import { api } from '../../__data__/api/api' import { isTeacher } from '../../utils/user' import { Lesson } from '../../__data__/model' -import { XlSpinner } from '../../components/xl-spinner' +import { XlSpinner, useSetBreadcrumbs } from '../../components' +import { qrCode } from '../../assets' import { LessonForm } from './components/lessons-form' import { Bar } from './components/bar' import { LessonItems } from './components/lesson-items' -import { BreadcrumbsWrapper } from './style' +import { CourseStatistics } from './components/statistics' const features = getFeatures('journal') const barFeature = features?.['lesson.bar'] const groupByDate = features?.['group.by.date'] +const courseStatistics = features?.['course.statistics'] const LessonList = () => { const { courseId } = useParams() const user = useAppSelector((s) => s.user) const { data, isLoading, error, isSuccess } = api.useLessonListQuery(courseId) + const { data: courseData } = api.useGetCourseByIdQuery(courseId) const [generateLessonsMutation, { data: generateLessons, isLoading: isLoadingGenerateLessons, error: errorGenerateLessons, isSuccess: isSuccessGenerateLessons }, ] = api.useGenerateLessonsMutation() + const { colorMode } = useColorMode() const [createLesson, crLQuery] = api.useCreateLessonMutation() const [deleteLesson, deletingRqst] = api.useDeleteLessonMutation() @@ -69,11 +76,41 @@ const LessonList = () => { const [editLesson, setEditLesson] = useState(null) const [suggestedLessonToCreate, setSuggestedLessonToCreate] = useState(null) const { t } = useTranslation() + + // Устанавливаем хлебные крошки для страницы списка уроков + useSetBreadcrumbs([ + { + title: t('journal.pl.breadcrumbs.home'), + path: '/' + }, + { + title: courseData?.name || t('journal.pl.breadcrumbs.course'), + isCurrentPage: true + } + ]) + const sorted = useMemo( () => [...(data?.body || [])]?.sort((a, b) => (a.date > b.date ? 1 : -1)), [data, data?.body], ) + // Найдем максимальное количество студентов среди всех уроков + const maxStudents = useMemo(() => { + if (!sorted || sorted.length === 0) return 1 + const max = Math.max(...sorted.map(lesson => lesson.students?.length || 0)) + return max > 0 ? max : 1 // Избегаем деления на ноль + }, [sorted]) + + // Функция для определения цвета на основе посещаемости + const getAttendanceColor = (attendance: number) => { + const percentage = (attendance / maxStudents) * 100 + if (percentage > 80) return { bg: 'green.100', color: 'green.800', dark: { bg: 'green.800', color: 'green.100' } } + if (percentage > 60) return { bg: 'teal.100', color: 'teal.800', dark: { bg: 'teal.800', color: 'teal.100' } } + if (percentage > 40) return { bg: 'yellow.100', color: 'yellow.800', dark: { bg: 'yellow.800', color: 'yellow.100' } } + if (percentage > 20) return { bg: 'orange.100', color: 'orange.800', dark: { bg: 'orange.800', color: 'orange.100' } } + return { bg: 'red.100', color: 'red.800', dark: { bg: 'red.800', color: 'red.100' } } + } + const lessonCalc = useMemo(() => { if (!isSuccess) { return [] @@ -275,7 +312,7 @@ const LessonList = () => { - {t('journal.pl.lesson.deleteConfirm', { date: dayjs(lessonToDelete?.date).format('DD.MM.YY') })} + {t('journal.pl.lesson.deleteConfirm', { date: formatDate(lessonToDelete?.date, 'DD.MM.YY') })} @@ -303,19 +340,6 @@ const LessonList = () => { - - - - - {t('journal.pl.common.journal')} - - - - - {t('journal.pl.common.course')} - - - {isTeacher(user) && ( @@ -346,6 +370,12 @@ const LessonList = () => { )} )} + + {/* Статистика курса */} + {!showForm && courseStatistics && ( + + )} + {barFeature && sorted?.length > 1 && ( { ))} ) : ( - - - - - {isTeacher(user) && ( - - )} - - - {isTeacher(user) && } - - - - - {lessonCalc?.map(({ data: lessons, date }) => ( - - ))} - -
- {t('journal.pl.lesson.link')} - - {groupByDate ? t('journal.pl.lesson.time') : t('journal.pl.common.date')} - {t('journal.pl.common.name')}{t('journal.pl.lesson.action')}{t('journal.pl.common.marked')}
-
+ + {lessonCalc?.map(({ data: lessons, date }) => ( + + {date && ( + + + {formatDate(date, 'DD MMMM YYYY')} + + + )} + + {lessons.map((lesson, index) => ( + + + {/* QR код и ссылка - левая часть карточки */} + {isTeacher(user) && ( + + + + QR код + + + + )} + + {/* Содержимое карточки */} + + + {/* Название урока */} + + {lesson.name} + + + + {formatDate(lesson.date, groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')} + + + + {/* Нижняя часть с метками и действиями */} + + + + {t('journal.pl.common.marked')}: + + + {lesson.students.length} + + + + {isTeacher(user) && ( + + } + > + {t('journal.pl.edit')} + + + handleEditLesson(lesson)} + icon={} + > + {t('journal.pl.edit')} + + setlessonToDelete(lesson)} + color="red.500" + > + {t('journal.pl.delete')} + + + + )} + + + + + ))} + + + ))} + )}
diff --git a/src/pages/style.ts b/src/pages/style.ts index 2588042..d245088 100644 --- a/src/pages/style.ts +++ b/src/pages/style.ts @@ -16,19 +16,96 @@ const reveal = keyframes` ` export const StudentList = styled.ul` - padding-left: 0px; - height: 600px; - justify-content: space-evenly; - padding-right: 20px; - display: flex; - flex-direction: row; - flex-wrap: wrap; - gap: 8px; + padding: 0; + list-style: none; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 16px; + width: 100%; + max-height: 600px; + overflow-y: auto; @media (max-width: 768px) { - height: auto; - max-height: 600px; - padding-right: 0; + gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + } + + /* Стили для motion.li элементов */ + li { + list-style: none; + height: 100%; + } + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + } + + .chakra-ui-dark &::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + } + + .chakra-ui-dark &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + } +` + +export const StudentListView = styled.ul` + padding: 0; + list-style: none; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 16px; + width: 100%; + + /* Адаптивные отступы на разных экранах */ + @media (max-width: 768px) { + gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + } + + /* Стили для контейнеров карточек */ + li { + list-style: none; + height: 100%; + transform-origin: center bottom; + } + + /* Добавляем плавные переходы между состояниями */ + li:hover { + z-index: 10; + } + + /* Стилизация скроллбара */ + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.03); + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.1); + border-radius: 4px; + } + + .chakra-ui-dark &::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.03); + } + + .chakra-ui-dark &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); } ` diff --git a/src/pages/user-page.tsx b/src/pages/user-page.tsx index 1bf4392..570aa13 100644 --- a/src/pages/user-page.tsx +++ b/src/pages/user-page.tsx @@ -1,9 +1,10 @@ -import React from 'react' +import React, { useEffect, useState } from 'react' import { useParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' +import { motion, AnimatePresence } from 'framer-motion' import { api } from '../__data__/api/api' -import dayjs from 'dayjs' +import { formatDate } from '../utils/dayjs-config' import { Alert, AlertIcon, @@ -12,18 +13,77 @@ import { Container, Spinner, Text, + Heading, + Badge, + Flex, + useColorMode, } from '@chakra-ui/react' import { UserCard } from '../components/user-card' +import { StudentListView } from './style' +import { useSetBreadcrumbs } from '../components' const UserPage = () => { const { lessonId, accessId } = useParams() const { t } = useTranslation() + const { colorMode } = useColorMode() const acc = api.useGetAccessQuery({ accessCode: accessId }) + const [animatedStudents, setAnimatedStudents] = useState([]) const ls = api.useLessonByIdQuery(lessonId, { pollingInterval: 1000, skipPollingIfUnfocused: true, }) + + // Устанавливаем хлебные крошки + useSetBreadcrumbs([ + { + title: t('journal.pl.breadcrumbs.home'), + path: '/' + }, + { + title: t('journal.pl.breadcrumbs.user'), + isCurrentPage: true + } + ]) + + // Эффект для поэтапного появления карточек студентов + useEffect(() => { + if (ls.data?.body?.students?.length) { + // Сначала очищаем список + setAnimatedStudents([]) + + // Затем постепенно добавляем студентов для красивой анимации + const students = [...ls.data.body.students] + const addStudentWithDelay = (index) => { + if (index < students.length) { + setAnimatedStudents(prev => [...prev, {...students[index], isNew: true}]) + + // Для следующего студента + setTimeout(() => { + addStudentWithDelay(index + 1) + }, 100) // Уменьшенная задержка для более плавной анимации + } + } + + // Запускаем процесс добавления с небольшой задержкой для лучшего UX + setTimeout(() => { + addStudentWithDelay(0) + }, 300) + } + }, [ls.data?.body?.students]) + + // Эффект для сброса флага "новизны" студентов + useEffect(() => { + if (animatedStudents.length > 0) { + const timeoutId = setTimeout(() => { + setAnimatedStudents(students => + students.map(student => ({...student, isNew: false})) + ) + }, 2000) + + return () => clearTimeout(timeoutId) + } + }, [animatedStudents]) if (acc.isLoading) { return ( @@ -42,13 +102,30 @@ const UserPage = () => { } return ( - - {acc.isLoading &&

{t('journal.pl.common.sending')}

} - {acc.isSuccess &&

{t('journal.pl.common.success')}

} + + {acc.isLoading && ( +
+ + {t('journal.pl.common.sending')} +
+ )} + + {acc.isSuccess && ( + + + + {t('journal.pl.common.success')} + + + )} {acc.error && ( - + {(acc as any).error?.data?.body?.errorMessage === 'Code is expired' ? ( @@ -60,31 +137,106 @@ const UserPage = () => { )} - - - {t('journal.pl.lesson.topicTitle')} {ls.data?.body?.name} - - - {dayjs(ls.data?.body?.date).format(t('journal.pl.lesson.dateFormat'))} - - - - {ls.data?.body?.students?.map((student) => ( - - ))} - + + + {t('journal.pl.lesson.topicTitle')} + + {ls.data?.body?.name} + + + + + + {formatDate(ls.data?.body?.date, t('journal.pl.lesson.dateFormat'))} + + + + {t('journal.pl.common.people')}: {animatedStudents.length} + + + + + + + {animatedStudents.length > 0 ? ( + + + {animatedStudents.map((student) => ( + + + + ))} + + + ) : ( + ls.data && ( +
+ + + {t('journal.pl.lesson.noStudents')} + {t('journal.pl.lesson.waitForStudents')} + + +
+ ) + )} +
) } diff --git a/src/utils/dayjs-config.ts b/src/utils/dayjs-config.ts new file mode 100644 index 0000000..7392238 --- /dev/null +++ b/src/utils/dayjs-config.ts @@ -0,0 +1,30 @@ +import dayjs from 'dayjs'; +import 'dayjs/locale/ru'; +import 'dayjs/locale/en'; +import i18next from 'i18next'; + +// Функция для обновления локали dayjs при изменении языка в i18next +export const updateDayjsLocale = () => { + const currentLocale = i18next.language; + + // Убедимся, что локаль поддерживается, иначе используем 'en' + const locale = ['ru', 'en'].includes(currentLocale) ? currentLocale : 'en'; + + // Установим локаль для dayjs + dayjs.locale(locale); +}; + +// Слушаем изменения языка и обновляем локаль dayjs +i18next.on('languageChanged', () => { + updateDayjsLocale(); +}); + +// Вызываем функцию инициализации при импорте +updateDayjsLocale(); + +// Хелпер для форматирования даты с учетом текущей локали +export const formatDate = (date: string | Date | number, format = 'DD.MM.YYYY') => { + return dayjs(date).format(format); +}; + +export default dayjs; \ No newline at end of file diff --git a/src/utils/time.ts b/src/utils/time.ts index 5806637..21c84c8 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -1,3 +1,3 @@ -import dayjs from "dayjs"; +import dayjs, { formatDate } from "./dayjs-config"; -export const dateToCalendarFormat = (date?: string) => dayjs(date).format('YYYY-MM-DDTHH:mm') +export const dateToCalendarFormat = (date?: string) => formatDate(date, 'YYYY-MM-DDTHH:mm') diff --git a/stubs/mocks/lessons/access-code/create/success.json b/stubs/mocks/lessons/access-code/create/success.json index 0f3ec54..8a51377 100644 --- a/stubs/mocks/lessons/access-code/create/success.json +++ b/stubs/mocks/lessons/access-code/create/success.json @@ -7,19 +7,25 @@ "name": "ВВОДНАЯ ПО JS.ПРИМЕНЕНИЕ И СПОСОБЫ ПОДКЛЮЧЕНИЯ НА СТРАНИЦЕ. LET, CONST. БАЗОВЫЕ ТИПЫ ДАННЫХ, ПРИВЕДЕНИЕ ТИПОВ. ПЕРЕМЕННЫЕ, ОБЛАСТЬ ВИДИМОСТИ ПЕРЕМЕННЫХ", "students": [ { - "sub": "f62905b1-e223-40ca-910f-c8d84c6137c1", - "email_verified": true, - "gravatar": "true", - "name": "Александр Примаков", - "groups": [ - "/inno-staff", - "/microfrontend-admin-user" - ], - "preferred_username": "primakov", - "given_name": "Александр", - "family_name": "Примаков", - "email": "primakovpro@gmail.com" - } + "sub": "fcde3f22-d9ba-412a-a572-c59e515a290f", + "email_verified": true, + "name": "Мария Капитанова", + "preferred_username": "maryaKapitan@gmail.com", + "given_name": "Мария", + "family_name": "Капитанова", + "email": "maryaKapitan@gmail.com", + "picture": "https://lh3.googleusercontent.com/a/ACg8ocJgIjjOFD2YUSyRF5kH4jaysE6X5p-kq0Cg0CFncfMi=s96-c" + }, + { + "sub": "8555885b-715c-4dee-a7c5-9563a6a05211", + "email_verified": true, + "name": "Евгения Жужова", + "preferred_username": "zhuzhova@gmail.com", + "given_name": "Евгения", + "family_name": "Жужова", + "email": "zhuzhova@gmail.com", + "picture": "https://lh3.googleusercontent.com/a/ACg8ocJUtJBAVBm642AxoGpMDDMV8CPu3MEoLjU3hmO7oisG=s96-c" + } ], "date": "2024-02-28T20:37:00.057Z", "created": "2024-02-28T20:37:00.057Z", diff --git a/stubs/mocks/lessons/list/success.json b/stubs/mocks/lessons/list/success.json index 7430e2a..06ce88f 100644 --- a/stubs/mocks/lessons/list/success.json +++ b/stubs/mocks/lessons/list/success.json @@ -1,599 +1,1613 @@ { "success": true, "body": [ - { - "_id": "65e2e5fbec37fec650f28489", - "name": "ВВЕДЕНИЕ В ВЕБ-РАЗРАБОТКУ. ИНСТРУМЕНТАРИЙ, ОБЗОР ВЕБ-ТЕХНОЛОГИЙ", - "teachers": [ - { - "sub": "f62905b1-e223-40ca-910f-c8d84c6137c1", - "email_verified": true, - "preferred_username": "primakov" - } - ], - "students": [ - { - "sub": "fcde3f22-d9ba-412a-a572-c59e515a290f", - "email_verified": true, - "name": "Мария Капитанова", - "preferred_username": "maryaKapitan@gmail.com", - "given_name": "Мария", - "family_name": "Капитанова", - "email": "maryaKapitan@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJgIjjOFD2YUSyRF5kH4jaysE6X5p-kq0Cg0CFncfMi=s96-c" - }, - { - "sub": "5b072deb-33ee-443e-9718-3b5720a3dfb7", - "email_verified": true, - "name": "Евгений Кореной", - "preferred_username": "koren@gmail.com", - "given_name": "Кореной", - "family_name": "Евгений", - "email": "koren@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJpVhDeG-Rpjjm2Un6r8ACz_s_injuIFKpzXf3qmyCn3Cg=s96-c" - }, - { - "sub": "7adf0cd1-cf07-4079-88d8-1a5c9b8f42c2", - "email_verified": true, - "name": "Ирина Игнатьева", - "preferred_username": "irign@gmailcom", - "given_name": "Ирина", - "family_name": "Игнатьева", - "email": "irign@gmailcom", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocL45E4Gt8D5oyIl3ipkcGsv4ShWGs3bdlwEMA_1rzGZ=s96-c" - }, - { - "sub": "95ccc005-95b9-4305-9447-364a32033911", - "email_verified": true, - "name": "Иван Петров", - "preferred_username": "petrov@mail.ru", - "given_name": "Иван", - "family_name": "Петров", - "email": "petrov@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocIgQn5mfDAh2djx-3ofG9z1Em26ZyuUgVPd-6rDOl6z=s96-c" - }, - { - "sub": "ede1ef2c-6ecf-484a-8fb8-282a77e1caa1", - "email_verified": true, - "name": "Константин Тимуров", - "preferred_username": "konstantK@gmail.com", - "given_name": "Константин", - "family_name": "Тимуров", - "email": "konstantK@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJjnOfqaoAU_D4STrJPN9fPOeJ8tv60WbWVZu2ZWcHs=s96-c" - }, - { - "sub": "92cc6a15-805c-4439-b592-b23f32d6d208", - "email_verified": true, - "name": "Александра Питерская", - "preferred_username": "piteralex@gmail.com", - "given_name": "Александра", - "family_name": "Питерская", - "email": "piteralex@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocKhbCbWvBBc_m7bjU5sLCE-dQ-KygBk-aUCSR8XaYtq=s96-c" - }, - { - "sub": "4a3ba8b8-4120-4877-a160-be9ba4d5b3e3", - "email_verified": true, - "name": "Анастасия Светлых", - "preferred_username": "anastasya@gmail.ocm", - "given_name": "Анастасия", - "family_name": "Светлых", - "email": "anastasya@gmail.ocm", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJnsM8UGhbH806yLVgWZ17g3-gJFVcG0Uz5kvqT7dvC=s96-c" - }, - { - "sub": "b4634921-00b3-4082-9284-8ac47f269394", - "email_verified": true, - "name": "Эмилия Снежко", - "preferred_username": "emi@mail.ru", - "given_name": "Эмилия", - "family_name": "Снежко", - "email": "emi@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocI98dzSFQDPr2LXMPFEUX8KLY6bY2m08O_aAj2B5KVNKg=s96-c" - }, - { - "sub": "bf1a95aa-39a2-4528-9b8d-319409995df5", - "email_verified": true, - "name": "Юлия Бобова", - "preferred_username": "bobova@gmail.com", - "given_name": "Юлия", - "family_name": "Бобова", - "email": "bobova@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJ_Ud4iI-jgqcJ3QJcWpESbRLX_C1BnB8_7uTTC-4Dn=s96-c" - }, - { - "sub": "c273a3e3-f7ba-4057-8c57-a1f43b6174a5", - "email_verified": true, - "name": "Анна Самоварова", - "preferred_username": "samovar@gmail.com", - "given_name": "Анна", - "family_name": "Самоварова", - "email": "samovar@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJOhIMdQkXPd55wTMgTTkUCnqbsu4EncgEPm67iz_mK=s96-c" - }, - { - "sub": "8555885b-715c-4dee-a7c5-9563a6a05211", - "email_verified": true, - "name": "Евгения Жужова", - "preferred_username": "zhuzhova@gmail.com", - "given_name": "Евгения", - "family_name": "Жужова", - "email": "zhuzhova@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJUtJBAVBm642AxoGpMDDMV8CPu3MEoLjU3hmO7oisG=s96-c" - }, - { - "sub": "12dee54f-64e9-4be3-9cb0-02ff07ab24fe", - "email_verified": true, - "name": "Эдгар Петренко", - "preferred_username": "petrenk@mail.ru", - "given_name": "Эдгар", - "family_name": "Петренко", - "email": "petrenk@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocLgKAZag32kpGVHMVbh_GsU-rX_MAtmeVIPoov0ZPBYIA=s96-c" - }, - { - "sub": "4082b72a-4730-4841-ad68-06a0e19263df", - "email_verified": true, - "name": "Елена Вавилон", - "preferred_username": "elenvavil@mail.ru", - "given_name": "Елена", - "family_name": "Вавилон", - "email": "elenvavil@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocKXcmzcqRch2--j2Ge2m9e8MIOZ8y1MjsQ0cSEoXOmW=s96-c" - }, - { - "sub": "9e8a08d8-d76a-4f26-99c5-9a1d3c067104", - "email_verified": true, - "name": "Ольга Шарова", - "preferred_username": "julyashap", - "given_name": "Ольга", - "family_name": "Шарова", - "email": "sharova@mail.ru" - } - ], - "date": "2024-03-02T08:40:27.390Z", - "created": "2024-03-02T08:40:27.390Z", - "__v": 0 - }, - { - "_id": "65e301c4ec37fec650f2aafe", - "name": "НАСТРОЙКА ОКРУЖЕНИЯ (GIT + VSCODE + NODEJS)", - "students": [ - { - "sub": "5b072deb-33ee-443e-9718-3b5720a3dfb7", - "email_verified": true, - "name": "Евгений Кореной", - "preferred_username": "koren@gmail.com", - "given_name": "Кореной", - "family_name": "Евгений", - "email": "koren@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJpVhDeG-Rpjjm2Un6r8ACz_s_injuIFKpzXf3qmyCn3Cg=s96-c" - }, - { - "sub": "7adf0cd1-cf07-4079-88d8-1a5c9b8f42c2", - "email_verified": true, - "name": "Ирина Игнатьева", - "preferred_username": "irign@gmailcom", - "given_name": "Ирина", - "family_name": "Игнатьева", - "email": "irign@gmailcom", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocL45E4Gt8D5oyIl3ipkcGsv4ShWGs3bdlwEMA_1rzGZ=s96-c" - }, - - { - "sub": "ede1ef2c-6ecf-484a-8fb8-282a77e1caa1", - "email_verified": true, - "name": "Константин Тимуров", - "preferred_username": "konstantK@gmail.com", - "given_name": "Константин", - "family_name": "Тимуров", - "email": "konstantK@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJjnOfqaoAU_D4STrJPN9fPOeJ8tv60WbWVZu2ZWcHs=s96-c" - }, - { - "sub": "92cc6a15-805c-4439-b592-b23f32d6d208", - "email_verified": true, - "name": "Александра Питерская", - "preferred_username": "piteralex@gmail.com", - "given_name": "Александра", - "family_name": "Питерская", - "email": "piteralex@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocKhbCbWvBBc_m7bjU5sLCE-dQ-KygBk-aUCSR8XaYtq=s96-c" - }, - - { - "sub": "b4634921-00b3-4082-9284-8ac47f269394", - "email_verified": true, - "name": "Эмилия Снежко", - "preferred_username": "emi@mail.ru", - "given_name": "Эмилия", - "family_name": "Снежко", - "email": "emi@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocI98dzSFQDPr2LXMPFEUX8KLY6bY2m08O_aAj2B5KVNKg=s96-c" - }, - { - "sub": "bf1a95aa-39a2-4528-9b8d-319409995df5", - "email_verified": true, - "name": "Юлия Бобова", - "preferred_username": "bobova@gmail.com", - "given_name": "Юлия", - "family_name": "Бобова", - "email": "bobova@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJ_Ud4iI-jgqcJ3QJcWpESbRLX_C1BnB8_7uTTC-4Dn=s96-c" - }, - { - "sub": "c273a3e3-f7ba-4057-8c57-a1f43b6174a5", - "email_verified": true, - "name": "Анна Самоварова", - "preferred_username": "samovar@gmail.com", - "given_name": "Анна", - "family_name": "Самоварова", - "email": "samovar@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJOhIMdQkXPd55wTMgTTkUCnqbsu4EncgEPm67iz_mK=s96-c" - }, - { - "sub": "8555885b-715c-4dee-a7c5-9563a6a05211", - "email_verified": true, - "name": "Евгения Жужова", - "preferred_username": "zhuzhova@gmail.com", - "given_name": "Евгения", - "family_name": "Жужова", - "email": "zhuzhova@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJUtJBAVBm642AxoGpMDDMV8CPu3MEoLjU3hmO7oisG=s96-c" - }, - - { - "sub": "4082b72a-4730-4841-ad68-06a0e19263df", - "email_verified": true, - "name": "Елена Вавилон", - "preferred_username": "elenvavil@mail.ru", - "given_name": "Елена", - "family_name": "Вавилон", - "email": "elenvavil@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocKXcmzcqRch2--j2Ge2m9e8MIOZ8y1MjsQ0cSEoXOmW=s96-c" - }, - { - "sub": "9e8a08d8-d76a-4f26-99c5-9a1d3c067104", - "email_verified": true, - "name": "Ольга Шарова", - "preferred_username": "julyashap", - "given_name": "Ольга", - "family_name": "Шарова", - "email": "sharova@mail.ru" - } - ], - "date": "2024-03-02T10:39:00.718Z", - "created": "2024-03-02T10:39:00.718Z", - "__v": 0 - }, - { - "_id": "65e78bebced789d2f6791315", - "name": "ПРОЕКТИРОВАНИЕ ИНТЕРФЕЙСОВ (MIND MAP. FIGMA)", - "students": [ - { - "sub": "fcde3f22-d9ba-412a-a572-c59e515a290f", - "email_verified": true, - "name": "Мария Капитанова", - "preferred_username": "maryaKapitan@gmail.com", - "given_name": "Мария", - "family_name": "Капитанова", - "email": "maryaKapitan@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJgIjjOFD2YUSyRF5kH4jaysE6X5p-kq0Cg0CFncfMi=s96-c" - }, - { - "sub": "5b072deb-33ee-443e-9718-3b5720a3dfb7", - "email_verified": true, - "name": "Евгений Кореной", - "preferred_username": "koren@gmail.com", - "given_name": "Кореной", - "family_name": "Евгений", - "email": "koren@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJpVhDeG-Rpjjm2Un6r8ACz_s_injuIFKpzXf3qmyCn3Cg=s96-c" - }, - - { - "sub": "95ccc005-95b9-4305-9447-364a32033911", - "email_verified": true, - "name": "Иван Петров", - "preferred_username": "petrov@mail.ru", - "given_name": "Иван", - "family_name": "Петров", - "email": "petrov@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocIgQn5mfDAh2djx-3ofG9z1Em26ZyuUgVPd-6rDOl6z=s96-c" - }, - { - "sub": "ede1ef2c-6ecf-484a-8fb8-282a77e1caa1", - "email_verified": true, - "name": "Константин Тимуров", - "preferred_username": "konstantK@gmail.com", - "given_name": "Константин", - "family_name": "Тимуров", - "email": "konstantK@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJjnOfqaoAU_D4STrJPN9fPOeJ8tv60WbWVZu2ZWcHs=s96-c" - }, - { - "sub": "92cc6a15-805c-4439-b592-b23f32d6d208", - "email_verified": true, - "name": "Александра Питерская", - "preferred_username": "piteralex@gmail.com", - "given_name": "Александра", - "family_name": "Питерская", - "email": "piteralex@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocKhbCbWvBBc_m7bjU5sLCE-dQ-KygBk-aUCSR8XaYtq=s96-c" - }, - - { - "sub": "b4634921-00b3-4082-9284-8ac47f269394", - "email_verified": true, - "name": "Эмилия Снежко", - "preferred_username": "emi@mail.ru", - "given_name": "Эмилия", - "family_name": "Снежко", - "email": "emi@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocI98dzSFQDPr2LXMPFEUX8KLY6bY2m08O_aAj2B5KVNKg=s96-c" - }, - { - "sub": "bf1a95aa-39a2-4528-9b8d-319409995df5", - "email_verified": true, - "name": "Юлия Бобова", - "preferred_username": "bobova@gmail.com", - "given_name": "Юлия", - "family_name": "Бобова", - "email": "bobova@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJ_Ud4iI-jgqcJ3QJcWpESbRLX_C1BnB8_7uTTC-4Dn=s96-c" - }, - { - "sub": "c273a3e3-f7ba-4057-8c57-a1f43b6174a5", - "email_verified": true, - "name": "Анна Самоварова", - "preferred_username": "samovar@gmail.com", - "given_name": "Анна", - "family_name": "Самоварова", - "email": "samovar@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJOhIMdQkXPd55wTMgTTkUCnqbsu4EncgEPm67iz_mK=s96-c" - }, - { - "sub": "8555885b-715c-4dee-a7c5-9563a6a05211", - "email_verified": true, - "name": "Евгения Жужова", - "preferred_username": "zhuzhova@gmail.com", - "given_name": "Евгения", - "family_name": "Жужова", - "email": "zhuzhova@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJUtJBAVBm642AxoGpMDDMV8CPu3MEoLjU3hmO7oisG=s96-c" - }, - { - "sub": "12dee54f-64e9-4be3-9cb0-02ff07ab24fe", - "email_verified": true, - "name": "Эдгар Петренко", - "preferred_username": "petrenk@mail.ru", - "given_name": "Эдгар", - "family_name": "Петренко", - "email": "petrenk@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocLgKAZag32kpGVHMVbh_GsU-rX_MAtmeVIPoov0ZPBYIA=s96-c" - }, - { - "sub": "4082b72a-4730-4841-ad68-06a0e19263df", - "email_verified": true, - "name": "Елена Вавилон", - "preferred_username": "elenvavil@mail.ru", - "given_name": "Елена", - "family_name": "Вавилон", - "email": "elenvavil@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocKXcmzcqRch2--j2Ge2m9e8MIOZ8y1MjsQ0cSEoXOmW=s96-c" - }, - { - "sub": "9e8a08d8-d76a-4f26-99c5-9a1d3c067104", - "email_verified": true, - "name": "Ольга Шарова", - "preferred_username": "julyashap", - "given_name": "Ольга", - "family_name": "Шарова", - "email": "sharova@mail.ru" - } - ], - "date": "2024-03-08T21:17:31.401Z", - "created": "2024-03-05T21:17:31.401Z", - "__v": 1 - }, - { - "_id": "65e78c0fced789d2f679131b", - "name": "ТЕХНОЛОГИЯ HTML СТРУКТУРА ДОКУМЕНТА И ОСНОВНЫЕ ПОНЯТИЯ.", - "students": [ - { - "sub": "fcde3f22-d9ba-412a-a572-c59e515a290f", - "email_verified": true, - "name": "Мария Капитанова", - "preferred_username": "maryaKapitan@gmail.com", - "given_name": "Мария", - "family_name": "Капитанова", - "email": "maryaKapitan@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJgIjjOFD2YUSyRF5kH4jaysE6X5p-kq0Cg0CFncfMi=s96-c" - }, - - { - "sub": "b4634921-00b3-4082-9284-8ac47f269394", - "email_verified": true, - "name": "Эмилия Снежко", - "preferred_username": "emi@mail.ru", - "given_name": "Эмилия", - "family_name": "Снежко", - "email": "emi@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocI98dzSFQDPr2LXMPFEUX8KLY6bY2m08O_aAj2B5KVNKg=s96-c" - }, - { - "sub": "bf1a95aa-39a2-4528-9b8d-319409995df5", - "email_verified": true, - "name": "Юлия Бобова", - "preferred_username": "bobova@gmail.com", - "given_name": "Юлия", - "family_name": "Бобова", - "email": "bobova@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJ_Ud4iI-jgqcJ3QJcWpESbRLX_C1BnB8_7uTTC-4Dn=s96-c" - }, - { - "sub": "c273a3e3-f7ba-4057-8c57-a1f43b6174a5", - "email_verified": true, - "name": "Анна Самоварова", - "preferred_username": "samovar@gmail.com", - "given_name": "Анна", - "family_name": "Самоварова", - "email": "samovar@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJOhIMdQkXPd55wTMgTTkUCnqbsu4EncgEPm67iz_mK=s96-c" - }, - { - "sub": "8555885b-715c-4dee-a7c5-9563a6a05211", - "email_verified": true, - "name": "Евгения Жужова", - "preferred_username": "zhuzhova@gmail.com", - "given_name": "Евгения", - "family_name": "Жужова", - "email": "zhuzhova@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJUtJBAVBm642AxoGpMDDMV8CPu3MEoLjU3hmO7oisG=s96-c" - }, - { - "sub": "12dee54f-64e9-4be3-9cb0-02ff07ab24fe", - "email_verified": true, - "name": "Эдгар Петренко", - "preferred_username": "petrenk@mail.ru", - "given_name": "Эдгар", - "family_name": "Петренко", - "email": "petrenk@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocLgKAZag32kpGVHMVbh_GsU-rX_MAtmeVIPoov0ZPBYIA=s96-c" - }, - { - "sub": "4082b72a-4730-4841-ad68-06a0e19263df", - "email_verified": true, - "name": "Елена Вавилон", - "preferred_username": "elenvavil@mail.ru", - "given_name": "Елена", - "family_name": "Вавилон", - "email": "elenvavil@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocKXcmzcqRch2--j2Ge2m9e8MIOZ8y1MjsQ0cSEoXOmW=s96-c" - } - ], - "date": "2024-03-08T21:18:07.033Z", - "created": "2024-03-05T21:18:07.033Z", - "__v": 22 - }, - { - "_id": "65e78c0fced789d2f6791tt5", - "name": "ВВОДНАЯ ПО JS.ПРИМЕНЕНИЕ И СПОСОБЫ ПОДКЛЮЧЕНИЯ НА СТРАНИЦЕ. LET, CONST. БАЗОВЫЕ ТИПЫ ДАННЫХ, ПРИВЕДЕНИЕ ТИПОВ. ПЕРЕМЕННЫЕ, ОБЛАСТЬ ВИДИМОСТИ ПЕРЕМЕННЫХ", - "students": [ - { - "sub": "fcde3f22-d9ba-412a-a572-c59e515a290f", - "email_verified": true, - "name": "Мария Капитанова", - "preferred_username": "maryaKapitan@gmail.com", - "given_name": "Мария", - "family_name": "Капитанова", - "email": "maryaKapitan@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJgIjjOFD2YUSyRF5kH4jaysE6X5p-kq0Cg0CFncfMi=s96-c" - }, - { - "sub": "5b072deb-33ee-443e-9718-3b5720a3dfb7", - "email_verified": true, - "name": "Евгений Кореной", - "preferred_username": "koren@gmail.com", - "given_name": "Кореной", - "family_name": "Евгений", - "email": "koren@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJpVhDeG-Rpjjm2Un6r8ACz_s_injuIFKpzXf3qmyCn3Cg=s96-c" - }, - { - "sub": "7adf0cd1-cf07-4079-88d8-1a5c9b8f42c2", - "email_verified": true, - "name": "Ирина Игнатьева", - "preferred_username": "irign@gmailcom", - "given_name": "Ирина", - "family_name": "Игнатьева", - "email": "irign@gmailcom", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocL45E4Gt8D5oyIl3ipkcGsv4ShWGs3bdlwEMA_1rzGZ=s96-c" - }, - { - "sub": "95ccc005-95b9-4305-9447-364a32033911", - "email_verified": true, - "name": "Иван Петров", - "preferred_username": "petrov@mail.ru", - "given_name": "Иван", - "family_name": "Петров", - "email": "petrov@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocIgQn5mfDAh2djx-3ofG9z1Em26ZyuUgVPd-6rDOl6z=s96-c" - }, - { - "sub": "ede1ef2c-6ecf-484a-8fb8-282a77e1caa1", - "email_verified": true, - "name": "Константин Тимуров", - "preferred_username": "konstantK@gmail.com", - "given_name": "Константин", - "family_name": "Тимуров", - "email": "konstantK@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJjnOfqaoAU_D4STrJPN9fPOeJ8tv60WbWVZu2ZWcHs=s96-c" - }, - { - "sub": "92cc6a15-805c-4439-b592-b23f32d6d208", - "email_verified": true, - "name": "Александра Питерская", - "preferred_username": "piteralex@gmail.com", - "given_name": "Александра", - "family_name": "Питерская", - "email": "piteralex@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocKhbCbWvBBc_m7bjU5sLCE-dQ-KygBk-aUCSR8XaYtq=s96-c" - }, - { - "sub": "4a3ba8b8-4120-4877-a160-be9ba4d5b3e3", - "email_verified": true, - "name": "Анастасия Светлых", - "preferred_username": "anastasya@gmail.ocm", - "given_name": "Анастасия", - "family_name": "Светлых", - "email": "anastasya@gmail.ocm", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJnsM8UGhbH806yLVgWZ17g3-gJFVcG0Uz5kvqT7dvC=s96-c" - }, - { - "sub": "b4634921-00b3-4082-9284-8ac47f269394", - "email_verified": true, - "name": "Эмилия Снежко", - "preferred_username": "emi@mail.ru", - "given_name": "Эмилия", - "family_name": "Снежко", - "email": "emi@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocI98dzSFQDPr2LXMPFEUX8KLY6bY2m08O_aAj2B5KVNKg=s96-c" - }, - { - "sub": "bf1a95aa-39a2-4528-9b8d-319409995df5", - "email_verified": true, - "name": "Юлия Бобова", - "preferred_username": "bobova@gmail.com", - "given_name": "Юлия", - "family_name": "Бобова", - "email": "bobova@gmail.com", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocJ_Ud4iI-jgqcJ3QJcWpESbRLX_C1BnB8_7uTTC-4Dn=s96-c" - }, - { - "sub": "4082b72a-4730-4841-ad68-06a0e19263df", - "email_verified": true, - "name": "Елена Вавилон", - "preferred_username": "elenvavil@mail.ru", - "given_name": "Елена", - "family_name": "Вавилон", - "email": "elenvavil@mail.ru", - "picture": "https://lh3.googleusercontent.com/a/ACg8ocKXcmzcqRch2--j2Ge2m9e8MIOZ8y1MjsQ0cSEoXOmW=s96-c" - }, - { - "sub": "9e8a08d8-d76a-4f26-99c5-9a1d3c067104", - "email_verified": true, - "name": "Ольга Шарова", - "preferred_username": "julyashap", - "given_name": "Ольга", - "family_name": "Шарова", - "email": "sharova@mail.ru" - } - ], - "date": "2024-03-08T21:18:07.033Z", - "created": "2024-03-05T21:18:07.033Z", - "__v": 22 - } + { + "_id": "66fa99a1f609679246366e21", + "name": "Вводное занятие", + "students": [ + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + }, + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + } + ], + "date": "2024-10-01T15:30:00.000Z", + "created": "2024-09-30T12:29:21.161Z", + "teachers": [], + "id": "66fa99a1f609679246366e21" + }, + { + "teachers": [], + "_id": "66feb05c120bbd66be14c4b8", + "name": "Webpack", + "students": [ + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + } + ], + "date": "2024-10-03T15:30:00.000Z", + "created": "2024-10-03T14:55:24.773Z", + "id": "66feb05c120bbd66be14c4b8" + }, + { + "teachers": [], + "_id": "67054c91120bbd66be14e4b6", + "name": "Typescript", + "students": [ + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + } + ], + "date": "2024-10-08T15:30:00.000Z", + "created": "2024-10-08T15:15:29.632Z", + "id": "67054c91120bbd66be14e4b6" + }, + { + "teachers": [], + "_id": "6707f28d120bbd66be150470", + "name": "React", + "students": [ + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + } + ], + "date": "2024-10-10T15:30:00.000Z", + "created": "2024-10-10T15:28:13.423Z", + "id": "6707f28d120bbd66be150470" + }, + { + "teachers": [], + "_id": "670e8a0d120bbd66be1538cf", + "name": "JSX МЖЦ", + "students": [ + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + } + ], + "date": "2024-10-15T15:30:00.000Z", + "created": "2024-10-15T15:28:13.663Z", + "id": "670e8a0d120bbd66be1538cf" + }, + { + "teachers": [], + "_id": "6717c4e66d7548fcb2246271", + "name": "Router", + "students": [ + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + } + ], + "date": "2024-10-22T15:30:00.000Z", + "created": "2024-10-22T15:29:42.174Z", + "id": "6717c4e66d7548fcb2246271" + }, + { + "teachers": [], + "_id": "671a68190a0f8ce8ef42d2d0", + "name": "routing", + "students": [ + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + } + ], + "date": "2024-10-24T15:30:00.000Z", + "created": "2024-10-24T15:30:33.885Z", + "id": "671a68190a0f8ce8ef42d2d0" + }, + { + "teachers": [], + "_id": "672103e6f66cb74806b11788", + "name": "i18next", + "students": [ + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + } + ], + "date": "2024-10-29T15:30:00.000Z", + "created": "2024-10-29T15:48:54.729Z", + "id": "672103e6f66cb74806b11788" + }, + { + "teachers": [], + "_id": "6721eb33f66cb74806b12817", + "name": "Оптимизация", + "students": [ + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + } + ], + "date": "2024-10-31T16:30:00.000Z", + "created": "2024-10-30T08:15:47.317Z", + "id": "6721eb33f66cb74806b12817" + }, + { + "teachers": [], + "_id": "672a3e94f66cb74806b1cb78", + "name": "Form", + "students": [ + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + } + ], + "date": "2024-11-05T15:30:00.000Z", + "created": "2024-11-05T15:49:40.616Z", + "id": "672a3e94f66cb74806b1cb78" + }, + { + "teachers": [], + "_id": "672ce301f66cb74806b1eb16", + "name": "Lottie, features, custom hooks", + "students": [ + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + } + ], + "date": "2024-11-07T15:30:00.000Z", + "created": "2024-11-07T15:55:45.984Z", + "id": "672ce301f66cb74806b1eb16" + }, + { + "teachers": [], + "_id": "67337630f66cb74806b23bed", + "name": "Context", + "students": [ + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + }, + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + } + ], + "date": "2024-11-12T15:30:00.000Z", + "created": "2024-11-12T15:37:20.233Z", + "id": "67337630f66cb74806b23bed" + }, + { + "teachers": [], + "_id": "673cccbcf66cb74806b2c4d9", + "name": "Nodejs intro", + "students": [ + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + }, + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + } + ], + "date": "2024-11-14T17:36:00.000Z", + "created": "2024-11-19T17:37:00.181Z", + "id": "673cccbcf66cb74806b2c4d9" + }, + { + "_id": "673cccfdf66cb74806b2c578", + "name": "nodejs api", + "students": [ + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + } + ], + "date": "2024-11-19T17:37:00.000Z", + "created": "2024-11-19T17:38:05.013Z", + "teachers": [ + { + "sub": "f62905b1-e223-40ca-910f-c8d84c6137c1", + "email_verified": true, + "name": "Преподаватель", + "preferred_username": "teacher", + "given_name": "Преподаватель", + "family_name": "", + "email": "teacher@example.com" + } + ], + "id": "673cccfdf66cb74806b2c578" + }, + { + "teachers": [], + "_id": "6707f28d120bbd66be150470", + "name": "React", + "students": [ + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + } + ], + "date": "2024-10-10T15:28:13.423Z", + "created": "2024-10-10T15:28:13.423Z", + "id": "6707f28d120bbd66be150470" + }, + { + "teachers": [], + "_id": "670e8a0d120bbd66be1538cf", + "name": "JSX МЖЦ", + "students": [ + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + } + ], + "date": "2024-10-15T15:30:00.000Z", + "created": "2024-10-15T15:28:13.663Z", + "id": "670e8a0d120bbd66be1538cf" + }, + { + "teachers": [], + "_id": "6717c4e66d7548fcb2246271", + "name": "Router", + "students": [ + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + } + ], + "date": "2024-10-31T16:30:00.000Z", + "created": "2024-10-30T08:15:47.317Z", + "id": "6721eb33f66cb74806b12817" + }, + { + "teachers": [], + "_id": "672a3e94f66cb74806b1cb78", + "name": "Redux", + "students": [ + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + } + ], + "date": "2024-11-05T15:30:00.000Z", + "created": "2024-11-05T15:49:40.616Z", + "id": "672a3e94f66cb74806b1cb78" + }, + { + "teachers": [], + "_id": "672d9214f66cb74806b1d557", + "name": "Redux Toolkit", + "students": [ + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + } + ], + "date": "2024-11-07T15:30:00.000Z", + "created": "2024-11-07T15:55:45.984Z", + "id": "672d9214f66cb74806b1d557" + }, + { + "teachers": [], + "_id": "67303594f66cb74806b1e018", + "name": "Redux Thunk", + "students": [ + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + }, + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + } + ], + "date": "2024-11-12T15:30:00.000Z", + "created": "2024-11-12T15:37:20.233Z", + "id": "67303594f66cb74806b1e018" + }, + { + "teachers": [], + "_id": "6732d930f66cb74806b1f019", + "name": "Redux Saga", + "students": [ + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + }, + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + } + ], + "date": "2024-11-14T17:36:00.000Z", + "created": "2024-11-19T17:37:00.181Z", + "id": "6732d930f66cb74806b1f019" + }, + { + "teachers": [], + "_id": "67357c9ff66cb74806b2001a", + "name": "Redux Saga", + "students": [ + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + } + ], + "date": "2024-11-19T17:37:00.000Z", + "created": "2024-11-19T17:38:05.013Z", + "teachers": [ + { + "sub": "f62905b1-e223-40ca-910f-c8d84c6137c1", + "email_verified": true, + "name": "Преподаватель", + "preferred_username": "teacher", + "given_name": "Преподаватель", + "family_name": "", + "email": "teacher@example.com" + } + ], + "id": "67357c9ff66cb74806b2001a" + }, + { + "_id": "673f5269f66cb74806b2d36a", + "name": "nodejs", + "students": [ + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + }, + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "1dfabe79-69c6-4c6e-b259-9e2443f2f6e1", + "email_verified": true, + "name": "Студент 4", + "preferred_username": "student4@example.com", + "given_name": "Студент", + "family_name": "4", + "email": "student4@example.com", + "picture": "https://example.com/avatar4.jpg" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + } + ], + "date": "2024-11-21T15:31:00.000Z", + "created": "2024-11-21T15:31:53.139Z", + "teachers": [ + { + "sub": "f62905b1-e223-40ca-910f-c8d84c6137c1", + "email_verified": true, + "name": "Преподаватель", + "preferred_username": "teacher", + "given_name": "Преподаватель", + "family_name": "", + "email": "teacher@example.com" + } + ], + "id": "673f5269f66cb74806b2d36a" + }, + { + "_id": "6745e776d3ac07f473850bd3", + "name": "express", + "students": [ + { + "sub": "a7d54d9a-d090-4ef6-bb72-b2cdcbc92ff1", + "email_verified": true, + "name": "Студент 1", + "preferred_username": "student1@example.com", + "given_name": "Студент", + "family_name": "1", + "email": "student1@example.com", + "picture": "https://example.com/avatar1.jpg" + }, + { + "sub": "2a988792-c77e-4add-bdf2-81e06a2aada2", + "email_verified": true, + "name": "Студент 2", + "preferred_username": "student2@example.com", + "given_name": "Студент", + "family_name": "2", + "email": "student2@example.com", + "picture": "https://example.com/avatar2.jpg" + }, + { + "sub": "175832fd-d2a9-4f06-905b-703d4054e357", + "email_verified": true, + "name": "Студент 5", + "preferred_username": "student5", + "given_name": "Студент", + "family_name": "5", + "email": "student5@example.com" + }, + { + "sub": "b8a8297e-1d44-40bf-820b-4d4d0ff135db", + "email_verified": true, + "name": "Студент 3", + "preferred_username": "student3@example.com", + "given_name": "Студент", + "family_name": "3", + "email": "student3@example.com", + "picture": "https://example.com/avatar3.jpg" + }, + { + "sub": "05b9332a-fe84-45a4-bbcc-f298c6acf8a2", + "email_verified": true, + "name": "Студент 6", + "preferred_username": "student6", + "given_name": "Студент", + "family_name": "6", + "email": "student6@example.com" + } + ], + "date": "2024-11-26T15:30:00.000Z", + "created": "2024-11-26T15:21:26.702Z", + "teachers": [ + { + "sub": "f62905b1-e223-40ca-910f-c8d84c6137c1", + "email_verified": true, + "name": "Преподаватель", + "preferred_username": "teacher", + "given_name": "Преподаватель", + "family_name": "", + "email": "teacher@example.com" + } + ], + "id": "6745e776d3ac07f473850bd3" + } ] -} +} \ No newline at end of file