Добавлен компонент CourseStatistics для отображения статистики курса, включая общее количество уроков, посещаемость, количество студентов и информацию о следующем занятии. Обновлены локализации для поддержки новых статистических данных.

This commit is contained in:
Primakov Alexandr Alexandrovich 2025-03-23 17:56:51 +03:00
parent bc33de2721
commit b37c96f640
4 changed files with 341 additions and 2 deletions

View File

@ -143,5 +143,17 @@
"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"
}

View File

@ -140,5 +140,17 @@
"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": "Прогресс курса"
}

View File

@ -0,0 +1,308 @@
import React, { useMemo } from 'react'
import dayjs from 'dayjs'
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.length) {
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={stats.attendanceTrend > 0 ? 'increase' : 'decrease'}
/>
<Text>
{Math.abs(Math.round(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
? dayjs(stats.nextLessonDate).format('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>
)
}

View File

@ -40,6 +40,7 @@ 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')
@ -346,6 +347,12 @@ const LessonList = () => {
)}
</Box>
)}
{/* Статистика курса */}
{!showForm && (
<CourseStatistics lessons={sorted} isLoading={isLoading} />
)}
{barFeature && sorted?.length > 1 && (
<Box height="300">
<Bar