Добавлены новые компоненты для отображения статистики курсов, включая статистику посещаемости, активности студентов и уроков. Обновлены локализации для поддержки новых данных и улучшено взаимодействие с API для получения информации о курсах и уроках.
This commit is contained in:
parent
b37c96f640
commit
5f952ece7a
@ -54,6 +54,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",
|
||||
@ -155,5 +156,40 @@
|
||||
"journal.pl.statistics.noUpcoming": "Not scheduled",
|
||||
"journal.pl.statistics.in": "in",
|
||||
"journal.pl.statistics.days": "days",
|
||||
"journal.pl.statistics.courseProgress": "Course Progress"
|
||||
"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"
|
||||
}
|
@ -51,6 +51,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}} успешно создана",
|
||||
@ -152,5 +153,40 @@
|
||||
"journal.pl.statistics.noUpcoming": "Не запланировано",
|
||||
"journal.pl.statistics.in": "через",
|
||||
"journal.pl.statistics.days": "дн.",
|
||||
"journal.pl.statistics.courseProgress": "Прогресс курса"
|
||||
"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": "новых"
|
||||
}
|
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'
|
@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Progress,
|
||||
Flex,
|
||||
Divider
|
||||
} from '@chakra-ui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
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>
|
||||
<Text fontWeight="medium" fontSize="md" mb={3}>
|
||||
{t('journal.pl.overview.activityStats')}
|
||||
</Text>
|
||||
|
||||
<Box mb={3}>
|
||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||
{t('journal.pl.overview.courseCompletion')}:
|
||||
</Text>
|
||||
<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}>
|
||||
<Text fontSize="sm" fontWeight="medium" mb={1}>
|
||||
{t('journal.pl.overview.studentAttendance')}:
|
||||
</Text>
|
||||
<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,57 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
VStack,
|
||||
HStack,
|
||||
Box,
|
||||
Text,
|
||||
Badge
|
||||
} from '@chakra-ui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
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}>
|
||||
{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]}>
|
||||
#{index + 1}
|
||||
</Badge>
|
||||
<Text fontSize="sm" fontWeight="medium" isTruncated flex="1">
|
||||
{course.name}
|
||||
</Text>
|
||||
<Badge colorScheme={getProgressColor(course.attendanceRate)}>
|
||||
{Math.round(course.attendanceRate)}%
|
||||
</Badge>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</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,82 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
VStack,
|
||||
HStack,
|
||||
Box,
|
||||
Text,
|
||||
Progress,
|
||||
Badge,
|
||||
Avatar,
|
||||
Tooltip
|
||||
} 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>
|
||||
|
||||
<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">
|
||||
<Tooltip label={student.name}>
|
||||
<Text fontSize="sm" fontWeight="medium" isTruncated maxW="150px">
|
||||
{student.name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<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>
|
||||
</Box>
|
||||
)
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
Tooltip,
|
||||
VStack
|
||||
} 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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="start" width="100%">
|
||||
<Text fontSize="sm" fontWeight="medium">
|
||||
{t('journal.pl.overview.mostActiveDay')}:
|
||||
</Text>
|
||||
<Badge colorScheme="blue" fontSize="md" px={2} py={1}>
|
||||
{getDayOfWeekName(mostActiveDayIndex)}
|
||||
</Badge>
|
||||
|
||||
{/* Визуализация активности по дням недели */}
|
||||
<Box w="100%" mt={2}>
|
||||
<HStack spacing={1} w="100%" justify="space-between">
|
||||
{weekdayActivity.map((count, index) => (
|
||||
<Tooltip
|
||||
key={index}
|
||||
label={getDayTooltip(index, count)}
|
||||
>
|
||||
<Box>
|
||||
<Box
|
||||
h={`${Math.max((count / Math.max(...weekdayActivity, 1)) * 50, 3)}px`}
|
||||
w="12px"
|
||||
bg={index === mostActiveDayIndex ? 'blue.400' : 'gray.300'}
|
||||
minH="3px"
|
||||
borderRadius="sm"
|
||||
/>
|
||||
<Text fontSize="xs" textAlign="center" mt={1}>
|
||||
{getShortDayName(index)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
</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'
|
235
src/pages/course-list/components/statistics/useStats.ts
Normal file
235
src/pages/course-list/components/statistics/useStats.ts
Normal file
@ -0,0 +1,235 @@
|
||||
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 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)
|
||||
}
|
||||
|
||||
// Для статистики посещаемости по курсу
|
||||
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 => {
|
||||
uniqueStudents.add(student.sub)
|
||||
|
||||
// Добавляем или обновляем данные студента в глобальной карте
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
// Потенциальные посещения для этого курса
|
||||
// (кол-во прошедших занятий * кол-во уникальных студентов)
|
||||
const courseUniqueStudents = new Set<string>()
|
||||
courseLessons.forEach(lesson => {
|
||||
lesson.students?.forEach(student => {
|
||||
courseUniqueStudents.add(student.sub)
|
||||
})
|
||||
})
|
||||
|
||||
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 = student.attended / student.total * 100
|
||||
})
|
||||
|
||||
// Находим самый активный день недели
|
||||
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,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useMemo, useEffect } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@ -15,7 +15,8 @@ import { api } from '../../__data__/api/api'
|
||||
import { isTeacher } from '../../utils/user'
|
||||
import { PageLoader } from '../../components/page-loader/page-loader'
|
||||
import { useGroupedCourses } from './hooks'
|
||||
import { CreateCourseForm, YearGroup } from './components'
|
||||
import { CreateCourseForm, YearGroup, CoursesOverview } from './components'
|
||||
import { Lesson } from '../../__data__/model'
|
||||
|
||||
/**
|
||||
* Основной компонент списка курсов
|
||||
@ -27,11 +28,48 @@ export const CoursesList = () => {
|
||||
const { t } = useTranslation()
|
||||
const { colorMode } = useColorMode()
|
||||
|
||||
// Создаем 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 +99,14 @@ export const CoursesList = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!showForm && (
|
||||
<CoursesOverview
|
||||
courses={courses}
|
||||
isLoading={isLoading}
|
||||
lessonsByCourse={lessonsByCourse}
|
||||
/>
|
||||
)}
|
||||
|
||||
{Object.keys(groupedCourses).length > 0 ? (
|
||||
Object.entries(groupedCourses)
|
||||
.sort(([yearA], [yearB]) => Number(yearB) - Number(yearA)) // Сортируем годы по убыванию
|
||||
|
Loading…
x
Reference in New Issue
Block a user