Merge branch 'master' of ssh://85.143.175.152:222/bro-js/journal.pl
This commit is contained in:
commit
2f84f4a00a
@ -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: {
|
||||
|
@ -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"
|
||||
}
|
@ -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": "Посещаемость рассчитана только по прошедшим занятиям"
|
||||
}
|
4
package-lock.json
generated
4
package-lock.json
generated
@ -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",
|
||||
|
@ -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": {
|
||||
|
@ -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: {
|
||||
|
@ -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<HTMLDivElement>;
|
||||
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: <ChevronRightIcon color="gray.400" fontSize="xs" />,
|
||||
md: <ChevronRightIcon color="gray.400" />
|
||||
});
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const newLang = i18n.language === 'ru' ? 'en' : 'ru';
|
||||
@ -24,47 +76,200 @@ export const AppHeader = ({ serviceMenuContainerRef }: AppHeaderProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
<Box
|
||||
as="header"
|
||||
width="100%"
|
||||
py={4}
|
||||
px={8}
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
py={{ base: 2, md: 3 }}
|
||||
bg={colorMode === 'light' ? 'white' : 'gray.800'}
|
||||
boxShadow="sm"
|
||||
position="sticky"
|
||||
top={0}
|
||||
zIndex={10}
|
||||
bg={colorMode === 'light' ? 'white' : 'gray.800'}
|
||||
boxShadow="sm"
|
||||
>
|
||||
{serviceMenuContainerRef && <div id="dots" ref={serviceMenuContainerRef}></div>}
|
||||
<Box>
|
||||
|
||||
</Box>
|
||||
<HStack spacing={4}>
|
||||
<Button
|
||||
onClick={toggleLanguage}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={i18n.language === 'ru'
|
||||
? t('journal.pl.lang.switchToEn')
|
||||
: t('journal.pl.lang.switchToRu')
|
||||
}
|
||||
>
|
||||
{i18n.language === 'ru' ? 'EN' : 'RU'}
|
||||
</Button>
|
||||
|
||||
<IconButton
|
||||
aria-label={colorMode === 'light'
|
||||
? t('journal.pl.theme.switchDark')
|
||||
: t('journal.pl.theme.switchLight')
|
||||
}
|
||||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||||
onClick={toggleColorMode}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
{/* Рендеринг dots контейнера вне условной логики, всегда присутствует в DOM */}
|
||||
{serviceMenuContainerRef && (
|
||||
<Box
|
||||
id="dots"
|
||||
ref={serviceMenuContainerRef}
|
||||
position="absolute"
|
||||
top="3"
|
||||
left="0"
|
||||
height="0"
|
||||
width="0"
|
||||
overflow="visible"
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Container maxW="container.xl" px={{ base: 2, sm: 4 }}>
|
||||
{isMobile ? (
|
||||
<>
|
||||
{/* Мобильная версия: верхняя строка с кнопками */}
|
||||
<Flex justifyContent="space-between" alignItems="center" h={{ base: "40px" }}>
|
||||
<Box>
|
||||
{/* Пустой контейнер для поддержания расположения */}
|
||||
</Box>
|
||||
|
||||
<HStack spacing={{ base: 1 }} flexShrink={0}>
|
||||
<Button
|
||||
onClick={toggleLanguage}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={i18n.language === 'ru'
|
||||
? t('journal.pl.lang.switchToEn')
|
||||
: t('journal.pl.lang.switchToRu')
|
||||
}
|
||||
fontSize={fontSize}
|
||||
px={{ base: 1 }}
|
||||
minW={{ base: "30px" }}
|
||||
h={{ base: "30px" }}
|
||||
>
|
||||
{i18n.language === 'ru' ? 'EN' : 'RU'}
|
||||
</Button>
|
||||
|
||||
<IconButton
|
||||
aria-label={colorMode === 'light'
|
||||
? t('journal.pl.theme.switchDark')
|
||||
: t('journal.pl.theme.switchLight')
|
||||
}
|
||||
icon={colorMode === 'light' ? <MoonIcon boxSize={{ base: "14px" }} /> : <SunIcon boxSize={{ base: "14px" }} />}
|
||||
onClick={toggleColorMode}
|
||||
variant="ghost"
|
||||
size={{ base: "sm" }}
|
||||
minW={{ base: "30px" }}
|
||||
h={{ base: "30px" }}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* Вертикальные хлебные крошки */}
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<VStack
|
||||
align="flex-start"
|
||||
spacing={0}
|
||||
mt={1}
|
||||
>
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<Flex
|
||||
key={index}
|
||||
align="center"
|
||||
w="100%"
|
||||
pl={index > 0 ? 3 : 1}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
_hover={!crumb.isCurrentPage && crumb.path ? {
|
||||
bg: colorMode === 'light' ? 'gray.50' : 'gray.700',
|
||||
} : {}}
|
||||
>
|
||||
{index > 0 && (
|
||||
<ChevronDownIcon
|
||||
color="gray.400"
|
||||
fontSize="10px"
|
||||
mr={2}
|
||||
transform="translateY(-2px)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{crumb.path && !crumb.isCurrentPage ? (
|
||||
<Link to={getFullPath(crumb.path)}>
|
||||
<Text
|
||||
fontWeight="medium"
|
||||
color={undefined}
|
||||
fontSize={fontSize}
|
||||
noOfLines={1}
|
||||
title={crumb.title}
|
||||
>
|
||||
{crumb.title}
|
||||
</Text>
|
||||
</Link>
|
||||
) : (
|
||||
<Text
|
||||
fontWeight={crumb.isCurrentPage ? "bold" : "medium"}
|
||||
color={crumb.isCurrentPage ? (colorMode === 'light' ? 'cyan.600' : 'cyan.300') : undefined}
|
||||
fontSize={fontSize}
|
||||
noOfLines={1}
|
||||
title={crumb.title}
|
||||
>
|
||||
{crumb.title}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Десктопная версия: всё в одну строку */
|
||||
<Flex justifyContent="space-between" alignItems="center" h="40px">
|
||||
<Flex align="center" overflow="hidden" flex={1} minW={0}>
|
||||
{/* Контейнер для разметки */}
|
||||
<Box w="24px" mr={{ sm: 3, md: 4 }} flexShrink={0} />
|
||||
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<Breadcrumb
|
||||
fontWeight="medium"
|
||||
fontSize={fontSize}
|
||||
separator={horizontalSeparator}
|
||||
spacing={{ sm: "1" }}
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
minW={0}
|
||||
>
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<BreadcrumbItem key={index} isCurrentPage={crumb.isCurrentPage}>
|
||||
<BreadcrumbLink
|
||||
as={crumb.path ? Link : undefined}
|
||||
to={getFullPath(crumb.path)}
|
||||
href={!crumb.path ? "#" : undefined}
|
||||
maxWidth={{ sm: "120px", md: "200px", lg: "300px" }}
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
display="inline-block"
|
||||
fontWeight={crumb.isCurrentPage ? "bold" : "medium"}
|
||||
color={crumb.isCurrentPage ? (colorMode === 'light' ? 'cyan.600' : 'cyan.300') : undefined}
|
||||
title={crumb.title}
|
||||
>
|
||||
{crumb.title}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</Breadcrumb>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<HStack spacing={{ sm: 2, md: 4 }} flexShrink={0} ml={{ sm: 2, md: 3 }}>
|
||||
<Button
|
||||
onClick={toggleLanguage}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={i18n.language === 'ru'
|
||||
? t('journal.pl.lang.switchToEn')
|
||||
: t('journal.pl.lang.switchToRu')
|
||||
}
|
||||
fontSize={fontSize}
|
||||
px={{ sm: 2, md: 3 }}
|
||||
minW={{ sm: "40px" }}
|
||||
h={{ sm: "34px" }}
|
||||
>
|
||||
{i18n.language === 'ru' ? 'EN' : 'RU'}
|
||||
</Button>
|
||||
|
||||
<IconButton
|
||||
aria-label={colorMode === 'light'
|
||||
? t('journal.pl.theme.switchDark')
|
||||
: t('journal.pl.theme.switchLight')
|
||||
}
|
||||
icon={colorMode === 'light' ? <MoonIcon boxSize={{ sm: "14px", md: "16px" }} /> : <SunIcon boxSize={{ sm: "14px", md: "16px" }} />}
|
||||
onClick={toggleColorMode}
|
||||
variant="ghost"
|
||||
size={{ sm: "sm", md: "md" }}
|
||||
minW={{ sm: "34px" }}
|
||||
h={{ sm: "34px" }}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
45
src/components/breadcrumbs/breadcrumbs-context.tsx
Normal file
45
src/components/breadcrumbs/breadcrumbs-context.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
export type Breadcrumb = {
|
||||
title: string;
|
||||
path?: string;
|
||||
isCurrentPage?: boolean;
|
||||
};
|
||||
|
||||
type BreadcrumbsContextType = {
|
||||
breadcrumbs: Breadcrumb[];
|
||||
setBreadcrumbs: (breadcrumbs: Breadcrumb[]) => void;
|
||||
};
|
||||
|
||||
const BreadcrumbsContext = createContext<BreadcrumbsContextType | undefined>(undefined);
|
||||
|
||||
export const BreadcrumbsProvider: React.FC<{children: ReactNode}> = ({ children }) => {
|
||||
const [breadcrumbs, setBreadcrumbs] = useState<Breadcrumb[]>([]);
|
||||
|
||||
return (
|
||||
<BreadcrumbsContext.Provider value={{ breadcrumbs, setBreadcrumbs }}>
|
||||
{children}
|
||||
</BreadcrumbsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useBreadcrumbs = () => {
|
||||
const context = useContext(BreadcrumbsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useBreadcrumbs must be used within a BreadcrumbsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useSetBreadcrumbs = (newBreadcrumbs: Breadcrumb[]) => {
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
React.useEffect(() => {
|
||||
setBreadcrumbs(newBreadcrumbs);
|
||||
|
||||
return () => {
|
||||
// При размонтировании компонента очищаем хлебные крошки
|
||||
setBreadcrumbs([]);
|
||||
};
|
||||
}, [setBreadcrumbs, JSON.stringify(newBreadcrumbs)]);
|
||||
};
|
1
src/components/breadcrumbs/index.ts
Normal file
1
src/components/breadcrumbs/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './breadcrumbs-context';
|
@ -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';
|
||||
export { AppHeader } from './app-header';
|
||||
export { BreadcrumbsProvider, useBreadcrumbs, useSetBreadcrumbs } from './breadcrumbs';
|
@ -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);
|
||||
}
|
||||
`
|
@ -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<any, keyof React.JSX.IntrinsicElements>;
|
||||
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' : ''}
|
||||
>
|
||||
<Avatar
|
||||
src={imageError ? getGravatarURL(student.email, null) : (student.picture || getGravatarURL(student.email, null))}
|
||||
@ -46,22 +49,19 @@ export const UserCard = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<p style={{
|
||||
marginTop: 6,
|
||||
color: colorMode === 'light' ? 'inherit' : 'var(--chakra-colors-gray-100)'
|
||||
}}>
|
||||
{student.name || student.preferred_username}{' '}
|
||||
</p>
|
||||
<NameOverlay>
|
||||
{student.name || student.preferred_username}
|
||||
{present && (
|
||||
<Box as="span" ml={2} display="inline-block" color={recentlyPresent ? "green.100" : "green.300"}>
|
||||
<CheckCircleIcon boxSize={3} />
|
||||
</Box>
|
||||
)}
|
||||
</NameOverlay>
|
||||
{onAddUser && !present && (
|
||||
<AddMissedButton onClick={() => onAddUser(student)}>
|
||||
add
|
||||
<AddMissedButton onClick={() => onAddUser(student)} aria-label="Отметить присутствие">
|
||||
<AddIcon boxSize={3} />
|
||||
</AddMissedButton>
|
||||
)}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
UserCard.defaultProps = {
|
||||
wrapperAS: 'div',
|
||||
onAddUser: void 0,
|
||||
}
|
||||
|
@ -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 }) => (
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
// Компонент, который соединяет хлебные крошки с AppHeader
|
||||
const HeaderWithBreadcrumbs = ({ serviceMenuContainerRef }: { serviceMenuContainerRef: React.RefObject<HTMLDivElement> }) => {
|
||||
const { breadcrumbs } = useBreadcrumbs();
|
||||
return <AppHeader serviceMenuContainerRef={serviceMenuContainerRef} breadcrumbs={breadcrumbs} />;
|
||||
};
|
||||
|
||||
interface DashboardProps {
|
||||
store: any; // Используем any, поскольку точный тип store не указан
|
||||
}
|
||||
@ -111,49 +117,51 @@ export const Dashboard = ({ store }: DashboardProps) => {
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<AppHeader serviceMenuContainerRef={serviceMenuContainerRef} />
|
||||
<Routes>
|
||||
<Route
|
||||
path={getNavigationValue('journal.main')}
|
||||
element={
|
||||
<Wrapper>
|
||||
<CourseListPage />
|
||||
</Wrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={`${getNavigationValue('journal.main')}/lessons-list/:courseId`}
|
||||
element={
|
||||
<Wrapper>
|
||||
<LessonListPage />
|
||||
</Wrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={`${getNavigationValue('journal.main')}/u/:lessonId/:accessId`}
|
||||
element={
|
||||
<Wrapper>
|
||||
<UserPage />
|
||||
</Wrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={`${getNavigationValue('journal.main')}/lesson/:courseId/:lessonId`}
|
||||
element={
|
||||
<Wrapper>
|
||||
<LessonDetailsPage />
|
||||
</Wrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={`${getNavigationValue('journal.main')}${getNavigationValue('link.journal.attendance')}`}
|
||||
element={
|
||||
<Wrapper>
|
||||
<AttendancePage />
|
||||
</Wrapper>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<BreadcrumbsProvider>
|
||||
<HeaderWithBreadcrumbs serviceMenuContainerRef={serviceMenuContainerRef} />
|
||||
<Routes>
|
||||
<Route
|
||||
path={getNavigationValue('journal.main')}
|
||||
element={
|
||||
<Wrapper>
|
||||
<CourseListPage />
|
||||
</Wrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={`${getNavigationValue('journal.main')}/lessons-list/:courseId`}
|
||||
element={
|
||||
<Wrapper>
|
||||
<LessonListPage />
|
||||
</Wrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={`${getNavigationValue('journal.main')}/u/:lessonId/:accessId`}
|
||||
element={
|
||||
<Wrapper>
|
||||
<UserPage />
|
||||
</Wrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={`${getNavigationValue('journal.main')}/lesson/:courseId/:lessonId`}
|
||||
element={
|
||||
<Wrapper>
|
||||
<LessonDetailsPage />
|
||||
</Wrapper>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={`${getNavigationValue('journal.main')}${getNavigationValue('link.journal.attendance')}`}
|
||||
element={
|
||||
<Wrapper>
|
||||
<AttendancePage />
|
||||
</Wrapper>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BreadcrumbsProvider>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
@ -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 <PageLoader />
|
||||
|
@ -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<any[]>([]);
|
||||
const [selectedTeachers, setSelectedTeachers] = useState<any[]>([]);
|
||||
|
||||
@ -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([]);
|
||||
};
|
||||
|
@ -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<AttendanceTableProps> = ({ 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<AttendanceTableProps> = ({ data }) => {
|
||||
</Td>
|
||||
)
|
||||
})}
|
||||
<Td>{dayjs(lesson.date).format('DD.MM.YYYY')}</Td>
|
||||
<Td>{formatDate(lesson.date, 'DD.MM.YYYY')}</Td>
|
||||
<Td><ShortText text={lesson.name} /></Td>
|
||||
|
||||
{data.students.map((st) => {
|
||||
|
76
src/pages/course-list/components/CoursesOverview.tsx
Normal file
76
src/pages/course-list/components/CoursesOverview.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
SimpleGrid,
|
||||
useColorModeValue,
|
||||
Card
|
||||
} from '@chakra-ui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Course, Lesson } from '../../../__data__/model'
|
||||
import {
|
||||
useStats,
|
||||
StatCards,
|
||||
StudentAttendanceList,
|
||||
CourseAttendanceList,
|
||||
ActivityStats
|
||||
} from './statistics'
|
||||
|
||||
interface CoursesOverviewProps {
|
||||
courses: Course[]
|
||||
isLoading: boolean
|
||||
// Детализированные данные с уроками (если есть)
|
||||
lessonsByCourse?: Record<string, Lesson[]>
|
||||
}
|
||||
|
||||
export const CoursesOverview: React.FC<CoursesOverviewProps> = ({
|
||||
courses = [],
|
||||
isLoading = false,
|
||||
lessonsByCourse = {}
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const bgColor = useColorModeValue('white', 'gray.700')
|
||||
|
||||
// Используем хук для расчета статистики
|
||||
const stats = useStats(courses, lessonsByCourse)
|
||||
|
||||
// Если загрузка или нет данных, возвращаем null
|
||||
if (isLoading || !courses.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box mb={6} py={3}>
|
||||
<Heading size="md" mb={4}>
|
||||
{t('journal.pl.overview.title')}
|
||||
</Heading>
|
||||
|
||||
{/* Основные показатели */}
|
||||
<StatCards stats={stats} bgColor={bgColor} />
|
||||
|
||||
{/* Дополнительная статистика */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={5} mb={5}>
|
||||
{/* Статистика посещаемости и топ-студенты */}
|
||||
<Card bg={bgColor} p={4} borderRadius="lg" boxShadow="sm">
|
||||
<StudentAttendanceList
|
||||
students={stats.topStudents}
|
||||
title={t('journal.pl.overview.topStudents')}
|
||||
/>
|
||||
|
||||
{stats.topCoursesByAttendance.length > 0 && (
|
||||
<>
|
||||
<Box h={3} />
|
||||
<CourseAttendanceList courses={stats.topCoursesByAttendance} />
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Статистика деятельности и активности */}
|
||||
<Card bg={bgColor} p={4} borderRadius="lg" boxShadow="sm">
|
||||
<ActivityStats stats={stats} />
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)
|
||||
}
|
@ -1,2 +1,3 @@
|
||||
export * from './CreateCourseForm'
|
||||
export * from './YearGroup'
|
||||
export * from './CreateCourseForm'
|
||||
export * from './CoursesOverview'
|
110
src/pages/course-list/components/statistics/ActivityStats.tsx
Normal file
110
src/pages/course-list/components/statistics/ActivityStats.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Progress,
|
||||
Flex,
|
||||
Divider,
|
||||
Tooltip
|
||||
} from '@chakra-ui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InfoOutlineIcon } from '@chakra-ui/icons'
|
||||
|
||||
import { CourseStats } from './useStats'
|
||||
import { WeekdayActivityChart } from './WeekdayActivityChart'
|
||||
|
||||
interface ActivityStatsProps {
|
||||
stats: CourseStats
|
||||
}
|
||||
|
||||
export const ActivityStats: React.FC<ActivityStatsProps> = ({ stats }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Определяем цвет для прогресса в зависимости от значения
|
||||
const getProgressColor = (value: number) => {
|
||||
if (value > 80) return 'green'
|
||||
if (value > 50) return 'blue'
|
||||
if (value > 30) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
// Вычисляем процент завершенности курсов
|
||||
const completionPercentage =
|
||||
stats.totalLessons > 0
|
||||
? (stats.completedLessons / stats.totalLessons) * 100
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Flex align="center" mb={3}>
|
||||
<Text fontWeight="medium" fontSize="md" mr={2}>
|
||||
{t('journal.pl.overview.activityStats')}
|
||||
</Text>
|
||||
<Tooltip label={t('journal.pl.overview.pastLessonsStats')}>
|
||||
<InfoOutlineIcon color="gray.400" boxSize={3} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
<Box mb={3}>
|
||||
<Flex align="center" mb={1}>
|
||||
<Text fontSize="sm" fontWeight="medium" mr={1}>
|
||||
{t('journal.pl.overview.courseCompletion')}:
|
||||
</Text>
|
||||
<Tooltip label={`${stats.completedLessons} / ${stats.totalLessons}`}>
|
||||
<InfoOutlineIcon color="gray.400" boxSize={3} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Progress
|
||||
value={completionPercentage}
|
||||
size="md"
|
||||
borderRadius="md"
|
||||
colorScheme={getProgressColor(completionPercentage)}
|
||||
mb={1}
|
||||
hasStripe
|
||||
/>
|
||||
<Flex justify="space-between" fontSize="sm">
|
||||
<Text>
|
||||
{stats.completedLessons} / {stats.totalLessons} {t('journal.pl.overview.lessons')}
|
||||
</Text>
|
||||
<Text fontWeight="medium">
|
||||
{Math.round(completionPercentage)}%
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Box mb={3}>
|
||||
<Flex align="center" mb={1}>
|
||||
<Text fontSize="sm" fontWeight="medium" mr={1}>
|
||||
{t('journal.pl.overview.studentAttendance')}:
|
||||
</Text>
|
||||
<Tooltip label={t('journal.pl.overview.attendanceHelp')}>
|
||||
<InfoOutlineIcon color="gray.400" boxSize={3} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Progress
|
||||
value={stats.averageAttendance}
|
||||
size="md"
|
||||
borderRadius="md"
|
||||
colorScheme={getProgressColor(stats.averageAttendance)}
|
||||
mb={1}
|
||||
hasStripe
|
||||
/>
|
||||
<Flex justify="space-between" fontSize="sm">
|
||||
<Text>
|
||||
{t('journal.pl.overview.averageRate')}
|
||||
</Text>
|
||||
<Text fontWeight="medium">
|
||||
{Math.round(stats.averageAttendance)}%
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
<Divider my={3} />
|
||||
|
||||
<WeekdayActivityChart
|
||||
weekdayActivity={stats.weekdayActivity}
|
||||
mostActiveDayIndex={stats.mostActiveDayIndex}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
VStack,
|
||||
HStack,
|
||||
Box,
|
||||
Text,
|
||||
Badge,
|
||||
Tooltip
|
||||
} from '@chakra-ui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CheckCircleIcon } from '@chakra-ui/icons'
|
||||
|
||||
interface CourseAttendanceProps {
|
||||
courses: Array<{id: string, name: string, attendanceRate: number}>
|
||||
}
|
||||
|
||||
export const CourseAttendanceList: React.FC<CourseAttendanceProps> = ({ courses }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Определяем цвет для прогресса в зависимости от значения
|
||||
const getProgressColor = (value: number) => {
|
||||
if (value > 80) return 'green'
|
||||
if (value > 50) return 'blue'
|
||||
if (value > 30) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
if (!courses?.length) {
|
||||
return (
|
||||
<Text color="gray.500" fontSize="sm" textAlign="center">
|
||||
{t('journal.pl.overview.noAttendanceData')}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text fontWeight="medium" fontSize="sm" mb={2} display="flex" alignItems="center">
|
||||
<CheckCircleIcon color="green.400" mr={2} />
|
||||
{t('journal.pl.overview.topAttendanceCourses')}
|
||||
</Text>
|
||||
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{courses.map((course, index) => (
|
||||
<HStack key={course.id} spacing={2}>
|
||||
<Badge
|
||||
colorScheme={['green', 'blue', 'yellow'][index]}
|
||||
borderRadius="full"
|
||||
minW="22px"
|
||||
textAlign="center"
|
||||
>
|
||||
#{index + 1}
|
||||
</Badge>
|
||||
<Tooltip label={course.name}>
|
||||
<Text fontSize="sm" fontWeight="medium" isTruncated flex="1">
|
||||
{course.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Badge
|
||||
colorScheme={getProgressColor(course.attendanceRate)}
|
||||
variant="solid"
|
||||
px={2}
|
||||
>
|
||||
{Math.round(course.attendanceRate)}%
|
||||
</Badge>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
<Text fontSize="xs" color="gray.500" mt={2} fontStyle="italic">
|
||||
{t('journal.pl.overview.attendanceHelp')}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
135
src/pages/course-list/components/statistics/StatCards.tsx
Normal file
135
src/pages/course-list/components/statistics/StatCards.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
SimpleGrid,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
Flex,
|
||||
HStack,
|
||||
Text,
|
||||
Icon,
|
||||
Badge
|
||||
} from '@chakra-ui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
FaGraduationCap,
|
||||
FaChalkboardTeacher,
|
||||
FaCalendarAlt,
|
||||
FaUsers
|
||||
} from 'react-icons/fa'
|
||||
|
||||
import { CourseStats } from './useStats'
|
||||
|
||||
interface StatCardsProps {
|
||||
stats: CourseStats
|
||||
bgColor: string
|
||||
}
|
||||
|
||||
export const StatCards: React.FC<StatCardsProps> = ({ stats, bgColor }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<SimpleGrid columns={{ base: 1, sm: 2, md: 4 }} spacing={5} mb={5}>
|
||||
{/* Статистика по курсам */}
|
||||
<Stat
|
||||
bg={bgColor}
|
||||
p={3}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
borderLeft="4px solid"
|
||||
borderLeftColor="blue.400"
|
||||
>
|
||||
<Flex align="center" mb={2}>
|
||||
<Icon as={FaGraduationCap} color="blue.400" mr={2} />
|
||||
<StatLabel>{t('journal.pl.overview.totalCourses')}</StatLabel>
|
||||
</Flex>
|
||||
<StatNumber fontSize="2xl">{stats.totalCourses}</StatNumber>
|
||||
<StatHelpText mb={0}>
|
||||
<HStack spacing={3} flexWrap="wrap">
|
||||
<Badge colorScheme="green">
|
||||
{stats.activeCourses} {t('journal.pl.overview.active')}
|
||||
</Badge>
|
||||
{stats.recentCoursesCount > 0 && (
|
||||
<Badge colorScheme="purple">
|
||||
+{stats.recentCoursesCount} {t('journal.pl.overview.new')}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
|
||||
{/* Статистика по урокам */}
|
||||
<Stat
|
||||
bg={bgColor}
|
||||
p={3}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
borderLeft="4px solid"
|
||||
borderLeftColor="green.400"
|
||||
>
|
||||
<Flex align="center" mb={2}>
|
||||
<Icon as={FaCalendarAlt} color="green.400" mr={2} />
|
||||
<StatLabel>{t('journal.pl.overview.totalLessons')}</StatLabel>
|
||||
</Flex>
|
||||
<StatNumber fontSize="2xl">{stats.totalLessons}</StatNumber>
|
||||
<StatHelpText mb={0}>
|
||||
<HStack spacing={3} flexWrap="wrap">
|
||||
<Badge colorScheme="blue">
|
||||
{stats.completedLessons} {t('journal.pl.overview.completed')}
|
||||
</Badge>
|
||||
<Badge colorScheme="orange">
|
||||
{stats.upcomingLessons} {t('journal.pl.overview.upcoming')}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
|
||||
{/* Статистика по студентам */}
|
||||
<Stat
|
||||
bg={bgColor}
|
||||
p={3}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
borderLeft="4px solid"
|
||||
borderLeftColor="purple.400"
|
||||
>
|
||||
<Flex align="center" mb={2}>
|
||||
<Icon as={FaUsers} color="purple.400" mr={2} />
|
||||
<StatLabel>{t('journal.pl.overview.totalStudents')}</StatLabel>
|
||||
</Flex>
|
||||
<StatNumber fontSize="2xl">{stats.totalStudents.size}</StatNumber>
|
||||
<StatHelpText mb={0}>
|
||||
<Text>
|
||||
{stats.averageAttendance > 0 ?
|
||||
`~${Math.round(stats.averageAttendance)}% ${t('journal.pl.overview.attendance')}` :
|
||||
t('journal.pl.overview.noAttendanceData')}
|
||||
</Text>
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
|
||||
{/* Статистика по преподавателям */}
|
||||
<Stat
|
||||
bg={bgColor}
|
||||
p={3}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
borderLeft="4px solid"
|
||||
borderLeftColor="orange.400"
|
||||
>
|
||||
<Flex align="center" mb={2}>
|
||||
<Icon as={FaChalkboardTeacher} color="orange.400" mr={2} />
|
||||
<StatLabel>{t('journal.pl.overview.totalTeachers')}</StatLabel>
|
||||
</Flex>
|
||||
<StatNumber fontSize="2xl">{stats.totalTeachers.size}</StatNumber>
|
||||
<StatHelpText mb={0}>
|
||||
<Text>
|
||||
{stats.activeCourses > 0 ?
|
||||
`~${(stats.totalTeachers.size / Math.max(1, stats.activeCourses)).toFixed(1)} ${t('journal.pl.overview.perCourse')}` :
|
||||
t('journal.pl.overview.noActiveData')}
|
||||
</Text>
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</SimpleGrid>
|
||||
)
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
VStack,
|
||||
HStack,
|
||||
Box,
|
||||
Text,
|
||||
Progress,
|
||||
Badge,
|
||||
Avatar,
|
||||
Tooltip,
|
||||
Flex
|
||||
} from '@chakra-ui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StarIcon } from '@chakra-ui/icons'
|
||||
|
||||
import { StudentAttendance } from './useStats'
|
||||
|
||||
interface StudentAttendanceListProps {
|
||||
students: StudentAttendance[]
|
||||
title: string
|
||||
}
|
||||
|
||||
export const StudentAttendanceList: React.FC<StudentAttendanceListProps> = ({
|
||||
students,
|
||||
title
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Определяем цвет для прогресса в зависимости от значения
|
||||
const getProgressColor = (value: number) => {
|
||||
if (value > 80) return 'green'
|
||||
if (value > 50) return 'blue'
|
||||
if (value > 30) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
if (!students?.length) {
|
||||
return (
|
||||
<Text color="gray.500" fontSize="sm" textAlign="center">
|
||||
{t('journal.pl.overview.noAttendanceData')}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text fontWeight="medium" fontSize="sm" mb={2} display="flex" alignItems="center">
|
||||
<StarIcon color="yellow.400" mr={2} />
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
<Text fontSize="xs" color="gray.500" mb={2}>
|
||||
{t('journal.pl.overview.pastLessonsStats')}
|
||||
</Text>
|
||||
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{students.map((student, index) => (
|
||||
<HStack key={student.id} spacing={3}>
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={student.name}
|
||||
src={student.avatarUrl}
|
||||
bg={index < 3 ? ['yellow.400', 'gray.400', 'orange.300'][index] : 'blue.300'}
|
||||
/>
|
||||
<Box flex="1">
|
||||
<Flex justify="space-between">
|
||||
<Tooltip label={student.name}>
|
||||
<Text fontSize="sm" fontWeight="medium" isTruncated maxW="150px">
|
||||
{student.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Tooltip label={`${student.attended} из ${student.total} занятий`}>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{student.attended}/{student.total}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
<Progress
|
||||
value={student.percent}
|
||||
size="xs"
|
||||
colorScheme={getProgressColor(student.percent)}
|
||||
borderRadius="full"
|
||||
mt={1}
|
||||
/>
|
||||
</Box>
|
||||
<Badge colorScheme={getProgressColor(student.percent)}>
|
||||
{Math.round(student.percent)}%
|
||||
</Badge>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
<Text fontSize="xs" color="gray.500" mt={2} fontStyle="italic">
|
||||
{t('journal.pl.overview.attendanceHelp')}
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
@ -0,0 +1,146 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Tooltip,
|
||||
VStack,
|
||||
Flex
|
||||
} from '@chakra-ui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface WeekdayActivityChartProps {
|
||||
weekdayActivity: number[]
|
||||
mostActiveDayIndex: number
|
||||
}
|
||||
|
||||
export const WeekdayActivityChart: React.FC<WeekdayActivityChartProps> = ({
|
||||
weekdayActivity,
|
||||
mostActiveDayIndex
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Переводим день недели в читаемый формат
|
||||
const getDayOfWeekName = (dayIndex: number) => {
|
||||
const days = [
|
||||
'journal.pl.days.sunday',
|
||||
'journal.pl.days.monday',
|
||||
'journal.pl.days.tuesday',
|
||||
'journal.pl.days.wednesday',
|
||||
'journal.pl.days.thursday',
|
||||
'journal.pl.days.friday',
|
||||
'journal.pl.days.saturday'
|
||||
]
|
||||
return t(days[dayIndex])
|
||||
}
|
||||
|
||||
// Получаем короткое название дня недели (первая буква)
|
||||
const getShortDayName = (dayIndex: number) => {
|
||||
return t(`journal.pl.days.${['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][dayIndex]}`).charAt(0)
|
||||
}
|
||||
|
||||
// Формируем подсказку для дня недели
|
||||
const getDayTooltip = (dayIndex: number, count: number) => {
|
||||
return `${t(`journal.pl.days.${['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][dayIndex]}`)}:
|
||||
${count} ${t('journal.pl.overview.lessons').toLowerCase()}`
|
||||
}
|
||||
|
||||
// Если нет данных по активности, показываем сообщение
|
||||
if (!weekdayActivity.some(count => count > 0)) {
|
||||
return (
|
||||
<Box textAlign="center" color="gray.500" fontSize="sm">
|
||||
{t('journal.pl.overview.noAttendanceData')}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Находим максимальное и суммарное значение для расчета процентов
|
||||
const maxValue = Math.max(...weekdayActivity)
|
||||
const totalLessons = weekdayActivity.reduce((sum, count) => sum + count, 0)
|
||||
|
||||
return (
|
||||
<VStack align="start" width="100%">
|
||||
<Flex justify="space-between" width="100%" align="center">
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{t('journal.pl.overview.mostActiveDay')}:
|
||||
</Text>
|
||||
<Badge colorScheme="blue" fontSize="md" px={2} py={1}>
|
||||
{getDayOfWeekName(mostActiveDayIndex)}
|
||||
</Badge>
|
||||
</Flex>
|
||||
|
||||
<Text fontSize="xs" color="gray.500" mb={2}>
|
||||
{t('journal.pl.overview.pastLessonsStats')}
|
||||
</Text>
|
||||
|
||||
{/* Визуализация активности по дням недели */}
|
||||
<Box w="100%" mt={2}>
|
||||
<HStack spacing={1} w="100%" justify="space-between" alignItems="flex-end">
|
||||
{weekdayActivity.map((count, index) => (
|
||||
<Tooltip
|
||||
key={index}
|
||||
label={getDayTooltip(index, count)}
|
||||
>
|
||||
<Box width="24px" display="flex" flexDirection="column" alignItems="center">
|
||||
{/* Область для числа */}
|
||||
<Box height="15px" mb={1}>
|
||||
{count > 0 && (
|
||||
<Text
|
||||
fontSize="10px"
|
||||
fontWeight="bold"
|
||||
color={index === mostActiveDayIndex ? 'blue.500' : 'gray.500'}
|
||||
lineHeight="15px"
|
||||
textAlign="center"
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Столбец графика */}
|
||||
<Box
|
||||
h={`${Math.max((count / Math.max(maxValue, 1)) * 50, 3)}px`}
|
||||
w="12px"
|
||||
bg={index === mostActiveDayIndex ? 'blue.400' : 'gray.300'}
|
||||
minH="3px"
|
||||
borderRadius="sm"
|
||||
/>
|
||||
|
||||
{/* Буква дня недели */}
|
||||
<Box height="15px" mt={1}>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight={index === mostActiveDayIndex ? "bold" : "normal"}
|
||||
lineHeight="15px"
|
||||
textAlign="center"
|
||||
>
|
||||
{getShortDayName(index)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Процент */}
|
||||
<Box height="15px">
|
||||
{count > 0 && totalLessons > 0 && (
|
||||
<Text
|
||||
fontSize="9px"
|
||||
color="gray.500"
|
||||
lineHeight="15px"
|
||||
textAlign="center"
|
||||
>
|
||||
{Math.round((count / totalLessons) * 100)}%
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
<Text fontSize="xs" color="gray.500" mt={2} fontStyle="italic">
|
||||
{t('journal.pl.overview.dayOfWeekHelp')}
|
||||
</Text>
|
||||
</VStack>
|
||||
)
|
||||
}
|
6
src/pages/course-list/components/statistics/index.ts
Normal file
6
src/pages/course-list/components/statistics/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from './useStats'
|
||||
export * from './StatCards'
|
||||
export * from './StudentAttendanceList'
|
||||
export * from './CourseAttendanceList'
|
||||
export * from './ActivityStats'
|
||||
export * from './WeekdayActivityChart'
|
239
src/pages/course-list/components/statistics/useStats.ts
Normal file
239
src/pages/course-list/components/statistics/useStats.ts
Normal file
@ -0,0 +1,239 @@
|
||||
import { useMemo } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import { Course, Lesson } from '../../../../__data__/model'
|
||||
|
||||
export interface StudentAttendance {
|
||||
id: string
|
||||
name: string
|
||||
attended: number
|
||||
total: number
|
||||
percent: number
|
||||
avatarUrl?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
export interface CourseStats {
|
||||
totalCourses: number
|
||||
activeCourses: number
|
||||
totalLessons: number
|
||||
completedLessons: number
|
||||
upcomingLessons: number
|
||||
averageAttendance: number
|
||||
totalStudents: Set<string>
|
||||
totalTeachers: Set<string>
|
||||
recentCoursesCount: number
|
||||
oldCoursesCount: number
|
||||
weekdayActivity: number[]
|
||||
mostActiveDayIndex: number
|
||||
topStudents: StudentAttendance[]
|
||||
topCoursesByAttendance: Array<{id: string, name: string, attendanceRate: number}>
|
||||
}
|
||||
|
||||
export const useStats = (
|
||||
courses: Course[],
|
||||
lessonsByCourse: Record<string, Lesson[]> = {}
|
||||
): CourseStats => {
|
||||
|
||||
return useMemo(() => {
|
||||
if (!courses?.length) {
|
||||
return {
|
||||
totalCourses: 0,
|
||||
activeCourses: 0,
|
||||
totalLessons: 0,
|
||||
completedLessons: 0,
|
||||
upcomingLessons: 0,
|
||||
averageAttendance: 0,
|
||||
totalStudents: new Set<string>(),
|
||||
totalTeachers: new Set<string>(),
|
||||
recentCoursesCount: 0,
|
||||
oldCoursesCount: 0,
|
||||
weekdayActivity: Array(7).fill(0),
|
||||
mostActiveDayIndex: 0,
|
||||
topStudents: [] as StudentAttendance[],
|
||||
topCoursesByAttendance: [] as {id: string, name: string, attendanceRate: number}[]
|
||||
}
|
||||
}
|
||||
|
||||
const now = dayjs()
|
||||
const threeMonthsAgo = now.subtract(3, 'month')
|
||||
const weekdayActivity = Array(7).fill(0)
|
||||
|
||||
// Множества для уникальных студентов и учителей
|
||||
const uniqueStudents = new Set<string>()
|
||||
const uniqueTeachers = new Set<string>()
|
||||
|
||||
// Количество курсов, созданных за последние 3 месяца
|
||||
const recentCourses = courses.filter(course =>
|
||||
dayjs(course.created).isAfter(threeMonthsAgo)
|
||||
)
|
||||
|
||||
// Количество активных курсов
|
||||
const activeCourses = []
|
||||
|
||||
let totalLessonsCount = 0
|
||||
let completedLessonsCount = 0
|
||||
let upcomingLessonsCount = 0
|
||||
let totalAttendances = 0
|
||||
let totalPossibleAttendances = 0
|
||||
|
||||
// Для отслеживания посещаемости студентов по всем курсам
|
||||
const globalStudentsMap = new Map<string, StudentAttendance>()
|
||||
|
||||
// Статистика посещаемости по курсам
|
||||
const courseAttendanceStats: {id: string, name: string, attendanceRate: number}[] = []
|
||||
|
||||
// Для каждого курса считаем статистику на основе данных об уроках
|
||||
courses.forEach(course => {
|
||||
// Добавляем учителей в множество
|
||||
course.teachers.forEach(teacher => {
|
||||
uniqueTeachers.add(teacher.sub)
|
||||
})
|
||||
|
||||
// Добавляем студентов в множество
|
||||
const courseUniqueStudents = new Set<string>()
|
||||
|
||||
// Получаем детализированные данные об уроках курса (если доступны)
|
||||
const courseLessons = lessonsByCourse[course._id] || []
|
||||
|
||||
// Если у нас есть детализированные данные по урокам
|
||||
if (courseLessons.length > 0) {
|
||||
// Добавляем количество уроков к общему счетчику
|
||||
totalLessonsCount += courseLessons.length
|
||||
|
||||
// Считаем завершенные и предстоящие уроки
|
||||
const completed = courseLessons.filter(lesson => dayjs(lesson.date).isBefore(now))
|
||||
const upcoming = courseLessons.filter(lesson => dayjs(lesson.date).isAfter(now))
|
||||
|
||||
completedLessonsCount += completed.length
|
||||
upcomingLessonsCount += upcoming.length
|
||||
|
||||
// Если у курса есть будущие занятия, считаем его активным
|
||||
if (upcoming.length > 0) {
|
||||
activeCourses.push(course)
|
||||
}
|
||||
|
||||
// Собираем всех уникальных студентов курса для более точной статистики
|
||||
courseLessons.forEach(lesson => {
|
||||
lesson.students?.forEach(student => {
|
||||
courseUniqueStudents.add(student.sub)
|
||||
uniqueStudents.add(student.sub)
|
||||
})
|
||||
})
|
||||
|
||||
// Для статистики посещаемости по курсу
|
||||
let courseAttendances = 0
|
||||
let coursePossibleAttendances = 0
|
||||
|
||||
// Считаем посещаемость ТОЛЬКО по прошедшим занятиям
|
||||
completed.forEach(lesson => {
|
||||
// Добавляем статистику по дням недели
|
||||
// В dayjs 0 = воскресенье, 1 = понедельник, ... 6 = суббота
|
||||
// Нужно проверить формат даты урока, что это валидная дата
|
||||
if (lesson.date && dayjs(lesson.date).isValid()) {
|
||||
const lessonDay = dayjs(lesson.date).day()
|
||||
weekdayActivity[lessonDay]++
|
||||
}
|
||||
|
||||
// Добавляем студентов в глобальное множество
|
||||
const lessonStudentsCount = lesson.students?.length || 0
|
||||
|
||||
// Добавляем в статистику посещаемости
|
||||
courseAttendances += lessonStudentsCount
|
||||
|
||||
// Обновляем счетчики общей посещаемости
|
||||
totalAttendances += lessonStudentsCount
|
||||
|
||||
// Собираем статистику по каждому студенту
|
||||
lesson.students?.forEach(student => {
|
||||
// Добавляем или обновляем данные студента в глобальной карте
|
||||
const studentId = student.sub
|
||||
const currentGlobal = globalStudentsMap.get(studentId) || {
|
||||
id: studentId,
|
||||
name: (student.family_name && student.given_name
|
||||
? `${student.family_name} ${student.given_name}`
|
||||
: student.name || student.email || student.preferred_username || student.family_name || student.given_name),
|
||||
attended: 0,
|
||||
total: 0,
|
||||
percent: 0,
|
||||
avatarUrl: student.picture,
|
||||
email: student.email
|
||||
}
|
||||
|
||||
currentGlobal.attended += 1
|
||||
globalStudentsMap.set(studentId, currentGlobal)
|
||||
})
|
||||
})
|
||||
|
||||
// Потенциальные посещения для этого курса рассчитываем только по прошедшим занятиям
|
||||
// и только для студентов, которые есть хотя бы на одном занятии
|
||||
// Кол-во прошедших занятий * кол-во уникальных студентов на курсе
|
||||
coursePossibleAttendances = completed.length * (courseUniqueStudents.size || 1)
|
||||
totalPossibleAttendances += coursePossibleAttendances
|
||||
|
||||
// Добавляем статистику курса, если есть прошедшие занятия
|
||||
if (completed.length > 0 && coursePossibleAttendances > 0) {
|
||||
courseAttendanceStats.push({
|
||||
id: course._id,
|
||||
name: course.name,
|
||||
attendanceRate: (courseAttendances / coursePossibleAttendances) * 100
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Если у нас нет детализированных данных, считаем на основе общих данных курса
|
||||
totalLessonsCount += course.lessons.length
|
||||
|
||||
// Предполагаем, что курс активен
|
||||
activeCourses.push(course)
|
||||
}
|
||||
})
|
||||
|
||||
// Отладочная информация по активности по дням недели
|
||||
console.log('Weekday activity:', weekdayActivity)
|
||||
|
||||
// Обрабатываем глобальную статистику посещаемости студентов
|
||||
// Устанавливаем общее число занятий для каждого студента
|
||||
globalStudentsMap.forEach(student => {
|
||||
// Устанавливаем максимально возможное кол-во занятий как общее число прошедших занятий
|
||||
// (это завышенная оценка, т.к. студент может быть не на всех курсах)
|
||||
student.total = completedLessonsCount
|
||||
student.percent = completedLessonsCount > 0 ? (student.attended / student.total) * 100 : 0
|
||||
})
|
||||
|
||||
// Находим самый активный день недели
|
||||
const maxValue = Math.max(...weekdayActivity)
|
||||
// Если максимальное значение = 0, то устанавливаем понедельник как самый активный день по умолчанию
|
||||
const mostActiveDayIndex = maxValue > 0 ? weekdayActivity.indexOf(maxValue) : 1
|
||||
|
||||
// Вычисляем среднюю посещаемость
|
||||
const averageAttendance = totalPossibleAttendances > 0
|
||||
? (totalAttendances / totalPossibleAttendances) * 100
|
||||
: 0
|
||||
|
||||
// Топ студенты по посещаемости (по всем курсам)
|
||||
const topStudents = Array.from(globalStudentsMap.values())
|
||||
.sort((a, b) => (b.percent - a.percent) || (b.attended - a.attended))
|
||||
.slice(0, 5)
|
||||
|
||||
// Сортируем курсы по посещаемости
|
||||
const topCoursesByAttendance = courseAttendanceStats
|
||||
.sort((a, b) => b.attendanceRate - a.attendanceRate)
|
||||
.slice(0, 3)
|
||||
|
||||
return {
|
||||
totalCourses: courses.length,
|
||||
activeCourses: activeCourses.length,
|
||||
totalLessons: totalLessonsCount,
|
||||
completedLessons: completedLessonsCount,
|
||||
upcomingLessons: upcomingLessonsCount,
|
||||
averageAttendance,
|
||||
totalStudents: uniqueStudents,
|
||||
totalTeachers: uniqueTeachers,
|
||||
recentCoursesCount: recentCourses.length,
|
||||
oldCoursesCount: courses.length - recentCourses.length,
|
||||
weekdayActivity,
|
||||
mostActiveDayIndex,
|
||||
topStudents,
|
||||
topCoursesByAttendance
|
||||
}
|
||||
}, [courses, lessonsByCourse])
|
||||
}
|
@ -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 }) => {
|
||||
<Badge colorScheme="blue">
|
||||
<HStack spacing={1}>
|
||||
<CalendarIcon boxSize="3" />
|
||||
<Text>{dayjs(course.startDt).format('DD.MM.YYYY')}</Text>
|
||||
<Text>{formatDate(course.startDt, 'DD.MM.YYYY')}</Text>
|
||||
</HStack>
|
||||
</Badge>
|
||||
<Badge colorScheme="purple">
|
||||
@ -243,7 +244,7 @@ export const CourseCard = ({ course }: { course: Course }) => {
|
||||
<Badge colorScheme="blue">
|
||||
<HStack spacing={1}>
|
||||
<CalendarIcon boxSize="3" />
|
||||
<Text>{dayjs(course.startDt).format('DD.MM.YYYY')}</Text>
|
||||
<Text>{formatDate(course.startDt, 'DD.MM.YYYY')}</Text>
|
||||
</HStack>
|
||||
</Badge>
|
||||
<Badge colorScheme="purple">
|
||||
@ -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')
|
||||
}
|
||||
</Text>
|
||||
@ -455,7 +458,7 @@ export const CourseCard = ({ course }: { course: Course }) => {
|
||||
<HStack spacing={2} mt={1} flexWrap="wrap">
|
||||
<Tag size="sm" colorScheme={isPast ? "green" : "blue"} borderRadius="full">
|
||||
<TagLeftIcon as={CalendarIcon} boxSize='10px' />
|
||||
<TagLabel>{dayjs(lesson.date).format('DD.MM.YYYY')}</TagLabel>
|
||||
<TagLabel>{formatDate(lesson.date, 'DD.MM.YYYY')}</TagLabel>
|
||||
</Tag>
|
||||
{isPast && lessonAttendance && (
|
||||
<Tag
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useMemo, useEffect } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@ -9,13 +9,16 @@ import {
|
||||
} from '@chakra-ui/react'
|
||||
import { AddIcon } from '@chakra-ui/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getFeatures } from '@brojs/cli'
|
||||
|
||||
import { useAppSelector } from '../../__data__/store'
|
||||
import { api } from '../../__data__/api/api'
|
||||
import { isTeacher } from '../../utils/user'
|
||||
import { PageLoader } from '../../components/page-loader/page-loader'
|
||||
import { useSetBreadcrumbs } from '../../components'
|
||||
import { useGroupedCourses } from './hooks'
|
||||
import { CreateCourseForm, YearGroup } from './components'
|
||||
import { CreateCourseForm, YearGroup, CoursesOverview } from './components'
|
||||
import { Lesson } from '../../__data__/model'
|
||||
|
||||
/**
|
||||
* Основной компонент списка курсов
|
||||
@ -27,11 +30,61 @@ export const CoursesList = () => {
|
||||
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<Record<string, Lesson[]>>({})
|
||||
|
||||
// Используем useMemo для проверки наличия данных
|
||||
const courses = useMemo(() => data?.body || [], [data])
|
||||
|
||||
// Загружаем данные для каждого курса параллельно
|
||||
useEffect(() => {
|
||||
if (courses.length > 0 && !showForm) {
|
||||
// Создаем запросы для получения данных о занятиях каждого курса
|
||||
const fetchLessonsForCourses = async () => {
|
||||
const lessonsData: Record<string, Lesson[]> = {}
|
||||
|
||||
// Получаем данные курсов параллельно (по 3 курса за раз, чтобы не перегружать сервер)
|
||||
for (let i = 0; i < courses.length; i += 3) {
|
||||
const batch = courses.slice(i, i + 3)
|
||||
const batchPromises = batch.map(async course => {
|
||||
// Используем существующий API метод с Lazy Query
|
||||
const response = await getLessons(course.id)
|
||||
if (response.data?.body) {
|
||||
lessonsData[course._id] = response.data.body
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(batchPromises)
|
||||
}
|
||||
|
||||
setLessonsByCourse(lessonsData)
|
||||
}
|
||||
|
||||
fetchLessonsForCourses()
|
||||
}
|
||||
}, [courses, showForm, getLessons])
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader />
|
||||
@ -61,6 +114,14 @@ export const CoursesList = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!showForm && coursesStatistics && (
|
||||
<CoursesOverview
|
||||
courses={courses}
|
||||
isLoading={isLoading}
|
||||
lessonsByCourse={lessonsByCourse}
|
||||
/>
|
||||
)}
|
||||
|
||||
{Object.keys(groupedCourses).length > 0 ? (
|
||||
Object.entries(groupedCourses)
|
||||
.sort(([yearA], [yearB]) => Number(yearB) - Number(yearA)) // Сортируем годы по убыванию
|
||||
|
@ -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<NewCourseForm>({
|
||||
defaultValues: {
|
||||
startDt: dayjs().format('YYYY-MM-DD'),
|
||||
startDt: formatDate(dayjs().toDate(), 'YYYY-MM-DD'),
|
||||
name: t('journal.pl.course.defaultName'),
|
||||
},
|
||||
})
|
||||
|
@ -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] = []
|
||||
}
|
||||
|
@ -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<string>())
|
||||
|
||||
// Добавляем состояние для отслеживания пульсации
|
||||
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 (
|
||||
<>
|
||||
<BreadcrumbsWrapper>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink as={Link} to={getNavigationValue('journal.main')}>
|
||||
{t('journal.pl.common.journal')}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink
|
||||
as={Link}
|
||||
to={`${getNavigationValue('journal.main')}/lessons-list/${courseId}`}
|
||||
>
|
||||
{t('journal.pl.common.course')}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbItem isCurrentPage>
|
||||
<BreadcrumbLink href="#">{t('journal.pl.common.lesson')}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
</BreadcrumbsWrapper>
|
||||
<Container maxW="2280px">
|
||||
<VStack align="left">
|
||||
<Heading as="h3" mt="4" mb="3">
|
||||
{t('journal.pl.lesson.topicTitle')}
|
||||
</Heading>
|
||||
<Box as="span">{accessCode?.body?.lesson?.name}</Box>
|
||||
<Box as="span">
|
||||
{dayjs(accessCode?.body?.lesson?.date).format(t('journal.pl.lesson.dateFormat'))}{' '}
|
||||
{t('journal.pl.common.marked')} - {accessCode?.body?.lesson?.students?.length}{' '}
|
||||
{AllStudents.isSuccess
|
||||
? `/ ${AllStudents?.data?.body?.length}`
|
||||
: ''}{' '}
|
||||
{t('journal.pl.common.people')}
|
||||
</Box>
|
||||
|
||||
</VStack>
|
||||
<Stack spacing="8" direction={{ base: "column", md: "row" }}>
|
||||
<Box flexShrink={0} alignSelf="center">
|
||||
<Stack spacing="8" direction={{ base: "column", md: "row" }} mt={6}>
|
||||
<Box
|
||||
flexShrink={0}
|
||||
alignSelf="flex-start"
|
||||
p={4}
|
||||
borderRadius="xl"
|
||||
bg={colorMode === "light" ? "gray.50" : "gray.700"}
|
||||
boxShadow="md"
|
||||
><Box pb={3}>
|
||||
{formatDate(accessCode?.body?.lesson?.date, t('journal.pl.lesson.dateFormat'))}{' '}
|
||||
{t('journal.pl.common.marked')} -
|
||||
{AllStudents.isSuccess && (
|
||||
<Box
|
||||
as="span"
|
||||
px={2}
|
||||
py={1}
|
||||
ml={2}
|
||||
borderRadius="md"
|
||||
fontWeight="bold"
|
||||
bg={getAttendanceColor(
|
||||
accessCode?.body?.lesson?.students?.length || 0,
|
||||
AllStudents?.data?.body?.length || 1
|
||||
).bg}
|
||||
color={getAttendanceColor(
|
||||
accessCode?.body?.lesson?.students?.length || 0,
|
||||
AllStudents?.data?.body?.length || 1
|
||||
).color}
|
||||
_dark={{
|
||||
bg: getAttendanceColor(
|
||||
accessCode?.body?.lesson?.students?.length || 0,
|
||||
AllStudents?.data?.body?.length || 1
|
||||
).dark.bg,
|
||||
color: getAttendanceColor(
|
||||
accessCode?.body?.lesson?.students?.length || 0,
|
||||
AllStudents?.data?.body?.length || 1
|
||||
).dark.color
|
||||
}}
|
||||
position="relative"
|
||||
animation={isPulsing ? "pulse 1.5s ease-in-out" : "none"}
|
||||
sx={{
|
||||
'@keyframes pulse': {
|
||||
'0%': { transform: 'scale(1)' },
|
||||
'50%': { transform: 'scale(1.15)', boxShadow: '0 0 10px rgba(66, 153, 225, 0.7)' },
|
||||
'100%': { transform: 'scale(1)' }
|
||||
}
|
||||
}}
|
||||
>
|
||||
{accessCode?.body?.lesson?.students?.length} / {AllStudents?.data?.body?.length}
|
||||
</Box>
|
||||
)}
|
||||
{!AllStudents.isSuccess && (
|
||||
<span> {accessCode?.body?.lesson?.students?.length}</span>
|
||||
)}{' '}
|
||||
{t('journal.pl.common.people')}
|
||||
</Box>
|
||||
<a href={userUrl}>
|
||||
<QRCanvas ref={canvRef} />
|
||||
</a>
|
||||
</Box>
|
||||
<StudentList>
|
||||
{isTeacher(user) && studentsArr.map((student) => (
|
||||
<UserCard
|
||||
wrapperAS="li"
|
||||
key={student.sub}
|
||||
student={student}
|
||||
present={student.present}
|
||||
onAddUser={(user: User) => manualAdd({ lessonId, user })}
|
||||
/>
|
||||
))}
|
||||
</StudentList>
|
||||
<Box
|
||||
flex={1}
|
||||
p={4}
|
||||
borderRadius="xl"
|
||||
bg={colorMode === "light" ? "gray.50" : "gray.700"}
|
||||
boxShadow="md"
|
||||
>
|
||||
<StudentList>
|
||||
{isTeacher(user) && (
|
||||
<AnimatePresence initial={false}>
|
||||
{studentsArr.map((student) => (
|
||||
<motion.li
|
||||
key={student.sub}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
// Добавляем подсветку для недавно отметившихся студентов
|
||||
boxShadow: student.recentlyPresent
|
||||
? ['0 0 0 0 rgba(66, 153, 225, 0)', '0 0 20px 5px rgba(66, 153, 225, 0.7)', '0 0 0 0 rgba(66, 153, 225, 0)']
|
||||
: '0 0 0 0 rgba(0, 0, 0, 0)'
|
||||
}}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
layout: { duration: 0.4 },
|
||||
boxShadow: {
|
||||
repeat: student.recentlyPresent ? 3 : 0,
|
||||
duration: 1.5
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UserCard
|
||||
wrapperAS="div"
|
||||
student={student}
|
||||
present={student.present}
|
||||
recentlyPresent={student.recentlyPresent}
|
||||
onAddUser={(user: User) => manualAdd({ lessonId, user })}
|
||||
/>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</StudentList>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
|
@ -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 (
|
||||
<ResponsiveBar
|
||||
@ -11,18 +16,46 @@ export const Bar = ({ data }: { data: BarDatum[] }) => {
|
||||
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) =>
|
||||
|
@ -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<ItemProps> = ({
|
||||
<>
|
||||
<Flex justify="space-between" align="center" mb={2}>
|
||||
<Text fontWeight="medium">{name}</Text>
|
||||
<Text fontSize="sm">{dayjs(date).format(groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')}</Text>
|
||||
<Text fontSize="sm">{formatDate(date, groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')}</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="space-between" align="center">
|
||||
@ -193,7 +193,7 @@ export const Item: React.FC<ItemProps> = ({
|
||||
</Td>
|
||||
)}
|
||||
<Td textAlign="center">
|
||||
{dayjs(date).format(groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')}
|
||||
{formatDate(date, groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')}
|
||||
</Td>
|
||||
<Td>{name}</Td>
|
||||
{isTeacher && (
|
||||
|
@ -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<LessonItemProps> = ({
|
||||
borderRadius="md"
|
||||
_dark={{ bg: "gray.700" }}
|
||||
>
|
||||
<Text fontWeight="bold">{dayjs(date).format('DD MMMM YYYY')}</Text>
|
||||
<Text fontWeight="bold">{formatDate(date, 'DD MMMM YYYY')}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{lessons.map((lesson) => (
|
||||
@ -78,7 +78,7 @@ export const LessonItems: React.FC<LessonItemProps> = ({
|
||||
{date && (
|
||||
<Tr>
|
||||
<Td colSpan={isTeacher ? 5 : 3}>
|
||||
{dayjs(date).format('DD MMMM YYYY')}
|
||||
{formatDate(date, 'DD MMMM YYYY')}
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
|
@ -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 && <CheckIcon color="green.400" />}
|
||||
</Flex>
|
||||
<Text fontSize="sm" color={textSecondaryColor}>
|
||||
{dayjs(suggestion.date).format('DD.MM.YYYY HH:mm')}
|
||||
{formatDate(suggestion.date, 'DD.MM.YYYY HH:mm')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
309
src/pages/lesson-list/components/statistics.tsx
Normal file
309
src/pages/lesson-list/components/statistics.tsx
Normal file
@ -0,0 +1,309 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import { formatDate } from '../../../utils/dayjs-config'
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
StatGroup,
|
||||
Flex,
|
||||
Icon,
|
||||
Progress,
|
||||
Divider,
|
||||
Badge,
|
||||
VStack,
|
||||
HStack,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
FaChalkboardTeacher,
|
||||
FaUserGraduate,
|
||||
FaClock,
|
||||
FaCalendarCheck,
|
||||
FaCalendarAlt,
|
||||
FaPercentage
|
||||
} from 'react-icons/fa'
|
||||
import { CalendarIcon, StarIcon, TimeIcon } from '@chakra-ui/icons'
|
||||
|
||||
import { Lesson } from '../../../__data__/model'
|
||||
|
||||
interface CourseStatisticsProps {
|
||||
lessons: Lesson[]
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export const CourseStatistics: React.FC<CourseStatisticsProps> = ({ lessons = [], isLoading }) => {
|
||||
const { t } = useTranslation()
|
||||
const statBgColor = useColorModeValue('white', 'gray.700')
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600')
|
||||
|
||||
// Вычисляем статистику курса
|
||||
const stats = useMemo(() => {
|
||||
if (!lessons || lessons.length === 0) {
|
||||
return {
|
||||
totalLessons: 0,
|
||||
completedLessons: 0,
|
||||
upcomingLessons: 0,
|
||||
attendanceRate: 0,
|
||||
averageStudentsPerLesson: 0,
|
||||
nextLessonDate: null,
|
||||
mostAttendedLesson: null,
|
||||
attendanceTrend: 0,
|
||||
totalStudents: 0,
|
||||
daysUntilNextLesson: 0,
|
||||
percentageCompleted: 0
|
||||
}
|
||||
}
|
||||
|
||||
const now = dayjs()
|
||||
const completed = lessons.filter(lesson => dayjs(lesson.date).isBefore(now))
|
||||
const upcoming = lessons.filter(lesson => dayjs(lesson.date).isAfter(now))
|
||||
|
||||
// Сортируем предстоящие занятия по дате (ближайшие вперед)
|
||||
const sortedUpcoming = [...upcoming].sort((a, b) =>
|
||||
dayjs(a.date).valueOf() - dayjs(b.date).valueOf()
|
||||
)
|
||||
|
||||
// Находим ближайшее занятие
|
||||
const nextLesson = sortedUpcoming.length > 0 ? sortedUpcoming[0] : null
|
||||
|
||||
// Вычисляем среднее количество студентов на занятии
|
||||
const totalStudentsCount = completed.reduce(
|
||||
(sum, lesson) => sum + (lesson.students?.length || 0),
|
||||
0
|
||||
)
|
||||
|
||||
const averageStudents = completed.length
|
||||
? totalStudentsCount / completed.length
|
||||
: 0
|
||||
|
||||
// Находим занятие с наибольшей посещаемостью
|
||||
let mostAttended = null
|
||||
let maxAttendance = 0
|
||||
|
||||
completed.forEach(lesson => {
|
||||
const attendance = lesson.students?.length || 0
|
||||
if (attendance > maxAttendance) {
|
||||
maxAttendance = attendance
|
||||
mostAttended = lesson
|
||||
}
|
||||
})
|
||||
|
||||
// Вычисляем тренд посещаемости (положительный или отрицательный)
|
||||
let attendanceTrend = 0
|
||||
|
||||
if (completed.length >= 2) {
|
||||
// Берем последние 5 занятий или меньше, если их меньше 5
|
||||
const recentLessons = [...completed]
|
||||
.sort((a, b) => dayjs(b.date).valueOf() - dayjs(a.date).valueOf())
|
||||
.slice(0, 5)
|
||||
|
||||
if (recentLessons.length >= 2) {
|
||||
const lastLesson = recentLessons[0]
|
||||
const previousLessons = recentLessons.slice(1)
|
||||
|
||||
const lastAttendance = lastLesson.students?.length || 0
|
||||
const avgPreviousAttendance = previousLessons.reduce(
|
||||
(sum, lesson) => sum + (lesson.students?.length || 0),
|
||||
0
|
||||
) / previousLessons.length
|
||||
|
||||
// Вычисляем процентное изменение
|
||||
attendanceTrend = avgPreviousAttendance
|
||||
? ((lastAttendance - avgPreviousAttendance) / avgPreviousAttendance) * 100
|
||||
: 0
|
||||
}
|
||||
}
|
||||
|
||||
// Вычисляем количество дней до следующего занятия
|
||||
const daysUntilNext = nextLesson
|
||||
? dayjs(nextLesson.date).diff(now, 'day')
|
||||
: 0
|
||||
|
||||
// Собираем все уникальные ID студентов
|
||||
const uniqueStudents = new Set()
|
||||
lessons.forEach(lesson => {
|
||||
lesson.students?.forEach(student => {
|
||||
uniqueStudents.add(student.sub)
|
||||
})
|
||||
})
|
||||
|
||||
// Вычисляем процент завершенного курса
|
||||
const percentComplete = lessons.length
|
||||
? (completed.length / lessons.length) * 100
|
||||
: 0
|
||||
|
||||
return {
|
||||
totalLessons: lessons.length,
|
||||
completedLessons: completed.length,
|
||||
upcomingLessons: upcoming.length,
|
||||
attendanceRate: completed.length ? (totalStudentsCount / (completed.length * uniqueStudents.size || 1)) * 100 : 0,
|
||||
averageStudentsPerLesson: Math.round(averageStudents * 10) / 10,
|
||||
nextLessonDate: nextLesson?.date || null,
|
||||
mostAttendedLesson: mostAttended,
|
||||
attendanceTrend,
|
||||
totalStudents: uniqueStudents.size,
|
||||
daysUntilNextLesson: daysUntilNext,
|
||||
percentageCompleted: percentComplete
|
||||
}
|
||||
}, [lessons])
|
||||
|
||||
// Определяем цвет для показателей статистики
|
||||
const getProgressColor = (value) => {
|
||||
if (value > 80) return 'green'
|
||||
if (value > 50) return 'blue'
|
||||
if (value > 30) return 'yellow'
|
||||
return 'red'
|
||||
}
|
||||
|
||||
if (isLoading || !lessons.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box mb={6} py={3}>
|
||||
<Heading size="md" mb={4}>
|
||||
{t('journal.pl.statistics.title')}
|
||||
</Heading>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, sm: 2, md: 4 }} spacing={5} mb={5}>
|
||||
{/* Статистика по занятиям */}
|
||||
<Stat
|
||||
bg={statBgColor}
|
||||
p={3}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
borderLeft="4px solid"
|
||||
borderLeftColor="blue.400"
|
||||
>
|
||||
<Flex align="center" mb={2}>
|
||||
<Icon as={FaCalendarAlt} color="blue.400" mr={2} />
|
||||
<StatLabel>{t('journal.pl.statistics.totalLessons')}</StatLabel>
|
||||
</Flex>
|
||||
<StatNumber fontSize="2xl">{stats.totalLessons}</StatNumber>
|
||||
<StatHelpText mb={0}>
|
||||
<HStack>
|
||||
<Icon as={FaCalendarCheck} color="green.400" boxSize="0.9em" />
|
||||
<Text>{stats.completedLessons} {t('journal.pl.statistics.completed')}</Text>
|
||||
</HStack>
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
|
||||
{/* Статистика по посещаемости */}
|
||||
<Stat
|
||||
bg={statBgColor}
|
||||
p={3}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
borderLeft="4px solid"
|
||||
borderLeftColor="green.400"
|
||||
>
|
||||
<Flex align="center" mb={2}>
|
||||
<Icon as={FaPercentage} color="green.400" mr={2} />
|
||||
<StatLabel>{t('journal.pl.statistics.attendanceRate')}</StatLabel>
|
||||
</Flex>
|
||||
<StatNumber fontSize="2xl">
|
||||
{Math.round(stats.attendanceRate)}%
|
||||
</StatNumber>
|
||||
<StatHelpText>
|
||||
{stats.attendanceTrend !== 0 && (
|
||||
<Flex align="center">
|
||||
<StatArrow
|
||||
type={Number(stats.attendanceTrend) > 0 ? 'increase' : 'decrease'}
|
||||
/>
|
||||
<Text>
|
||||
{Math.abs(Math.round(Number(stats.attendanceTrend)))}%
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
|
||||
{/* Статистика по студентам */}
|
||||
<Stat
|
||||
bg={statBgColor}
|
||||
p={3}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
borderLeft="4px solid"
|
||||
borderLeftColor="purple.400"
|
||||
>
|
||||
<Flex align="center" mb={2}>
|
||||
<Icon as={FaUserGraduate} color="purple.400" mr={2} />
|
||||
<StatLabel>{t('journal.pl.statistics.totalStudents')}</StatLabel>
|
||||
</Flex>
|
||||
<StatNumber fontSize="2xl">{stats.totalStudents}</StatNumber>
|
||||
<StatHelpText mb={0}>
|
||||
<Text>
|
||||
~ {stats.averageStudentsPerLesson} {t('journal.pl.statistics.perLesson')}
|
||||
</Text>
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
|
||||
{/* Следующее занятие */}
|
||||
<Stat
|
||||
bg={statBgColor}
|
||||
p={3}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
borderLeft="4px solid"
|
||||
borderLeftColor="orange.400"
|
||||
>
|
||||
<Flex align="center" mb={2}>
|
||||
<Icon as={FaClock} color="orange.400" mr={2} />
|
||||
<StatLabel>{t('journal.pl.statistics.nextLesson')}</StatLabel>
|
||||
</Flex>
|
||||
<StatNumber fontSize="xl">
|
||||
{stats.nextLessonDate
|
||||
? formatDate(stats.nextLessonDate, 'DD.MM.YYYY')
|
||||
: t('journal.pl.statistics.noUpcoming')
|
||||
}
|
||||
</StatNumber>
|
||||
<StatHelpText mb={0}>
|
||||
{stats.nextLessonDate && (
|
||||
<Text>
|
||||
{t('journal.pl.statistics.in')} {stats.daysUntilNextLesson} {t('journal.pl.statistics.days')}
|
||||
</Text>
|
||||
)}
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
</SimpleGrid>
|
||||
|
||||
<Box
|
||||
bg={statBgColor}
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
borderTop="1px solid"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Text fontWeight="bold" mb={2}>
|
||||
{t('journal.pl.statistics.courseProgress')}
|
||||
</Text>
|
||||
<Progress
|
||||
value={stats.percentageCompleted}
|
||||
size="lg"
|
||||
borderRadius="md"
|
||||
colorScheme={getProgressColor(stats.percentageCompleted)}
|
||||
mb={1}
|
||||
hasStripe
|
||||
/>
|
||||
<Flex justify="space-between" fontSize="sm">
|
||||
<Text>
|
||||
{t('journal.pl.statistics.completed')}: {stats.completedLessons} / {stats.totalLessons}
|
||||
</Text>
|
||||
<Text fontWeight="medium">
|
||||
{Math.round(stats.percentageCompleted)}%
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
@ -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<Lesson>(null)
|
||||
const [suggestedLessonToCreate, setSuggestedLessonToCreate] = useState(null)
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Устанавливаем хлебные крошки для страницы списка уроков
|
||||
useSetBreadcrumbs([
|
||||
{
|
||||
title: t('journal.pl.breadcrumbs.home'),
|
||||
path: '/'
|
||||
},
|
||||
{
|
||||
title: courseData?.name || t('journal.pl.breadcrumbs.course'),
|
||||
isCurrentPage: true
|
||||
}
|
||||
])
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...(data?.body || [])]?.sort((a, b) => (a.date > b.date ? 1 : -1)),
|
||||
[data, data?.body],
|
||||
)
|
||||
|
||||
// Найдем максимальное количество студентов среди всех уроков
|
||||
const maxStudents = useMemo(() => {
|
||||
if (!sorted || sorted.length === 0) return 1
|
||||
const max = Math.max(...sorted.map(lesson => lesson.students?.length || 0))
|
||||
return max > 0 ? max : 1 // Избегаем деления на ноль
|
||||
}, [sorted])
|
||||
|
||||
// Функция для определения цвета на основе посещаемости
|
||||
const getAttendanceColor = (attendance: number) => {
|
||||
const percentage = (attendance / maxStudents) * 100
|
||||
if (percentage > 80) return { bg: 'green.100', color: 'green.800', dark: { bg: 'green.800', color: 'green.100' } }
|
||||
if (percentage > 60) return { bg: 'teal.100', color: 'teal.800', dark: { bg: 'teal.800', color: 'teal.100' } }
|
||||
if (percentage > 40) return { bg: 'yellow.100', color: 'yellow.800', dark: { bg: 'yellow.800', color: 'yellow.100' } }
|
||||
if (percentage > 20) return { bg: 'orange.100', color: 'orange.800', dark: { bg: 'orange.800', color: 'orange.100' } }
|
||||
return { bg: 'red.100', color: 'red.800', dark: { bg: 'red.800', color: 'red.100' } }
|
||||
}
|
||||
|
||||
const lessonCalc = useMemo(() => {
|
||||
if (!isSuccess) {
|
||||
return []
|
||||
@ -275,7 +312,7 @@ const LessonList = () => {
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">
|
||||
{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') })}
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogBody>
|
||||
@ -303,19 +340,6 @@ const LessonList = () => {
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
</AlertDialog>
|
||||
<BreadcrumbsWrapper>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink as={Link} to={getNavigationValue('journal.main')}>
|
||||
{t('journal.pl.common.journal')}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
<BreadcrumbItem isCurrentPage>
|
||||
<BreadcrumbLink href="#">{t('journal.pl.common.course')}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
</BreadcrumbsWrapper>
|
||||
<Container maxW="container.xl" position="relative">
|
||||
{isTeacher(user) && (
|
||||
<Box mt="15" mb="15">
|
||||
@ -346,6 +370,12 @@ const LessonList = () => {
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Статистика курса */}
|
||||
{!showForm && courseStatistics && (
|
||||
<CourseStatistics lessons={sorted} isLoading={isLoading} />
|
||||
)}
|
||||
|
||||
{barFeature && sorted?.length > 1 && (
|
||||
<Box height="300">
|
||||
<Bar
|
||||
@ -371,38 +401,157 @@ const LessonList = () => {
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<TableContainer whiteSpace="wrap" pb={13}>
|
||||
<Table variant="striped" colorScheme="cyan">
|
||||
<Thead>
|
||||
<Tr>
|
||||
{isTeacher(user) && (
|
||||
<Th align="center" width={1}>
|
||||
{t('journal.pl.lesson.link')}
|
||||
</Th>
|
||||
)}
|
||||
<Th textAlign="center" width={1}>
|
||||
{groupByDate ? t('journal.pl.lesson.time') : t('journal.pl.common.date')}
|
||||
</Th>
|
||||
<Th width="100%">{t('journal.pl.common.name')}</Th>
|
||||
{isTeacher(user) && <Th>{t('journal.pl.lesson.action')}</Th>}
|
||||
<Th isNumeric>{t('journal.pl.common.marked')}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{lessonCalc?.map(({ data: lessons, date }) => (
|
||||
<LessonItems
|
||||
courseId={courseId}
|
||||
date={date}
|
||||
isTeacher={isTeacher(user)}
|
||||
lessons={lessons}
|
||||
setlessonToDelete={setlessonToDelete}
|
||||
setEditLesson={handleEditLesson}
|
||||
key={date}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<Box pb={13}>
|
||||
{lessonCalc?.map(({ data: lessons, date }) => (
|
||||
<Box key={date} mb={6}>
|
||||
{date && (
|
||||
<Box
|
||||
p={3}
|
||||
mb={4}
|
||||
bg="cyan.50"
|
||||
borderRadius="md"
|
||||
_dark={{ bg: "cyan.900" }}
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{formatDate(date, 'DD MMMM YYYY')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
{lessons.map((lesson, index) => (
|
||||
<Box
|
||||
key={lesson.id}
|
||||
borderRadius="lg"
|
||||
boxShadow="md"
|
||||
bg="white"
|
||||
_dark={{ bg: "gray.700" }}
|
||||
transition="all 0.3s"
|
||||
_hover={{
|
||||
transform: "translateX(5px)",
|
||||
boxShadow: "lg"
|
||||
}}
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
mb={4}
|
||||
animation={`slideIn 0.6s ease-out ${index * 0.15}s both`}
|
||||
sx={{
|
||||
'@keyframes slideIn': {
|
||||
'0%': {
|
||||
opacity: 0,
|
||||
transform: 'translateX(-30px)'
|
||||
},
|
||||
'100%': {
|
||||
opacity: 1,
|
||||
transform: 'translateX(0)'
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Flex direction={{ base: "column", sm: "row" }}>
|
||||
{/* QR код и ссылка - левая часть карточки */}
|
||||
{isTeacher(user) && (
|
||||
<Link
|
||||
to={`${getNavigationValue('journal.main')}/lesson/${courseId}/${lesson.id}`}
|
||||
>
|
||||
<Box
|
||||
p={4}
|
||||
bg="cyan.500"
|
||||
_dark={{ bg: "cyan.600" }}
|
||||
color="white"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
transition="all 0.2s"
|
||||
_hover={{ bg: "cyan.600", _dark: { bg: "cyan.700" } }}
|
||||
height="100%"
|
||||
minW="150px"
|
||||
>
|
||||
<Box
|
||||
mr={0}
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
p={2}
|
||||
display="flex"
|
||||
>
|
||||
<img width={32} src={qrCode} alt="QR код" />
|
||||
</Box>
|
||||
</Box>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Содержимое карточки */}
|
||||
<Box p={5} w="100%" display="flex" flexDirection="column" justifyContent="space-between">
|
||||
<Flex mb={3} justify="space-between" align="center">
|
||||
{/* Название урока */}
|
||||
<Text fontWeight="bold" fontSize="xl" lineHeight="1.4" flex="1">
|
||||
{lesson.name}
|
||||
</Text>
|
||||
|
||||
<Text fontSize="sm" color="gray.500" _dark={{ color: "gray.300" }} ml={3} whiteSpace="nowrap">
|
||||
{formatDate(lesson.date, groupByDate ? 'HH:mm' : 'HH:mm DD.MM.YY')}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* Нижняя часть с метками и действиями */}
|
||||
<Flex justifyContent="space-between" alignItems="center" mt={1}>
|
||||
<Flex align="center">
|
||||
<Text fontSize="sm" mr={2}>
|
||||
{t('journal.pl.common.marked')}:
|
||||
</Text>
|
||||
<Text
|
||||
px={2}
|
||||
py={1}
|
||||
bg={getAttendanceColor(lesson.students.length).bg}
|
||||
color={getAttendanceColor(lesson.students.length).color}
|
||||
_dark={{
|
||||
bg: getAttendanceColor(lesson.students.length).dark.bg,
|
||||
color: getAttendanceColor(lesson.students.length).dark.color
|
||||
}}
|
||||
borderRadius="md"
|
||||
fontWeight="bold"
|
||||
fontSize="sm"
|
||||
>
|
||||
{lesson.students.length}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{isTeacher(user) && (
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
size="sm"
|
||||
colorScheme="cyan"
|
||||
variant="ghost"
|
||||
rightIcon={<EditIcon />}
|
||||
>
|
||||
{t('journal.pl.edit')}
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
onClick={() => handleEditLesson(lesson)}
|
||||
icon={<EditIcon />}
|
||||
>
|
||||
{t('journal.pl.edit')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => setlessonToDelete(lesson)}
|
||||
color="red.500"
|
||||
>
|
||||
{t('journal.pl.delete')}
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Container>
|
||||
</>
|
||||
|
@ -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);
|
||||
}
|
||||
`
|
||||
|
||||
|
@ -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 (
|
||||
<Container>
|
||||
{acc.isLoading && <h1>{t('journal.pl.common.sending')}</h1>}
|
||||
{acc.isSuccess && <h1>{t('journal.pl.common.success')}</h1>}
|
||||
<Container maxW="container.lg" pt={4}>
|
||||
{acc.isLoading && (
|
||||
<Center py={4}>
|
||||
<Spinner mr={2} />
|
||||
<Text>{t('journal.pl.common.sending')}</Text>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{acc.isSuccess && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Alert status="success" mb={4} borderRadius="lg">
|
||||
<AlertIcon />
|
||||
{t('journal.pl.common.success')}
|
||||
</Alert>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{acc.error && (
|
||||
<Box mb="6" mt="2">
|
||||
<Alert status="warning">
|
||||
<Alert status="warning" borderRadius="lg">
|
||||
<AlertIcon />
|
||||
{(acc as any).error?.data?.body?.errorMessage ===
|
||||
'Code is expired' ? (
|
||||
@ -60,31 +137,106 @@ const UserPage = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box mb={6}>
|
||||
<Text fontSize={18} fontWeight={600} as="h1" mt="4" mb="3">
|
||||
{t('journal.pl.lesson.topicTitle')} {ls.data?.body?.name}
|
||||
</Text>
|
||||
|
||||
<span>{dayjs(ls.data?.body?.date).format(t('journal.pl.lesson.dateFormat'))}</span>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
as="ul"
|
||||
display="flex"
|
||||
flexWrap="wrap"
|
||||
justifyContent="center"
|
||||
gap={3}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
{ls.data?.body?.students?.map((student) => (
|
||||
<UserCard
|
||||
width="40%"
|
||||
wrapperAS="li"
|
||||
key={student.sub}
|
||||
student={student}
|
||||
present
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box
|
||||
mb={6}
|
||||
p={5}
|
||||
borderRadius="xl"
|
||||
bg={colorMode === "light" ? "gray.50" : "gray.700"}
|
||||
boxShadow="md"
|
||||
>
|
||||
<Heading fontSize="xl" fontWeight={600} mb={2}>
|
||||
{t('journal.pl.lesson.topicTitle')}
|
||||
<Box as="span" ml={2} color={colorMode === "light" ? "blue.500" : "blue.300"}>
|
||||
{ls.data?.body?.name}
|
||||
</Box>
|
||||
</Heading>
|
||||
|
||||
<Flex align="center" justify="space-between" mt={3}>
|
||||
<Text color={colorMode === "light" ? "gray.600" : "gray.300"}>
|
||||
{formatDate(ls.data?.body?.date, t('journal.pl.lesson.dateFormat'))}
|
||||
</Text>
|
||||
|
||||
<Badge colorScheme="green" fontSize="md" borderRadius="full" px={3} py={1}>
|
||||
{t('journal.pl.common.people')}: {animatedStudents.length}
|
||||
</Badge>
|
||||
</Flex>
|
||||
</Box>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.2 }}
|
||||
>
|
||||
{animatedStudents.length > 0 ? (
|
||||
<StudentListView>
|
||||
<AnimatePresence initial={true}>
|
||||
{animatedStudents.map((student) => (
|
||||
<motion.li
|
||||
key={student.sub}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.6, y: 20 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
boxShadow: student.isNew
|
||||
? ['0 0 0 0 rgba(66, 153, 225, 0)', '0 0 20px 5px rgba(66, 153, 225, 0.7)', '0 0 0 0 rgba(66, 153, 225, 0)']
|
||||
: '0 0 0 0 rgba(0, 0, 0, 0)'
|
||||
}}
|
||||
exit={{ opacity: 0, scale: 0.6, y: 20 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 25,
|
||||
delay: 0.03 * animatedStudents.indexOf(student), // Уменьшенная задержка для более плавного появления
|
||||
boxShadow: {
|
||||
repeat: student.isNew ? 3 : 0,
|
||||
duration: 1.5
|
||||
}
|
||||
}}
|
||||
>
|
||||
<UserCard
|
||||
width="100%"
|
||||
wrapperAS="div"
|
||||
student={student}
|
||||
present={true}
|
||||
recentlyPresent={student.isNew}
|
||||
/>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</StudentListView>
|
||||
) : (
|
||||
ls.data && (
|
||||
<Center py={10} px={5}>
|
||||
<Box
|
||||
textAlign="center"
|
||||
p={6}
|
||||
borderRadius="xl"
|
||||
bg={colorMode === "light" ? "gray.50" : "gray.700"}
|
||||
boxShadow="md"
|
||||
width="100%"
|
||||
maxWidth="500px"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.3 }}
|
||||
>
|
||||
<Heading size="md" mb={4}>{t('journal.pl.lesson.noStudents')}</Heading>
|
||||
<Text>{t('journal.pl.lesson.waitForStudents')}</Text>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</Center>
|
||||
)
|
||||
)}
|
||||
</motion.div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
30
src/utils/dayjs-config.ts
Normal file
30
src/utils/dayjs-config.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/ru';
|
||||
import 'dayjs/locale/en';
|
||||
import i18next from 'i18next';
|
||||
|
||||
// Функция для обновления локали dayjs при изменении языка в i18next
|
||||
export const updateDayjsLocale = () => {
|
||||
const currentLocale = i18next.language;
|
||||
|
||||
// Убедимся, что локаль поддерживается, иначе используем 'en'
|
||||
const locale = ['ru', 'en'].includes(currentLocale) ? currentLocale : 'en';
|
||||
|
||||
// Установим локаль для dayjs
|
||||
dayjs.locale(locale);
|
||||
};
|
||||
|
||||
// Слушаем изменения языка и обновляем локаль dayjs
|
||||
i18next.on('languageChanged', () => {
|
||||
updateDayjsLocale();
|
||||
});
|
||||
|
||||
// Вызываем функцию инициализации при импорте
|
||||
updateDayjsLocale();
|
||||
|
||||
// Хелпер для форматирования даты с учетом текущей локали
|
||||
export const formatDate = (date: string | Date | number, format = 'DD.MM.YYYY') => {
|
||||
return dayjs(date).format(format);
|
||||
};
|
||||
|
||||
export default dayjs;
|
@ -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')
|
||||
|
@ -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",
|
||||
|
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user